File size: 9,883 Bytes
8d0bbe4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""EC number prediction backend for the ProtHGT Space.

Unlike GO term prediction (one flat decoder per sub-ontology), EC prediction uses
a single joint model with a shared HGT backbone (plus a normalized skip
connection) and four level-specific decoders, run as a top-down cascade so the
predicted EC levels form a consistent hierarchy.
"""
import os
import gzip
import json
import shutil
from functools import lru_cache

import torch
from torch.nn import Linear, LayerNorm
from torch_geometric.nn import HGTConv, MLP
import pandas as pd
import yaml
from huggingface_hub import hf_hub_download

from run_prothgt_app import load_name_info

EC_MODELS_DIR = os.path.join("data", "models", "ec")
EC_CONFIG_PATH = os.path.join(EC_MODELS_DIR, "prothgt-ec-config.yaml")
EC_MODEL_PATH = os.path.join(EC_MODELS_DIR, "prothgt-ec-model.pt")
EC_KG_PATH = os.path.join("data", "prothgt-ec-kg.pt")
# EC class/subclass/sub-subclass (L1-L3) names from the ExPASy ENZYME nomenclature;
# L4 (full EC number) names come from name_info instead.
EC_CLASS_NAMES_PATH = os.path.join("data", "ec_class_names.json")

LEVELS = ['l1', 'l2', 'l3', 'l4']
LEVEL_TARGET_TYPE = {'l1': 'EC_class_1', 'l2': 'EC_class_2', 'l3': 'EC_class_3', 'l4': 'EC_number'}
LEVEL_LABEL = {'l1': 'L1', 'l2': 'L2', 'l3': 'L3', 'l4': 'L4'}
CHILDREN_KEY = {'l2': 'l1_children', 'l3': 'l2_children', 'l4': 'l3_children'}

# EC taxonomy edges used to find each node's children for the cascade.
CHILD_EDGE_TYPES = {
    'l1_children': ('EC_class_1', 'rev_ec_parent_1', 'EC_class_2'),
    'l2_children': ('EC_class_2', 'rev_ec_parent_2', 'EC_class_3'),
    'l3_children': ('EC_class_3', 'rev_ec_parent_3', 'EC_number'),
}

@lru_cache(maxsize=1)
def load_ec_class_names(path=EC_CLASS_NAMES_PATH):
    """{EC class prefix (L1-L3) -> name}, e.g. '2.7.11' -> 'Protein-serine/threonine kinases'."""
    with open(path, encoding='utf-8') as f:
        return json.load(f)


# ---------------------------------------------------------------------------
# Model (ported from the ProtHGT src/ec package)
# ---------------------------------------------------------------------------
class ProtHGT(torch.nn.Module):
    """HGT backbone with a normalized pre/post-GNN skip connection."""
    def __init__(self, data, hidden_channels, num_heads, num_layers, mlp_hidden_layers, mlp_dropout):
        super().__init__()
        self.lin_dict = torch.nn.ModuleDict()
        for node_type in data.node_types:
            self.lin_dict[node_type] = Linear(data[node_type].x.size(1), hidden_channels)

        self.convs = torch.nn.ModuleList()
        for _ in range(num_layers):
            self.convs.append(HGTConv(hidden_channels, hidden_channels, data.metadata(), num_heads, group='sum'))

        self.initial_norm = LayerNorm(hidden_channels)
        self.gnn_norm = LayerNorm(hidden_channels)
        self.mlp = MLP(mlp_hidden_layers, dropout=mlp_dropout, norm=None)

    def generate_embeddings(self, x_dict, edge_index_dict):
        initial_dict = {nt: self.lin_dict[nt](x).relu_() for nt, x in x_dict.items()}
        gnn_dict = initial_dict
        for conv in self.convs:
            out = conv(gnn_dict, edge_index_dict)
            gnn_dict = {nt: (out[nt] if out.get(nt) is not None else prev)
                        for nt, prev in gnn_dict.items()}
        return {nt: torch.cat([self.initial_norm(initial_dict[nt]), self.gnn_norm(gnn_dict[nt])], dim=-1)
                for nt in initial_dict}


class ProtHGTHier(torch.nn.Module):
    """Shared ProtHGT backbone + one MLP decoder per EC level."""
    def __init__(self, data, hidden_channels, num_heads, num_layers, mlp_hidden_layers, mlp_dropout):
        super().__init__()
        self.backbone = ProtHGT(data, hidden_channels, num_heads, num_layers, mlp_hidden_layers, mlp_dropout)
        self.backbone.mlp = torch.nn.Identity()
        self.decoders = torch.nn.ModuleDict({
            level: MLP(mlp_hidden_layers, dropout=mlp_dropout, norm=None) for level in LEVELS
        })

    def generate_embeddings(self, x_dict, edge_index_dict):
        return self.backbone.generate_embeddings(x_dict, edge_index_dict)


def load_hierarchy_masks(data):
    """{'l1_children': {ec_class_1_idx: [ec_class_2_idx, ...]}, ...} from the EC taxonomy edges."""
    from collections import defaultdict
    masks = {}
    for name, et in CHILD_EDGE_TYPES.items():
        row, col = data[et].edge_index
        children = defaultdict(list)
        for r, c in zip(row.tolist(), col.tolist()):
            children[r].append(c)
        masks[name] = dict(children)
    return masks


# ---------------------------------------------------------------------------
# Loading
# ---------------------------------------------------------------------------
def load_ec_kg(output=EC_KG_PATH):
    """Download (if needed) and load the EC knowledge graph. Callers should cache the result."""
    if not os.path.exists(output):
        cached_path = hf_hub_download(
            repo_id="HUBioDataLab/ProtHGT",
            filename="knowledge_graphs/ec/prothgt-ec-kg.pt",
            repo_type="dataset",
        )
        os.makedirs(os.path.dirname(output), exist_ok=True)
        shutil.copy(cached_path, output)
        print(f"EC KG downloaded to {output}")
    print('Loading EC knowledge graph...')
    return torch.load(output)


def load_ec_model(heterodata, config_path=EC_CONFIG_PATH, model_path=EC_MODEL_PATH):
    """Build ProtHGTHier and load its weights. Callers should cache the result."""
    with open(config_path) as f:
        cfg = yaml.safe_load(f)
    h = cfg['hidden_channels']
    model = ProtHGTHier(
        heterodata, hidden_channels=h, num_heads=cfg['num_heads'], num_layers=cfg['num_layers'],
        mlp_hidden_layers=[4 * h, h, 1], mlp_dropout=cfg['mlp_dropout'],
    )
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model.load_state_dict(torch.load(model_path, map_location=device))
    model.eval()
    print(f'Loaded EC model weights from {model_path}')
    return model


# ---------------------------------------------------------------------------
# Cascade prediction
# ---------------------------------------------------------------------------
@torch.no_grad()
def _cascade_protein(model, x_dict, masks, protein_idx, inv_maps, threshold, device):
    """Top-down cascade for one protein: L1 over all classes, each deeper level only
    over the children of the kept nodes above. Returns (level_key, ec_string, prob) rows."""
    rows = []
    kept_prev = []
    for level in LEVELS:
        target_type = LEVEL_TARGET_TYPE[level]
        if level == 'l1':
            candidates = list(range(x_dict[target_type].size(0)))
        else:
            ckey = CHILDREN_KEY[level]
            candidates = sorted({c for p in kept_prev for c in masks[ckey].get(p, [])})
        if not candidates:
            break

        cand = torch.tensor(candidates, dtype=torch.long, device=device)
        p_emb = x_dict['Protein'][protein_idx].unsqueeze(0).expand(len(candidates), -1)
        z = torch.cat([p_emb, x_dict[target_type][cand]], dim=-1)
        probs = torch.sigmoid(model.decoders[level](z).view(-1))

        keep = (probs > threshold).nonzero(as_tuple=False).view(-1)
        kept_prev = [candidates[i] for i in keep.tolist()]
        for i in keep.tolist():
            rows.append((level, inv_maps[target_type][candidates[i]], float(probs[i])))
    return rows


def generate_ec_prediction_df(protein_ids, heterodata, model, threshold=0.5, progress_callback=None):
    """Cascade EC predictions for the given proteins. The backbone embeddings are
    computed once over the whole graph; the decoders then run per protein."""
    if isinstance(protein_ids, str):
        protein_ids = [protein_ids]

    # The full-graph forward over the whole KG is memory-heavy for this model
    # (hidden=256); fall back to CPU if a GPU runs out of memory.
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    def _embed(dev):
        model.to(dev)
        hd = heterodata.to(dev)
        with torch.no_grad():
            return hd, model.generate_embeddings(hd.x_dict, hd.edge_index_dict)

    try:
        heterodata, x_dict = _embed(device)
    except RuntimeError as e:
        if device.type == 'cuda' and 'out of memory' in str(e).lower():
            print('GPU out of memory during the full-graph forward; falling back to CPU...')
            torch.cuda.empty_cache()
            device = torch.device('cpu')
            heterodata, x_dict = _embed(device)
        else:
            raise
    masks = load_hierarchy_masks(heterodata)
    inv_maps = {
        LEVEL_TARGET_TYPE[level]: {idx: tok for tok, idx in heterodata[LEVEL_TARGET_TYPE[level]]['id_mapping'].items()}
        for level in LEVELS
    }
    id_mapping = heterodata['Protein']['id_mapping']
    name_info = load_name_info()
    ec_l4_names = name_info['EC_number']        # full EC number (L4) -> enzyme name
    ec_class_names = load_ec_class_names()      # L1-L3 class prefix -> class name

    rows = []
    for i, pid in enumerate(protein_ids):
        if progress_callback is not None:
            progress_callback(i, len(protein_ids))
        protein_name = name_info['Protein'].get(pid, pid)
        for level, token, prob in _cascade_protein(
                model, x_dict, masks, id_mapping[pid], inv_maps, threshold, device):
            ec_name = ec_l4_names.get(token, '') if level == 'l4' else ec_class_names.get(token, '')
            rows.append({
                'UniProt_ID': pid,
                'Protein': protein_name,
                'EC': token,
                'EC_name': ec_name,
                'EC_level': LEVEL_LABEL[level],
                'Probability': prob,
            })

    df = pd.DataFrame(rows, columns=['UniProt_ID', 'Protein', 'EC', 'EC_name', 'EC_level', 'Probability'])
    return heterodata, df