{"cells":[{"cell_type":"markdown","metadata":{},"source":"# CAFA-6 Ultimate v4 - MLP Ensemble + Manual Features\n\n**Objetivo: Top 10 (>0.40)**\n\nImplementando approach KTDK (Top 1):\n- 3 MLPs en ensemble por aspecto\n- 85 features manuales de secuencia\n- 20 features de taxonomy\n- AdamW + CosineAnnealing\n- 40 epochs"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"import os\nimport gc\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader, random_split\nfrom sklearn.preprocessing import MultiLabelBinarizer, StandardScaler\nfrom collections import Counter, defaultdict\nfrom tqdm.auto import tqdm\nimport warnings\nwarnings.filterwarnings('ignore')\n\nprint(f\"PyTorch: {torch.__version__}\")\nprint(f\"CUDA available: {torch.cuda.is_available()}\")\nif torch.cuda.is_available():\n    print(f\"GPU: {torch.cuda.get_device_name(0)}\")"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"class CFG:\n    # Paths\n    TRAIN_TERMS = '/kaggle/input/cafa-6-protein-function-prediction/Train/train_terms.tsv'\n    TRAIN_SEQS = '/kaggle/input/cafa-6-protein-function-prediction/Train/train_sequences.fasta'\n    TRAIN_TAX = '/kaggle/input/cafa-6-protein-function-prediction/Train/train_taxonomy.tsv'\n    TEST_SEQS = '/kaggle/input/cafa-6-protein-function-prediction/Test/testsuperset.fasta'\n    TEST_TAX = '/kaggle/input/cafa-6-protein-function-prediction/Test/testsuperset-taxon-list.tsv'\n    OBO_FILE = '/kaggle/input/cafa-6-protein-function-prediction/Train/go-basic.obo'\n    \n    # ESM2 embeddings (1280 dim)\n    EMB_PATH = '/kaggle/input/cafa6-protein-embeddings-esm2/protein_embeddings.npy'\n    EMB_IDS = '/kaggle/input/cafa6-protein-embeddings-esm2/protein_ids.csv'\n    \n    # GOA\n    GOA_PATH = '/kaggle/input/goa-uniprot-all/selection_ids_goa_uniprot_all.csv'\n    \n    # Training\n    DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n    THRESHOLDS = {'C': 0.08, 'F': 0.08, 'P': 0.01}\n    MIN_PREDS = 25\n    \n# Ensemble configs (3 models per aspect)\nENSEMBLE_CONFIGS = [\n    {'BS': 32, 'EPOCHS': 40, 'LR': 1e-3, 'D1': 1024, 'D2': 512, 'Drop': 0.3},\n    {'BS': 32, 'EPOCHS': 40, 'LR': 1e-3, 'D1': 2048, 'D2': 1024, 'Drop': 0.4},\n    {'BS': 32, 'EPOCHS': 40, 'LR': 1e-3, 'D1': 1024, 'D2': 768, 'Drop': 0.3},\n]\n\nprint(f\"Device: {CFG.DEVICE}\")\nprint(f\"Ensemble: {len(ENSEMBLE_CONFIGS)} models per aspect\")"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# Manual sequence feature extractor (85 dims)\nclass SequenceFeatureExtractor:\n    @staticmethod\n    def extract(seq):\n        if not seq:\n            return np.zeros(85, dtype=np.float32)\n        try:\n            length = len(seq)\n            aa_counts = Counter(seq)\n            standard = 'ACDEFGHIKLMNPQRSTVWY'\n            \n            # 1. Amino acid frequencies (20 dims)\n            aa_freq = np.array([aa_counts.get(aa, 0)/length for aa in standard], dtype=np.float32)\n            \n            # 2. Basic properties (4 dims)\n            hydrophobic = sum(aa_counts.get(aa, 0) for aa in 'AILMFWYV') / length\n            charged = sum(aa_counts.get(aa, 0) for aa in 'DEKR') / length\n            aa_weights = {'A': 89, 'C': 121, 'D': 133, 'E': 147, 'F': 165, 'G': 75, 'H': 155,\n                          'I': 131, 'K': 146, 'L': 131, 'M': 149, 'N': 132, 'P': 115, 'Q': 146,\n                          'R': 174, 'S': 105, 'T': 119, 'V': 117, 'W': 204, 'Y': 181}\n            mol_weight = sum(aa_counts.get(aa, 0) * aa_weights.get(aa, 0) for aa in aa_counts)\n            props = np.array([np.log1p(length), hydrophobic, charged, np.log1p(mol_weight)], dtype=np.float32)\n            \n            # 3. Group features (6 dims)\n            groups = {'hydro': 'AILMFWYV', 'polar': 'STNQ', 'pos': 'RK', 'neg': 'DE', 'arom': 'FWY', 'aliph': 'ILV'}\n            group_feats = np.array([sum(1 for aa in seq if aa in chars)/length for chars in groups.values()], dtype=np.float32)\n            \n            # 4. Dipeptide frequencies (20 dims)\n            top_di = ['AL', 'LA', 'LE', 'EA', 'AA', 'AS', 'SA', 'EL', 'LL', 'AE',\n                      'SE', 'ES', 'GA', 'AG', 'VA', 'AV', 'LV', 'VL', 'LS', 'SL']\n            di_freq = np.zeros(20, dtype=np.float32)\n            if length > 1:\n                di_counts = Counter([seq[i:i+2] for i in range(length-1)])\n                di_freq = np.array([di_counts.get(dp, 0)/(length-1) for dp in top_di], dtype=np.float32)\n            \n            # 5. Tripeptide frequencies (20 dims)\n            top_tri = ['ALA', 'LEA', 'EAL', 'LAL', 'AAA', 'LLE', 'ELE', 'ALE', 'GAL', 'ASA',\n                       'VLA', 'LAV', 'SLS', 'LSL', 'GLA', 'LAG', 'AVL', 'VLA', 'SLE', 'LES']\n            tri_freq = np.zeros(20, dtype=np.float32)\n            if length > 2:\n                tri_counts = Counter([seq[i:i+3] for i in range(length-2)])\n                tri_freq = np.array([tri_counts.get(tp, 0)/(length-2) for tp in top_tri], dtype=np.float32)\n            \n            # 6. Extra dipeptides (10 dims)\n            extra_di = ['RE', 'ER', 'VE', 'EV', 'TE', 'ET', 'AV', 'VA', 'GL', 'LG']\n            extra_freq = np.zeros(10, dtype=np.float32)\n            if length > 1:\n                di_counts = Counter([seq[i:i+2] for i in range(length-1)])\n                extra_freq = np.array([di_counts.get(dp, 0)/(length-1) for dp in extra_di], dtype=np.float32)\n            \n            # 7. Misc features (5 dims)\n            arom = sum(aa_counts.get(aa, 0) for aa in 'FWY') / length\n            instab = (hydrophobic + charged) / 2\n            iso = np.mean([ord(aa) for aa in seq[:100]])/255 if seq else 0\n            misc = np.array([arom, instab, iso, 0, 0], dtype=np.float32)\n            \n            return np.concatenate([aa_freq, props, group_feats, di_freq, tri_freq, extra_freq, misc])\n        except:\n            return np.zeros(85, dtype=np.float32)\n\nprint(\"SequenceFeatureExtractor ready (85 dims)\")"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# GO Ontology utilities\ndef parse_obo(obo_file):\n    print(f\"Parsing GO ontology...\")\n    go_parents = defaultdict(list)\n    go_children = defaultdict(list)\n    \n    with open(obo_file, 'r') as f:\n        current_term = None\n        for line in f:\n            line = line.strip()\n            if line.startswith(\"id: GO:\"):\n                current_term = line.split(\"id: \")[1]\n            elif line.startswith(\"is_a:\") and current_term:\n                parent = line.split(\"is_a: \")[1].split(\" !\")[0].strip()\n                go_parents[current_term].append(parent)\n                go_children[parent].append(current_term)\n            elif line.startswith(\"relationship: part_of\") and current_term:\n                parent = line.split(\"part_of \")[1].split(\" !\")[0].strip()\n                go_parents[current_term].append(parent)\n                go_children[parent].append(current_term)\n    \n    print(f\"Loaded {len(go_parents):,} GO terms\")\n    return go_parents, go_children\n\ndef get_all_ancestors(term, go_parents, cache):\n    if term in cache:\n        return cache[term]\n    ancestors = set()\n    for parent in go_parents.get(term, []):\n        ancestors.add(parent)\n        ancestors.update(get_all_ancestors(parent, go_parents, cache))\n    cache[term] = ancestors\n    return ancestors\n\ndef get_descendants(term, go_children, cache):\n    if term in cache:\n        return cache[term]\n    descendants = set()\n    stack = [term]\n    while stack:\n        current = stack.pop()\n        for child in go_children.get(current, []):\n            if child not in descendants:\n                descendants.add(child)\n                stack.append(child)\n    cache[term] = descendants\n    return descendants\n\nprint(\"GO utilities ready\")"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# Parse FASTA files (no biopython needed)\ndef parse_fasta(path):\n    seqs = {}\n    feats = {}\n    print(f\"Parsing {os.path.basename(path)}...\")\n    \n    current_id = None\n    current_seq = []\n    \n    with open(path, 'r') as f:\n        for line in f:\n            line = line.strip()\n            if line.startswith('>'):\n                # Save previous sequence\n                if current_id is not None and current_seq:\n                    seq = ''.join(current_seq)\n                    seqs[current_id] = seq\n                    feats[current_id] = SequenceFeatureExtractor.extract(seq)\n                \n                # Parse new ID\n                header = line[1:]  # Remove '>'\n                if '|' in header:\n                    current_id = header.split('|')[1].split()[0]\n                else:\n                    current_id = header.split()[0]\n                current_seq = []\n            else:\n                current_seq.append(line)\n        \n        # Don't forget last sequence\n        if current_id is not None and current_seq:\n            seq = ''.join(current_seq)\n            seqs[current_id] = seq\n            feats[current_id] = SequenceFeatureExtractor.extract(seq)\n    \n    print(f\"  Loaded {len(seqs):,} sequences\")\n    return seqs, feats\n\nprint(\"FASTA parser ready (no biopython)\")"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# Load all data\nprint(\"=\"*50)\nprint(\"LOADING DATA\")\nprint(\"=\"*50)\n\n# 1. ESM2 Embeddings\nprint(\"\\nLoading ESM2 embeddings...\")\nemb_ids = pd.read_csv(CFG.EMB_IDS)['protein_id'].tolist()\nemb_data = np.load(CFG.EMB_PATH)\nembed_dict = {pid: emb_data[i] for i, pid in enumerate(emb_ids)}\nEMB_DIM = emb_data.shape[1]\ndel emb_data\ngc.collect()\nprint(f\"  {len(embed_dict):,} embeddings, dim={EMB_DIM}\")\n\n# 2. Sequences + Manual Features\nprint(\"\\nExtracting sequence features...\")\ntrain_seqs, train_feats = parse_fasta(CFG.TRAIN_SEQS)\ntest_seqs, test_feats = parse_fasta(CFG.TEST_SEQS)\n\n# 3. Taxonomy\nprint(\"\\nLoading taxonomy...\")\ntrain_tax_df = pd.read_csv(CFG.TRAIN_TAX, sep='\\t', header=None, names=['pid', 'taxid'])\ntest_tax_df = pd.read_csv(CFG.TEST_TAX, sep='\\t', header=None, names=['pid', 'taxid'])\ntop_taxa = train_tax_df['taxid'].value_counts().head(20).index.tolist()\nprint(f\"  Top 20 taxa: {top_taxa[:5]}...\")\n\ndef get_tax_vector(pid, tax_map):\n    vec = np.zeros(len(top_taxa), dtype=np.float32)\n    if pid in tax_map and tax_map[pid] in top_taxa:\n        vec[top_taxa.index(tax_map[pid])] = 1.0\n    return vec\n\ntrain_tax_map = train_tax_df.set_index('pid')['taxid'].to_dict()\ntest_tax_map = test_tax_df.set_index('pid')['taxid'].to_dict()\n\n# 4. Scale manual features and combine\nprint(\"\\nScaling and combining features...\")\nall_pids_train = list(train_feats.keys())\nall_pids_test = list(test_feats.keys())\n\nX_manual_train = np.array([train_feats[p] for p in all_pids_train])\nX_manual_test = np.array([test_feats[p] for p in all_pids_test])\n\nscaler = StandardScaler()\nX_manual_train = scaler.fit_transform(X_manual_train)\nX_manual_test = scaler.transform(X_manual_test)\n\n# Rebuild feature dicts with scaled features + taxonomy\nfor i, pid in enumerate(all_pids_train):\n    train_feats[pid] = np.concatenate([X_manual_train[i], get_tax_vector(pid, train_tax_map)])\nfor i, pid in enumerate(all_pids_test):\n    test_feats[pid] = np.concatenate([X_manual_test[i], get_tax_vector(pid, test_tax_map)])\n\nMANUAL_DIM = 85 + 20  # manual features + taxonomy\nINPUT_DIM = EMB_DIM + MANUAL_DIM\nprint(f\"  Input dim: {EMB_DIM} (ESM) + {MANUAL_DIM} (manual+tax) = {INPUT_DIM}\")\n\n# 5. GO Ontology\ngo_parents, go_children = parse_obo(CFG.OBO_FILE)\n\n# 6. Training terms\nprint(\"\\nLoading training terms...\")\ntrain_terms = pd.read_csv(CFG.TRAIN_TERMS, sep='\\t')\nprint(f\"  {len(train_terms):,} annotations\")\n\nprint(\"\\n\" + \"=\"*50)\nprint(\"DATA LOADED SUCCESSFULLY\")\nprint(\"=\"*50)"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# MLP Model\nclass MLP(nn.Module):\n    def __init__(self, input_dim, num_classes, h1, h2, drop):\n        super().__init__()\n        self.net = nn.Sequential(\n            nn.Linear(input_dim, h1),\n            nn.BatchNorm1d(h1),\n            nn.ReLU(),\n            nn.Dropout(drop),\n            nn.Linear(h1, h2),\n            nn.BatchNorm1d(h2),\n            nn.ReLU(),\n            nn.Dropout(drop),\n            nn.Linear(h2, num_classes)\n        )\n    \n    def forward(self, x):\n        return self.net(x)\n\n# Dataset\nclass ProteinDataset(Dataset):\n    def __init__(self, pids, labels, emb_dict, feat_dict):\n        self.pids = pids\n        self.labels = labels\n        self.emb = emb_dict\n        self.feat = feat_dict\n    \n    def __len__(self):\n        return len(self.pids)\n    \n    def __getitem__(self, idx):\n        pid = self.pids[idx]\n        x = np.concatenate([self.emb[pid], self.feat[pid]]).astype(np.float32)\n        if self.labels is not None:\n            y = self.labels[idx].toarray().flatten() if hasattr(self.labels[idx], 'toarray') else self.labels[idx]\n            return torch.from_numpy(x), torch.tensor(y, dtype=torch.float32)\n        return torch.from_numpy(x)\n\nprint(\"MLP and Dataset ready\")"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# Training loop\naspects = {'Biological Process': 'P', 'Molecular Function': 'F', 'Cellular Component': 'C'}\nsubmission_dfs = []\n\nfor long_name, short_code in aspects.items():\n    print(f\"\\n{'='*60}\")\n    print(f\"ASPECT: {long_name} ({short_code})\")\n    print('='*60)\n    \n    # Get data for this aspect\n    aspect_df = train_terms[train_terms['aspect'] == short_code]\n    prot_terms = aspect_df.groupby('EntryID')['term'].apply(list).to_dict()\n    valid_pids = [p for p in prot_terms.keys() if p in embed_dict and p in train_feats]\n    \n    if not valid_pids:\n        print(f\"  No valid proteins, skipping...\")\n        continue\n    \n    print(f\"  Valid proteins: {len(valid_pids):,}\")\n    \n    # Create labels\n    mlb = MultiLabelBinarizer(sparse_output=True)\n    y_train = mlb.fit_transform([prot_terms[p] for p in valid_pids])\n    num_classes = len(mlb.classes_)\n    print(f\"  GO terms: {num_classes:,}\")\n    \n    # Create dataset\n    ds = ProteinDataset(valid_pids, y_train, embed_dict, train_feats)\n    \n    # Train ensemble of 3 models\n    models_list = []\n    \n    for i, conf in enumerate(ENSEMBLE_CONFIGS):\n        print(f\"\\n  Model {i+1}/3: D1={conf['D1']}, D2={conf['D2']}, Drop={conf['Drop']}\")\n        \n        # Split\n        train_len = int(0.9 * len(ds))\n        train_ds, val_ds = random_split(ds, [train_len, len(ds) - train_len])\n        train_loader = DataLoader(train_ds, batch_size=conf['BS'], shuffle=True, num_workers=2)\n        val_loader = DataLoader(val_ds, batch_size=conf['BS'], num_workers=2)\n        \n        # Model\n        model = MLP(INPUT_DIM, num_classes, conf['D1'], conf['D2'], conf['Drop']).to(CFG.DEVICE)\n        optimizer = optim.AdamW(model.parameters(), lr=conf['LR'], weight_decay=1e-4)\n        criterion = nn.BCEWithLogitsLoss()\n        scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=conf['EPOCHS'], eta_min=1e-6)\n        \n        # Train\n        for epoch in range(conf['EPOCHS']):\n            model.train()\n            t_loss = 0\n            for x, y in train_loader:\n                x, y = x.to(CFG.DEVICE), y.to(CFG.DEVICE)\n                optimizer.zero_grad()\n                out = model(x)\n                loss = criterion(out, y)\n                loss.backward()\n                optimizer.step()\n                t_loss += loss.item()\n            \n            scheduler.step()\n            \n            if (epoch + 1) % 10 == 0:\n                model.eval()\n                v_loss = 0\n                with torch.no_grad():\n                    for x, y in val_loader:\n                        x, y = x.to(CFG.DEVICE), y.to(CFG.DEVICE)\n                        out = model(x)\n                        v_loss += criterion(out, y).item()\n                print(f\"    Epoch {epoch+1}: Train={t_loss/len(train_loader):.4f}, Val={v_loss/len(val_loader):.4f}\")\n        \n        models_list.append(model)\n        print(f\"    Model {i+1} trained!\")\n    \n    # Predict with ensemble\n    print(f\"\\n  Predicting with ensemble...\")\n    test_pids = [p for p in test_seqs.keys() if p in embed_dict and p in test_feats]\n    test_ds = ProteinDataset(test_pids, None, embed_dict, test_feats)\n    test_loader = DataLoader(test_ds, batch_size=64, num_workers=2)\n    \n    temp_preds = []\n    threshold = CFG.THRESHOLDS[short_code]\n    \n    with torch.no_grad():\n        for m in models_list:\n            m.eval()\n        \n        batch_idx = 0\n        for x in tqdm(test_loader, desc=f\"  Predicting {short_code}\"):\n            x = x.to(CFG.DEVICE)\n            # Average predictions from all 3 models\n            outputs = [torch.sigmoid(m(x)) for m in models_list]\n            avg_out = torch.stack(outputs).mean(dim=0).cpu().numpy()\n            \n            for i in range(len(x)):\n                pid = test_pids[batch_idx + i]\n                scores = avg_out[i]\n                idxs = np.where(scores >= threshold)[0]\n                if len(idxs) < CFG.MIN_PREDS:\n                    idxs = np.argsort(scores)[-CFG.MIN_PREDS:]\n                for idx in idxs:\n                    temp_preds.append({'pid': pid, 'term': mlb.classes_[idx], 'p': float(scores[idx])})\n            batch_idx += len(x)\n    \n    print(f\"  Generated {len(temp_preds):,} predictions\")\n    submission_dfs.append(pd.DataFrame(temp_preds))\n    \n    # Cleanup\n    del models_list, model, optimizer, criterion, train_loader, val_loader, ds\n    gc.collect()\n    torch.cuda.empty_cache()\n\nprint(\"\\n\" + \"=\"*60)\nprint(\"ALL ASPECTS TRAINED\")\nprint(\"=\"*60)"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# Combine and propagate\nprint(\"\\nPost-processing...\")\n\nraw_df = pd.concat(submission_dfs, ignore_index=True)\nprint(f\"Raw predictions: {len(raw_df):,}\")\n\n# Propagate to ancestors\nprint(\"Propagating to ancestors...\")\nancestor_cache = {}\npropagated_rows = []\n\nfor pid, group in tqdm(raw_df.groupby('pid'), desc=\"Propagating\"):\n    term_scores = {}\n    for _, row in group.iterrows():\n        term_scores[row['term']] = max(term_scores.get(row['term'], 0), row['p'])\n    \n    initial_terms = list(term_scores.keys())\n    for term in initial_terms:\n        score = term_scores[term]\n        for ancestor in get_all_ancestors(term, go_parents, ancestor_cache):\n            term_scores[ancestor] = max(term_scores.get(ancestor, 0), score)\n    \n    for term, score in term_scores.items():\n        propagated_rows.append({'pid': pid, 'term': term, 'p': score})\n\npropagated_df = pd.DataFrame(propagated_rows)\nprint(f\"After propagation: {len(propagated_df):,}\")"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# GOA corrections\nprint(\"\\nApplying GOA corrections...\")\n\nif os.path.exists(CFG.GOA_PATH):\n    goa = pd.read_csv(CFG.GOA_PATH)\n    col_map = {'db_object_id': 'pid', 'go_id': 'term'}\n    goa = goa.rename(columns=col_map)\n    \n    # Negative annotations\n    if 'qualifier' in goa.columns:\n        print(\"Processing negative annotations...\")\n        negs = goa[goa['qualifier'].str.contains('NOT', na=False)]\n        \n        expanded_neg_list = []\n        descendant_cache = {}\n        \n        for _, row in tqdm(negs.iterrows(), total=len(negs), desc=\"Expanding negatives\"):\n            pid, term = row['pid'], row['term']\n            expanded_neg_list.append((pid, term))\n            for kid in get_descendants(term, go_children, descendant_cache):\n                expanded_neg_list.append((pid, kid))\n        \n        neg_set = set(expanded_neg_list)\n        propagated_df['key'] = list(zip(propagated_df['pid'], propagated_df['term']))\n        before = len(propagated_df)\n        propagated_df = propagated_df[~propagated_df['key'].isin(neg_set)].drop(columns=['key'])\n        print(f\"  Removed {before - len(propagated_df):,} negative predictions\")\n    \n    # Positive annotations\n    print(\"Applying positive annotations...\")\n    pos = goa[~goa['qualifier'].str.contains('NOT', na=False)] if 'qualifier' in goa.columns else goa\n    relevant_pids = set(propagated_df['pid'].unique())\n    pos = pos[pos['pid'].isin(relevant_pids)]\n    pos_df = pd.DataFrame({'pid': pos['pid'], 'term': pos['term'], 'p': 1.0})\n    \n    final_df = pd.concat([propagated_df, pos_df], ignore_index=True)\n    final_df = final_df.groupby(['pid', 'term'], as_index=False)['p'].max()\n    print(f\"  Applied {len(pos_df):,} positive annotations\")\nelse:\n    final_df = propagated_df\n    print(\"No GOA file found\")\n\nprint(f\"Final predictions: {len(final_df):,}\")"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"# Save submission\nprint(\"\\nSaving submission...\")\nfinal_df = final_df.sort_values(['pid', 'p'], ascending=[True, False])\nfinal_df.to_csv('submission.tsv', sep='\\t', index=False, header=False)\n\nprint(\"\\n\" + \"=\"*60)\nprint(\"CAFA-6 Ultimate v4 - MLP Ensemble Complete!\")\nprint(\"=\"*60)\nprint(f\"Total predictions: {len(final_df):,}\")\nprint(f\"Unique proteins: {final_df['pid'].nunique():,}\")\nprint(f\"Unique GO terms: {final_df['term'].nunique():,}\")\nprint(\"=\"*60)\n\n!head submission.tsv"}],"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.10.0"}},"nbformat":4,"nbformat_minor":4}