{"cells": [{"id": "a1b2c3d4-0001", "cell_type": "markdown", "metadata": {}, "source": "# CAFA 6: Protein Function Prediction\n\nPredict Gene Ontology (GO) terms for proteins based on their amino acid sequences across three subontologies:\n- **MF** \u00e2\u20ac\u201d Molecular Function\n- **BP** \u00e2\u20ac\u201d Biological Process\n- **CC** \u00e2\u20ac\u201d Cellular Component\n\n**Evaluation:** IA-weighted Fmax, averaged across MF, BP, CC."}, {"id": "a1b2c3d4-0002", "cell_type": "markdown", "metadata": {}, "source": "## 0. Diagnostics \u00e2\u20ac\u201d Verify Input Files"}, {"id": "a1b2c3d4-0003", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "import os\nfrom pathlib import Path\n\ninput_root = Path('/kaggle/input')\nprint('=== /kaggle/input contents ===')\nif input_root.exists():\n    for p in sorted(input_root.rglob('*')):\n        if p.is_file():\n            print(f'  {p.relative_to(input_root)}  ({p.stat().st_size/1e6:.1f} MB)')\n        else:\n            print(f'  [DIR] {p.relative_to(input_root)}/')\nelse:\n    print('  /kaggle/input does not exist!')\n\n# Auto-discover DATA_DIR \u00e2\u20ac\u201d the root folder that contains Train/ and Test/ subdirs\nCANDIDATES = [\n    Path('/kaggle/input/competitions/cafa-6-protein-function-prediction'),\n    Path('/kaggle/input/cafa-6-protein-function-prediction'),\n    Path('/kaggle/input/cafa6-protein-function-prediction'),\n]\nDATA_DIR = None\nfor c in CANDIDATES:\n    if c.exists():\n        DATA_DIR = c\n        break\n\n# Fallback: find train_terms.tsv; it lives inside Train/, so go two levels up\nif DATA_DIR is None:\n    for p in input_root.rglob('train_terms.tsv'):\n        DATA_DIR = p.parent.parent\n        break\n\nif DATA_DIR:\n    print(f'\\nDATA_DIR resolved to: {DATA_DIR}')\nelse:\n    raise FileNotFoundError(\n        'Competition data not found under /kaggle/input.\\n'\n        'Steps to fix:\\n'\n        '  1. Accept rules at kaggle.com/competitions/cafa-6-protein-function-prediction/rules\\n'\n        '  2. Add competition data via notebook settings -> Add Data'\n    )"}, {"id": "a1b2c3d4-0004", "cell_type": "markdown", "metadata": {}, "source": "## 1. Setup & Imports"}, {"id": "a1b2c3d4-0005", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "import re\nimport warnings\nfrom itertools import product\nfrom collections import defaultdict, Counter\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport matplotlib.ticker as ticker\nimport seaborn as sns\n\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.multiclass import OneVsRestClassifier\nfrom sklearn.preprocessing import MultiLabelBinarizer\n\nwarnings.filterwarnings('ignore')\nsns.set_theme(style='whitegrid', palette='muted')\npd.set_option('display.max_colwidth', 80)\n\nWORK_DIR = Path('/kaggle/working')\n\ndef find_file(base, *candidates):\n    \"\"\"Try each relative path; fall back to recursive name search.\"\"\"\n    for rel in candidates:\n        p = base / rel\n        if p.exists():\n            return p\n    name = Path(candidates[0]).name\n    for p in base.rglob(name):\n        return p\n    raise FileNotFoundError(f'{name} not found under {base}')\n\n# Actual Kaggle layout (confirmed from logs):\n#   Train/train_sequences.fasta\n#   Train/train_terms.tsv\n#   Train/go-basic.obo\n#   Test/testsuperset.fasta      (NOT \"Test (Targets)\")\n#   IA.tsv                       (NOT IA.txt)\nTRAIN_FASTA  = find_file(DATA_DIR, 'Train/train_sequences.fasta', 'train_sequences.fasta')\nTRAIN_TERMS  = find_file(DATA_DIR, 'Train/train_terms.tsv',       'train_terms.tsv')\nTEST_FASTA   = find_file(DATA_DIR, 'Test/testsuperset.fasta',     'testsuperset.fasta')\nGO_BASIC_OBO = find_file(DATA_DIR, 'Train/go-basic.obo',          'go-basic.obo')\nIA_FILE      = find_file(DATA_DIR, 'IA.tsv', 'IA.txt')\n\nprint('Files found:')\nfor label, path in [('Train FASTA', TRAIN_FASTA), ('Train terms', TRAIN_TERMS),\n                    ('Test FASTA',  TEST_FASTA),  ('GO OBO',      GO_BASIC_OBO),\n                    ('IA weights',  IA_FILE)]:\n    print(f'  {label}: {path}')"}, {"id": "a1b2c3d4-0006", "cell_type": "markdown", "metadata": {}, "source": "## 2. Load Gene Ontology DAG"}, {"id": "a1b2c3d4-0007", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "def parse_obo(obo_path):\n    \"\"\"Parse go-basic.obo -> {term_id: {name, namespace, parents}}.\"\"\"\n    terms = {}\n    current = None\n    with open(obo_path) as f:\n        for line in f:\n            line = line.strip()\n            if line == '[Term]':\n                current = {'parents': [], 'is_obsolete': False}\n            elif line == '' and current:\n                if not current['is_obsolete'] and 'id' in current:\n                    terms[current['id']] = current\n                current = None\n            elif current is not None:\n                if line.startswith('id: '):\n                    current['id'] = line[4:]\n                elif line.startswith('name: '):\n                    current['name'] = line[6:]\n                elif line.startswith('namespace: '):\n                    current['namespace'] = line[11:]\n                elif line.startswith('is_a: '):\n                    parent = line[6:].split(' ! ')[0].strip()\n                    current['parents'].append(parent)\n                elif line == 'is_obsolete: true':\n                    current['is_obsolete'] = True\n    return terms\n\n\ndef build_ancestor_map(terms):\n    \"\"\"Build {term_id -> set of all ancestor term_ids (inclusive)}.\"\"\"\n    cache = {}\n    def ancestors(tid):\n        if tid in cache:\n            return cache[tid]\n        result = {tid}\n        for p in terms.get(tid, {}).get('parents', []):\n            result |= ancestors(p)\n        cache[tid] = result\n        return result\n    for tid in terms:\n        ancestors(tid)\n    return cache\n\n\ngo_terms     = parse_obo(GO_BASIC_OBO)\nancestor_map = build_ancestor_map(go_terms)\n\nNAMESPACES = {\n    'molecular_function': 'MF',\n    'biological_process': 'BP',\n    'cellular_component': 'CC',\n}\n\nns_terms = defaultdict(set)\nfor tid, info in go_terms.items():\n    ns = NAMESPACES.get(info.get('namespace', ''))\n    if ns:\n        ns_terms[ns].add(tid)\n\nprint(f'GO terms loaded: {len(go_terms):,}')\nfor ns, s in ns_terms.items():\n    print(f'  {ns}: {len(s):,}')"}, {"id": "a1b2c3d4-0008", "cell_type": "markdown", "metadata": {}, "source": "## 3. Load Information Accretion Weights"}, {"id": "a1b2c3d4-0009", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "ia = {}\nwith open(IA_FILE) as f:\n    for line in f:\n        parts = line.strip().split('\\t')\n        if len(parts) == 2:\n            ia[parts[0]] = float(parts[1])\n\nprint(f'IA weights loaded: {len(ia):,} terms')\nprint(pd.Series(ia).describe().round(4))"}, {"id": "a1b2c3d4-0010", "cell_type": "markdown", "metadata": {}, "source": "## 4. Parse FASTA Files"}, {"id": "a1b2c3d4-0011", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "def parse_fasta(fasta_path):\n    \"\"\"Parse FASTA; extracts UniProt accession from headers like sp|Q5W0B1|GENE_HUMAN.\"\"\"\n    seqs = {}\n    current_id, current_seq = None, []\n    with open(fasta_path) as f:\n        for line in f:\n            line = line.strip()\n            if line.startswith('>'):\n                if current_id:\n                    seqs[current_id] = ''.join(current_seq)\n                raw_id = line[1:].split()[0]\n                # UniProt format: sp|ACCESSION|NAME  or  tr|ACCESSION|NAME\n                parts = raw_id.split('|')\n                current_id = parts[1] if len(parts) >= 2 else raw_id\n                current_seq = []\n            else:\n                current_seq.append(line)\n    if current_id:\n        seqs[current_id] = ''.join(current_seq)\n    return seqs\n\n\ntrain_seqs = parse_fasta(TRAIN_FASTA)\ntest_seqs  = parse_fasta(TEST_FASTA)\n\nprint(f'Train sequences: {len(train_seqs):,}')\nprint(f'Test  sequences: {len(test_seqs):,}')\n\n# Sanity-check: show a few IDs from each\nprint('Train ID samples:', list(train_seqs.keys())[:5])\nprint('Test  ID samples:', list(test_seqs.keys())[:5])\n\ntrain_lens = [len(s) for s in train_seqs.values()]\ntest_lens  = [len(s) for s in test_seqs.values()]\n\nfig, axes = plt.subplots(1, 2, figsize=(12, 4))\nfor ax, lens, title in zip(axes, [train_lens, test_lens], ['Train', 'Test']):\n    ax.hist(lens, bins=60, color='steelblue', edgecolor='white')\n    ax.set_title(f'{title} Sequence Length Distribution')\n    ax.set_xlabel('Length (aa)')\n    ax.set_ylabel('Count')\n    ax.xaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: f'{int(x):,}'))\nplt.tight_layout()\nplt.show()"}, {"id": "a1b2c3d4-0012", "cell_type": "markdown", "metadata": {}, "source": "## 5. Load Training Annotations"}, {"id": "a1b2c3d4-0013", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "# train_terms.tsv has a header row and uses F/P/C not MF/BP/CC\ntrain_terms_df = pd.read_csv(TRAIN_TERMS, sep='\\t', header=0)\ntrain_terms_df.columns = ['protein_id', 'term', 'ontology']\n\n# Map single-letter codes to full namespace names\nONT_MAP = {'F': 'MF', 'P': 'BP', 'C': 'CC'}\ntrain_terms_df['ontology'] = train_terms_df['ontology'].map(ONT_MAP)\ntrain_terms_df = train_terms_df.dropna(subset=['ontology']).reset_index(drop=True)\n\nprint(train_terms_df.shape)\nprint(train_terms_df.head())\nprint('\\nAnnotations per ontology:')\nprint(train_terms_df['ontology'].value_counts())\nprint('\\nProteins per ontology:')\nprint(train_terms_df.groupby('ontology')['protein_id'].nunique())"}, {"id": "a1b2c3d4-0014", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "terms_per_protein = train_terms_df.groupby(['protein_id', 'ontology'])['term'].count().reset_index()\nterms_per_protein.columns = ['protein_id', 'ontology', 'n_terms']\n\nfig, axes = plt.subplots(1, 3, figsize=(15, 4))\nfor ax, ont in zip(axes, ['MF', 'BP', 'CC']):\n    subset = terms_per_protein[terms_per_protein['ontology'] == ont]['n_terms']\n    ax.hist(subset, bins=50, color='coral', edgecolor='white')\n    ax.set_title(f'{ont}: Terms per Protein')\n    ax.set_xlabel('# GO Terms')\n    ax.set_ylabel('# Proteins')\n    ax.set_yscale('log')\nplt.tight_layout()\nplt.show()"}, {"id": "a1b2c3d4-0015", "cell_type": "markdown", "metadata": {}, "source": "## 6. Feature Engineering \u00e2\u20ac\u201d Amino Acid k-mer Composition"}, {"id": "a1b2c3d4-0016", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "import gc\nAMINO_ACIDS = list('ACDEFGHIKLMNPQRSTVWY')\n\ndef get_kmer_vocab(k):\n    return {''.join(p): i for i, p in enumerate(product(AMINO_ACIDS, repeat=k))}\n\n\ndef build_feature_matrix(seq_dict, vocabs_ks):\n    ids = list(seq_dict.keys())\n    n_features = sum(len(v) for v, _ in vocabs_ks)\n    X = np.zeros((len(ids), n_features), dtype=np.float32)\n    for i, pid in enumerate(ids):\n        seq = seq_dict[pid].upper()\n        offset = 0\n        for vocab, k in vocabs_ks:\n            total = 0\n            for j in range(len(seq) - k + 1):\n                kmer = seq[j:j+k]\n                if kmer in vocab:\n                    X[i, offset + vocab[kmer]] += 1\n                    total += 1\n            if total > 0:\n                X[i, offset:offset+len(vocab)] /= total\n            offset += len(vocab)\n    return ids, X\n\n\nvocabs_ks = [(get_kmer_vocab(1), 1), (get_kmer_vocab(2), 2)]  # 420 features total\n\nprint('Building train features...')\ntrain_ids, X_train = build_feature_matrix(train_seqs, vocabs_ks)\nprint('Building test features...')\ntest_ids,  X_test  = build_feature_matrix(test_seqs,  vocabs_ks)\n\n# Free sequence dicts \u00e2\u20ac\u201d no longer needed\ndel train_seqs, test_seqs\ngc.collect()\n\nprint(f'X_train: {X_train.shape}')\nprint(f'X_test:  {X_test.shape}')\nprint('Sequence dicts freed.')"}, {"id": "a1b2c3d4-0017", "cell_type": "markdown", "metadata": {}, "source": "## 7. Propagate Train Labels to Ancestor Terms"}, {"id": "a1b2c3d4-0018", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "def build_propagated_labels(terms_df, ancestor_map, namespace):\n    ont_df = terms_df[terms_df['ontology'] == namespace]\n    prot_to_terms = defaultdict(set)\n    for _, row in ont_df.iterrows():\n        prot_to_terms[row['protein_id']] |= ancestor_map.get(row['term'], {row['term']})\n    return prot_to_terms\n\n\npropagated = {}\nfor ns in ['MF', 'BP', 'CC']:\n    propagated[ns] = build_propagated_labels(train_terms_df, ancestor_map, ns)\n    print(f'{ns}: {len(propagated[ns]):,} proteins with propagated labels')\n\n# Free train annotations \u00e2\u20ac\u201d keep ancestor_map for inline propagation during prediction\ndel train_terms_df\ngc.collect()\nprint('train_terms_df freed. ancestor_map retained for propagation.')"}, {"id": "a1b2c3d4-0019", "cell_type": "markdown", "metadata": {}, "source": "## 8. Train Classifiers (One-vs-Rest Logistic Regression)"}, {"id": "a1b2c3d4-0020", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "MIN_TERM_FREQ = 100\n\nmodels     = {}\nmlbs       = {}\nterm_lists = {}\n\ntrain_id_to_idx = {pid: i for i, pid in enumerate(train_ids)}\n\nfor ns in ['MF', 'BP', 'CC']:\n    print(f'\\n=== {ns} ===')\n    prot_terms = propagated[ns]\n\n    common_ids  = [pid for pid in train_ids if pid in prot_terms]\n    labels_list = [list(prot_terms[pid]) for pid in common_ids]\n    print(f'  Proteins with annotations: {len(common_ids):,}')\n\n    term_counts    = Counter(t for ts in labels_list for t in ts)\n    frequent_terms = {t for t, c in term_counts.items() if c >= MIN_TERM_FREQ}\n    print(f'  Frequent terms (>={MIN_TERM_FREQ}): {len(frequent_terms):,}')\n\n    labels_filtered = [[t for t in ts if t in frequent_terms] for ts in labels_list]\n    mask = [bool(ts) for ts in labels_filtered]\n    common_ids_f    = [common_ids[i] for i, ok in enumerate(mask) if ok]\n    labels_filtered = [ts for ts, ok in zip(labels_filtered, mask) if ok]\n\n    mlb = MultiLabelBinarizer(sparse_output=True)\n    Y   = mlb.fit_transform(labels_filtered)\n    mlbs[ns]       = mlb\n    term_lists[ns] = mlb.classes_\n    print(f'  Label matrix: {Y.shape}')\n\n    row_idx = [train_id_to_idx[pid] for pid in common_ids_f]\n\n    # n_jobs=1 \u00e2\u20ac\u201d avoids joblib loky copying X to each worker process (major OOM cause)\n    clf = OneVsRestClassifier(\n        LogisticRegression(C=1.0, solver='liblinear', max_iter=500),\n        n_jobs=1\n    )\n    clf.fit(X_train[row_idx], Y)\n    models[ns] = clf\n\n    # Free per-ns temporaries immediately\n    del labels_list, labels_filtered, Y, row_idx\n    gc.collect()\n    print(f'  Done.')\n\n# Free propagated labels and training matrix\ndel propagated, X_train\ngc.collect()\nprint('\\nTraining complete. X_train and propagated freed.')"}, {"id": "a1b2c3d4-0021", "cell_type": "markdown", "metadata": {}, "source": "## 9. Predict on Test Set"}, {"id": "a1b2c3d4-0022", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "# Combined: predict + propagate + write in one streaming pass.\n# Processes BATCH_SIZE proteins at a time across all 3 namespaces,\n# propagates ancestor scores inline, writes directly to disk.\n# Peak memory: ~batch_size \u00c3\u2014 max_terms \u00c3\u2014 8 bytes per namespace call (~50 MB).\n\nSCORE_THRESHOLD       = 0.1   # higher = fewer predictions held in RAM\nMAX_TERMS_PER_PROTEIN = 1500\nBATCH_SIZE            = 1000\nSUBMISSION_PATH       = WORK_DIR / 'submission.tsv'\n\nn_lines = 0\nn_test  = len(test_ids)\n\nwith open(SUBMISSION_PATH, 'w', buffering=1 << 20) as out_f:\n    for batch_start in range(0, n_test, BATCH_SIZE):\n        batch_end  = min(batch_start + BATCH_SIZE, n_test)\n        X_batch    = X_test[batch_start:batch_end]\n        batch_pids = test_ids[batch_start:batch_end]\n        batch_size = batch_end - batch_start\n\n        # Per-protein score dict: list of {term: max_score}\n        prot_scores = [defaultdict(float) for _ in range(batch_size)]\n\n        for ns in ['MF', 'BP', 'CC']:\n            terms       = term_lists[ns]\n            proba_batch = models[ns].predict_proba(X_batch)   # (batch, n_terms)\n\n            for i in range(batch_size):\n                scores = proba_batch[i]\n                for j in np.where(scores >= SCORE_THRESHOLD)[0]:\n                    term  = terms[j]\n                    score = float(scores[j])\n                    # Inline ancestor propagation (max-score rule)\n                    for anc in ancestor_map.get(term, (term,)):\n                        if score > prot_scores[i].get(anc, 0.0):\n                            prot_scores[i][anc] = score\n\n        # Write this batch to file (sort by score desc, cap at 1500)\n        for i, pid in enumerate(batch_pids):\n            top = sorted(prot_scores[i].items(), key=lambda x: -x[1])[:MAX_TERMS_PER_PROTEIN]\n            for term, score in top:\n                out_f.write(f'{pid}\\t{term}\\t{score:.3f}\\n')\n                n_lines += 1\n\n        if batch_start % (BATCH_SIZE * 20) == 0:\n            print(f'  {batch_end:,}/{n_test:,} proteins done, {n_lines:,} lines written')\n\nprint(f'\\nSubmission written: {SUBMISSION_PATH}')\nprint(f'Lines: {n_lines:,}  |  Proteins: {n_test:,}')"}, {"id": "a1b2c3d4-0023", "cell_type": "markdown", "metadata": {}, "source": "## 10. Propagate Predictions to Ancestors"}, {"id": "a1b2c3d4-0024", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "# Propagation is now done inline during prediction (cell above).\n# This cell is intentionally left as a no-op.\nprint('Propagation: done inline during prediction.')"}, {"id": "a1b2c3d4-0025", "cell_type": "markdown", "metadata": {}, "source": "## 11. Enforce 1500-Term Cap Per Protein & Write Submission"}, {"id": "a1b2c3d4-0026", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "# Submission already written in the prediction cell above.\nsub_df = pd.read_csv(SUBMISSION_PATH, sep='\\t', header=None,\n                     names=['protein_id', 'go_term', 'score'])\nprint(f'Submission rows: {len(sub_df):,}')\nprint(sub_df.head(10))\nprint(sub_df.describe())"}, {"id": "a1b2c3d4-0027", "cell_type": "markdown", "metadata": {}, "source": "## 12. Validation \u00e2\u20ac\u201d IA-Weighted Fmax Estimate"}, {"id": "a1b2c3d4-0028", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "# Lightweight validation: sample a few proteins from the submission and report score stats.\n# Full Fmax validation is skipped to conserve memory (models + X_test already large).\nsub_df_val = pd.read_csv(SUBMISSION_PATH, sep='\\t', header=None,\n                         names=['protein_id', 'go_term', 'score'])\nprint(f'Submission rows:    {len(sub_df_val):,}')\nprint(f'Proteins covered:   {sub_df_val[\"protein_id\"].nunique():,}')\nprint(f'Score distribution:\\n{sub_df_val[\"score\"].describe().round(4)}')\nprint('\\nTop-5 most-predicted GO terms:')\nprint(sub_df_val['go_term'].value_counts().head())"}, {"id": "a1b2c3d4-0029", "cell_type": "markdown", "metadata": {}, "source": "## 13. Score Distribution Plots"}, {"id": "a1b2c3d4-0030", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "sub_df_val['namespace'] = sub_df_val['go_term'].map(\n    lambda t: NAMESPACES.get(go_terms.get(t, {}).get('namespace', ''), 'Unknown')\n)\n\nfig, axes = plt.subplots(1, 3, figsize=(15, 4))\nfor ax, ns in zip(axes, ['MF', 'BP', 'CC']):\n    ax.hist(sub_df_val[sub_df_val['namespace'] == ns]['score'], bins=40,\n            color='mediumseagreen', edgecolor='white')\n    ax.set_title(f'{ns} Score Distribution')\n    ax.set_xlabel('Predicted Score')\n    ax.set_ylabel('Count')\nplt.tight_layout()\nplt.show()\n\nprint('\\nPrediction counts by namespace:')\nprint(sub_df_val['namespace'].value_counts())\n\n# Free to save memory before notebook ends\ndel sub_df_val\ngc.collect()\nprint('\\nDone.')"}, {"id": "a1b2c3d4-0031", "cell_type": "markdown", "metadata": {}, "source": "## 14. (Optional) Free Text Prediction"}, {"id": "a1b2c3d4-0032", "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": "# Uncomment to append text predictions to submission\n# TEXT_PREDICTIONS = {\n#     'SOME_PROT_ID': [(0.9, 'This protein is involved in ...')]\n# }\n# with open(SUBMISSION_PATH, 'a') as f:\n#     for pid, lines in TEXT_PREDICTIONS.items():\n#         for conf, text in lines:\n#             f.write(f'{pid}\\tText\\t{conf:.3f}\\t{text}\\n')\n\nprint('Text predictions: skipped (optional).')\nprint(f'\\nFinal submission: {SUBMISSION_PATH}')"}, {"id": "a1b2c3d4-0033", "cell_type": "markdown", "metadata": {}, "source": "---\n## Summary\n\n| Step | Details |\n|------|---------|\n| Features | AA composition (k=1) + dipeptide frequencies (k=2) \u00e2\u2020\u2019 420-dim |\n| Labels | Propagated GO annotations, terms with freq >= 50 |\n| Model | One-vs-Rest Logistic Regression per namespace (MF/BP/CC) |\n| Post-processing | Ancestor propagation (max-score), capped at 1500 terms/protein |\n| Metric | IA-weighted Fmax averaged across MF, BP, CC |\n\n**Upgrade paths:** ESM-2 embeddings | MLP/XGBoost | BLASTp homology transfer | DeepGOPlus-style graph propagation"}], "metadata": {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, "language_info": {"name": "python", "version": "3.12.0"}}, "nbformat": 4, "nbformat_minor": 5}