随附材料 · 实验代码

kg_experiment.py

"""
OA (Disease::DOID:8398) gene-candidate ranking robustness on Hetionet v1.0.
Only stdlib + numpy/scipy/sklearn. Deterministic given seed.
"""
import os, sys, gzip, json, time, argparse
os.environ.setdefault("OMP_NUM_THREADS", "2")
import numpy as np
import scipy.sparse as sp
from scipy.stats import kendalltau

RNG_GLOBAL = None

def log(msg):
    print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)


def load_graph(nodes_path, edges_path):
    ids, kinds = [], []
    with open(nodes_path, "r", encoding="utf-8") as f:
        header = f.readline()
        for line in f:
            parts = line.rstrip("\n").split("\t")
            if len(parts) < 3:
                continue
            ids.append(parts[0])
            kinds.append(parts[2])
    id2idx = {nid: i for i, nid in enumerate(ids)}
    N = len(ids)
    kinds = np.array(kinds)

    edges_by_meta = {}
    with gzip.open(edges_path, "rt", encoding="utf-8") as f:
        header = f.readline()
        for line in f:
            s, me, t = line.rstrip("\n").split("\t")
            si, ti = id2idx[s], id2idx[t]
            edges_by_meta.setdefault(me, []).append((si, ti))
    return ids, kinds, id2idx, N, edges_by_meta


def build_meta_matrix(pairs, N):
    if not pairs:
        return sp.csr_matrix((N, N))
    rows = np.array([p[0] for p in pairs])
    cols = np.array([p[1] for p in pairs])
    data = np.ones(len(pairs), dtype=np.float64)
    M = sp.coo_matrix((data, (rows, cols)), shape=(N, N)).tocsr()
    M.sum_duplicates()
    M.data[:] = 1.0  # collapse multi-edges to single presence
    return M


def build_full_homogeneous(edges_by_meta, N):
    rows, cols = [], []
    for me, pairs in edges_by_meta.items():
        for s, t in pairs:
            rows.append(s); cols.append(t)
            rows.append(t); cols.append(s)
    data = np.ones(len(rows), dtype=np.float64)
    A = sp.coo_matrix((data, (rows, cols)), shape=(N, N)).tocsr()
    A.sum_duplicates()
    A.data[:] = 1.0
    return A


def degree_weight(A, w):
    """Return D^-w * A * D^-w  (elementwise edge reweighting by endpoint degree)."""
    deg = np.asarray(A.sum(axis=1)).ravel()
    deg_safe = np.where(deg > 0, deg, 1.0)
    dinv = deg_safe ** (-w)
    Dinv = sp.diags(dinv)
    return (Dinv @ A @ Dinv).tocsr(), deg


EDGE_WEIGHT_EXP = {
    "unweighted": 0.0,
    "degree_normalized_w0.4": 0.4,
    "inverse_degree": 1.0,
}


def ppr_scores(W_row_normalized, restart_idx, alpha, n_iter=30):
    N = W_row_normalized.shape[0]
    p = np.zeros(N)
    p[restart_idx] = 1.0
    restart = p.copy()
    for _ in range(n_iter):
        p = alpha * restart + (1 - alpha) * (W_row_normalized.T @ p)
    return p


def row_normalize(A):
    deg = np.asarray(A.sum(axis=1)).ravel()
    deg_safe = np.where(deg > 0, deg, 1.0)
    Dinv = sp.diags(1.0 / deg_safe)
    return (Dinv @ A).tocsr()


def rank_of_positive(scores, pos_idx, exclude_idx_set):
    """1-indexed rank of pos_idx among all candidates (excluding exclude_idx_set, pos itself stays)."""
    pos_score = scores[pos_idx]
    mask = np.ones(len(scores), dtype=bool)
    if exclude_idx_set:
        mask[list(exclude_idx_set)] = False
    mask[pos_idx] = True
    cand_scores = scores[mask]
    # rank = 1 + number of candidates strictly greater, ties get half-credit avg
    greater = np.sum(cand_scores > pos_score)
    equal = np.sum(cand_scores == pos_score) - 1  # subtract self
    rank = 1 + greater + equal / 2.0
    n_neg = mask.sum() - 1
    return rank, n_neg


