{"metadata":{"kernelspec":{"display_name":"Python 3 (ipykernel)","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.12.12"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceType":"competition","sourceId":118765,"databundleVersionId":15231210},{"sourceType":"datasetVersion","sourceId":14604295,"datasetId":9328538,"databundleVersionId":15440074},{"sourceType":"datasetVersion","sourceId":14874339,"datasetId":9502242,"databundleVersionId":15736806},{"sourceType":"datasetVersion","sourceId":10855324,"datasetId":6742586,"databundleVersionId":11219268}],"dockerImageVersionId":31260,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"Reference\n\nhttps://www.kaggle.com/code/llkh0a/stanford-rna-3d-folding-part-2-protenix-tbm","metadata":{"execution":{"iopub.execute_input":"2026-02-16T19:38:36.011993Z","iopub.status.busy":"2026-02-16T19:38:36.011656Z","iopub.status.idle":"2026-02-16T19:38:36.016065Z","shell.execute_reply":"2026-02-16T19:38:36.015433Z","shell.execute_reply.started":"2026-02-16T19:38:36.011965Z"}}},{"cell_type":"code","source":"# !pip install /kaggle/input/datasets/ogurtsov/biopython/biopython-1.85-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# PRETRAIN NOTEBOOK (SINGLE CELL)\n# Create 3 different \"pretrained TBM packs\" + meta-ensemble weights\n# Output artifacts (saved to /kaggle/working):\n#   1) rna3d_tbm_pack_A.pkl\n#   2) rna3d_tbm_pack_B.pkl\n#   3) rna3d_tbm_pack_C.pkl\n#   4) rna3d_meta_ensemble.json\n#\n# What \"pretrained pack\" contains:\n#   - template_df (train+val sequences)\n#   - template_coord_map (train+val labels -> coords)\n#   - per-pack config (aligner params, thresholds, refinement params, rng seeds)\n#   - helper metadata\n#\n# Meta-ensembling here:\n#   - optional quick validation eval (subset) to estimate per-pack error\n#   - compute ensemble weights ~ inverse(error)\n#   - store weights in rna3d_meta_ensemble.json\n#\n# Later notebook usage:\n#   - load packs + meta_ensemble.json\n#   - for each test target: run TBM prediction for each pack\n#   - meta-ensemble coords (weighted average after per-pack refinement)\n#   - then write submission.csv\n# ============================================================\n\nimport os, gc, json, time, pickle, math\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nfrom Bio.Align import PairwiseAligner\nfrom tqdm import tqdm\n\n# -------------------------\n# Paths\n# -------------------------\nDATA_ROOT = os.environ.get(\"RNA_DATA_ROOT\", \"/kaggle/input/stanford-rna-3d-folding-2\")\nTRAIN_CSV = f\"{DATA_ROOT}/train_sequences.csv\"\nVAL_CSV   = f\"{DATA_ROOT}/validation_sequences.csv\"\nTRAIN_LBL = f\"{DATA_ROOT}/train_labels.csv\"\nVAL_LBL   = f\"{DATA_ROOT}/validation_labels.csv\"\n\nOUT_DIR = Path(os.environ.get(\"RNA_PRETRAIN_OUTDIR\", \"/kaggle/working\"))\nOUT_DIR.mkdir(parents=True, exist_ok=True)\n\nPACK_A_PATH = OUT_DIR / \"rna3d_tbm_pack_A.pkl\"\nPACK_B_PATH = OUT_DIR / \"rna3d_tbm_pack_B.pkl\"\nPACK_C_PATH = OUT_DIR / \"rna3d_tbm_pack_C.pkl\"\nMETA_PATH   = OUT_DIR / \"rna3d_meta_ensemble.json\"\n\n# -------------------------\n# Small prints\n# -------------------------\nprint(\"============================================================\")\nprint(\"PRETRAIN: building 3 TBM packs + meta-ensemble weights\")\nprint(\"DATA_ROOT:\", DATA_ROOT)\nprint(\"OUT_DIR  :\", str(OUT_DIR))\nprint(\"============================================================\")\n\n# -------------------------\n# Core helpers\n# -------------------------\ndef build_aligner(params: dict) -> PairwiseAligner:\n    al = PairwiseAligner()\n    al.mode = \"global\"\n    al.match_score = float(params.get(\"match_score\", 2.0))\n    al.mismatch_score = float(params.get(\"mismatch_score\", -1.5))\n    al.open_gap_score = float(params.get(\"open_gap_score\", -8.0))\n    al.extend_gap_score = float(params.get(\"extend_gap_score\", -0.4))\n\n    # keep symmetric gap settings unless overridden\n    al.query_left_open_gap_score = float(params.get(\"query_left_open_gap_score\", al.open_gap_score))\n    al.query_left_extend_gap_score = float(params.get(\"query_left_extend_gap_score\", al.extend_gap_score))\n    al.query_right_open_gap_score = float(params.get(\"query_right_open_gap_score\", al.open_gap_score))\n    al.query_right_extend_gap_score = float(params.get(\"query_right_extend_gap_score\", al.extend_gap_score))\n\n    al.target_left_open_gap_score = float(params.get(\"target_left_open_gap_score\", al.open_gap_score))\n    al.target_left_extend_gap_score = float(params.get(\"target_left_extend_gap_score\", al.extend_gap_score))\n    al.target_right_open_gap_score = float(params.get(\"target_right_open_gap_score\", al.open_gap_score))\n    al.target_right_extend_gap_score = float(params.get(\"target_right_extend_gap_score\", al.extend_gap_score))\n    return al\n\ndef labels_to_coord_map(labels_df: pd.DataFrame) -> dict:\n    coord_map = {}\n    prefixes = labels_df[\"ID\"].str.rsplit(\"_\", n=1).str[0]\n    for prefix, grp in labels_df.groupby(prefixes):\n        coord_map[prefix] = grp.sort_values(\"resid\")[[\"x_1\", \"y_1\", \"z_1\"]].values.astype(np.float32)\n    return coord_map\n\ndef aligned_strings(query_seq: str, template_seq: str, aln) -> tuple[str, str]:\n    q_segs, t_segs = aln.aligned\n    aq, at, qi, ti = [], [], 0, 0\n    for (qs, qe), (ts, te) in zip(q_segs, t_segs):\n        while qi < qs:\n            aq.append(query_seq[qi]); at.append(\"-\"); qi += 1\n        while ti < ts:\n            aq.append(\"-\"); at.append(template_seq[ti]); ti += 1\n        for qp, tp in zip(range(qs, qe), range(ts, te)):\n            aq.append(query_seq[qp]); at.append(template_seq[tp])\n        qi, ti = qe, te\n    while qi < len(query_seq):\n        aq.append(query_seq[qi]); at.append(\"-\"); qi += 1\n    while ti < len(template_seq):\n        aq.append(\"-\"); at.append(template_seq[ti]); ti += 1\n    return \"\".join(aq), \"\".join(at)\n\ndef top_templates(query_seq: str, template_df: pd.DataFrame, template_coord_map: dict, aligner: PairwiseAligner,\n                  top_n: int = 30, len_ratio_limit: float = 0.30):\n    results = []\n    for _, r in template_df.iterrows():\n        tid = r[\"target_id\"]\n        tseq = r[\"sequence\"]\n\n        if tid not in template_coord_map:\n            continue\n\n        if abs(len(tseq) - len(query_seq)) / max(len(tseq), len(query_seq)) > len_ratio_limit:\n            continue\n\n        aln = next(iter(aligner.align(query_seq, tseq)))\n        norm_s = aln.score / (2 * min(len(query_seq), len(tseq)))\n\n        identical = 0\n        for (qs, qe), (ts, te) in zip(*aln.aligned):\n            for qp, tp in zip(range(qs, qe), range(ts, te)):\n                if query_seq[qp] == tseq[tp]:\n                    identical += 1\n        pct_id = 100.0 * identical / max(1, len(query_seq))\n\n        aq, at = aligned_strings(query_seq, tseq, aln)\n        results.append((tid, tseq, float(norm_s), template_coord_map[tid], float(pct_id), aq, at))\n\n    results.sort(key=lambda x: x[2], reverse=True)\n    return results[:top_n]\n\ndef adapt_template(query_seq: str, template_seq: str, template_coords: np.ndarray, aligner: PairwiseAligner) -> np.ndarray:\n    aln = next(iter(aligner.align(query_seq, template_seq)))\n    out = np.full((len(query_seq), 3), np.nan, dtype=np.float32)\n\n    for (qs, qe), (ts, te) in zip(*aln.aligned):\n        chunk = template_coords[ts:te]\n        if len(chunk) == (qe - qs):\n            out[qs:qe] = chunk\n\n    for i in range(len(out)):\n        if np.isnan(out[i, 0]):\n            pv = next((j for j in range(i - 1, -1, -1) if not np.isnan(out[j, 0])), -1)\n            nv = next((j for j in range(i + 1, len(out)) if not np.isnan(out[j, 0])), -1)\n            if pv >= 0 and nv >= 0:\n                w = (i - pv) / (nv - pv)\n                out[i] = (1 - w) * out[pv] + w * out[nv]\n            elif pv >= 0:\n                out[i] = out[pv] + np.array([3.0, 0.0, 0.0], dtype=np.float32)\n            elif nv >= 0:\n                out[i] = out[nv] + np.array([3.0, 0.0, 0.0], dtype=np.float32)\n            else:\n                out[i] = np.array([i * 3.0, 0.0, 0.0], dtype=np.float32)\n\n    return np.nan_to_num(out)\n\ndef constraint_refine(coords: np.ndarray, segs: list, confidence: float, passes: int,\n                      bond_strength: float, d2_strength: float, lap_strength: float, avoid_strength: float) -> np.ndarray:\n    X = coords.copy()\n    strength = max(0.78 * (1.0 - min(confidence, 0.97)), 0.02)\n\n    for _ in range(passes):\n        for s, e in segs:\n            C = X[s:e]\n            L = e - s\n            if L < 3:\n                continue\n\n            d = C[1:] - C[:-1]\n            dist = np.linalg.norm(d, axis=1) + 1e-6\n            adj = d * ((5.95 - dist) / dist)[:, None] * (bond_strength * strength)\n            C[:-1] -= adj\n            C[1:] += adj\n\n            d2 = C[2:] - C[:-2]\n            d2n = np.linalg.norm(d2, axis=1) + 1e-6\n            adj2 = d2 * ((10.2 - d2n) / d2n)[:, None] * (d2_strength * strength)\n            C[:-2] -= adj2\n            C[2:] += adj2\n\n            C[1:-1] += (lap_strength * strength) * (0.5 * (C[:-2] + C[2:]) - C[1:-1])\n\n            if L >= 25 and avoid_strength > 0:\n                idx = np.linspace(0, L - 1, min(L, 160)).astype(int) if L > 220 else np.arange(L)\n                P = C[idx]\n                diff = P[:, None, :] - P[None, :, :]\n                dm = np.linalg.norm(diff, axis=2) + 1e-6\n                sep = np.abs(idx[:, None] - idx[None, :])\n                mask = (sep > 2) & (dm < 3.2)\n                if np.any(mask):\n                    vec = (diff * ((3.2 - dm) / dm)[:, :, None] * mask[:, :, None]).sum(axis=1)\n                    C[idx] += (avoid_strength * strength) * vec\n\n            X[s:e] = C\n    return X\n\ndef rotmat(axis, ang):\n    a = np.asarray(axis, float)\n    a /= np.linalg.norm(a) + 1e-12\n    x, y, z = a\n    c, s = np.cos(ang), np.sin(ang)\n    CC = 1 - c\n    return np.array([\n        [c + x*x*CC, x*y*CC - z*s, x*z*CC + y*s],\n        [y*x*CC + z*s, c + y*y*CC, y*z*CC - x*s],\n        [z*x*CC - y*s, z*y*CC + x*s, c + z*z*CC],\n    ], dtype=np.float32)\n\ndef apply_hinge(coords, seg, rng, deg=22):\n    s, e = seg\n    L = e - s\n    if L < 30:\n        return coords\n    pivot = s + int(rng.integers(10, L - 10))\n    R = rotmat(rng.normal(size=3), np.deg2rad(float(rng.uniform(-deg, deg))))\n    X = coords.copy()\n    p0 = X[pivot].copy()\n    X[pivot + 1:e] = (X[pivot + 1:e] - p0) @ R.T + p0\n    return X\n\ndef jitter_segs(coords, segs, rng, deg=12, trans=1.5):\n    X = coords.copy()\n    gc0 = X.mean(0, keepdims=True)\n    for s, e in segs:\n        R = rotmat(rng.normal(size=3), np.deg2rad(float(rng.uniform(-deg, deg))))\n        shift = rng.normal(size=3)\n        shift = shift / (np.linalg.norm(shift) + 1e-12) * float(rng.uniform(0, trans))\n        c = X[s:e].mean(0, keepdims=True)\n        X[s:e] = (X[s:e] - c) @ R.T + c + shift\n    X -= X.mean(0, keepdims=True) - gc0\n    return X\n\ndef smooth_wiggle(coords, segs, rng, amp=0.8):\n    X = coords.copy()\n    for s, e in segs:\n        L = e - s\n        if L < 20:\n            continue\n        ctrl = np.linspace(0, L - 1, 6)\n        disp = rng.normal(0, amp, (6, 3))\n        t = np.arange(L)\n        X[s:e] += np.vstack([np.interp(t, ctrl, disp[:, k]) for k in range(3)]).T\n    return X\n\n# ---- quick Kabsch alignment error for weight estimation ----\ndef kabsch_align(P, Q):\n    # align P -> Q\n    Pc = P - P.mean(axis=0, keepdims=True)\n    Qc = Q - Q.mean(axis=0, keepdims=True)\n    C = Pc.T @ Qc\n    V, S, Wt = np.linalg.svd(C)\n    d = np.sign(np.linalg.det(V @ Wt))\n    D = np.diag([1.0, 1.0, d])\n    R = V @ D @ Wt\n    t = Q.mean(axis=0) - (P.mean(axis=0) @ R)\n    return R, t\n\ndef rmsd_after_align(P, Q):\n    if P.shape != Q.shape or P.shape[0] < 3:\n        return float(\"inf\")\n    R, t = kabsch_align(P, Q)\n    P2 = (P @ R) + t\n    return float(np.sqrt(np.mean(np.sum((P2 - Q) ** 2, axis=1))))\n\n# -------------------------\n# Load data once\n# -------------------------\nprint(\"\\n[LOAD] reading train/val sequences + labels ...\")\ntrain_seqs = pd.read_csv(TRAIN_CSV)\nval_seqs   = pd.read_csv(VAL_CSV)\ntrain_lbl  = pd.read_csv(TRAIN_LBL)\nval_lbl    = pd.read_csv(VAL_LBL)\n\ntemplate_df = pd.concat([train_seqs, val_seqs], ignore_index=True)\nlabels_df   = pd.concat([train_lbl, val_lbl], ignore_index=True)\n\ntemplate_coord_map = labels_to_coord_map(labels_df)\n\nprint(\"[LOAD] template_df:\", template_df.shape, \"| coord_map:\", len(template_coord_map))\nprint(\"[LOAD] examples:\", template_df.head(2).to_dict(\"records\"))\n\n# -------------------------\n# Define 3 \"pretrained TBM packs\"\n# -------------------------\n# Pack A: balanced (close to your original)\nPACK_A = {\n    \"pack_name\": \"A_balanced\",\n    \"aligner\": {\"match_score\": 2.0, \"mismatch_score\": -1.5, \"open_gap_score\": -8.0, \"extend_gap_score\": -0.4},\n    \"tbm\": {\"top_n\": 30, \"len_ratio_limit\": 0.30, \"min_similarity\": 0.00, \"min_pct_identity\": 50.0},\n    \"refine\": {\"passes\": 2, \"bond_strength\": 0.22, \"d2_strength\": 0.10, \"lap_strength\": 0.06, \"avoid_strength\": 0.015},\n    \"diversity\": {\"slot1_noise_base\": 0.01, \"slot1_noise_scale\": 0.06, \"hinge_deg\": 22.0, \"jitter_deg\": 12.0, \"jitter_trans\": 1.5, \"wiggle_amp\": 0.8},\n}\n\n# Pack B: stricter templates + stronger refinement (less noise)\nPACK_B = {\n    \"pack_name\": \"B_strict_refine\",\n    \"aligner\": {\"match_score\": 2.0, \"mismatch_score\": -1.2, \"open_gap_score\": -9.0, \"extend_gap_score\": -0.5},\n    \"tbm\": {\"top_n\": 40, \"len_ratio_limit\": 0.25, \"min_similarity\": 0.05, \"min_pct_identity\": 60.0},\n    \"refine\": {\"passes\": 3, \"bond_strength\": 0.26, \"d2_strength\": 0.12, \"lap_strength\": 0.07, \"avoid_strength\": 0.018},\n    \"diversity\": {\"slot1_noise_base\": 0.005, \"slot1_noise_scale\": 0.04, \"hinge_deg\": 16.0, \"jitter_deg\": 9.0, \"jitter_trans\": 1.0, \"wiggle_amp\": 0.5},\n}\n\n# Pack C: more permissive templates + more diversity (useful for hard targets)\nPACK_C = {\n    \"pack_name\": \"C_diverse\",\n    \"aligner\": {\"match_score\": 2.0, \"mismatch_score\": -1.8, \"open_gap_score\": -7.0, \"extend_gap_score\": -0.35},\n    \"tbm\": {\"top_n\": 60, \"len_ratio_limit\": 0.35, \"min_similarity\": -0.01, \"min_pct_identity\": 45.0},\n    \"refine\": {\"passes\": 2, \"bond_strength\": 0.20, \"d2_strength\": 0.09, \"lap_strength\": 0.055, \"avoid_strength\": 0.016},\n    \"diversity\": {\"slot1_noise_base\": 0.015, \"slot1_noise_scale\": 0.08, \"hinge_deg\": 28.0, \"jitter_deg\": 14.0, \"jitter_trans\": 2.0, \"wiggle_amp\": 1.1},\n}\n\nPACKS = [PACK_A, PACK_B, PACK_C]\n\n# -------------------------\n# Build and save packs\n# -------------------------\ndef save_pack(pack_cfg: dict, out_path: Path):\n    obj = {\n        \"pack_cfg\": pack_cfg,\n        \"created_utc\": time.strftime(\"%Y-%m-%d %H:%M:%S\", time.gmtime()),\n        \"template_df\": template_df,                # sequences\n        \"template_coord_map\": template_coord_map,  # labels -> coords\n        \"notes\": \"TBM-only pretrained pack for later submission notebook.\",\n    }\n    with open(out_path, \"wb\") as f:\n        pickle.dump(obj, f, protocol=pickle.HIGHEST_PROTOCOL)\n\nprint(\"\\n[SAVE] writing packs ...\")\nsave_pack(PACK_A, PACK_A_PATH)\nprint(\"  saved:\", str(PACK_A_PATH))\nsave_pack(PACK_B, PACK_B_PATH)\nprint(\"  saved:\", str(PACK_B_PATH))\nsave_pack(PACK_C, PACK_C_PATH)\nprint(\"  saved:\", str(PACK_C_PATH))\n\n# -------------------------\n# Optional: quick validation estimate for weights\n# -------------------------\n# This is NOT heavy training; it's a small sanity evaluation on a subset of validation targets.\n# You can increase PRETRAIN_VAL_EVAL_N to use more validation targets.\nPRETRAIN_VAL_EVAL_N = int(os.environ.get(\"PRETRAIN_VAL_EVAL_N\", \"200\"))  # keep safe by default\nPRETRAIN_VAL_TOPK   = int(os.environ.get(\"PRETRAIN_VAL_TOPK\", \"25\"))     # per target template scan top-k (still scans all templates; keep N moderate)\n\nval_eval_df = val_seqs.copy()\nif PRETRAIN_VAL_EVAL_N > 0 and PRETRAIN_VAL_EVAL_N < len(val_eval_df):\n    val_eval_df = val_eval_df.sample(PRETRAIN_VAL_EVAL_N, random_state=7).reset_index(drop=True)\n\n# segments: validation rows usually single chain\nval_segs_map = {r[\"target_id\"]: [(0, len(r[\"sequence\"]))] for _, r in val_eval_df.iterrows()}\n\ndef predict_one_pack_on_seq(pack_cfg: dict, qid: str, qseq: str) -> np.ndarray:\n    al = build_aligner(pack_cfg[\"aligner\"])\n    tbm = pack_cfg[\"tbm\"]\n    ref = pack_cfg[\"refine\"]\n    div = pack_cfg[\"diversity\"]\n\n    # find candidates\n    cands = top_templates(\n        qseq, template_df, template_coord_map, al,\n        top_n=int(tbm[\"top_n\"]), len_ratio_limit=float(tbm[\"len_ratio_limit\"])\n    )\n\n    preds = []\n    used = set()\n    segs = val_segs_map.get(qid, [(0, len(qseq))])\n\n    for i, (tid, tseq, sim, tcoords, pct_id, _, _) in enumerate(cands):\n        if len(preds) >= 1:  # for weight estimation, 1 sample is enough\n            break\n        if sim < float(tbm[\"min_similarity\"]) or pct_id < float(tbm[\"min_pct_identity\"]):\n            break\n        if tid in used:\n            continue\n\n        rng = np.random.default_rng((hash(qid) + i * 10007) % (2**32))\n        adapted = adapt_template(qseq, tseq, tcoords, al)\n\n        # apply a small deterministic diversity for stability\n        X = adapted.copy()\n        X = X + rng.normal(0, float(div[\"slot1_noise_base\"]), X.shape)\n\n        refined = constraint_refine(\n            X, segs=segs, confidence=float(sim), passes=int(ref[\"passes\"]),\n            bond_strength=float(ref[\"bond_strength\"]), d2_strength=float(ref[\"d2_strength\"]),\n            lap_strength=float(ref[\"lap_strength\"]), avoid_strength=float(ref[\"avoid_strength\"]),\n        )\n        preds.append(refined)\n        used.add(tid)\n\n    if len(preds) == 0:\n        # fallback: simple helix\n        n = len(qseq)\n        coords = np.zeros((n, 3), dtype=np.float32)\n        for i in range(n):\n            ang = i * 0.6\n            coords[i] = [10.0 * np.cos(ang), 10.0 * np.sin(ang), i * 2.5]\n        return coords\n\n    return preds[0]\n\nprint(\"\\n[EVAL] quick validation to estimate pack weights ...\")\nprint(\"  PRETRAIN_VAL_EVAL_N:\", len(val_eval_df))\nprint(\"  This scans templates; if slow, reduce PRETRAIN_VAL_EVAL_N.\")\n\npack_errors = []\nfor pack_cfg in PACKS:\n    pack_name = pack_cfg[\"pack_name\"]\n    print(\"\\n  -> evaluating pack:\", pack_name)\n    errs = []\n    for _, r in tqdm(val_eval_df.iterrows(), total=len(val_eval_df), desc=f\"eval_{pack_name}\"):\n        qid = r[\"target_id\"]\n        qseq = r[\"sequence\"]\n        if qid not in template_coord_map:\n            continue\n        gt = template_coord_map[qid]  # validation labels exist in coord_map\n        pr = predict_one_pack_on_seq(pack_cfg, qid, qseq)\n\n        # align and compute RMSD\n        L = min(len(gt), len(pr))\n        e = rmsd_after_align(pr[:L], gt[:L])\n        if np.isfinite(e):\n            errs.append(e)\n\n    mean_err = float(np.mean(errs)) if len(errs) else float(\"inf\")\n    print(f\"    mean_RMSD_align = {mean_err:.4f}  (n={len(errs)})\")\n    pack_errors.append((pack_name, mean_err))\n\n# weights ~ inverse error (with floor)\neps = 1e-6\nraw_w = []\nfor name, err in pack_errors:\n    if not np.isfinite(err) or err <= 0:\n        w = 0.0\n    else:\n        w = 1.0 / (err + eps)\n    raw_w.append((name, w))\n\ns = sum(w for _, w in raw_w)\nif s <= 0:\n    weights = {name: 1.0 / len(raw_w) for name, _ in raw_w}\nelse:\n    weights = {name: float(w / s) for name, w in raw_w}\n\nmeta = {\n    \"created_utc\": time.strftime(\"%Y-%m-%d %H:%M:%S\", time.gmtime()),\n    \"eval_n\": int(len(val_eval_df)),\n    \"pack_errors_mean_rmsd_aligned\": {name: float(err) for name, err in pack_errors},\n    \"ensemble_weights\": weights,\n    \"packs\": {\n        \"A\": str(PACK_A_PATH.name),\n        \"B\": str(PACK_B_PATH.name),\n        \"C\": str(PACK_C_PATH.name),\n    },\n    \"notes\": \"Weights computed from quick validation aligned-RMSD on subset. Later notebook should load packs + use these weights.\",\n}\n\nwith open(META_PATH, \"w\", encoding=\"utf-8\") as f:\n    json.dump(meta, f, indent=2)\n\nprint(\"\\n[SAVE] meta ensemble ->\", str(META_PATH))\nprint(\"Weights:\", meta[\"ensemble_weights\"])\nprint(\"Done.\")","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}