{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"f967dc2b","cell_type":"markdown","source":"# Phase 2 — Branch A: Repertoire Composition (V/J usage, diversity, k-mer, LR/XGB/LGB/CatBoost)\n\nKeeps the tabular feature-engineering pipeline as Branch A: V/J usage, length/diversity/clonality, abundance stats, k-mer/positional motifs, public-clone burden, and approximate/shared-sequence overlap features. Models: LR, XGB, LGB, CatBoost. Outputs branch_a_oof.csv for fusion.\n\n---\n\n## Kaggle inputs to add before running this notebook\n\nAdd the following as Kaggle inputs (via the \"Add Input\" button on the right\npanel of the notebook editor):\n- AIRR-ML competition dataset\n- Phase 1 notebook output (project/artifacts/phase1/)\n\n## Workflow\n\n1. Run the **SETUP** cell — it creates `/kaggle/working/project/` and auto-merges\n   any previous-phase notebook outputs found under `/kaggle/input/`.\n2. Run each subsequent cell in order. The **Run** cell executes the phase's\n   training script; the **Inspect** cell prints a quick summary of the outputs.\n3. When the run completes, click **Save Version → Save & Run All (Commit)** so\n   the next phase can pick this phase's outputs up via \"Add Input\".\n","metadata":{}},{"id":"26e2c858","cell_type":"code","source":"# ============================================================\n# SETUP — Initialize project + merge previous-phase inputs\n# ============================================================\n# This cell:\n#   1. Creates /kaggle/working/project/ fresh (idempotent re-runs).\n#   2. Auto-detects previous-phase notebook outputs under /kaggle/input/.\n#   3. Merges their project/ contents (src/, artifacts/, configs/) into\n#      /kaggle/working/project/ so this phase can build on them.\n#\n# Kaggle \"Add Input\" workflow:\n#   - Phase 1: add the AIRR-ML competition dataset only.\n#   - Phase N (N>=2): add the AIRR-ML competition dataset AND the previous\n#     phase notebook output(s). For Phase 9 add ALL of Phases 1..8.\n# ============================================================\n\nimport os\nimport shutil\nfrom pathlib import Path\n\nPROJECT_ROOT = Path(\"/kaggle/working/project\")\n\n# Reset working project dir (idempotent re-runs)\nif PROJECT_ROOT.exists():\n    shutil.rmtree(PROJECT_ROOT)\nPROJECT_ROOT.mkdir(parents=True, exist_ok=True)\n(PROJECT_ROOT / \"src\").mkdir(parents=True, exist_ok=True)\n(PROJECT_ROOT / \"src\" / \"__init__.py\").write_text(\"\", encoding=\"utf-8\")\n(PROJECT_ROOT / \"configs\").mkdir(parents=True, exist_ok=True)\n(PROJECT_ROOT / \"artifacts\").mkdir(parents=True, exist_ok=True)\n\n# Auto-detect and merge previous-phase inputs.\n# Each previous-phase notebook output should contain a top-level `project/`\n# directory (created by Phase 1 and propagated through every later phase).\nprev_inputs_found = []\ninput_root = Path(\"/kaggle/input\")\nif input_root.exists():\n    for entry in sorted(input_root.iterdir()):\n        if not entry.is_dir():\n            continue\n        prev_project = entry / \"project\"\n        if not prev_project.is_dir():\n            continue\n        prev_inputs_found.append(entry.name)\n        for sub in [\"src\", \"artifacts\", \"configs\"]:\n            src_dir = prev_project / sub\n            if not src_dir.exists():\n                continue\n            for path in src_dir.rglob(\"*\"):\n                if path.is_file():\n                    rel = path.relative_to(src_dir)\n                    target = PROJECT_ROOT / sub / rel\n                    target.parent.mkdir(parents=True, exist_ok=True)\n                    shutil.copy2(path, target)\n\nprint(\"PROJECT_ROOT :\", PROJECT_ROOT)\nif prev_inputs_found:\n    print(\"Previous-phase inputs merged:\")\n    for name in prev_inputs_found:\n        print(\"  -\", name)\nelse:\n    print(\"Previous-phase inputs: (none — running fresh)\")\nprint(\"\\nExisting artifacts:\")\nart_dir = PROJECT_ROOT / \"artifacts\"\nif art_dir.exists():\n    for p in sorted(art_dir.glob(\"*\")):\n        print(\"  -\", p.name)\nelse:\n    print(\"  (none)\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-24T09:28:01.770341Z","iopub.execute_input":"2026-07-24T09:28:01.770698Z","iopub.status.idle":"2026-07-24T09:28:01.783063Z","shell.execute_reply.started":"2026-07-24T09:28:01.770669Z","shell.execute_reply":"2026-07-24T09:28:01.782154Z"}},"outputs":[],"execution_count":null},{"id":"031e9a22","cell_type":"code","source":"# Cell 1 — branch_a.yaml write করো\nfrom pathlib import Path\n\nPROJECT_ROOT = Path(\"/kaggle/working/project\")\n\nbranch_a_yaml = \"\"\"\nenabled: true\n\npaths:\n  output_root: /kaggle/working/project/artifacts/phase2_branch_a\n\nfeatures:\n  k_list: [3, 4]\n  top_kmers_per_k:\n    3: 300\n    4: 450\n  top_pos_motifs: 20\n  max_sequences_per_file: 50000\n\npublic_clone:\n  max_files: 20\n  min_freq: 0.18\n  enrichment: 6.0\n  top_n_default: 2500\n\ntraining:\n  seeds: [42, 52, 62]\n  top_features: 650\n\"\"\"\n\n(PROJECT_ROOT / \"configs\" / \"branch_a.yaml\").write_text(branch_a_yaml.strip() + \"\\n\", encoding=\"utf-8\")\nprint(\"Written:\", PROJECT_ROOT / \"configs\" / \"branch_a.yaml\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-24T09:28:01.784508Z","iopub.execute_input":"2026-07-24T09:28:01.784839Z","iopub.status.idle":"2026-07-24T09:28:01.796644Z","shell.execute_reply.started":"2026-07-24T09:28:01.784817Z","shell.execute_reply":"2026-07-24T09:28:01.795846Z"}},"outputs":[],"execution_count":null},{"id":"87574edf","cell_type":"code","source":"#Cell 2 — src/branch_a_features.py, src/branch_a_models.py, train_branch_a.py write করো\nfrom pathlib import Path\nfrom textwrap import dedent\n\nPROJECT_ROOT = Path(\"/kaggle/working/project\")\n\nbranch_a_features_py = dedent(\"\"\"\nfrom __future__ import annotations\n\nimport math\nfrom collections import Counter\nfrom pathlib import Path\nfrom typing import Dict, Optional, Tuple\n\nimport numpy as np\nimport pandas as pd\n\nAA_PROPERTIES = {\n    \"A\": {\"hydro\": 1.8, \"vol\": 88.6, \"charge\": 0.0, \"polar\": 0},\n    \"R\": {\"hydro\": -4.5, \"vol\": 173.4, \"charge\": 1.0, \"polar\": 1},\n    \"N\": {\"hydro\": -3.5, \"vol\": 114.1, \"charge\": 0.0, \"polar\": 1},\n    \"D\": {\"hydro\": -3.5, \"vol\": 111.1, \"charge\": -1.0, \"polar\": 1},\n    \"C\": {\"hydro\": 2.5, \"vol\": 108.5, \"charge\": 0.0, \"polar\": 0},\n    \"Q\": {\"hydro\": -3.5, \"vol\": 143.8, \"charge\": 0.0, \"polar\": 1},\n    \"E\": {\"hydro\": -3.5, \"vol\": 138.4, \"charge\": -1.0, \"polar\": 1},\n    \"G\": {\"hydro\": -0.4, \"vol\": 60.1, \"charge\": 0.0, \"polar\": 0},\n    \"H\": {\"hydro\": -3.2, \"vol\": 153.2, \"charge\": 0.5, \"polar\": 1},\n    \"I\": {\"hydro\": 4.5, \"vol\": 166.7, \"charge\": 0.0, \"polar\": 0},\n    \"L\": {\"hydro\": 3.8, \"vol\": 166.7, \"charge\": 0.0, \"polar\": 0},\n    \"K\": {\"hydro\": -3.9, \"vol\": 168.6, \"charge\": 1.0, \"polar\": 1},\n    \"M\": {\"hydro\": 1.9, \"vol\": 162.9, \"charge\": 0.0, \"polar\": 0},\n    \"F\": {\"hydro\": 2.8, \"vol\": 189.9, \"charge\": 0.0, \"polar\": 0},\n    \"P\": {\"hydro\": -1.6, \"vol\": 112.7, \"charge\": 0.0, \"polar\": 0},\n    \"S\": {\"hydro\": -0.8, \"vol\": 89.0, \"charge\": 0.0, \"polar\": 1},\n    \"T\": {\"hydro\": -0.7, \"vol\": 116.1, \"charge\": 0.0, \"polar\": 1},\n    \"W\": {\"hydro\": -0.9, \"vol\": 227.8, \"charge\": 0.0, \"polar\": 0},\n    \"Y\": {\"hydro\": -1.3, \"vol\": 193.6, \"charge\": 0.0, \"polar\": 1},\n    \"V\": {\"hydro\": 4.2, \"vol\": 140.0, \"charge\": 0.0, \"polar\": 0},\n}\nAA_LIST = sorted(list(AA_PROPERTIES.keys()))\nAROMATIC = set([\"F\", \"W\", \"Y\"])\nSMALL = set([\"A\", \"G\", \"S\", \"T\", \"P\"])\nHYDROPHOBIC = set([\"A\", \"V\", \"I\", \"L\", \"M\", \"F\", \"W\", \"Y\", \"C\"])\nPOLAR = set([aa for aa, p in AA_PROPERTIES.items() if p[\"polar\"] == 1])\n\n\ndef weighted_quantile(values, weights, q):\n    values = np.asarray(values, dtype=float)\n    weights = np.asarray(weights, dtype=float)\n    if len(values) == 0:\n        return np.nan\n    sorter = np.argsort(values)\n    values = values[sorter]\n    weights = weights[sorter]\n    total = np.sum(weights)\n    if total <= 0:\n        return float(np.quantile(values, q))\n    cdf = np.cumsum(weights) / total\n    return float(np.interp(q, cdf, values))\n\n\ndef read_repertoire(tsv_path: Path, max_seqs: Optional[int] = None, random_state: int = 42) -> pd.DataFrame:\n    cols = [\"junction_aa\", \"v_call\", \"j_call\", \"templates\"]\n    try:\n        header = pd.read_csv(tsv_path, sep=\"\\\\t\", nrows=0)\n        usecols = [c for c in cols if c in header.columns]\n        df = pd.read_csv(tsv_path, sep=\"\\\\t\", usecols=usecols)\n    except Exception:\n        return pd.DataFrame(columns=cols)\n\n    for c in cols:\n        if c not in df.columns:\n            df[c] = \"\" if c != \"templates\" else 1.0\n\n    df[\"junction_aa\"] = df[\"junction_aa\"].fillna(\"\").astype(str)\n    df[\"templates\"] = pd.to_numeric(df[\"templates\"], errors=\"coerce\").fillna(1.0).clip(lower=1.0)\n\n    if max_seqs and len(df) > max_seqs:\n        rng = np.random.RandomState(random_state)\n        weights = df[\"templates\"].values.astype(float)\n        s = weights.sum()\n        if s <= 0:\n            idx = rng.choice(len(df), size=max_seqs, replace=False)\n        else:\n            weights = weights / s\n            idx = rng.choice(len(df), size=max_seqs, replace=False, p=weights)\n        df = df.iloc[idx].reset_index(drop=True)\n\n    return df\n\n\ndef sequence_signature(seq: str) -> str:\n    if not seq:\n        return \"UNK\"\n    return f\"{len(seq)}|{seq[:3]}|{seq[-3:]}\"\n\n\ndef counter_from_df(df: pd.DataFrame) -> Tuple[Counter, Counter]:\n    seq_counter = Counter()\n    sig_counter = Counter()\n    if df is None or len(df) == 0:\n        return seq_counter, sig_counter\n\n    seqs = df[\"junction_aa\"].fillna(\"\").astype(str).tolist()\n    tmpls = pd.to_numeric(df[\"templates\"], errors=\"coerce\").fillna(1.0).clip(lower=1.0).tolist()\n\n    for s, w in zip(seqs, tmpls):\n        if not s:\n            continue\n        seq_counter[s] += float(w)\n        sig_counter[sequence_signature(s)] += float(w)\n    return seq_counter, sig_counter\n\n\ndef build_public_clone_bundle(\n    fold_train_meta: pd.DataFrame,\n    dataset_path: Path,\n    max_files: int = 20,\n    min_freq: float = 0.18,\n    enrichment: float = 6.0,\n    top_n: int = 2000,\n    max_seqs_per_file: int = 60000,\n    random_state: int = 42,\n) -> Dict[str, Dict]:\n    meta = fold_train_meta.copy()\n    y = pd.to_numeric(meta[\"label_positive\"], errors=\"coerce\").fillna(0).astype(int)\n\n    pos_rows = meta.loc[y == 1].head(max_files)\n    neg_rows = meta.loc[y == 0].head(max_files)\n\n    def collect(rows):\n        exact_counts = Counter()\n        approx_counts = Counter()\n        for _, row in rows.iterrows():\n            fn = row[\"filename\"]\n            df = read_repertoire(dataset_path / fn, max_seqs=max_seqs_per_file, random_state=random_state)\n            seq_counter, sig_counter = counter_from_df(df)\n            exact_counts.update(seq_counter.keys())   # unique presence per repertoire\n            approx_counts.update(sig_counter.keys())\n        return exact_counts, approx_counts\n\n    pos_exact, pos_approx = collect(pos_rows)\n    neg_exact, neg_approx = collect(neg_rows)\n\n    n_pos = max(1, len(pos_rows))\n    n_neg = max(1, len(neg_rows))\n\n    exact_scored = []\n    for seq, count in pos_exact.items():\n        pf = count / n_pos\n        nf = neg_exact.get(seq, 0) / n_neg\n        if pf >= min_freq and pf > nf * enrichment:\n            score = float(np.log((pf + 1e-6) / (nf + 1e-6)))\n            exact_scored.append({\"key\": seq, \"score\": score})\n\n    approx_scored = []\n    for sig, count in pos_approx.items():\n        pf = count / n_pos\n        nf = neg_approx.get(sig, 0) / n_neg\n        if pf >= min_freq and pf > nf * enrichment:\n            score = float(np.log((pf + 1e-6) / (nf + 1e-6)))\n            approx_scored.append({\"key\": sig, \"score\": score})\n\n    exact_scored = sorted(exact_scored, key=lambda x: -x[\"score\"])[:top_n]\n    approx_scored = sorted(approx_scored, key=lambda x: -x[\"score\"])[:top_n]\n\n    return {\n        \"exact\": {x[\"key\"]: x for x in exact_scored},\n        \"approx\": {x[\"key\"]: x for x in approx_scored},\n    }\n\n\nclass FeatureExtractor:\n    def __init__(self, k_list=None, top_kmers_per_k=None, top_pos_motifs: int = 25):\n        self.k_list = k_list or [3, 4]\n        self.top_kmers_per_k = top_kmers_per_k or {3: 400, 4: 600}\n        self.top_pos_motifs = top_pos_motifs\n\n    @staticmethod\n    def gene_family(gene_call: str) -> str:\n        if not isinstance(gene_call, str) or not gene_call:\n            return \"UNK\"\n        return gene_call.split(\"*\")[0].split(\"-\")[0].upper() or \"UNK\"\n\n    def _clone_summary(self, unique_clone_counter: Counter, total_weight: float, features: Dict[str, float]):\n        clone_weights = np.array(list(unique_clone_counter.values()), dtype=np.float32)\n        if len(clone_weights) == 0:\n            return\n\n        clone_probs = clone_weights / max(1e-9, clone_weights.sum())\n        features[\"clone_top1_frac\"] = float(clone_probs.max())\n        features[\"clone_top10_frac\"] = float(np.sort(clone_probs)[::-1][:10].sum())\n        features[\"clone_shannon\"] = float(-(clone_probs * np.log(clone_probs + 1e-12)).sum())\n        features[\"clone_simpson\"] = float(1.0 - np.sum(clone_probs ** 2))\n\n        sorted_w = np.sort(clone_weights)\n        n = len(sorted_w)\n        gini = (2 * np.sum((np.arange(1, n + 1) * sorted_w)) / (n * np.sum(sorted_w))) - ((n + 1) / n)\n        features[\"clone_gini\"] = float(gini)\n        features[\"hill_q0\"] = float(len(clone_weights))\n        features[\"hill_q1\"] = float(np.exp(features[\"clone_shannon\"]))\n        features[\"hill_q2\"] = float(1.0 / max(1e-9, np.sum(clone_probs ** 2)))\n\n        rare_thr = np.quantile(clone_weights, 0.10)\n        features[\"rare_clone_burden\"] = float(clone_weights[clone_weights <= rare_thr].sum() / max(1e-9, total_weight))\n        features[\"singleton_like_burden\"] = float(np.sum(clone_weights <= 1.0) / max(1, len(clone_weights)))\n        features[\"clone_count_log\"] = float(np.log1p(len(clone_weights)))\n\n    def _aa_physchem(self, seqs, weights, features):\n        aa_counter = Counter()\n        total_aa_weight = 0.0\n\n        hydro_vals, vol_vals, charge_vals, hydro_w = [], [], [], []\n        aromatic_num = small_num = hydrophobic_num = polar_num = cysteine_num = glycine_num = proline_num = 0.0\n\n        for seq, w in zip(seqs, weights):\n            valid_props = []\n            for aa in seq:\n                if aa in AA_PROPERTIES:\n                    aa_counter[aa] += w\n                    total_aa_weight += w\n                    valid_props.append(AA_PROPERTIES[aa])\n                    if aa in AROMATIC:\n                        aromatic_num += w\n                    if aa in SMALL:\n                        small_num += w\n                    if aa in HYDROPHOBIC:\n                        hydrophobic_num += w\n                    if aa in POLAR:\n                        polar_num += w\n                    if aa == \"C\":\n                        cysteine_num += w\n                    if aa == \"G\":\n                        glycine_num += w\n                    if aa == \"P\":\n                        proline_num += w\n\n            if len(valid_props) > 0:\n                hydro_vals.append(np.mean([p[\"hydro\"] for p in valid_props]))\n                vol_vals.append(np.mean([p[\"vol\"] for p in valid_props]))\n                charge_vals.append(np.mean([p[\"charge\"] for p in valid_props]))\n                hydro_w.append(w)\n\n        if total_aa_weight > 0:\n            for aa in AA_LIST:\n                features[f\"aa_frac_{aa}\"] = float(aa_counter.get(aa, 0.0) / total_aa_weight)\n            features[\"aa_aromatic_frac\"] = float(aromatic_num / total_aa_weight)\n            features[\"aa_small_frac\"] = float(small_num / total_aa_weight)\n            features[\"aa_hydrophobic_frac\"] = float(hydrophobic_num / total_aa_weight)\n            features[\"aa_polar_frac\"] = float(polar_num / total_aa_weight)\n            features[\"aa_cysteine_frac\"] = float(cysteine_num / total_aa_weight)\n            features[\"aa_glycine_frac\"] = float(glycine_num / total_aa_weight)\n            features[\"aa_proline_frac\"] = float(proline_num / total_aa_weight)\n\n        if len(hydro_vals) > 0:\n            hydro_w = np.asarray(hydro_w, dtype=np.float32)\n            features[\"phys_hydro_mean\"] = float(np.average(np.array(hydro_vals), weights=hydro_w))\n            features[\"phys_vol_mean\"] = float(np.average(np.array(vol_vals), weights=hydro_w))\n            features[\"phys_charge_mean\"] = float(np.average(np.array(charge_vals), weights=hydro_w))\n            features[\"phys_charge_abs_mean\"] = float(np.average(np.abs(np.array(charge_vals)), weights=hydro_w))\n\n    def _kmer_features(self, seqs, weights, features):\n        for k in self.k_list:\n            km_counter = Counter()\n            total_k = 0.0\n            for seq, w in zip(seqs, weights):\n                if len(seq) < k:\n                    continue\n                for i in range(len(seq) - k + 1):\n                    km = seq[i:i + k]\n                    if all(ch in AA_PROPERTIES for ch in km):\n                        km_counter[km] += w\n                        total_k += w\n            if total_k > 0:\n                keep_n = self.top_kmers_per_k.get(k, None)\n                items = km_counter.most_common(keep_n) if keep_n is not None else km_counter.items()\n                for km, cnt in items:\n                    features[f\"kmer_{k}_{km}\"] = float(cnt / total_k)\n\n    def _positional_features(self, seqs, weights, features):\n        pos_k = 3\n        start_counter, end_counter, prefix1, suffix1 = Counter(), Counter(), Counter(), Counter()\n        total_pos = 0.0\n        length_buckets = {\n            \"short\": [],\n            \"medium\": [],\n            \"long\": [],\n        }\n\n        for seq, w in zip(seqs, weights):\n            L = len(seq)\n            if L <= 13:\n                length_buckets[\"short\"].append((seq, w))\n            elif L <= 17:\n                length_buckets[\"medium\"].append((seq, w))\n            else:\n                length_buckets[\"long\"].append((seq, w))\n\n            if len(seq) >= pos_k:\n                s0 = seq[:pos_k]\n                s1 = seq[-pos_k:]\n                if all(ch in AA_PROPERTIES for ch in s0):\n                    start_counter[s0] += w\n                if all(ch in AA_PROPERTIES for ch in s1):\n                    end_counter[s1] += w\n                total_pos += w\n\n            if len(seq) >= 1:\n                a0, a1 = seq[0], seq[-1]\n                if a0 in AA_PROPERTIES:\n                    prefix1[a0] += w\n                if a1 in AA_PROPERTIES:\n                    suffix1[a1] += w\n\n        if total_pos > 0:\n            for motif, cnt in start_counter.most_common(self.top_pos_motifs):\n                features[f\"pos_start3_{motif}\"] = float(cnt / total_pos)\n            for motif, cnt in end_counter.most_common(self.top_pos_motifs):\n                features[f\"pos_end3_{motif}\"] = float(cnt / total_pos)\n            for aa, cnt in prefix1.items():\n                features[f\"pos_first_{aa}\"] = float(cnt / total_pos)\n            for aa, cnt in suffix1.items():\n                features[f\"pos_last_{aa}\"] = float(cnt / total_pos)\n\n        total_w = float(np.sum(weights))\n        if total_w > 0:\n            for bucket, pairs in length_buckets.items():\n                bw = float(sum(w for _, w in pairs))\n                features[f\"lb_{bucket}_frac\"] = bw / total_w\n                if bw > 0:\n                    bc_start = Counter()\n                    bc_end = Counter()\n                    for seq, w in pairs:\n                        if len(seq) >= 3:\n                            bc_start[seq[:3]] += w\n                            bc_end[seq[-3:]] += w\n                    for motif, cnt in bc_start.most_common(5):\n                        features[f\"lb_{bucket}_start3_{motif}\"] = float(cnt / bw)\n                    for motif, cnt in bc_end.most_common(5):\n                        features[f\"lb_{bucket}_end3_{motif}\"] = float(cnt / bw)\n\n    def _gene_usage(self, df, weights, total_weight, features):\n        if \"v_call\" in df.columns:\n            vf_counter = Counter()\n            for fam, w in zip(df[\"v_call\"].fillna(\"\").astype(str).apply(self.gene_family), weights):\n                vf_counter[fam] += float(w)\n            for fam, cnt in vf_counter.most_common(25):\n                features[f\"v_fam_{fam}\"] = float(cnt / total_weight)\n\n        if \"j_call\" in df.columns:\n            jf_counter = Counter()\n            for fam, w in zip(df[\"j_call\"].fillna(\"\").astype(str).apply(self.gene_family), weights):\n                jf_counter[fam] += float(w)\n            for fam, cnt in jf_counter.most_common(15):\n                features[f\"j_fam_{fam}\"] = float(cnt / total_weight)\n\n        if (\"v_call\" in df.columns) and (\"j_call\" in df.columns):\n            vj_counter = Counter()\n            v_series = df[\"v_call\"].fillna(\"\").astype(str).apply(self.gene_family)\n            j_series = df[\"j_call\"].fillna(\"\").astype(str).apply(self.gene_family)\n            for v, j, w in zip(v_series, j_series, weights):\n                vj_counter[f\"{v}|{j}\"] += float(w)\n            for pair, cnt in vj_counter.most_common(30):\n                features[f\"vj_pair_{pair}\"] = float(cnt / total_weight)\n\n    def _template_stats(self, weights, features):\n        tmpl = np.asarray(weights, dtype=float)\n        if len(tmpl) == 0:\n            return\n        features[\"tmpl_mean\"] = float(np.mean(tmpl))\n        features[\"tmpl_std\"] = float(np.std(tmpl))\n        features[\"tmpl_median\"] = float(np.median(tmpl))\n        features[\"tmpl_q90\"] = float(np.quantile(tmpl, 0.90))\n        features[\"tmpl_top1_over_sum\"] = float(np.max(tmpl) / max(1.0, np.sum(tmpl)))\n        features[\"tmpl_log_sum\"] = float(np.log1p(np.sum(tmpl)))\n        p_t = tmpl / np.sum(tmpl)\n        features[\"tmpl_entropy\"] = float(-(p_t * np.log(p_t + 1e-12)).sum())\n\n    def _public_features(self, unique_clone_counter: Counter, total_weight: float, pub_bundle, features):\n        if not pub_bundle:\n            return\n\n        exact_dict = pub_bundle.get(\"exact\", {})\n        approx_dict = pub_bundle.get(\"approx\", {})\n\n        exact_scores = []\n        approx_scores = []\n        exact_hit_weight = 0.0\n        approx_hit_weight = 0.0\n        exact_hit_count = 0\n        approx_hit_count = 0\n\n        overlap_sum = 0.0\n        overlap_count = 0\n\n        for seq, cw in unique_clone_counter.items():\n            if seq in exact_dict:\n                sc = float(exact_dict[seq][\"score\"])\n                exact_scores.append(sc * math.log1p(cw))\n                exact_hit_weight += cw\n                exact_hit_count += 1\n\n            sig = sequence_signature(seq)\n            if sig in approx_dict:\n                sc = float(approx_dict[sig][\"score\"])\n                approx_scores.append(sc * math.log1p(cw))\n                approx_hit_weight += cw\n                approx_hit_count += 1\n                overlap_sum += cw\n                overlap_count += 1\n\n        features[\"pub_hits\"] = float(exact_hit_count)\n        features[\"pub_hit_weight\"] = float(exact_hit_weight / max(1.0, total_weight))\n        features[\"pub_score_sum\"] = float(np.sum(exact_scores)) if len(exact_scores) else 0.0\n        features[\"pub_score_max\"] = float(np.max(exact_scores)) if len(exact_scores) else 0.0\n\n        features[\"pub_approx_hits\"] = float(approx_hit_count)\n        features[\"pub_approx_hit_weight\"] = float(approx_hit_weight / max(1.0, total_weight))\n        features[\"pub_approx_score_sum\"] = float(np.sum(approx_scores)) if len(approx_scores) else 0.0\n        features[\"pub_approx_score_max\"] = float(np.max(approx_scores)) if len(approx_scores) else 0.0\n\n        features[\"overlap_burden\"] = float(overlap_sum / max(1.0, total_weight))\n        features[\"clone_sharing_burden\"] = float(overlap_count / max(1, len(unique_clone_counter)))\n\n    def _metadata_features(self, meta_row, ds_id, features):\n        if meta_row is None:\n            return\n        for col in meta_row.index:\n            if col in {\"filename\", \"repertoire_id\", \"label_positive\", \"dataset_name\", \"dataset_id\", \"absolute_path\"}:\n                continue\n            val = meta_row[col]\n            coln = str(col).strip().lower()\n            if pd.isna(val):\n                continue\n            if isinstance(val, (int, float, np.number)):\n                features[f\"meta_{coln}\"] = float(val)\n            else:\n                sval = str(val).strip()\n                if not sval:\n                    continue\n                if coln in {\"sex\", \"race\", \"locus\", \"chain\", \"receptor_type\"}:\n                    key = \"\".join(ch if ch.isalnum() else \"_\" for ch in sval.lower())[:25]\n                    features[f\"meta_{coln}_{key}\"] = 1.0\n                elif \"hla\" in coln:\n                    features[f\"meta_{coln}_present\"] = 1.0\n\n        if ds_id == 7 and meta_row is not None and \"race\" in meta_row.index:\n            features[\"meta_ds7_race_missing\"] = float(pd.isna(meta_row[\"race\"]))\n        if ds_id == 8 and meta_row is not None:\n            for hla in [\"hla_A\", \"hla_B\", \"hla_C\", \"hla_DRB1\", \"A\", \"B\", \"C\", \"DRB1\"]:\n                if hla in meta_row.index:\n                    features[f\"meta_{hla}_present\"] = 1.0 if pd.notna(meta_row[hla]) else 0.0\n\n    def extract_all(\n        self,\n        df: pd.DataFrame,\n        pub_bundle: Optional[Dict] = None,\n        meta_row: Optional[pd.Series] = None,\n        ds_id: int = 1,\n    ) -> Dict[str, float]:\n        features: Dict[str, float] = {}\n        if df is None or len(df) == 0:\n            return features\n\n        seq_counter, _ = counter_from_df(df)\n        if len(seq_counter) == 0:\n            return features\n\n        seqs = list(seq_counter.keys())\n        weights = np.array(list(seq_counter.values()), dtype=np.float32)\n        total_weight = float(weights.sum())\n\n        features[\"seq_count\"] = float(len(df))\n        features[\"unique_seq_count\"] = float(len(seq_counter))\n        features[\"unique_ratio\"] = float(len(seq_counter) / max(1, len(df)))\n        features[\"total_templates\"] = float(total_weight)\n\n        lens = np.array([len(s) for s in seqs], dtype=np.float32)\n        if len(lens) > 0:\n            features[\"len_mean\"] = float(np.average(lens, weights=weights))\n            features[\"len_std\"] = float(np.sqrt(np.average((lens - np.average(lens, weights=weights)) ** 2, weights=weights)))\n            features[\"len_min\"] = float(np.min(lens))\n            features[\"len_max\"] = float(np.max(lens))\n            features[\"len_median\"] = weighted_quantile(lens, weights, 0.50)\n            features[\"len_q10\"] = weighted_quantile(lens, weights, 0.10)\n            features[\"len_q90\"] = weighted_quantile(lens, weights, 0.90)\n\n        self._clone_summary(seq_counter, total_weight, features)\n        self._aa_physchem(seqs, weights, features)\n        self._kmer_features(seqs, weights, features)\n        self._positional_features(seqs, weights, features)\n\n        uniq_df = (\n            df.sort_values(\"templates\", ascending=False)\n              .drop_duplicates(\"junction_aa\")\n              .copy()\n        )\n        uniq_df = uniq_df[uniq_df[\"junction_aa\"].isin(seqs)].copy()\n        uniq_df = uniq_df.set_index(\"junction_aa\").reindex(seqs).reset_index()\n\n        self._gene_usage(uniq_df, weights, total_weight, features)\n        self._template_stats(weights, features)\n        self._public_features(seq_counter, total_weight, pub_bundle, features)\n        self._metadata_features(meta_row, ds_id, features)\n        return features\n\"\"\")\n\nbranch_a_models_py = dedent(\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Dict, List, Tuple\n\nimport numpy as np\nimport pandas as pd\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.metrics import roc_auc_score\nfrom sklearn.pipeline import Pipeline\nfrom sklearn.preprocessing import StandardScaler\n\ntry:\n    import xgboost as xgb\n    HAS_XGB = True\nexcept Exception:\n    HAS_XGB = False\n\ntry:\n    import lightgbm as lgb\n    HAS_LGB = True\nexcept Exception:\n    HAS_LGB = False\n\ntry:\n    from catboost import CatBoostClassifier\n    HAS_CATBOOST = True\nexcept Exception:\n    HAS_CATBOOST = False\n\n\ndef safe_auc(y_true, y_prob) -> float:\n    y_true = np.asarray(y_true)\n    y_prob = np.asarray(y_prob)\n    if len(np.unique(y_true)) < 2:\n        return np.nan\n    return float(roc_auc_score(y_true, y_prob))\n\n\ndef check_gpu() -> bool:\n    try:\n        import subprocess\n        r = subprocess.run([\"nvidia-smi\"], capture_output=True, text=True, timeout=5)\n        return r.returncode == 0\n    except Exception:\n        return False\n\n\ndef select_features_xgb(X_train: pd.DataFrame, y_train: np.ndarray, top_features: int = 650, gpu_ok: bool = False) -> Tuple[List[str], pd.DataFrame]:\n    X_train = X_train.fillna(0.0)\n    all_cols = X_train.columns.tolist()\n\n    protected_prefixes = (\n        \"len_\", \"clone_\", \"tmpl_\", \"phys_\", \"aa_\", \"meta_\", \"pub_\", \"v_fam_\", \"j_fam_\", \"vj_pair_\",\n        \"seq_count\", \"unique_\", \"total_templates\", \"pos_first_\", \"pos_last_\", \"lb_\", \"hill_\", \"overlap_\",\n        \"clone_sharing_\", \"rare_clone_\"\n    )\n    protected = [c for c in all_cols if c.startswith(protected_prefixes)]\n\n    if not HAS_XGB:\n        selected = list(dict.fromkeys(protected + all_cols[:max(0, top_features - len(protected))]))\n        selected = selected[:min(top_features, len(all_cols))]\n        imp = pd.DataFrame({\"feature\": selected, \"gain\": np.linspace(len(selected), 1, len(selected))})\n        return selected, imp\n\n    dtrain = xgb.DMatrix(X_train, label=y_train, feature_names=all_cols)\n    params = {\n        \"objective\": \"binary:logistic\",\n        \"eval_metric\": \"auc\",\n        \"max_depth\": 4,\n        \"learning_rate\": 0.08,\n        \"subsample\": 0.9,\n        \"colsample_bytree\": 0.8,\n        \"tree_method\": \"hist\",\n        \"device\": \"cuda\" if gpu_ok else \"cpu\",\n        \"seed\": 42,\n        \"verbosity\": 0,\n    }\n\n    try:\n        bst = xgb.train(params, dtrain, num_boost_round=80, verbose_eval=False)\n    except Exception:\n        params[\"device\"] = \"cpu\"\n        bst = xgb.train(params, dtrain, num_boost_round=80, verbose_eval=False)\n\n    scores = bst.get_score(importance_type=\"gain\")\n    if len(scores) == 0:\n        selected = list(dict.fromkeys(protected + all_cols[:max(0, top_features - len(protected))]))\n        selected = selected[:min(top_features, len(all_cols))]\n        imp = pd.DataFrame({\"feature\": selected, \"gain\": np.linspace(len(selected), 1, len(selected))})\n        return selected, imp\n\n    imp = pd.DataFrame({\"feature\": list(scores.keys()), \"gain\": list(scores.values())}).sort_values(\"gain\", ascending=False)\n\n    selected = list(dict.fromkeys(protected))\n    for f in imp[\"feature\"].tolist():\n        if f not in selected:\n            selected.append(f)\n        if len(selected) >= min(top_features, len(all_cols)):\n            break\n\n    if len(selected) < min(top_features, len(all_cols)):\n        remaining = [c for c in all_cols if c not in selected]\n        selected.extend(remaining[:min(top_features, len(all_cols)) - len(selected)])\n\n    return selected, imp\n\n\ndef _train_lr(X_tr, y_tr, X_va):\n    pipe = Pipeline([\n        (\"scaler\", StandardScaler()),\n        (\"lr\", LogisticRegression(\n            C=0.8,\n            max_iter=3000,\n            solver=\"liblinear\",\n            class_weight=\"balanced\",\n            random_state=42,\n        )),\n    ])\n    pipe.fit(X_tr, y_tr)\n    pred = pipe.predict_proba(X_va)[:, 1]\n    return pipe, pred\n\n\ndef _train_xgb(X_tr, y_tr, X_va, y_va, ds_scale, seed, gpu_ok):\n    if not HAS_XGB:\n        return None, None\n    dtr = xgb.DMatrix(X_tr, label=y_tr)\n    dva = xgb.DMatrix(X_va, label=y_va)\n\n    params = {\n        \"objective\": \"binary:logistic\",\n        \"eval_metric\": \"auc\",\n        \"max_depth\": 6,\n        \"learning_rate\": 0.03,\n        \"subsample\": 0.85,\n        \"colsample_bytree\": 0.85,\n        \"min_child_weight\": 10,\n        \"reg_alpha\": 0.20,\n        \"reg_lambda\": 1.50,\n        \"scale_pos_weight\": ds_scale,\n        \"tree_method\": \"hist\",\n        \"device\": \"cuda\" if gpu_ok else \"cpu\",\n        \"seed\": seed,\n        \"verbosity\": 0,\n    }\n    try:\n        bst = xgb.train(\n            params,\n            dtr,\n            num_boost_round=1200,\n            evals=[(dva, \"valid\")],\n            early_stopping_rounds=60,\n            verbose_eval=False,\n        )\n    except Exception:\n        params[\"device\"] = \"cpu\"\n        bst = xgb.train(\n            params,\n            dtr,\n            num_boost_round=1200,\n            evals=[(dva, \"valid\")],\n            early_stopping_rounds=60,\n            verbose_eval=False,\n        )\n    pred = bst.predict(dva)\n    return bst, pred\n\n\ndef _train_lgb(X_tr, y_tr, X_va, y_va, ds_scale, seed, gpu_ok):\n    if not HAS_LGB:\n        return None, None\n    dtr = lgb.Dataset(X_tr, label=y_tr)\n    dva = lgb.Dataset(X_va, label=y_va, reference=dtr)\n    params = {\n        \"objective\": \"binary\",\n        \"metric\": \"auc\",\n        \"learning_rate\": 0.025,\n        \"num_leaves\": 63,\n        \"feature_fraction\": 0.85,\n        \"bagging_fraction\": 0.85,\n        \"bagging_freq\": 1,\n        \"min_data_in_leaf\": 20,\n        \"lambda_l1\": 0.10,\n        \"lambda_l2\": 1.00,\n        \"scale_pos_weight\": ds_scale,\n        \"device_type\": \"gpu\" if gpu_ok else \"cpu\",\n        \"seed\": seed,\n        \"verbosity\": -1,\n    }\n    try:\n        bst = lgb.train(\n            params,\n            dtr,\n            num_boost_round=1200,\n            valid_sets=[dva],\n            callbacks=[lgb.early_stopping(60, verbose=False)],\n        )\n    except Exception:\n        params[\"device_type\"] = \"cpu\"\n        bst = lgb.train(\n            params,\n            dtr,\n            num_boost_round=1200,\n            valid_sets=[dva],\n            callbacks=[lgb.early_stopping(60, verbose=False)],\n        )\n    pred = bst.predict(X_va)\n    return bst, pred\n\n\ndef _train_cat(X_tr, y_tr, X_va, y_va, seed, gpu_ok):\n    if not HAS_CATBOOST:\n        return None, None\n    params = dict(\n        loss_function=\"Logloss\",\n        eval_metric=\"AUC\",\n        iterations=1500,\n        depth=6,\n        learning_rate=0.03,\n        l2_leaf_reg=5.0,\n        random_seed=seed,\n        verbose=False,\n        allow_writing_files=False,\n        task_type=\"GPU\" if gpu_ok else \"CPU\",\n        auto_class_weights=\"Balanced\",\n    )\n    try:\n        model = CatBoostClassifier(**params)\n        model.fit(X_tr, y_tr, eval_set=(X_va, y_va), use_best_model=True, verbose=False)\n    except Exception:\n        params[\"task_type\"] = \"CPU\"\n        model = CatBoostClassifier(**params)\n        model.fit(X_tr, y_tr, eval_set=(X_va, y_va), use_best_model=True, verbose=False)\n    pred = model.predict_proba(X_va)[:, 1]\n    return model, pred\n\n\ndef get_feature_groups(columns: List[str]) -> Dict[str, List[str]]:\n    cols = list(columns)\n\n    def pick(prefixes):\n        return [c for c in cols if c.startswith(prefixes)]\n\n    groups = {\n        \"core\": [c for c in cols if c.startswith((\"seq_count\", \"unique_\", \"total_templates\", \"len_\", \"clone_\", \"tmpl_\", \"hill_\", \"meta_\"))],\n        \"physchem\": pick((\"aa_\", \"phys_\")),\n        \"motif\": pick((\"kmer_\", \"pos_\", \"lb_\")),\n        \"gene\": pick((\"v_fam_\", \"j_fam_\", \"vj_pair_\")),\n        \"public\": pick((\"pub_\", \"overlap_\", \"clone_sharing_\", \"rare_clone_\")),\n    }\n    groups[\"full\"] = cols\n    return groups\n\n\ndef run_ablation_xgb(X: pd.DataFrame, y: np.ndarray, folds_df: pd.DataFrame, gpu_ok: bool, top_features: int = 650):\n    groups = get_feature_groups(list(X.columns))\n    stages = [\n        (\"core\", groups[\"core\"]),\n        (\"core+physchem\", list(dict.fromkeys(groups[\"core\"] + groups[\"physchem\"]))),\n        (\"core+physchem+motif\", list(dict.fromkeys(groups[\"core\"] + groups[\"physchem\"] + groups[\"motif\"]))),\n        (\"core+physchem+motif+gene\", list(dict.fromkeys(groups[\"core\"] + groups[\"physchem\"] + groups[\"motif\"] + groups[\"gene\"]))),\n        (\"core+physchem+motif+gene+public\", list(dict.fromkeys(groups[\"core\"] + groups[\"physchem\"] + groups[\"motif\"] + groups[\"gene\"] + groups[\"public\"]))),\n        (\"full\", groups[\"full\"]),\n    ]\n\n    results = []\n    use_xgb = HAS_XGB\n\n    for stage_name, stage_cols in stages:\n        if len(stage_cols) == 0:\n            continue\n\n        oof = np.zeros(len(X), dtype=float)\n        valid_mask = np.zeros(len(X), dtype=bool)\n\n        for fold in sorted(folds_df[\"fold\"].dropna().unique()):\n            tr_idx = folds_df.index[folds_df[\"fold\"] != fold].to_numpy()\n            va_idx = folds_df.index[folds_df[\"fold\"] == fold].to_numpy()\n\n            X_tr = X.iloc[tr_idx][stage_cols].fillna(0.0)\n            X_va = X.iloc[va_idx][stage_cols].fillna(0.0)\n            y_tr = y[tr_idx]\n            y_va = y[va_idx]\n\n            selected, _ = select_features_xgb(X_tr, y_tr, top_features=min(top_features, len(stage_cols)), gpu_ok=gpu_ok)\n            X_tr2 = X_tr[selected].astype(np.float32).values\n            X_va2 = X_va[selected].astype(np.float32).values\n\n            if use_xgb:\n                _, pred = _train_xgb(X_tr2, y_tr, X_va2, y_va, 1.0, 42, gpu_ok)\n                if pred is None:\n                    _, pred = _train_lr(X_tr2, y_tr, X_va2)\n            else:\n                _, pred = _train_lr(X_tr2, y_tr, X_va2)\n\n            oof[va_idx] = pred\n            valid_mask[va_idx] = True\n\n        auc = safe_auc(y[valid_mask], oof[valid_mask])\n        results.append({\n            \"stage\": stage_name,\n            \"n_features_stage\": len(stage_cols),\n            \"auc\": auc,\n        })\n\n    return pd.DataFrame(results)\n\n\nclass BranchAMultiSeedRunner:\n    def __init__(self, seeds, scale_pos_weight_map=None, gpu_ok=False, top_features=650):\n        self.seeds = list(seeds)\n        self.scale_pos_weight_map = scale_pos_weight_map or {}\n        self.gpu_ok = gpu_ok\n        self.top_features = top_features\n\n    def run_dataset(\n        self,\n        feature_df: pd.DataFrame,\n        fold_df: pd.DataFrame,\n        ds_name: str,\n        ds_id: int,\n        out_dir: Path,\n    ):\n        out_dir.mkdir(parents=True, exist_ok=True)\n\n        df = feature_df.copy().reset_index(drop=True)\n        folds = fold_df.copy().reset_index(drop=True)\n\n        y = df[\"label_positive\"].astype(int).values\n        key_cols = [\"ID\", \"dataset\", \"label_positive\"]\n        X_all = df.drop(columns=key_cols, errors=\"ignore\").fillna(0.0)\n\n        selected_records = []\n        oof_frames = []\n        summary_rows = []\n        ds_scale = self.scale_pos_weight_map.get(ds_id, 1.0)\n\n        for seed in self.seeds:\n            seed_pred = {\n                \"ID\": df[\"ID\"].values,\n                \"dataset\": df[\"dataset\"].values,\n                \"label_positive\": y,\n                \"fold\": folds[\"fold\"].values,\n                \"seed\": seed,\n            }\n\n            available_models = [\"LR\"]\n            if HAS_XGB:\n                available_models.append(\"XGB\")\n            if HAS_LGB:\n                available_models.append(\"LGB\")\n            if HAS_CATBOOST:\n                available_models.append(\"CAT\")\n\n            for m in available_models:\n                seed_pred[m] = np.nan\n\n            for fold in sorted(folds[\"fold\"].dropna().unique()):\n                tr_idx = folds.index[folds[\"fold\"] != fold].to_numpy()\n                va_idx = folds.index[folds[\"fold\"] == fold].to_numpy()\n\n                X_tr = X_all.iloc[tr_idx].copy()\n                X_va = X_all.iloc[va_idx].copy()\n                y_tr = y[tr_idx]\n                y_va = y[va_idx]\n\n                selected_cols, _ = select_features_xgb(\n                    X_tr, y_tr, top_features=min(self.top_features, X_tr.shape[1]), gpu_ok=self.gpu_ok\n                )\n                selected_records.append(\n                    pd.DataFrame({\n                        \"dataset\": ds_name,\n                        \"seed\": seed,\n                        \"fold\": fold,\n                        \"feature\": selected_cols,\n                    })\n                )\n\n                X_tr_np = X_tr[selected_cols].fillna(0.0).astype(np.float32).values\n                X_va_np = X_va[selected_cols].fillna(0.0).astype(np.float32).values\n\n                _, pred_lr = _train_lr(X_tr_np, y_tr, X_va_np)\n                seed_pred[\"LR\"][va_idx] = pred_lr\n                summary_rows.append({\"dataset\": ds_name, \"seed\": seed, \"fold\": fold, \"model\": \"LR\", \"auc\": safe_auc(y_va, pred_lr)})\n\n                if HAS_XGB:\n                    _, pred_xgb = _train_xgb(X_tr_np, y_tr, X_va_np, y_va, ds_scale, seed, self.gpu_ok)\n                    seed_pred[\"XGB\"][va_idx] = pred_xgb\n                    summary_rows.append({\"dataset\": ds_name, \"seed\": seed, \"fold\": fold, \"model\": \"XGB\", \"auc\": safe_auc(y_va, pred_xgb)})\n\n                if HAS_LGB:\n                    _, pred_lgb = _train_lgb(X_tr_np, y_tr, X_va_np, y_va, ds_scale, seed, self.gpu_ok)\n                    seed_pred[\"LGB\"][va_idx] = pred_lgb\n                    summary_rows.append({\"dataset\": ds_name, \"seed\": seed, \"fold\": fold, \"model\": \"LGB\", \"auc\": safe_auc(y_va, pred_lgb)})\n\n                if HAS_CATBOOST:\n                    _, pred_cat = _train_cat(X_tr_np, y_tr, X_va_np, y_va, seed, self.gpu_ok)\n                    seed_pred[\"CAT\"][va_idx] = pred_cat\n                    summary_rows.append({\"dataset\": ds_name, \"seed\": seed, \"fold\": fold, \"model\": \"CAT\", \"auc\": safe_auc(y_va, pred_cat)})\n\n            pred_df = pd.DataFrame(seed_pred)\n            model_cols = [c for c in [\"LR\", \"XGB\", \"LGB\", \"CAT\"] if c in pred_df.columns]\n            pred_df[\"AVG\"] = pred_df[model_cols].mean(axis=1)\n            oof_frames.append(pred_df)\n\n            for m in model_cols + [\"AVG\"]:\n                summary_rows.append({\n                    \"dataset\": ds_name,\n                    \"seed\": seed,\n                    \"fold\": -1,\n                    \"model\": m,\n                    \"auc\": safe_auc(pred_df[\"label_positive\"].values, pred_df[m].values),\n                })\n\n        oof_df = pd.concat(oof_frames, ignore_index=True)\n        summary_df = pd.DataFrame(summary_rows)\n\n        agg_summary = (\n            summary_df.groupby([\"dataset\", \"seed\", \"model\"], as_index=False)[\"auc\"]\n            .agg(mean_auc=\"mean\", std_auc=\"std\", max_auc=\"max\")\n            .sort_values([\"dataset\", \"seed\", \"mean_auc\"], ascending=[True, True, False])\n        )\n\n        overall_summary = (\n            agg_summary.groupby([\"dataset\", \"model\"], as_index=False)[\"mean_auc\"]\n            .agg(seed_mean_auc=\"mean\", seed_std_auc=\"std\", seed_max_auc=\"max\")\n            .sort_values([\"dataset\", \"seed_mean_auc\"], ascending=[True, False])\n        )\n\n        selected_df = pd.concat(selected_records, ignore_index=True) if selected_records else pd.DataFrame(columns=[\"dataset\",\"seed\",\"fold\",\"feature\"])\n        selected_df.to_csv(out_dir / f\"selected_features_{ds_name}.csv\", index=False)\n\n        return oof_df, summary_df, agg_summary, overall_summary\n\"\"\")\n\ntrain_branch_a_py = dedent(\"\"\"\nfrom __future__ import annotations\n\nimport sys\nfrom pathlib import Path\n\nimport pandas as pd\nimport yaml\nfrom joblib import Parallel, delayed\nfrom tqdm import tqdm\n\nPROJECT_ROOT = Path(__file__).resolve().parent\nif str(PROJECT_ROOT) not in sys.path:\n    sys.path.insert(0, str(PROJECT_ROOT))\n\nfrom src.branch_a_features import (\n    FeatureExtractor,\n    build_public_clone_bundle,\n    read_repertoire,\n)\nfrom src.branch_a_models import BranchAMultiSeedRunner, check_gpu, run_ablation_xgb\nfrom src.utils import ensure_dir, dataset_id_from_name, seed_everything\n\n\nSCALE_POS_WEIGHT = {\n    1: 1.0, 2: 1.0, 3: 1.0, 4: 1.0, 5: 1.0, 6: 1.0, 7: 5.04, 8: 2.05\n}\n\n\ndef _extract_one(row_dict, ds_path: Path, ds_id: int, extractor: FeatureExtractor, cfg: dict, pub_bundle=None):\n    df = read_repertoire(\n        ds_path / row_dict[\"filename\"],\n        max_seqs=cfg[\"max_sequences_per_file\"],\n        random_state=cfg[\"random_state\"],\n    )\n    meta_row = pd.Series(row_dict)\n    feats = extractor.extract_all(df, pub_bundle=pub_bundle, meta_row=meta_row, ds_id=ds_id)\n    return {\n        \"ID\": row_dict[\"repertoire_id\"],\n        \"dataset\": row_dict[\"dataset_name\"],\n        \"label_positive\": int(row_dict[\"label_positive\"]) if pd.notna(row_dict[\"label_positive\"]) else pd.NA,\n        **feats,\n    }\n\n\ndef build_base_feature_table(meta_ds: pd.DataFrame, ds_path: Path, ds_id: int, extractor: FeatureExtractor, cfg: dict):\n    rows = meta_ds.to_dict(orient=\"records\")\n    feats = Parallel(n_jobs=cfg[\"n_jobs\"], backend=\"loky\")(\n        delayed(_extract_one)(r, ds_path, ds_id, extractor, cfg, None)\n        for r in tqdm(rows, total=len(rows), leave=False)\n    )\n    out = pd.DataFrame(feats).fillna(0.0)\n    return out\n\n\ndef add_fold_local_public_features(base_df: pd.DataFrame, meta_ds: pd.DataFrame, fold_df: pd.DataFrame, ds_path: Path, ds_id: int, extractor: FeatureExtractor, cfg: dict):\n    base_df = base_df.copy().reset_index(drop=True)\n    meta_ds = meta_ds.copy().reset_index(drop=True)\n    fold_df = fold_df.copy().reset_index(drop=True)\n\n    if not ((base_df[\"ID\"].astype(str).tolist() == meta_ds[\"repertoire_id\"].astype(str).tolist()) and\n            (meta_ds[\"repertoire_id\"].astype(str).tolist() == fold_df[\"repertoire_id\"].astype(str).tolist())):\n        raise ValueError(\"ID alignment mismatch among base_df, meta_ds, and fold_df\")\n\n    final_df = base_df.copy()\n    pub_cols_seen = set()\n\n    for fold in sorted(fold_df[\"fold\"].dropna().unique()):\n        tr_meta = meta_ds.loc[fold_df[\"fold\"] != fold].copy()\n        va_idx = fold_df.index[fold_df[\"fold\"] == fold].to_numpy()\n\n        bundle = build_public_clone_bundle(\n            fold_train_meta=tr_meta,\n            dataset_path=ds_path,\n            max_files=cfg[\"pub_max_files\"],\n            min_freq=cfg[\"pub_min_freq\"],\n            enrichment=cfg[\"pub_enrich\"],\n            top_n=cfg[\"pub_top_n\"],\n            max_seqs_per_file=cfg[\"max_sequences_per_file\"],\n            random_state=cfg[\"random_state\"],\n        )\n\n        fold_rows = []\n        for idx in va_idx:\n            row_dict = meta_ds.iloc[idx].to_dict()\n            x = _extract_one(row_dict, ds_path, ds_id, extractor, cfg, pub_bundle=bundle)\n            fold_rows.append({\"ID\": row_dict[\"repertoire_id\"], **x})\n\n        fold_pub = pd.DataFrame(fold_rows)\n        fold_pub = fold_pub.loc[:, ~fold_pub.columns.duplicated()].copy()\n\n        protected = {\"ID\", \"dataset\", \"label_positive\"}\n        pub_cols = [c for c in fold_pub.columns if c not in protected]\n        pub_only_cols = [c for c in pub_cols if c.startswith((\"pub_\", \"overlap_\", \"clone_sharing_\"))]\n\n        if len(pub_only_cols) == 0:\n            continue\n\n        pub_cols_seen.update(pub_only_cols)\n\n        for c in pub_only_cols:\n            if c not in final_df.columns:\n                final_df[c] = 0.0\n\n        id_to_values = fold_pub.set_index(\"ID\")[pub_only_cols]\n        current_ids = final_df.loc[va_idx, \"ID\"].astype(str).tolist()\n        aligned = id_to_values.reindex(current_ids).fillna(0.0).to_numpy()\n\n        for j, c in enumerate(pub_only_cols):\n            final_df.loc[va_idx, c] = aligned[:, j]\n\n    for c in pub_cols_seen:\n        final_df[c] = pd.to_numeric(final_df[c], errors=\"coerce\").fillna(0.0)\n\n    return final_df\n\n\ndef main():\n    cfg_base = yaml.safe_load((PROJECT_ROOT / \"configs\" / \"base.yaml\").read_text())\n    cfg_branch = yaml.safe_load((PROJECT_ROOT / \"configs\" / \"branch_a.yaml\").read_text())\n\n    runtime = cfg_base[\"runtime\"]\n    phase1_paths = cfg_base[\"paths\"]\n    cfg = {\n        \"random_state\": runtime[\"random_state\"],\n        \"n_jobs\": runtime[\"n_jobs\"],\n        \"k_list\": cfg_branch[\"features\"][\"k_list\"],\n        \"top_kmers_per_k\": cfg_branch[\"features\"][\"top_kmers_per_k\"],\n        \"top_pos_motifs\": cfg_branch[\"features\"][\"top_pos_motifs\"],\n        \"max_sequences_per_file\": cfg_branch[\"features\"][\"max_sequences_per_file\"],\n        \"pub_max_files\": cfg_branch[\"public_clone\"][\"max_files\"],\n        \"pub_min_freq\": cfg_branch[\"public_clone\"][\"min_freq\"],\n        \"pub_enrich\": cfg_branch[\"public_clone\"][\"enrichment\"],\n        \"pub_top_n\": cfg_branch[\"public_clone\"][\"top_n_default\"],\n    }\n\n    output_root = ensure_dir(cfg_branch[\"paths\"][\"output_root\"])\n    phase1_root = Path(phase1_paths[\"output_root\"])\n\n    seed_everything(runtime[\"random_state\"])\n    gpu_ok = check_gpu()\n\n    canonical = pd.read_csv(phase1_root / \"canonical_metadata.csv\")\n    all_folds = pd.read_csv(phase1_root / \"all_folds.csv\")\n\n    train_root = Path(phase1_paths[\"train_root\"])\n\n    seeds = cfg_branch[\"training\"][\"seeds\"]\n    top_features = cfg_branch[\"training\"][\"top_features\"]\n\n    extractor = FeatureExtractor(\n        k_list=cfg[\"k_list\"],\n        top_kmers_per_k=cfg[\"top_kmers_per_k\"],\n        top_pos_motifs=cfg[\"top_pos_motifs\"],\n    )\n\n    oof_all = []\n    summary_all = []\n    ablation_all = []\n\n    train_canonical = canonical[canonical[\"source\"] == \"train\"].copy()\n    train_canonical = train_canonical[pd.notna(train_canonical[\"label_positive\"])].copy()\n\n    dataset_names = sorted(train_canonical[\"dataset_name\"].unique().tolist())\n\n    print(\"=\" * 80)\n    print(\"PHASE 2: Branch A - repertoire composition baseline\")\n    print(\"=\" * 80)\n    print(\"Datasets:\", dataset_names)\n    print(\"GPU available:\", gpu_ok)\n\n    for ds_name in dataset_names:\n        ds_id = dataset_id_from_name(ds_name)\n        ds_path = train_root / ds_name\n\n        print(f\"\\\\n{'-' * 80}\")\n        print(f\"Branch A training: {ds_name} (id={ds_id})\")\n        print(f\"{'-' * 80}\")\n\n        meta_ds = train_canonical[train_canonical[\"dataset_name\"] == ds_name].copy().reset_index(drop=True)\n        fold_ds = all_folds[all_folds[\"dataset_name\"] == ds_name].copy().reset_index(drop=True)\n\n        keep_cols = [\"dataset_name\", \"repertoire_id\", \"fold\", \"splitter\", \"n_splits_used\"]\n        fold_ds = fold_ds[keep_cols].copy()\n        meta_ds = meta_ds.merge(fold_ds, on=[\"dataset_name\", \"repertoire_id\"], how=\"inner\")\n        fold_ds = meta_ds[[\"dataset_name\", \"repertoire_id\", \"fold\", \"splitter\", \"n_splits_used\"]].copy()\n\n        base_df = build_base_feature_table(meta_ds, ds_path, ds_id, extractor, cfg)\n        base_cache_path = output_root / f\"branch_a_base_features_{ds_name}.csv\"\n        base_df.to_csv(base_cache_path, index=False)\n        print(\"Base feature cache saved:\", base_cache_path)\n\n        feature_df = add_fold_local_public_features(base_df, meta_ds, fold_ds, ds_path, ds_id, extractor, cfg)\n        feature_df.to_csv(output_root / f\"branch_a_features_with_fold_public_{ds_name}.csv\", index=False)\n\n        runner = BranchAMultiSeedRunner(\n            seeds=seeds,\n            scale_pos_weight_map=SCALE_POS_WEIGHT,\n            gpu_ok=gpu_ok,\n            top_features=top_features,\n        )\n\n        oof_df, summary_df, agg_summary, overall_summary = runner.run_dataset(\n            feature_df=feature_df,\n            fold_df=fold_ds,\n            ds_name=ds_name,\n            ds_id=ds_id,\n            out_dir=output_root,\n        )\n\n        oof_all.append(oof_df)\n        summary_all.append(summary_df)\n        overall_summary.to_csv(output_root / f\"branch_a_summary_{ds_name}.csv\", index=False)\n\n        X = feature_df.drop(columns=[\"ID\", \"dataset\", \"label_positive\"], errors=\"ignore\").fillna(0.0)\n        y = feature_df[\"label_positive\"].astype(int).values\n        ablation_df = run_ablation_xgb(X, y, fold_ds, gpu_ok=gpu_ok, top_features=top_features)\n        ablation_df.insert(0, \"dataset\", ds_name)\n        ablation_all.append(ablation_df)\n        ablation_df.to_csv(output_root / f\"branch_a_feature_ablation_{ds_name}.csv\", index=False)\n\n        print(\"\\\\nSummary:\")\n        print(overall_summary.to_string(index=False))\n        print(\"\\\\nAblation:\")\n        print(ablation_df.to_string(index=False))\n\n    final_oof = pd.concat(oof_all, ignore_index=True) if len(oof_all) else pd.DataFrame()\n    final_summary_fold = pd.concat(summary_all, ignore_index=True) if len(summary_all) else pd.DataFrame()\n    final_ablation = pd.concat(ablation_all, ignore_index=True) if len(ablation_all) else pd.DataFrame()\n\n    final_oof.to_csv(output_root / \"branch_a_oof.csv\", index=False)\n\n    if len(final_summary_fold):\n        summary = (\n            final_summary_fold[final_summary_fold[\"fold\"] >= 0]\n            .groupby([\"dataset\", \"seed\", \"model\"], as_index=False)[\"auc\"]\n            .agg(mean_auc=\"mean\", std_auc=\"std\", max_auc=\"max\")\n        )\n        summary.to_csv(output_root / \"branch_a_summary.csv\", index=False)\n    else:\n        pd.DataFrame().to_csv(output_root / \"branch_a_summary.csv\", index=False)\n\n    final_ablation.to_csv(output_root / \"branch_a_feature_ablation.csv\", index=False)\n\n    print(\"\\\\n\" + \"=\" * 80)\n    print(\"PHASE 2 DONE\")\n    print(\"Saved:\")\n    print(output_root / \"branch_a_oof.csv\")\n    print(output_root / \"branch_a_summary.csv\")\n    print(output_root / \"branch_a_feature_ablation.csv\")\n    print(\"=\" * 80)\n\n\nif __name__ == \"__main__\":\n    main()\n\"\"\")\n\n(PROJECT_ROOT / \"src\" / \"branch_a_features.py\").write_text(branch_a_features_py, encoding=\"utf-8\")\n(PROJECT_ROOT / \"src\" / \"branch_a_models.py\").write_text(branch_a_models_py, encoding=\"utf-8\")\n(PROJECT_ROOT / \"train_branch_a.py\").write_text(train_branch_a_py, encoding=\"utf-8\")\n\nprint(\"Written:\")\nprint(\"-\", PROJECT_ROOT / \"src\" / \"branch_a_features.py\")\nprint(\"-\", PROJECT_ROOT / \"src\" / \"branch_a_models.py\")\nprint(\"-\", PROJECT_ROOT / \"train_branch_a.py\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-24T09:28:02.162196Z","iopub.execute_input":"2026-07-24T09:28:02.162795Z","iopub.status.idle":"2026-07-24T09:28:02.193478Z","shell.execute_reply.started":"2026-07-24T09:28:02.162767Z","shell.execute_reply":"2026-07-24T09:28:02.192859Z"}},"outputs":[],"execution_count":null},{"id":"57b399f8","cell_type":"code","source":"#Cell 3 — imports verify করো\nimport sys\nfrom pathlib import Path\n\nPROJECT_ROOT = Path(\"/kaggle/working/project\")\nif str(PROJECT_ROOT) not in sys.path:\n    sys.path.insert(0, str(PROJECT_ROOT))\n\nfrom src.branch_a_features import FeatureExtractor, build_public_clone_bundle, read_repertoire\nfrom src.branch_a_models import BranchAMultiSeedRunner, run_ablation_xgb, check_gpu\n\nprint(\"Phase 2 imports OK\")\nprint(\"GPU available:\", check_gpu())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-24T09:28:02.194759Z","iopub.execute_input":"2026-07-24T09:28:02.195051Z","iopub.status.idle":"2026-07-24T09:28:02.616122Z","shell.execute_reply.started":"2026-07-24T09:28:02.195028Z","shell.execute_reply":"2026-07-24T09:28:02.615289Z"}},"outputs":[],"execution_count":null},{"id":"17c5e4aa","cell_type":"code","source":"# Cell 4 — Phase 2 run করো\n!python /kaggle/working/project/train_branch_a.py","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-24T09:28:02.617168Z","iopub.execute_input":"2026-07-24T09:28:02.617635Z","iopub.status.idle":"2026-07-24T09:28:06.267668Z","shell.execute_reply.started":"2026-07-24T09:28:02.617609Z","shell.execute_reply":"2026-07-24T09:28:06.266624Z"}},"outputs":[],"execution_count":null},{"id":"5e67ddf2","cell_type":"code","source":"# Cell 5 — outputs inspect করো\nfrom pathlib import Path\nimport pandas as pd\n\nOUT_DIR = Path(\"/kaggle/working/project/artifacts/phase2_branch_a\")\n\nprint(\"OUT_DIR exists:\", OUT_DIR.exists())\nprint(\"Files:\")\nfound = sorted(OUT_DIR.glob(\"*\")) if OUT_DIR.exists() else []\nfor p in found:\n    print(\"-\", p.name)\nif not found:\n    print(\"(none — the Branch A training step did not write any outputs here.\")\n    print(\" Check the cell that runs Branch A for errors before this one.)\")\n\ndef show(name):\n    path = OUT_DIR / name\n    print(f\"\\n{name}\")\n    if path.exists():\n        display(pd.read_csv(path).head() if \"oof\" in name or \"ablation\" in name else pd.read_csv(path))\n    else:\n        print(f\"  -> missing: {path}\")\n\nshow(\"branch_a_oof.csv\")\nshow(\"branch_a_summary.csv\")\nshow(\"branch_a_feature_ablation.csv\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-24T09:36:38.793363Z","iopub.execute_input":"2026-07-24T09:36:38.794292Z","iopub.status.idle":"2026-07-24T09:36:38.801561Z","shell.execute_reply.started":"2026-07-24T09:36:38.794261Z","shell.execute_reply":"2026-07-24T09:36:38.800803Z"}},"outputs":[],"execution_count":null},{"id":"86a661fd-a0d2-48af-af3b-ef241232ff04","cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}