def auc_from_rank(rank, n_neg):
    if n_neg <= 0:
        return 1.0
    return 1.0 - (rank - 1) / n_neg


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--data-dir", default="data")
    ap.add_argument("--out", default="results.json")
    ap.add_argument("--seeds", nargs="+", type=int, default=[0, 1, 2])
    ap.add_argument("--quick", action="store_true", help="tiny smoke-test run")
    args = ap.parse_args()

    t0 = time.time()
    log("loading graph...")
    ids, kinds, id2idx, N, edges_by_meta = load_graph(
        os.path.join(args.data_dir, "nodes.tsv"),
        os.path.join(args.data_dir, "edges.sif.gz"),
    )
    log(f"N={N} nodes, {sum(len(v) for v in edges_by_meta.values())} directed edge rows, "
        f"{len(edges_by_meta)} metaedge types")

    OA = "Disease::DOID:8398"
    oa_idx = id2idx[OA]
    gene_mask = (kinds == "Gene")
    gene_indices = np.where(gene_mask)[0]
    log(f"gene candidate pool size = {len(gene_indices)}")

    DaG = build_meta_matrix(edges_by_meta.get("DaG", []), N)
    DlA = build_meta_matrix(edges_by_meta.get("DlA", []), N)
    AeG = build_meta_matrix(edges_by_meta.get("AeG", []), N)
    GiG = build_meta_matrix(edges_by_meta.get("GiG", []), N)
    GiG_sym = (GiG + GiG.T).tocsr()
    GiG_sym.data[:] = 1.0

    A_full = build_full_homogeneous(edges_by_meta, N)
    log(f"A_full nnz={A_full.nnz}")

    oa_gene_row = DaG.getrow(oa_idx)
    positive_genes = oa_gene_row.indices.copy()
    log(f"OA positive DaG genes = {len(positive_genes)} (expect 62)")
    if args.quick:
        positive_genes = positive_genes[:6]
        log(f"[quick] truncated to {len(positive_genes)} positives")

    other_disease_gene_idx = set(np.where(DaG.sum(axis=0) > 0)[1] if False else [])
    # genes associated (DaG) with any OTHER disease -> hard-negative pool
    DaG_csc = DaG.tocsc()
    gene_disease_deg = np.asarray(DaG_csc.sum(axis=0)).ravel()
    other_disease_mask = np.zeros(N, dtype=bool)
    other_disease_mask[gene_indices] = gene_disease_deg[gene_indices] > 0
    # remove OA's own positives from that "other disease" pool definition per-fold (handled later)

    full_deg = np.asarray(A_full.sum(axis=1)).ravel()

    # ---- Precompute weighted matrices per edge_weight scheme (reused across folds) ----
    weighted_full = {}
    weighted_dla = {}
    weighted_aeg = {}
    weighted_gig = {}
    for scheme, w in EDGE_WEIGHT_EXP.items():
        Wf, _ = degree_weight(A_full, w)
        weighted_full[scheme] = Wf
        Wa, _ = degree_weight(DlA, w)
        weighted_dla[scheme] = Wa
        Wb, _ = degree_weight(AeG, w)
        weighted_aeg[scheme] = Wb
        Wc, _ = degree_weight(GiG_sym, w)
        weighted_gig[scheme] = Wc
    log(f"weighted matrices built in {time.time()-t0:.1f}s")

    # row-normalized transition matrices for PPR, per scheme
    ppr_transition = {scheme: row_normalize(weighted_full[scheme]) for scheme in EDGE_WEIGHT_EXP}

    # DWPC score vectors per scheme (fold-invariant: no DaG involved)
    dwpc_scores = {}
    for scheme in EDGE_WEIGHT_EXP:
        MP1 = (weighted_dla[scheme] @ weighted_aeg[scheme]).tocsr()
        MP2 = (weighted_dla[scheme] @ weighted_aeg[scheme] @ weighted_gig[scheme]).tocsr()
        row = np.asarray(MP1.getrow(oa_idx).todense()).ravel() + np.asarray(MP2.getrow(oa_idx).todense()).ravel()
        dwpc_scores[scheme] = row
    log("dwpc score vectors ready")

    # AA/CN score vectors per scheme, fold dependent only via zeroing oa-gene entries of weighted_full
    def aa_scores(scheme, zero_pairs=()):
        Wf = weighted_full[scheme]
        row = Wf.getrow(oa_idx).copy().tolil()
        for g in zero_pairs:
            row[0, g] = 0.0
        row = row.tocsr()
        score = (row @ Wf)
        return np.asarray(score.todense()).ravel()

    def degree_null_scores(zero_gene=None):
        d = full_deg.copy()
        if zero_gene is not None:
            d = d.copy()
            d[zero_gene] -= 1
            d[oa_idx] -= 1
        return d

    ALPHAS = [0.15, 0.30, 0.50]
    SCHEMES = list(EDGE_WEIGHT_EXP.keys())
    TOPKS = [20, 50, 100, 200]
    NEG_SCHEMES = ["uniform", "degree_matched", "other_disease_genes"]
    N_NEG_PER_FOLD = 20 if not args.quick else 5

    scorer_names = ["scorer-ppr", "scorer-dwpc", "scorer-adamic-adar",
                    "baseline-degree-null"]

    # ---- canonical config used for headline arm metrics (auroc/auprc/recall/mrr) ----
    CANON_SCHEME = "degree_normalized_w0.4"
    CANON_ALPHA = 0.15

    def rank_gene_pool_scores(scores):
        return scores[gene_indices]

    def score_vector_ppr(scheme, alpha, zero_pair=None):
        W = ppr_transition[scheme]
        restart_idx = oa_idx
        if zero_pair is None:
            p = ppr_scores(W, restart_idx, alpha)
            return p
        gene = zero_pair
        Wm = W.copy()
        # zero row entries corresponding to edge oa<->gene in transition (approx: zero column gene in row oa's original out-mass already baked into row-normalization; we zero the raw weighted edge before row-normalizing for exactness)
        Wf = weighted_full[scheme].tolil()
        old_og = Wf[oa_idx, gene]
        old_go = Wf[gene, oa_idx]
        Wf[oa_idx, gene] = 0.0
        Wf[gene, oa_idx] = 0.0
        Wf = Wf.tocsr()
        Wn = row_normalize(Wf)
        p = ppr_scores(Wn, restart_idx, alpha)
        return p

    # -------------------- Part A: LOO evaluation for arms (canonical config, 3 seeds) --------------------
    log("Part A: leave-one-out evaluation of arms (auroc/auprc/recall/mrr)")
    arms_results = {name: {"per_seed": {}} for name in scorer_names + ["baseline-random-permutation"]}

    fold_genes = positive_genes

    per_fold_records = []  # jsonl-able raw records
    g2l = {int(g): i for i, g in enumerate(gene_indices)}  # global node idx -> gene-pool local idx

    for seed in args.seeds:
        rng = np.random.default_rng(seed)
        fold_auc = {name: [] for name in scorer_names}
        fold_recall = {name: {k: [] for k in TOPKS} for name in scorer_names}
        fold_mrr = {name: [] for name in scorer_names}
        fold_auprc_scores = {name: {ns: {"y": [], "s": []} for ns in NEG_SCHEMES} for name in scorer_names}
        dm_negs = {}  # gene -> degree-matched negatives, reused by the random-permutation arm

        for gene in fold_genes:
            exclude = set(fold_genes.tolist()) - {gene}  # filtered ranking: exclude other true positives

            # scorer: ppr canonical
            p_ppr = score_vector_ppr(CANON_SCHEME, CANON_ALPHA, zero_pair=gene)
            s_ppr = rank_gene_pool_scores(p_ppr)
            rank_ppr, nneg_ppr = rank_of_positive(s_ppr, np.where(gene_indices == gene)[0][0],
                                                    {np.where(gene_indices == g2)[0][0] for g2 in exclude if g2 in set(gene_indices.tolist())})
            auc_ppr = auc_from_rank(rank_ppr, nneg_ppr)

            # scorer: dwpc canonical (fold invariant, but exclude-set differs -> recompute rank only)
            s_dwpc_full = dwpc_scores[CANON_SCHEME]
            s_dwpc = rank_gene_pool_scores(s_dwpc_full)
            rank_dwpc, nneg_dwpc = rank_of_positive(s_dwpc, np.where(gene_indices == gene)[0][0],
                                                      {np.where(gene_indices == g2)[0][0] for g2 in exclude})
            auc_dwpc = auc_from_rank(rank_dwpc, nneg_dwpc)

            # scorer: adamic-adar canonical
            s_aa_full = aa_scores(CANON_SCHEME, zero_pairs=[gene])
            s_aa = rank_gene_pool_scores(s_aa_full)
            rank_aa, nneg_aa = rank_of_positive(s_aa, np.where(gene_indices == gene)[0][0],
                                                  {np.where(gene_indices == g2)[0][0] for g2 in exclude})
            auc_aa = auc_from_rank(rank_aa, nneg_aa)

            # baseline: degree null
            s_dn_full = degree_null_scores(zero_gene=gene)
            s_dn = rank_gene_pool_scores(s_dn_full)
            rank_dn, nneg_dn = rank_of_positive(s_dn, np.where(gene_indices == gene)[0][0],
                                                  {np.where(gene_indices == g2)[0][0] for g2 in exclude})
            auc_dn = auc_from_rank(rank_dn, nneg_dn)

            for name, rank, auc in [
                ("scorer-ppr", rank_ppr, auc_ppr),
                ("scorer-dwpc", rank_dwpc, auc_dwpc),
                ("scorer-adamic-adar", rank_aa, auc_aa),
                ("baseline-degree-null", rank_dn, auc_dn),
            ]:
                fold_auc[name].append(auc)
                fold_mrr[name].append(1.0 / rank)
                for k in TOPKS:
                    fold_recall[name][k].append(1.0 if rank <= k else 0.0)

            per_fold_records.append({
                "seed": seed, "gene_idx": int(gene),
                "rank_ppr": rank_ppr, "rank_dwpc": rank_dwpc, "rank_aa": rank_aa, "rank_degnull": rank_dn,
            })

            # negative sampling for AUPRC (per scorer, per neg scheme)
            candidate_pool = np.array([g for g in gene_indices if g != gene and g not in exclude])
            for ns in NEG_SCHEMES:
                if ns == "uniform":
                    negs = rng.choice(candidate_pool, size=min(N_NEG_PER_FOLD, len(candidate_pool)), replace=False)
                elif ns == "degree_matched":
                    pos_deg = full_deg[gene]
                    diffs = np.abs(full_deg[candidate_pool] - pos_deg)
                    order = np.argsort(diffs)
                    negs = candidate_pool[order[:min(N_NEG_PER_FOLD, len(candidate_pool))]]
                    dm_negs[int(gene)] = negs
                else:  # other_disease_genes
                    hard_pool = candidate_pool[other_disease_mask[candidate_pool]]
                    if len(hard_pool) < N_NEG_PER_FOLD:
                        extra = candidate_pool[~other_disease_mask[candidate_pool]]
                        hard_pool = np.concatenate([hard_pool, rng.choice(extra, size=N_NEG_PER_FOLD - len(hard_pool), replace=False)]) if len(extra) > 0 else hard_pool
                    negs = rng.choice(hard_pool, size=min(N_NEG_PER_FOLD, len(hard_pool)), replace=False)

                for name, full_scores in [
                    ("scorer-ppr", p_ppr), ("scorer-dwpc", s_dwpc_full),
                    ("scorer-adamic-adar", s_aa_full), ("baseline-degree-null", s_dn_full),
                ]:
                    fold_auprc_scores[name][ns]["y"].append(1)
                    fold_auprc_scores[name][ns]["s"].append(full_scores[gene])
                    for ng in negs:
                        fold_auprc_scores[name][ns]["y"].append(0)
                        fold_auprc_scores[name][ns]["s"].append(full_scores[ng])

        # random permutation baseline
        rp_auc, rp_mrr, rp_recall = [], [], {k: [] for k in TOPKS}
        rp_auprc_y, rp_auprc_s = [], []
        for gene in fold_genes:
            perm_scores = rng.permutation(len(gene_indices)).astype(float)
            exclude = set(fold_genes.tolist()) - {gene}
            excl_local = {np.where(gene_indices == g2)[0][0] for g2 in exclude}
            gidx_local = np.where(gene_indices == gene)[0][0]
            rank, nneg = rank_of_positive(perm_scores, gidx_local, excl_local)
            rp_auc.append(auc_from_rank(rank, nneg))
            rp_mrr.append(1.0 / rank)
            for k in TOPKS:
                rp_recall[k].append(1.0 if rank <= k else 0.0)
            # same degree-matched negatives as the other arms, so AUPRC is comparable
            rp_auprc_y.append(1)
            rp_auprc_s.append(perm_scores[gidx_local])
            for ng in dm_negs[int(gene)]:
                rp_auprc_y.append(0)
                rp_auprc_s.append(perm_scores[g2l[int(ng)]])
        from sklearn.metrics import average_precision_score
        arms_results.setdefault("baseline-random-permutation", {"per_seed": {}})
        arms_results["baseline-random-permutation"]["per_seed"][seed] = {
            "auroc": float(np.mean(rp_auc)), "mrr": float(np.mean(rp_mrr)),
            **{f"recall_at_{k}": float(np.mean(rp_recall[k])) for k in TOPKS},
            "auprc_degmatched": float(average_precision_score(rp_auprc_y, rp_auprc_s)),
        }

        for name in scorer_names:
            y = fold_auprc_scores[name]["degree_matched"]["y"]
            s = fold_auprc_scores[name]["degree_matched"]["s"]
            auprc = average_precision_score(y, s) if len(set(y)) > 1 else float("nan")
            arms_results[name]["per_seed"][seed] = {
                "auroc": float(np.mean(fold_auc[name])),
                "mrr": float(np.mean(fold_mrr[name])),
                **{f"recall_at_{k}": float(np.mean(fold_recall[name][k])) for k in TOPKS},
                "auprc_degmatched": float(auprc),
            }
        log(f"seed {seed} done: " + ", ".join(f"{n}:auroc={arms_results[n]['per_seed'][seed]['auroc']:.3f}" for n in scorer_names))

    # -------------------- Part B: 36-setting grid for Jaccard/Kendall robustness (P1) + overlap w/ degree-null (P2) --------------------
    log("Part B: 36-setting grid (full graph, no fold removal) for Jaccard/Kendall robustness")
    GRID_SCORERS = ["ppr", "dwpc", "adamic_adar", "degree_null"]
    grid_top50 = {}
    grid_full_rank = {}
    for scorer in GRID_SCORERS:
        for scheme in SCHEMES:
            for alpha in ALPHAS:
                key = f"{scorer}|{scheme}|{alpha}"
                if scorer == "ppr":
                    p = score_vector_ppr(scheme, alpha, zero_pair=None)
                    s = rank_gene_pool_scores(p)
                elif scorer == "dwpc":
                    s = rank_gene_pool_scores(dwpc_scores[scheme])
                elif scorer == "adamic_adar":
                    s = rank_gene_pool_scores(aa_scores(scheme))
                else:
                    s = rank_gene_pool_scores(full_deg)
                order = np.argsort(-s)
                top50 = set(gene_indices[order[:50]].tolist())
                grid_top50[key] = top50
                grid_full_rank[key] = s
    log(f"grid computed: {len(grid_top50)} settings")

    keys = list(grid_top50.keys())
    jacc_vals = []
    kendall_vals = []
    rng_sub = np.random.default_rng(0)
    sub_idx = rng_sub.choice(len(gene_indices), size=min(1500, len(gene_indices)), replace=False)
    for i in range(len(keys)):
        for j in range(i + 1, len(keys)):
            a, b = grid_top50[keys[i]], grid_top50[keys[j]]
            inter = len(a & b); union = len(a | b)
            jacc_vals.append(inter / union if union > 0 else 0.0)
            ra = grid_full_rank[keys[i]][sub_idx]
            rb = grid_full_rank[keys[j]][sub_idx]
            tau, _ = kendalltau(ra, rb)
            kendall_vals.append(float(tau) if tau == tau else 0.0)
    jacc_vals = np.array(jacc_vals)
    kendall_vals = np.array(kendall_vals)
    log(f"pairwise settings compared = {len(jacc_vals)} (expect C(36,2)=630); "
        f"jaccard median={np.median(jacc_vals):.3f}, kendall median={np.median(kendall_vals):.3f}")

    # P2: degree-null canonical AUROC + top50 overlap vs canonical non-baseline arms
    degnull_auroc_mean = float(np.mean([arms_results["baseline-degree-null"]["per_seed"][s]["auroc"] for s in args.seeds]))
    degnull_top50_canon = grid_top50[f"degree_null|{CANON_SCHEME}|{CANON_ALPHA}"]
    overlaps = {}
    for scorer, gname in [("ppr", "scorer-ppr"), ("dwpc", "scorer-dwpc"), ("adamic_adar", "scorer-adamic-adar")]:
        key = f"{scorer}|{CANON_SCHEME}|{CANON_ALPHA}"
        ov = len(grid_top50[key] & degnull_top50_canon)
        overlaps[gname] = ov
    max_overlap_frac = max(overlaps.values()) / 50.0
    log(f"degree-null canonical AUROC={degnull_auroc_mean:.3f}; top50 overlap with pipelines={overlaps} "
        f"(max frac={max_overlap_frac:.2f})")

    # -------------------- verdicts --------------------
    p1_median_jaccard = float(np.median(jacc_vals))
    p1_outcome = "confirmed" if p1_median_jaccard < 0.5 else "refuted"
    p2_outcome = "confirmed" if (degnull_auroc_mean >= 0.70 and max_overlap_frac >= 0.30) else "refuted"

    runtime = time.time() - t0
    log(f"TOTAL runtime = {runtime:.1f}s")

    # -------------------- assemble results.json --------------------
    def arm_metrics(name):
        seeds_data = arms_results[name]["per_seed"]
        metric_keys = ["auroc", "mrr", "auprc_degmatched"] + [f"recall_at_{k}" for k in TOPKS]
        out = {}
        for mk in metric_keys:
            vals = [seeds_data[s][mk] for s in args.seeds]
            vals = [v for v in vals if v == v]  # drop nan
            out[mk] = {
                "per_seed": [seeds_data[s][mk] for s in args.seeds],
                "mean": float(np.mean(vals)) if vals else float("nan"),
                "std": float(np.std(vals)) if vals else float("nan"),
            }
        return out

    arms = [
        {"name": "baseline-degree-null", "is_baseline": True,
         "what": "忽略OA节点,仅用Gene在全图(homogeneous graph)中的度排序,走同样的62折留一评测",
         "metrics": arm_metrics("baseline-degree-null")},
        {"name": "baseline-random-permutation", "is_baseline": True,
         "what": "每fold对候选基因随机打分,给出评测流水线下限",
         "metrics": arm_metrics("baseline-random-permutation")},
        {"name": "scorer-ppr", "is_baseline": False,
         "what": f"以OA为重启源的个性化PageRank,行归一化转移矩阵,30轮幂迭代,canonical config: edge_weight={CANON_SCHEME}, alpha={CANON_ALPHA}",
         "metrics": arm_metrics("scorer-ppr")},
        {"name": "scorer-dwpc", "is_baseline": False,
         "what": f"度加权路径计数,元路径 D-DlA-A-AeG-G (2跳) 与 D-DlA-A-AeG-G-GiG-G (3跳) 之和,canonical edge_weight={CANON_SCHEME}",
         "metrics": arm_metrics("scorer-dwpc")},
        {"name": "scorer-adamic-adar", "is_baseline": False,
         "what": f"OA与Gene在同质化全图上的广义Adamic-Adar/共邻指标(度幂次由edge_weight方案决定),canonical edge_weight={CANON_SCHEME}",
         "metrics": arm_metrics("scorer-adamic-adar")},
    ]

    headline = {
        "metric": "auroc",
        "baseline_mean": arm_metrics("baseline-degree-null")["auroc"]["mean"],
        # 必须对应 arms 里某一条,取 canonical PPR;三臂均值在 arms 里查不到出处
        "proposed_mean": arm_metrics("scorer-ppr")["auroc"]["mean"],
        "delta": None,
        "claim": (f"36套设定两两Top-50 Jaccard中位数={p1_median_jaccard:.3f}"
                  f"({'<0.5,P1成立' if p1_outcome=='confirmed' else '>=0.5,P1被推翻,候选名单对设定选择不敏感'});"
                  f"纯度数零模型AUROC={degnull_auroc_mean:.3f},与三条推理臂canonical Top-50最大重合率={max_overlap_frac:.2f}"
                  f"({'均达标,P2成立' if p2_outcome=='confirmed' else '未同时达标,P2被推翻'})。"),
    }
    headline["delta"] = headline["proposed_mean"] - headline["baseline_mean"]

    negative_result = (p1_outcome == "refuted") or (p2_outcome == "refuted")

    results = {
        "task_slug": "oa-kg-gene-ranking-robustness",
        "question": "在Hetionet上做OA候选基因排序时,候选名单对边权/打分函数/重启率/负样本采样等设定的敏感程度,以及纯度数零模型的水平",
        "falsifiable_prediction": ("P1: 36套设定两两Top-50 Jaccard中位数<0.5。"
                                    "P2: 纯度数零模型AUROC>=0.70且其Top-50与完整流程Top-50重合>=30%。"),
        # schema 只收 confirmed|refuted|inconclusive;两条独立预测一成立一推翻,合并只能是混合
        "prediction_outcome": (p1_outcome if p1_outcome == p2_outcome else "inconclusive"),
        "prediction_outcome_detail": f"P1={p1_outcome}, P2={p2_outcome}",
        "negative_result": negative_result,
        "dataset": f"Hetionet v1.0 (CC0), {N} nodes, {A_full.nnz//2} undirected homogeneous edges, "
                   f"OA={OA} 有 {len(positive_genes)} 条DaG边(留一交叉验证), Gene候选池={len(gene_indices)}",
        "env": {"python": "3.12.3", "torch": "n/a (no torch used)",
                "extra_packages": []},
        "seeds": list(args.seeds),
        "arms": arms,
        "headline": headline,
        "sweep_robustness": {
            "n_settings": len(keys),
            "n_pairs": len(jacc_vals),
            "jaccard_top50_median": p1_median_jaccard,
            "jaccard_top50_p25": float(np.percentile(jacc_vals, 25)),
            "jaccard_top50_p75": float(np.percentile(jacc_vals, 75)),
            "kendall_tau_median": float(np.median(kendall_vals)),
            "kendall_tau_p25": float(np.percentile(kendall_vals, 25)),
            "kendall_tau_p75": float(np.percentile(kendall_vals, 75)),
            "kendall_subsample_n_genes": int(len(sub_idx)),
        },
        "degree_null_vs_pipelines": {
            "degnull_auroc_mean": degnull_auroc_mean,
            "top50_overlap_with_pipelines": overlaps,
            "max_overlap_fraction": max_overlap_frac,
        },
        "deviations": [
            "DWPC只实现了2条代表性元路径 (D-DlA-A-AeG-G 与 D-DlA-A-AeG-G-GiG-G),未覆盖原计划提到的DaG/GiG/GpBP全部元路径组合,因40分钟预算不够枚举更多;两条元路径均不使用DaG边,天然避免与留一评测标签泄漏。",
            "PPR/AA的edge_weight加权应用于全图同质邻接矩阵的度幂次归一化,不是逐metaedge类型分别加权;DWPC的edge_weight则是在各自metaedge的二部图度上归一化——三者共用同一个w取值(0/0.4/1.0)但数学形式不同,这是同一个'边权强度旋钮'在不同打分函数里的具体化,不是同一个矩阵。",
            "PPR的edge_weight归一化度数用的是全图整体度(不含留一fold的单边移除),留一只体现在:1)转移矩阵里把该OA-gene边置零后重新行归一化,2)degree-null分数里从全图度数减1。度数归一化系数D^-w本身不随fold重算,是预算内的简化,已在NOTES.md说明。",
            "Kendall tau为提速在1500个随机抽样基因上计算(不是全部20945个候选),抽样种子固定为0,已记录在sweep_robustness字段。",
            "AUPRC(auprc_degmatched)每fold只采样20个负样本(--quick模式5个)做pooled average precision,不是对全量负样本做PR曲线。",
        ],
        "runtime_sec": runtime,
    }

    with open(args.out, "w", encoding="utf-8") as f:
        json.dump(results, f, ensure_ascii=False, indent=2)
    log(f"results written to {args.out}")

    with open("logs/per_fold_raw.jsonl", "w", encoding="utf-8") as f:
        for rec in per_fold_records:
            f.write(json.dumps(rec) + "\n")
    log(f"per-fold raw ranks written to logs/per_fold_raw.jsonl ({len(per_fold_records)} records)")

    with open("logs/grid_top50.json", "w", encoding="utf-8") as f:
        json.dump({k: sorted(list(v)) for k, v in grid_top50.items()}, f)
    log("grid top50 lists written to logs/grid_top50.json")

    return results


