Files
2026-08-03 18:42:47 +08:00

1380 lines
51 KiB
Python
Executable File

#!/usr/bin/env python3
"""
PPI Skill - Protein-Protein Interaction Analysis
蛋白互作网络分析技能 (基于STRING数据库)
Usage:
python run.py --gene-ids genes.txt --species ath --prefix Ft.SPL --output ./ppi
python run.py --config config.yaml --output ./ppi
python run.py --demo --output ./ppi
"""
import argparse
import hashlib
import json
import logging
import math
import os
import sys
import urllib.parse
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
try:
import requests
HAS_REQUESTS = True
except ImportError:
HAS_REQUESTS = False
import pandas as pd
import yaml
from gfa_core.id_resolution import IdResolutionError, ReferenceAliasIndex
from gfa_core.ppi_string_policy import (
STRING_SPECIES_MAP,
normalize_gene_ids_for_string,
string_taxon_id,
unique_preserve_order,
)
from gfa_core.string_rate_gate import (
StringRateGate,
is_authoritative_string_https_url,
string_https_request_identity,
)
from gfa_core.string_species_registry import StringSnapshotError, load_string_snapshot
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class StringApiError(RuntimeError):
"""Typed failure from a STRING transport, HTTP, JSON, or protocol boundary."""
def __init__(
self,
message: str,
*,
kind: str,
status_code: int | None = None,
endpoint: str = "",
) -> None:
super().__init__(message)
self.kind = kind
self.status_code = status_code
self.endpoint = endpoint
class StringAPI:
"""STRING API client with stable-version discovery and typed failures."""
BASE_URL = "https://string-db.org/api"
CALLER_IDENTITY = "PlantAI"
SPECIES_MAP = STRING_SPECIES_MAP
def __init__(
self,
species: str = "ath",
*,
http_client: Any = None,
session: Any = None,
stable_api_base: str | None = None,
rate_gate: StringRateGate | Any | None = None,
rate_gate_path: Path | str | None = None,
min_interval: float = 1.0,
clock: Callable[[], float] | None = None,
sleeper: Callable[[float], None] | None = None,
):
self.species = species
self.species_id = self.resolve_species_id(species)
self._http = http_client or session or (requests if HAS_REQUESTS else None)
self.stable_api_base = self._normalize_api_base(stable_api_base or "")
gate_options: Dict[str, Any] = {
"path": rate_gate_path,
"min_interval": min_interval,
}
if clock is not None:
gate_options["clock"] = clock
if sleeper is not None:
gate_options["sleeper"] = sleeper
self.rate_gate = rate_gate or StringRateGate(**gate_options)
@staticmethod
def resolve_species_id(species: str) -> str:
raw = str(species or "").strip()
if raw.isdigit():
snapshot_root = Path(__file__).resolve().parents[2] / "data" / "string" / "v12.0"
try:
snapshot = load_string_snapshot(snapshot_root)
except StringSnapshotError as exc:
raise ValueError(f"STRING snapshot registry unavailable: {exc}") from exc
if raw not in snapshot.registry:
raise ValueError(f"STRING TaxID {raw} is not in the fixed STRING snapshot registry")
return raw
species_id = string_taxon_id(species, "")
if not species_id:
raise ValueError(f"Unsupported or empty STRING species: {species!r}")
return species_id
@staticmethod
def _normalize_api_base(address: str) -> str:
value = str(address or "").strip()
if not value:
return ""
try:
parsed = urllib.parse.urlsplit(value)
except ValueError as exc:
raise StringApiError(
"Malformed STRING stable address", kind="protocol", endpoint="version"
) from exc
hostname = (parsed.hostname or "").lower()
if (
not is_authoritative_string_https_url(value)
or parsed.query
or parsed.fragment
or parsed.path not in {"", "/", "/api", "/api/"}
):
raise StringApiError(
"Untrusted STRING stable address", kind="protocol", endpoint="version"
)
return f"https://{hostname}/api"
def _request_json(
self,
method: str,
url: str,
*,
allow_404: bool = False,
**kwargs: Any,
) -> Any:
if self._http is None:
raise StringApiError(
"requests module not available", kind="transport", endpoint=url
)
self.rate_gate.wait()
try:
request_method = getattr(self._http, method.lower())
response = request_method(
url, timeout=30, allow_redirects=False, **kwargs
)
except Exception as exc:
raise StringApiError(
f"STRING transport failure: {exc}", kind="transport", endpoint=url
) from exc
response_url = getattr(response, "url", None)
if string_https_request_identity(response_url) != string_https_request_identity(
url
):
raise StringApiError(
f"STRING response URL does not match request: {response_url!r}",
kind="protocol",
endpoint=url,
)
raw_status = getattr(response, "status_code", None)
if isinstance(raw_status, bool) or not isinstance(raw_status, int):
raise StringApiError(
f"Malformed STRING HTTP status: {raw_status!r}",
kind="protocol",
endpoint=url,
)
status_code = raw_status
if allow_404 and status_code == 404:
return []
if status_code < 200 or status_code >= 300:
raise StringApiError(
f"STRING HTTP {status_code}",
kind="http",
status_code=status_code,
endpoint=url,
)
try:
return response.json()
except Exception as exc:
raise StringApiError(
f"STRING returned invalid JSON: {exc}", kind="json", endpoint=url
) from exc
def get_version(self) -> Dict[str, Any]:
"""Discover and retain the stable API address for this STRING version."""
payload = self._request_json(
"GET",
f"{self.BASE_URL}/json/version",
params={"caller_identity": self.CALLER_IDENTITY},
)
if isinstance(payload, list) and len(payload) == 1:
version = payload[0]
elif isinstance(payload, dict):
version = payload
else:
raise StringApiError(
"STRING version response must be one object",
kind="protocol",
endpoint="version",
)
if not isinstance(version, dict):
raise StringApiError(
"STRING version response contains a non-object",
kind="protocol",
endpoint="version",
)
stable_address = version.get("string_stable_address") or version.get(
"stable_address"
)
if not isinstance(stable_address, str) or not stable_address.strip():
raise StringApiError(
"STRING version response has no stable address",
kind="protocol",
endpoint="version",
)
self.stable_api_base = self._normalize_api_base(stable_address)
return dict(version)
def _stable_base(self) -> str:
if not self.stable_api_base:
self.get_version()
return self.stable_api_base
@classmethod
def validate_mapping_rows(
cls,
payload: Any,
identifiers: List[str],
species_id: str,
) -> List[Dict[str, Any]]:
if not isinstance(payload, list):
raise StringApiError(
"STRING mapping response must be a list", kind="protocol"
)
seen_indices = set()
validated = []
expected_taxon = int(species_id)
for row in payload:
if not isinstance(row, dict):
raise StringApiError(
"STRING mapping row must be an object", kind="protocol"
)
query_index = row.get("queryIndex")
if (
isinstance(query_index, bool)
or not isinstance(query_index, int)
or query_index < 0
or query_index >= len(identifiers)
or query_index in seen_indices
):
raise StringApiError(
f"Invalid STRING queryIndex: {query_index!r}", kind="protocol"
)
seen_indices.add(query_index)
taxon = row.get("ncbiTaxonId")
parsed_taxon = cls._validated_taxon(taxon)
if parsed_taxon != expected_taxon:
raise StringApiError(
f"STRING mapping taxon {parsed_taxon} != {expected_taxon}",
kind="protocol",
)
cls._validated_string_id(row.get("stringId"), parsed_taxon)
if (
not isinstance(row.get("queryItem"), str)
or row["queryItem"] != identifiers[query_index]
or not isinstance(row.get("stringId"), str)
or not row["stringId"].strip()
or not isinstance(row.get("preferredName"), str)
or not row["preferredName"].strip()
):
raise StringApiError("Malformed STRING mapping row", kind="protocol")
validated.append(dict(row))
return sorted(validated, key=lambda row: row["queryIndex"])
@staticmethod
def _validated_taxon(value: Any) -> int:
if isinstance(value, bool):
raise StringApiError(f"Invalid STRING taxon: {value!r}", kind="protocol")
if isinstance(value, int):
return value
if isinstance(value, str) and value.isdigit():
return int(value)
raise StringApiError(f"Invalid STRING taxon: {value!r}", kind="protocol")
@staticmethod
def _validated_string_id(value: Any, taxon_id: int) -> str:
if not isinstance(value, str) or value != value.strip():
raise StringApiError(
f"Invalid STRING identifier: {value!r}", kind="protocol"
)
prefix, separator, entity = value.partition(".")
if (
separator != "."
or prefix != str(taxon_id)
or not entity
or any(character.isspace() for character in entity)
):
raise StringApiError(
f"Invalid STRING identifier for taxon {taxon_id}: {value!r}",
kind="protocol",
)
return value
def get_string_ids(self, identifiers: List[str]) -> List[Dict[str, Any]]:
"""Resolve candidate identifiers, attributing rows only by queryIndex."""
self._validate_identifiers(identifiers)
url = f"{self._stable_base()}/json/get_string_ids"
payload = self._request_json(
"POST",
url,
allow_404=True,
data={
"identifiers": "\r\n".join(identifiers),
"species": self.species_id,
"echo_query": 1,
"caller_identity": self.CALLER_IDENTITY,
},
)
return self.validate_mapping_rows(payload, identifiers, self.species_id)
@staticmethod
def _validate_identifiers(identifiers: List[str]) -> None:
if not isinstance(identifiers, list) or not identifiers:
raise ValueError("identifiers must be a non-empty list")
if any(not isinstance(item, str) or not item.strip() for item in identifiers):
raise ValueError("identifiers must contain non-empty strings")
def get_network(
self,
proteins: List[str],
required_score: int = 200,
network_type: str = "functional",
) -> List[Dict[str, Any]]:
"""Return a validated STRING network; an empty list is scientifically valid."""
self._validate_identifiers(proteins)
if (
isinstance(required_score, bool)
or not isinstance(required_score, int)
or not 0 <= required_score <= 1000
):
raise ValueError("required_score must be an integer from 0 to 1000")
if network_type != "functional":
raise ValueError("network_type is fixed to 'functional'")
selected_taxon = int(self.species_id)
for protein in proteins:
self._validated_string_id(protein, selected_taxon)
submitted_ids = set(proteins)
url = f"{self._stable_base()}/json/network"
payload = self._request_json(
"POST",
url,
data={
"identifiers": "\r\n".join(proteins),
"species": self.species_id,
"required_score": required_score,
"network_type": "functional",
"add_nodes": 0,
"caller_identity": self.CALLER_IDENTITY,
},
)
if not isinstance(payload, list):
raise StringApiError(
"STRING network response must be a list",
kind="protocol",
endpoint=url,
)
for row in payload:
if not isinstance(row, dict):
raise StringApiError(
"Malformed STRING network row", kind="protocol", endpoint=url
)
parsed_taxon = self._validated_taxon(row.get("ncbiTaxonId"))
preferred_a = row.get("preferredName_A")
preferred_b = row.get("preferredName_B")
score = row.get("score")
if (
parsed_taxon != int(self.species_id)
or not isinstance(preferred_a, str)
or not preferred_a.strip()
or not isinstance(preferred_b, str)
or not preferred_b.strip()
or isinstance(score, bool)
or not isinstance(score, (int, float))
or not math.isfinite(score)
or not 0 <= score <= 1
):
raise StringApiError(
"Malformed STRING network row", kind="protocol", endpoint=url
)
string_id_a = self._validated_string_id(
row.get("stringId_A"), parsed_taxon
)
string_id_b = self._validated_string_id(
row.get("stringId_B"), parsed_taxon
)
if (
string_id_a not in submitted_ids
or string_id_b not in submitted_ids
or score < required_score / 1000
):
raise StringApiError(
"STRING network row contradicts submitted identifiers or score threshold",
kind="protocol",
endpoint=url,
)
return payload
def generate_string_url(proteins: List[str], species: str = "3702", required_score: int = 200) -> str:
"""生成STRING网页链接
Args:
proteins: 蛋白ID列表
species: STRING物种ID (默认3702=拟南芥)
Returns:
STRING网络查看URL
"""
base_url = "https://string-db.org/cgi/network"
# 使用实际换行符,urllib.parse.urlencode会自动编码
identifiers = "\r\n".join(proteins)
params = {
"identifiers": identifiers,
"species": species,
"required_score": str(required_score),
"network_type": "functional"
}
return f"{base_url}?{urllib.parse.urlencode(params)}"
@dataclass(frozen=True, slots=True)
class PpiQueryOrigin:
canonical_target_id: str
ortholog_reference_id: str
raw_query_id: str
relationship_source: str
@dataclass(frozen=True, slots=True)
class PpiResolutionRow:
canonical_target_id: str
ortholog_reference_id: str
raw_query_id: str
candidate_gene_id: str
selected_query_id: str
string_id: str
string_preferred_name: str
mapping_status: str
method: str
evidence_source: str
relationship_source: str
species: str
taxon_id: str
reason: str
@dataclass(slots=True)
class StringResolutionResult:
selected_string_ids: List[str]
rows: List[PpiResolutionRow]
metrics: Dict[str, Any]
status: str = "success"
issue_code: str = ""
@dataclass(frozen=True, slots=True)
class _ResolutionCandidate:
value: str
namespace: str
method: str
evidence_source: str
def parse_ortholog_mapping(mapping_file: Path) -> List[PpiQueryOrigin]:
"""Parse every canonical-to-reference relationship without rewriting IDs."""
mapping: List[PpiQueryOrigin] = []
if not mapping_file.exists():
return mapping
with open(mapping_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("\t")
if len(parts) >= 2:
source_id = parts[0].strip()
target_id = parts[1].strip()
if source_id and target_id:
mapping.append(
PpiQueryOrigin(
canonical_target_id=source_id,
ortholog_reference_id=target_id,
raw_query_id=target_id,
relationship_source=str(mapping_file),
)
)
return mapping
class PpiSkill:
"""PPI分析技能主类"""
def __init__(
self,
config_path: Optional[str] = None,
*,
string_api_factory: Callable[[str], Any] | None = None,
):
self.config = self._load_config(config_path)
self.checksums = {}
self.start_time = None
self.end_time = None
self.ortholog_mapping: List[PpiQueryOrigin] | None = None
self.string_api_factory = string_api_factory or StringAPI
def _load_config(self, config_path: Optional[str]) -> Dict:
"""加载配置文件"""
if config_path and os.path.exists(config_path):
with open(config_path) as f:
return yaml.safe_load(f)
default_config = Path(__file__).parent / "config.yaml"
with open(default_config) as f:
return yaml.safe_load(f)
def _compute_checksum(self, filepath: Path) -> str:
"""计算文件SHA256校验"""
sha256 = hashlib.sha256()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return sha256.hexdigest()
def _record_file(self, filepath: Path, file_type: str = "output") -> Dict:
"""记录文件信息"""
if filepath.exists():
return {
"path": str(filepath),
"sha256": self._compute_checksum(filepath),
"size": filepath.stat().st_size,
"type": file_type
}
return {}
def load_gene_ids(self, gene_file: Path) -> List[str]:
"""加载基因ID列表"""
gene_ids = []
with open(gene_file, 'r') as f:
for line in f:
gene_id = line.strip()
if gene_id:
gene_ids.append(gene_id)
return gene_ids
def load_ortholog_mapping(self, mapping_file: Path) -> List[PpiQueryOrigin]:
"""加载同源基因映射"""
self.ortholog_mapping = parse_ortholog_mapping(mapping_file)
logger.info(f"Loaded {len(self.ortholog_mapping)} ortholog mappings from {mapping_file}")
return self.ortholog_mapping
@staticmethod
def _query_candidates(
raw_query_id: str,
normalization_species_label: str,
alias_index: ReferenceAliasIndex | None,
) -> List[_ResolutionCandidate]:
raw = str(raw_query_id or "").strip()
candidates: List[_ResolutionCandidate] = []
def add(value: str, namespace: str, method: str, evidence_source: str) -> None:
token = str(value or "").strip()
if token and all(candidate.value != token for candidate in candidates):
candidates.append(
_ResolutionCandidate(token, namespace, method, evidence_source)
)
add(raw, "raw", "raw_query", "raw_query")
normalized = normalize_gene_ids_for_string([raw], normalization_species_label)
if normalized:
add(
normalized[0],
"normalized",
f"species_rule:{normalization_species_label}",
"ppi_string_policy",
)
if "." in raw and raw.rsplit(".", 1)[1].isdigit():
add(
raw.rsplit(".", 1)[0],
"versionless",
"versionless_lookup_candidate",
"raw_query_id",
)
if alias_index is not None:
for candidate in alias_index.candidates(raw):
add(
candidate.value,
candidate.namespace,
candidate.method,
candidate.evidence_source,
)
return candidates
def resolve_query_ids(
self,
origins: List[PpiQueryOrigin],
species: str,
id_map_file: Path | None,
*,
api: Any = None,
canonical_target_ids: List[str] | None = None,
normalization_species_label: str = "",
) -> StringResolutionResult:
"""Resolve relationship queries without collapsing their provenance rows."""
species_id = StringAPI.resolve_species_id(species)
if not isinstance(origins, list) or any(
not isinstance(item, PpiQueryOrigin) for item in origins
):
raise ValueError("origins must be a list of PpiQueryOrigin")
alias_index = (
ReferenceAliasIndex.from_tsv(
Path(id_map_file),
str(normalization_species_label or species).strip(),
)
if id_map_file is not None
else None
)
unique_queries = unique_preserve_order(
[origin.raw_query_id for origin in origins if origin.raw_query_id]
)
candidates_by_query: Dict[str, List[_ResolutionCandidate]] = {}
query_errors: Dict[str, IdResolutionError] = {}
normalization_label = str(normalization_species_label or species).strip()
for raw_query in unique_queries:
try:
candidates_by_query[raw_query] = self._query_candidates(
raw_query, normalization_label, alias_index
)
except IdResolutionError as exc:
candidates_by_query[raw_query] = []
query_errors[raw_query] = exc
probes = unique_preserve_order(
[
candidate.value
for raw_query in unique_queries
for candidate in candidates_by_query[raw_query]
]
)
string_api = api or self.string_api_factory(species)
mapping_rows = string_api.get_string_ids(probes) if probes else []
validated_rows = StringAPI.validate_mapping_rows(
mapping_rows, probes, species_id
)
mapped_by_probe = {
probes[row["queryIndex"]]: row for row in validated_rows
}
query_results: Dict[str, Dict[str, Any]] = {}
for raw_query in unique_queries:
candidates = candidates_by_query[raw_query]
successful = [
(candidate, mapped_by_probe[candidate.value])
for candidate in candidates
if candidate.value in mapped_by_probe
]
string_ids = unique_preserve_order(
[row["stringId"] for _candidate, row in successful]
)
if raw_query in query_errors:
query_results[raw_query] = {
"status": "ambiguous",
"reason": query_errors[raw_query].code,
"string_id": "",
"preferred_name": "",
"selected_candidate": None,
"raw_resolved": False,
"geneid_fallback": False,
}
elif not successful:
query_results[raw_query] = {
"status": "unresolved",
"reason": "no_string_mapping",
"string_id": "",
"preferred_name": "",
"selected_candidate": None,
"raw_resolved": False,
"geneid_fallback": False,
}
elif len(string_ids) > 1:
query_results[raw_query] = {
"status": "ambiguous",
"reason": "candidate_string_ids_conflict",
"string_id": "",
"preferred_name": "",
"selected_candidate": None,
"raw_resolved": False,
"geneid_fallback": False,
}
else:
selected_candidate, selected_row = successful[0]
raw_resolved = any(
candidate.namespace in {"raw", "normalized", "versionless"}
for candidate, _row in successful
)
geneid_resolved = any(
candidate.namespace == "ncbi_gene_id"
for candidate, _row in successful
)
query_results[raw_query] = {
"status": "resolved",
"reason": "candidates_converged",
"string_id": string_ids[0],
"preferred_name": str(selected_row.get("preferredName") or ""),
"selected_candidate": selected_candidate,
"raw_resolved": raw_resolved,
"geneid_fallback": geneid_resolved and not raw_resolved,
}
rows = [
self._resolution_row(origin, query_results[origin.raw_query_id], species)
for origin in origins
]
rows = self._exclude_conflicting_orthologs(rows)
selected_string_ids = unique_preserve_order(
[row.string_id for row in rows if row.mapping_status == "resolved"]
)
metrics = self._resolution_metrics(
origins,
rows,
unique_queries,
query_results,
selected_string_ids,
canonical_target_ids=canonical_target_ids,
)
if selected_string_ids:
return StringResolutionResult(selected_string_ids, rows, metrics)
return StringResolutionResult(
selected_string_ids,
rows,
metrics,
status="id_resolution_failure",
issue_code="ppi_string_id_resolution_failed",
)
@staticmethod
def _resolution_row(
origin: PpiQueryOrigin,
query_result: Dict[str, Any],
species: str,
) -> PpiResolutionRow:
selected = query_result.get("selected_candidate")
return PpiResolutionRow(
canonical_target_id=origin.canonical_target_id,
ortholog_reference_id=origin.ortholog_reference_id,
raw_query_id=origin.raw_query_id,
candidate_gene_id=(
selected.value
if selected is not None
and selected.namespace in {"ncbi_gene_id", "gene_symbol"}
else ""
),
selected_query_id=selected.value if selected is not None else "",
string_id=query_result["string_id"],
string_preferred_name=query_result["preferred_name"],
mapping_status=query_result["status"],
method=selected.method if selected is not None else "",
evidence_source=(
(
"ortholog_relationship"
if origin.ortholog_reference_id
else "canonical_member_input"
)
if selected is not None and selected.namespace == "raw"
else selected.evidence_source
if selected is not None
else origin.relationship_source
),
relationship_source=origin.relationship_source,
species=species,
taxon_id=StringAPI.resolve_species_id(species),
reason=query_result["reason"],
)
@staticmethod
def _exclude_conflicting_orthologs(
rows: List[PpiResolutionRow],
) -> List[PpiResolutionRow]:
rows_by_canonical: Dict[str, List[PpiResolutionRow]] = {}
for row in rows:
rows_by_canonical.setdefault(row.canonical_target_id, []).append(row)
conflicting = set()
for canonical, canonical_rows in rows_by_canonical.items():
references = {row.raw_query_id for row in canonical_rows}
if len(references) < 2:
continue
string_ids = {
row.string_id
for row in canonical_rows
if row.mapping_status == "resolved" and row.string_id
}
if (
len(string_ids) != 1
or any(row.mapping_status != "resolved" for row in canonical_rows)
):
conflicting.add(canonical)
return [
PpiResolutionRow(
**{
**{
field: getattr(row, field)
for field in row.__dataclass_fields__
},
"candidate_gene_id": "",
"selected_query_id": "",
"string_id": "",
"string_preferred_name": "",
"mapping_status": "ambiguous_ortholog",
"method": "",
"reason": "ortholog_references_do_not_converge",
}
)
if row.canonical_target_id in conflicting
else row
for row in rows
]
@staticmethod
def _resolution_metrics(
origins: List[PpiQueryOrigin],
rows: List[PpiResolutionRow],
unique_queries: List[str],
query_results: Dict[str, Dict[str, Any]],
selected_string_ids: List[str],
*,
canonical_target_ids: List[str] | None = None,
) -> Dict[str, Any]:
input_members = unique_preserve_order(
list(canonical_target_ids or [])
+ [origin.canonical_target_id for origin in origins]
)
resolved_queries = sum(
result["status"] == "resolved" for result in query_results.values()
)
states_by_member: Dict[str, set[str]] = {}
for row in rows:
states_by_member.setdefault(row.canonical_target_id, set()).add(
row.mapping_status
)
resolved_members = sum(states == {"resolved"} for states in states_by_member.values())
ambiguous_members = sum(
bool(states.intersection({"ambiguous", "ambiguous_ortholog"}))
for states in states_by_member.values()
)
unresolved_members = len(input_members) - resolved_members - ambiguous_members
input_count = len(input_members)
query_count = len(unique_queries)
probe_status = (
"complete"
if query_count and resolved_queries == query_count
else "partial"
if resolved_queries
else "failed"
)
return {
"input_member_count": input_count,
"relationship_count": len(origins),
"unique_reference_query_count": query_count,
"raw_resolved_query_count": sum(
bool(result["raw_resolved"]) for result in query_results.values()
),
"geneid_fallback_resolved_query_count": sum(
bool(result["geneid_fallback"]) for result in query_results.values()
),
"resolved_reference_query_count": resolved_queries,
"selected_string_query_count": len(selected_string_ids),
"resolved_member_count": resolved_members,
"unresolved_member_count": unresolved_members,
"ambiguous_member_count": ambiguous_members,
"member_mapping_rate": round(resolved_members / input_count, 6)
if input_count
else 0,
"query_mapping_rate": round(resolved_queries / query_count, 6)
if query_count
else 0,
"probe_status": probe_status,
}
def write_string_resolution_report(
self,
resolution: StringResolutionResult,
output_dir: Path,
prefix: str,
) -> tuple[Path, Dict[str, Any]]:
"""Serialize one deterministic summary row per input relationship."""
columns = [
"canonical_target_id",
"ortholog_reference_id",
"raw_query_id",
"candidate_gene_id",
"selected_query_id",
"string_id",
"string_preferred_name",
"mapping_status",
"method",
"evidence_source",
"relationship_source",
"species",
"taxon_id",
"reason",
]
output_file = output_dir / f"{prefix}_id_resolution.tsv"
with output_file.open("w", encoding="utf-8") as handle:
handle.write("\t".join(columns) + "\n")
for row in resolution.rows:
values = [
str(getattr(row, column)).replace("\t", " ").replace("\n", " ")
for column in columns
]
handle.write("\t".join(values) + "\n")
return output_file, dict(resolution.metrics)
def analyze_network(
self,
species: str,
required_score: int = 200,
*,
origins: List[PpiQueryOrigin],
canonical_target_ids: List[str],
id_map_file: Path | None = None,
normalization_species_label: str = "",
) -> Dict:
"""分析互作网络
Returns:
Dict with keys: interactions, stats, string_url
"""
api = self.string_api_factory(species)
resolution = self.resolve_query_ids(
origins,
species,
id_map_file,
api=api,
canonical_target_ids=canonical_target_ids,
normalization_species_label=normalization_species_label,
)
network_query_ids = resolution.selected_string_ids
if resolution.status == "id_resolution_failure":
return {
"status": "id_resolution_failure",
"issue_code": resolution.issue_code,
"interactions": [],
"stats": {},
"string_url": "",
"id_resolution": resolution,
}
logger.info(
f"Querying STRING for {len(network_query_ids)} genes (species: {species})"
)
network_data = api.get_network(network_query_ids, required_score)
# 生成STRING网页链接 (无论是否有网络数据都生成)
string_url = generate_string_url(
network_query_ids, api.species_id, required_score
)
logger.info(f"STRING URL: {string_url}")
if not network_data:
logger.info("STRING returned a valid empty network")
return {
"interactions": [],
"stats": {
"input_genes": len(network_query_ids),
"genes_in_network": 0,
"total_interactions": 0,
"avg_score": 0,
"high_confidence": 0,
"medium_confidence": 0,
"low_confidence": 0,
"network_status": "empty_network",
},
"string_url": string_url,
"id_resolution": resolution,
}
# 解析互作数据
interactions = []
proteins_in_network = set()
for interaction in network_data:
if isinstance(interaction, dict):
p1 = interaction.get("preferredName_A", "")
p2 = interaction.get("preferredName_B", "")
score = interaction.get("score", 0)
interactions.append({
"protein1": p1,
"protein2": p2,
"score": score
})
proteins_in_network.add(p1)
proteins_in_network.add(p2)
# 统计
stats = {
"input_genes": len(network_query_ids),
"genes_in_network": len(proteins_in_network),
"total_interactions": len(interactions),
"avg_score": sum(i["score"] for i in interactions) / len(interactions) if interactions else 0,
"high_confidence": sum(1 for i in interactions if i["score"] >= 0.7),
"medium_confidence": sum(1 for i in interactions if 0.4 <= i["score"] < 0.7),
"low_confidence": sum(1 for i in interactions if i["score"] < 0.4),
"network_status": "network_detected",
}
logger.info(f"Network: {stats['genes_in_network']}/{stats['input_genes']} genes, "
f"{stats['total_interactions']} interactions")
return {
"interactions": interactions,
"stats": stats,
"string_url": string_url,
"id_resolution": resolution,
}
def save_network(self, interactions: List[Dict], output_file: Path) -> Path:
"""保存网络数据"""
df = pd.DataFrame(interactions, columns=["protein1", "protein2", "score"])
df.to_csv(output_file, sep='\t', index=False)
logger.info(f"Network saved: {output_file}")
return output_file
def run(
self,
gene_ids_file: Path,
species: str,
prefix: str,
output_dir: Path,
required_score: int = 200,
mapping_file: Optional[Path] = None,
id_map_file: Optional[Path] = None,
normalization_species_label: str = "",
ppi_provenance: Optional[Dict[str, Any]] = None,
) -> Dict:
"""运行PPI分析
Args:
gene_ids_file: 基因ID列表文件
species: 目标物种代码 (用于STRING查询)
prefix: 输出前缀
output_dir: 输出目录
required_score: 置信度阈值
mapping_file: 同源映射文件 (可选,用于非模式植物)
id_map_file: 当前任务/物种的本地ID证据文件 (可选)
normalization_species_label: 仅用于本地ID规范化规则,不作为STRING查询身份
"""
self.start_time = datetime.now()
logger.info(f"Starting PPI analysis for {prefix}")
logger.info(f"Input: {gene_ids_file}")
logger.info(f"Species: {species}")
logger.info(f"Output: {output_dir}")
# 创建输出目录
output_dir.mkdir(parents=True, exist_ok=True)
# 加载基因ID
gene_ids = self.load_gene_ids(gene_ids_file)
logger.info(f"Loaded {len(gene_ids)} gene IDs")
if len(gene_ids) == 0:
logger.error("No gene IDs loaded")
return self._generate_failed_manifest(
prefix, output_dir, "No gene IDs", ppi_provenance=ppi_provenance
)
# 加载同源映射 (如果有)
ortholog_mode = mapping_file is not None
self.ortholog_mapping = None
if ortholog_mode:
if not mapping_file.exists():
return self._generate_failed_manifest(
prefix,
output_dir,
f"Ortholog mapping file does not exist: {mapping_file}",
ppi_provenance=ppi_provenance,
)
self.load_ortholog_mapping(mapping_file)
if ortholog_mode:
requested_members = set(gene_ids)
origins = [
origin
for origin in (self.ortholog_mapping or [])
if origin.canonical_target_id in requested_members
]
mapped_members = {origin.canonical_target_id for origin in origins}
if len(mapped_members) < len(requested_members):
logger.warning(
"%s/%s canonical members have no Ortholog relationship",
len(requested_members) - len(mapped_members),
len(requested_members),
)
else:
origins = [
PpiQueryOrigin(
canonical_target_id=gene_id,
ortholog_reference_id="",
raw_query_id=gene_id,
relationship_source=str(gene_ids_file),
)
for gene_id in gene_ids
]
result = self.analyze_network(
species,
required_score,
origins=origins,
id_map_file=id_map_file,
canonical_target_ids=gene_ids,
normalization_species_label=normalization_species_label,
)
resolution = result["id_resolution"]
id_resolution_file, id_resolution_metrics = (
self.write_string_resolution_report(resolution, output_dir, prefix)
)
if result.get("status") == "id_resolution_failure":
return self._generate_failed_manifest(
prefix,
output_dir,
"No identifiers resolved unambiguously in STRING",
issue_code=str(result.get("issue_code") or ""),
metrics={"id_resolution": id_resolution_metrics},
artifacts={"id_resolution": self._record_file(id_resolution_file, "tsv")},
ppi_provenance=ppi_provenance,
)
query_ids = resolution.selected_string_ids
interactions = result["interactions"]
stats = result["stats"]
string_url = result.get("string_url", "")
# 保存网络数据
network_file = output_dir / f"{prefix}_network.tsv"
self.save_network(interactions, network_file)
# 保存STRING URL到文件
if string_url:
url_file = output_dir / f"{prefix}_url.txt"
with open(url_file, 'w') as f:
f.write("# STRING Protein Interaction Network\n\n")
f.write(f"**Query**: {prefix}\n")
f.write(f"**Species**: {species}\n")
f.write(f"**Genes**: {len(query_ids)}\n\n")
f.write(f"View online: {string_url}\n")
logger.info(f"STRING URL saved: {url_file}")
# 生成报告
report_path = self._generate_report(stats, prefix, output_dir, species, string_url)
# 生成manifest
manifest = self._generate_manifest(
gene_ids_file,
species,
prefix,
output_dir,
stats,
network_file,
report_path,
string_url,
id_resolution_file=id_resolution_file,
id_resolution_metrics=id_resolution_metrics,
normalization_species_label=normalization_species_label,
ppi_provenance=ppi_provenance,
)
self.end_time = datetime.now()
elapsed = (self.end_time - self.start_time).total_seconds()
logger.info(f"PPI analysis completed in {elapsed:.1f}s")
return manifest
def _generate_report(self, stats: Dict, prefix: str, output_dir: Path,
species: str, string_url: str = "") -> Path:
"""生成Markdown报告"""
report_path = output_dir / f"{prefix}_report.md"
with open(report_path, 'w') as f:
f.write(f"# PPI Network Analysis Report - {prefix}\n\n")
f.write(f"**Generated**: {datetime.now().isoformat()}\n")
f.write(f"**Species**: {species}\n")
f.write(f"**Data Source**: STRING Database\n\n")
f.write("## Network Statistics\n\n")
f.write(f"- **Input Genes**: {stats.get('input_genes', 0)}\n")
f.write(f"- **Genes in Network**: {stats.get('genes_in_network', 0)}\n")
f.write(f"- **Total Interactions**: {stats.get('total_interactions', 0)}\n")
f.write(f"- **Average Score**: {stats.get('avg_score', 0):.3f}\n")
f.write(f"- **High Confidence (≥0.7)**: {stats.get('high_confidence', 0)}\n")
f.write(f"- **Medium Confidence (0.4-0.7)**: {stats.get('medium_confidence', 0)}\n")
f.write(f"- **Low Confidence (<0.4)**: {stats.get('low_confidence', 0)}\n\n")
if string_url:
f.write("## STRING Network\n\n")
f.write(f"[View Interactive Network]({string_url})\n\n")
f.write("## Output Files\n\n")
f.write(f"- `{prefix}_network.tsv` - Interaction network data\n")
f.write(f"- `{prefix}_report.md` - This report\n")
return report_path
def _generate_manifest(self, input_file: Path, species: str, prefix: str,
output_dir: Path, stats: Dict, network_file: Path,
report: Path, string_url: str = "",
id_resolution_file: Optional[Path] = None,
id_resolution_metrics: Optional[Dict] = None,
normalization_species_label: str = "",
ppi_provenance: Optional[Dict[str, Any]] = None) -> Dict:
"""生成manifest"""
self.end_time = datetime.now()
elapsed = (self.end_time - self.start_time).total_seconds()
artifacts = {}
if network_file and network_file.exists():
artifacts["network"] = self._record_file(network_file, "tsv")
if report and report.exists():
artifacts["report"] = self._record_file(report, "markdown")
if id_resolution_file and id_resolution_file.exists():
artifacts["id_resolution"] = self._record_file(id_resolution_file, "tsv")
# 添加URL文件到artifacts
url_file = output_dir / f"{prefix}_url.txt"
if url_file.exists():
artifacts["string_url"] = self._record_file(url_file, "txt")
manifest = {
"manifest_version": "1.0",
"stage": "ppi",
"status": "success",
"prefix": prefix,
"timestamp": self.end_time.isoformat(),
"elapsed_sec": round(elapsed, 2),
"input": {"gene_ids": str(input_file), "species": species},
"parameters": {
"string_taxon_id": StringAPI.resolve_species_id(species),
"normalization_species_label": str(normalization_species_label or species).strip(),
},
"metrics": {
**stats,
"id_resolution": id_resolution_metrics or {},
},
"artifacts": artifacts,
"string_url": string_url,
"ppi_provenance": dict(ppi_provenance or {}),
}
manifest_path = output_dir / f"{prefix}.manifest.json"
with open(manifest_path, 'w') as f:
json.dump(manifest, f, indent=2)
manifest["manifest_path"] = str(manifest_path)
return manifest
def _generate_failed_manifest(
self,
prefix: str,
output_dir: Path,
message: str,
*,
issue_code: str = "ppi_execution_failed",
metrics: Optional[Dict[str, Any]] = None,
artifacts: Optional[Dict[str, Any]] = None,
ppi_provenance: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
output_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"manifest_version": "1.0",
"stage": "ppi",
"status": "failed",
"prefix": prefix,
"timestamp": datetime.now().isoformat(),
"message": message,
"issue_code": issue_code,
"metrics": dict(metrics or {}),
"artifacts": dict(artifacts or {}),
"ppi_provenance": dict(ppi_provenance or {}),
}
manifest_path = output_dir / f"{prefix}.manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return {**manifest, "manifest_path": str(manifest_path)}
def main():
parser = argparse.ArgumentParser(description="PPI Skill - Protein-Protein Interaction Analysis")
parser.add_argument("-i", "--gene-ids", help="Gene ID list file")
parser.add_argument("-s", "--species", default="ath", help="Species code (ath/osa/zma)")
parser.add_argument(
"--normalization-species-label",
default="",
help="Local species label used only for gene-ID normalization rules",
)
parser.add_argument("-p", "--prefix", required=True, help="Output prefix")
parser.add_argument("-o", "--output", required=True, help="Output directory")
parser.add_argument("--score", type=int, default=200, help="Confidence threshold (0-1000)")
parser.add_argument("--mapping", help="Ortholog mapping file (for non-model species)")
parser.add_argument("--id-map", help="Species-matched local PEP/CDS ID evidence TSV")
parser.add_argument("--provenance-json", help="Authoritative PPI provenance JSON from preflight/backend")
parser.add_argument("--config", help="Config file path")
parser.add_argument("--demo", action="store_true", help="Demo mode")
args = parser.parse_args()
# Demo模式
if args.demo:
output_path = Path(args.output)
output_path.mkdir(parents=True, exist_ok=True)
demo_genes = output_path / "demo_genes.txt"
with open(demo_genes, 'w') as f:
f.write("AT1G01010\nAT1G01020\nAT1G01030\n")
args.gene_ids = str(demo_genes)
if not args.gene_ids:
parser.error("--gene-ids is required (or use --demo)")
# 运行技能
skill = PpiSkill(config_path=args.config)
provenance = {}
if args.provenance_json:
provenance = json.loads(Path(args.provenance_json).read_text(encoding="utf-8"))
try:
result = skill.run(
gene_ids_file=Path(args.gene_ids),
species=args.species,
prefix=args.prefix,
output_dir=Path(args.output),
required_score=args.score,
mapping_file=Path(args.mapping) if args.mapping else None,
id_map_file=Path(args.id_map) if args.id_map else None,
normalization_species_label=args.normalization_species_label,
ppi_provenance=provenance,
)
except Exception as exc:
result = skill._generate_failed_manifest(
args.prefix, Path(args.output), str(exc), ppi_provenance=provenance
)
if result.get("status") == "failed":
print(f"\n❌ PPI analysis failed: {result.get('message')}")
sys.exit(1)
print(f"\n✅ PPI analysis completed!")
print(f" Manifest: {result.get('manifest_path', 'N/A')}")
print(f" Genes in network: {result['metrics'].get('genes_in_network', 0)}/{result['metrics'].get('input_genes', 0)}")
print(f" Interactions: {result['metrics'].get('total_interactions', 0)}")
sys.exit(0)
if __name__ == "__main__":
main()