if __name__ == "__main__":
    main()

make_figs.py

import json
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

with open("results.json", "r", encoding="utf-8") as f:
    R = json.load(f)

fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))

# Panel 1: AUROC per arm across seeds
ax = axes[0]
names = [a["name"] for a in R["arms"]]
means = [a["metrics"]["auroc"]["mean"] for a in R["arms"]]
stds = [a["metrics"]["auroc"]["std"] for a in R["arms"]]
colors = ["#888" if a["is_baseline"] else "#2b6cb0" for a in R["arms"]]
ax.bar(range(len(names)), means, yerr=stds, color=colors, capsize=4)
ax.set_xticks(range(len(names)))
ax.set_xticklabels(names, rotation=30, ha="right", fontsize=8)
ax.set_ylabel("AUROC (62-fold LOO, mean over seeds)")
ax.axhline(0.5, color="k", linestyle="--", linewidth=0.8)
ax.set_title("Arm comparison: AUROC")

# Panel 2: Jaccard(top50) distribution across 630 setting-pairs
ax = axes[1]
sw = R["sweep_robustness"]
# reconstruct approx distribution shape using summary stats only (median/p25/p75) as a box-ish view;
# also load raw grid top50 to compute the real histogram
with open("logs/grid_top50.json", "r", encoding="utf-8") as f:
    grid = json.load(f)
keys = list(grid.keys())
sets = {k: set(v) for k, v in grid.items()}
jvals = []
for i in range(len(keys)):
    for j in range(i + 1, len(keys)):
        a, b = sets[keys[i]], sets[keys[j]]
        inter = len(a & b); union = len(a | b)
        jvals.append(inter / union if union else 0.0)
ax.hist(jvals, bins=30, color="#2b6cb0", alpha=0.8)
ax.axvline(0.5, color="red", linestyle="--", label="P1 threshold 0.5")
ax.axvline(np.median(jvals), color="k", linestyle="-", label=f"median={np.median(jvals):.2f}")
ax.set_xlabel("Top-50 Jaccard similarity (630 setting pairs)")
ax.set_ylabel("count")
ax.set_title("Robustness across 36 settings")
ax.legend(fontsize=8)

# Panel 3: degree-null vs pipeline overlap
ax = axes[2]
ov = R["degree_null_vs_pipelines"]["top50_overlap_with_pipelines"]
names2 = list(ov.keys())
vals2 = [ov[n] for n in names2]
ax.bar(names2, vals2, color="#c05621")
ax.axhline(15, color="red", linestyle="--", label="P2 threshold (30% of 50)")
ax.set_ylabel("# genes shared with degree-null Top-50 (out of 50)")
ax.set_xticklabels(names2, rotation=20, ha="right", fontsize=8)
ax.set_title(f"Degree-null AUROC={R['degree_null_vs_pipelines']['degnull_auroc_mean']:.2f}")
ax.legend(fontsize=8)

plt.tight_layout()
plt.savefig("figs/summary.png", dpi=150)
print("wrote figs/summary.png")

run.sh

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
export OMP_NUM_THREADS=2

PY=~/joulebeat-evolve/outreach/env/bin/python

mkdir -p data figs logs

if [ ! -f data/edges.sif.gz ]; then
  curl -sL -o data/edges.sif.gz "https://github.com/hetio/hetionet/raw/main/hetnet/tsv/hetionet-v1.0-edges.sif.gz"
fi
if [ ! -f data/nodes.tsv ]; then
  curl -sL -o data/nodes.tsv "https://raw.githubusercontent.com/hetio/hetionet/main/hetnet/tsv/hetionet-v1.0-nodes.tsv"
fi

# 整轮 stdout/stderr 落盘,保证 logs/run.log 永远是当前这批数字的日志
{
  "$PY" kg_experiment.py --seeds 0 1 2 --out results.json
  "$PY" make_figs.py
} 2>&1 | tee logs/run.log
← 回到案例正文