hsfast-ml / features.py
ali
Deploy hsFAST ML service β€” ESM2-150M gated model (epoch 4)
b72d311
Raw
History Blame Contribute Delete
12.1 kB
"""
Mutation feature engineering.
All features are derived from the mutation itself (from_aa, to_aa, position)
and the local sequence context around the mutation site.
Feature vector layout (61 dims max):
[0-55] physicochemical (blosum, KD, vol, charge, burial, flags, one-hot)
[56-58] secondary structure one-hot: [sst_helix, sst_strand, sst_loop]
[59] relative solvent accessibility (RSA, 0=buried, 1=surface)
[60] esm_masked_marginal (optional β€” only when model trained with ESM-2)
"""
import numpy as np
AMINO_ACIDS = list('ACDEFGHIKLMNPQRSTVWY')
AA_INDEX = {aa: i for i, aa in enumerate(AMINO_ACIDS)}
# BLOSUM62 matrix
BLOSUM62 = {
'A': {'A':4,'R':-1,'N':-2,'D':-2,'C':0,'Q':-1,'E':-1,'G':0,'H':-2,'I':-1,'L':-1,'K':-1,'M':-1,'F':-2,'P':-1,'S':1,'T':0,'W':-3,'Y':-2,'V':0},
'R': {'A':-1,'R':5,'N':0,'D':-2,'C':-3,'Q':1,'E':0,'G':-2,'H':0,'I':-3,'L':-2,'K':2,'M':-1,'F':-3,'P':-2,'S':-1,'T':-1,'W':-3,'Y':-2,'V':-3},
'N': {'A':-2,'R':0,'N':6,'D':1,'C':-3,'Q':0,'E':0,'G':0,'H':1,'I':-3,'L':-3,'K':0,'M':-2,'F':-3,'P':-2,'S':1,'T':0,'W':-4,'Y':-2,'V':-3},
'D': {'A':-2,'R':-2,'N':1,'D':6,'C':-3,'Q':0,'E':2,'G':-1,'H':-1,'I':-3,'L':-4,'K':-1,'M':-3,'F':-3,'P':-1,'S':0,'T':-1,'W':-4,'Y':-3,'V':-3},
'C': {'A':0,'R':-3,'N':-3,'D':-3,'C':9,'Q':-3,'E':-4,'G':-3,'H':-3,'I':-1,'L':-1,'K':-3,'M':-1,'F':-2,'P':-3,'S':-1,'T':-1,'W':-2,'Y':-2,'V':-1},
'Q': {'A':-1,'R':1,'N':0,'D':0,'C':-3,'Q':5,'E':2,'G':-2,'H':0,'I':-3,'L':-2,'K':1,'M':0,'F':-3,'P':-1,'S':0,'T':-1,'W':-2,'Y':-1,'V':-2},
'E': {'A':-1,'R':0,'N':0,'D':2,'C':-4,'Q':2,'E':5,'G':-2,'H':0,'I':-3,'L':-3,'K':1,'M':-2,'F':-3,'P':-1,'S':0,'T':-1,'W':-3,'Y':-2,'V':-2},
'G': {'A':0,'R':-2,'N':0,'D':-1,'C':-3,'Q':-2,'E':-2,'G':6,'H':-2,'I':-4,'L':-4,'K':-2,'M':-3,'F':-3,'P':-2,'S':0,'T':-2,'W':-2,'Y':-3,'V':-3},
'H': {'A':-2,'R':0,'N':1,'D':-1,'C':-3,'Q':0,'E':0,'G':-2,'H':8,'I':-3,'L':-3,'K':-1,'M':-2,'F':-1,'P':-2,'S':-1,'T':-2,'W':-2,'Y':2,'V':-3},
'I': {'A':-1,'R':-3,'N':-3,'D':-3,'C':-1,'Q':-3,'E':-3,'G':-4,'H':-3,'I':4,'L':2,'K':-3,'M':1,'F':0,'P':-3,'S':-2,'T':-1,'W':-3,'Y':-1,'V':3},
'L': {'A':-1,'R':-2,'N':-3,'D':-4,'C':-1,'Q':-2,'E':-3,'G':-4,'H':-3,'I':2,'L':4,'K':-2,'M':2,'F':0,'P':-3,'S':-2,'T':-1,'W':-2,'Y':-1,'V':1},
'K': {'A':-1,'R':2,'N':0,'D':-1,'C':-3,'Q':1,'E':1,'G':-2,'H':-1,'I':-3,'L':-2,'K':5,'M':-1,'F':-3,'P':-1,'S':0,'T':-1,'W':-3,'Y':-2,'V':-2},
'M': {'A':-1,'R':-1,'N':-2,'D':-3,'C':-1,'Q':0,'E':-2,'G':-3,'H':-2,'I':1,'L':2,'K':-1,'M':5,'F':0,'P':-2,'S':-1,'T':-1,'W':-1,'Y':-1,'V':1},
'F': {'A':-2,'R':-3,'N':-3,'D':-3,'C':-2,'Q':-3,'E':-3,'G':-3,'H':-1,'I':0,'L':0,'K':-3,'M':0,'F':6,'P':-4,'S':-2,'T':-2,'W':1,'Y':3,'V':-1},
'P': {'A':-1,'R':-2,'N':-2,'D':-1,'C':-3,'Q':-1,'E':-1,'G':-2,'H':-2,'I':-3,'L':-3,'K':-1,'M':-2,'F':-4,'P':7,'S':-1,'T':-1,'W':-4,'Y':-3,'V':-2},
'S': {'A':1,'R':-1,'N':1,'D':0,'C':-1,'Q':0,'E':0,'G':0,'H':-1,'I':-2,'L':-2,'K':0,'M':-1,'F':-2,'P':-1,'S':4,'T':1,'W':-3,'Y':-2,'V':-2},
'T': {'A':0,'R':-1,'N':0,'D':-1,'C':-1,'Q':-1,'E':-1,'G':-2,'H':-2,'I':-1,'L':-1,'K':-1,'M':-1,'F':-2,'P':-1,'S':1,'T':5,'W':-2,'Y':-2,'V':0},
'W': {'A':-3,'R':-3,'N':-4,'D':-4,'C':-2,'Q':-2,'E':-3,'G':-2,'H':-2,'I':-3,'L':-2,'K':-3,'M':-1,'F':1,'P':-4,'S':-3,'T':-2,'W':11,'Y':2,'V':-3},
'Y': {'A':-2,'R':-2,'N':-2,'D':-3,'C':-2,'Q':-1,'E':-2,'G':-3,'H':2,'I':-1,'L':-1,'K':-2,'M':-1,'F':3,'P':-3,'S':-2,'T':-2,'W':2,'Y':7,'V':-1},
'V': {'A':0,'R':-3,'N':-3,'D':-3,'C':-1,'Q':-2,'E':-2,'G':-3,'H':-3,'I':3,'L':1,'K':-2,'M':1,'F':-1,'P':-2,'S':-2,'T':0,'W':-3,'Y':-1,'V':4},
}
# Kyte-Doolittle hydrophobicity
KD = {
'A':1.8,'R':-4.5,'N':-3.5,'D':-3.5,'C':2.5,'Q':-3.5,'E':-3.5,'G':-0.4,
'H':-3.2,'I':4.5,'L':3.8,'K':-3.9,'M':1.9,'F':2.8,'P':-1.6,'S':-0.8,
'T':-0.7,'W':-0.9,'Y':-1.3,'V':4.2,
}
# Residue volumes (Γ…Β³)
VOL = {
'A':88.6,'R':173.4,'N':114.1,'D':111.1,'C':108.5,'Q':143.8,'E':138.4,'G':60.1,
'H':153.2,'I':166.7,'L':166.7,'K':168.6,'M':162.9,'F':189.9,'P':112.7,'S':89.0,
'T':116.1,'W':227.8,'Y':193.6,'V':140.0,
}
# Net charge at pH 7
CHARGE = {
'A':0,'R':1,'N':0,'D':-1,'C':0,'Q':0,'E':-1,'G':0,'H':0.1,'I':0,
'L':0,'K':1,'M':0,'F':0,'P':0,'S':0,'T':0,'W':0,'Y':0,'V':0,
}
# Chou-Fasman helix (PΞ±) and strand (PΞ²) propensities (original 1974 values)
HELIX_PROP = {
'A':1.42,'R':0.98,'N':0.67,'D':1.01,'C':0.70,'Q':1.11,'E':1.51,'G':0.57,
'H':1.00,'I':1.08,'L':1.21,'K':1.16,'M':1.45,'F':1.13,'P':0.57,'S':0.77,
'T':0.83,'W':1.08,'Y':0.69,'V':1.06,
}
STRAND_PROP = {
'A':0.83,'R':0.93,'N':0.89,'D':0.54,'C':1.19,'Q':1.10,'E':0.37,'G':0.75,
'H':0.87,'I':1.60,'L':1.30,'K':0.74,'M':1.05,'F':1.38,'P':0.55,'S':0.75,
'T':1.19,'W':1.37,'Y':1.47,'V':1.70,
}
# SST canonical names β†’ class index (0=helix, 1=strand, 2=loop)
_SST_MAP = {
'alphahelix': 0, '3-10helix': 0,
'strand': 1, 'isolatedbeta-bridge': 1,
'turn': 2, 'bend': 2,
}
# Condition normalisation constants
TEMP_REF_K = 298.15
TEMP_SCALE = 15.0
PH_REF = 7.0
PH_SCALE = 1.5
FEATURE_NAMES = (
['blosum62', 'delta_kd', 'delta_vol', 'delta_charge', 'burial_score',
'is_pro_to', 'is_gly_to', 'is_cys_from', 'is_pro_from', 'is_gly_from',
'abs_delta_kd', 'abs_delta_vol', 'abs_delta_charge', 'position_frac',
'temp_norm', 'ph_norm']
+ [f'from_{aa}' for aa in AMINO_ACIDS]
+ [f'to_{aa}' for aa in AMINO_ACIDS]
+ ['sst_helix', 'sst_strand', 'sst_loop'] # Phase 4 β€” real from S1724 or Chou-Fasman proxy
+ ['rsa'] # Phase 4 β€” real from S1724 or burial-score proxy
+ ['esm_masked_marginal'] # Phase 3 β€” only when ESM-2 was available at training
)
# ── Structural feature helpers ────────────────────────────────────────────────
def burial_score(seq: str, pos: int, window: int = 4) -> float:
"""KD average of window neighbours β€” approximates hydrophobic burial."""
vals = []
for i in range(max(0, pos - window), min(len(seq), pos + window + 1)):
if i != pos:
vals.append(KD.get(seq[i], 0.0))
return float(np.mean(vals)) if vals else 0.0
def encode_sst(sst_str: str | None) -> list[float]:
"""
Map a canonical SST string (e.g. 'AlphaHelix') to one-hot [helix, strand, loop].
Unknown/None defaults to loop.
"""
if sst_str is None:
return [0.0, 0.0, 1.0]
idx = _SST_MAP.get(sst_str.lower().replace(' ', ''), 2)
oh = [0.0, 0.0, 0.0]
oh[idx] = 1.0
return oh
def predict_sst(seq: str, pos: int, window: int = 7) -> list[float]:
"""
Chou-Fasman window-average secondary structure prediction.
Returns one-hot [helix, strand, loop].
~70% 3-class accuracy β€” used as inference-time proxy when real SST unavailable.
"""
start = max(0, pos - window // 2)
end = min(len(seq), pos + window // 2 + 1)
window_seq = seq[start:end]
if not window_seq:
return [0.0, 0.0, 1.0]
avg_h = float(np.mean([HELIX_PROP.get(aa, 1.0) for aa in window_seq]))
avg_s = float(np.mean([STRAND_PROP.get(aa, 1.0) for aa in window_seq]))
if avg_h >= avg_s and avg_h > 1.0:
return [1.0, 0.0, 0.0]
if avg_s > avg_h and avg_s > 1.0:
return [0.0, 1.0, 0.0]
return [0.0, 0.0, 1.0]
def approx_rsa(burial: float) -> float:
"""
Sequence-based RSA proxy: sigmoid of negated KD-burial score.
burial > 0 (hydrophobic context) β†’ lower RSA (more buried).
Returns float in ~(0.15, 0.85).
"""
return float(1.0 / (1.0 + np.exp(burial * 0.8)))
# ── Main feature extractor ────────────────────────────────────────────────────
def extract(from_aa: str, to_aa: str, position: int,
sequence: str = '', conditions: dict = None,
esm_score: float | None = None,
rsa: float | None = None,
sst: str | None = None) -> np.ndarray:
"""
Returns a 1-D feature vector for one single-point mutation.
Phase 1-2 (always):
56 physicochemical features (BLOSUM62, KD, volume, charge, burial, flags, one-hot AA)
Phase 4 (always, real or proxied):
sst_helix / sst_strand / sst_loop β€” from S1724 CSV or Chou-Fasman proxy at inference
rsa β€” from S1724 CSV or burial-score sigmoid proxy
Phase 3 (optional):
esm_masked_marginal β€” only when model trained with ESM-2 (None β†’ omit, keeps 60-dim)
conditions keys (pre-normalised by caller):
temp_norm β€” (T_kelvin - 298.15) / 15.0
ph_norm β€” (pH - 7.0) / 1.5
"""
blosum = BLOSUM62.get(from_aa, {}).get(to_aa, -4)
d_kd = KD.get(to_aa, 0.0) - KD.get(from_aa, 0.0)
d_vol = VOL.get(to_aa, 110.0) - VOL.get(from_aa, 110.0)
d_chg = CHARGE.get(to_aa, 0.0) - CHARGE.get(from_aa, 0.0)
pos_in_seq = position - 1 # 0-based
burial = burial_score(sequence, pos_in_seq) if sequence else 0.0
pos_frac = position / max(len(sequence), 1) if sequence else 0.5
cond = conditions or {}
temp_norm = float(cond.get('temp_norm', 0.0))
ph_norm = float(cond.get('ph_norm', 0.0))
from_oh = [1.0 if aa == from_aa else 0.0 for aa in AMINO_ACIDS]
to_oh = [1.0 if aa == to_aa else 0.0 for aa in AMINO_ACIDS]
# Phase 4 structural features
sst_vec = encode_sst(sst) if sst is not None else predict_sst(sequence, pos_in_seq)
rsa_val = float(rsa) if rsa is not None else approx_rsa(burial)
vec = [
float(blosum),
d_kd,
d_vol,
d_chg,
burial,
float(to_aa == 'P'),
float(to_aa == 'G'),
float(from_aa == 'C'),
float(from_aa == 'P'),
float(from_aa == 'G'),
abs(d_kd),
abs(d_vol),
abs(d_chg),
pos_frac,
temp_norm,
ph_norm,
] + from_oh + to_oh + sst_vec + [rsa_val] # 60 dims
if esm_score is not None:
vec.append(float(esm_score)) # 61st dim
return np.array(vec, dtype=np.float32)
def feature_matrix(mutations: list[tuple], sequence: str = '',
conditions: dict = None) -> np.ndarray:
"""mutations: list of (from_aa, to_aa, position) tuples"""
return np.vstack([extract(f, t, p, sequence, conditions) for f, t, p in mutations])
# ── Sequence-window features for ProtStabCNN ─────────────────────────────────
CNN_WINDOW = 25 # residues on each side + centre = 25
CNN_SCALARS = 6 # blosum, d_kd, d_vol, d_chg, rsa, pos_frac
CNN_FEAT_DIM = CNN_WINDOW * len(AMINO_ACIDS) + CNN_SCALARS # 25Γ—20 + 6 = 506
def extract_window(from_aa: str, to_aa: str, position: int,
sequence: str, W: int = CNN_WINDOW) -> np.ndarray:
"""
506-dim feature vector for ProtStabCNN:
500 = W=25 residue window one-hot (WT sequence, zero-padded at termini)
6 = [blosum62, d_kd, d_vol, d_chg, rsa, pos_frac]
"""
pos_0 = position - 1
half = W // 2
L = len(sequence)
# Extract window with zero-padding at boundaries
window_oh = []
for offset in range(-half, half + 1):
idx = pos_0 + offset
if 0 <= idx < L:
aa = sequence[idx]
else:
aa = None # padding
oh = [1.0 if (aa is not None and aa == a) else 0.0 for a in AMINO_ACIDS]
window_oh.extend(oh)
# 5 physicochemical scalars
blosum = float(BLOSUM62.get(from_aa, {}).get(to_aa, -4))
d_kd = KD.get(to_aa, 0.0) - KD.get(from_aa, 0.0)
d_vol = VOL.get(to_aa, 110.0) - VOL.get(from_aa, 110.0)
d_chg = CHARGE.get(to_aa, 0.0) - CHARGE.get(from_aa, 0.0)
burial = burial_score(sequence, pos_0) if sequence else 0.0
rsa = approx_rsa(burial)
pos_frac = (position / max(L, 1)) if L else 0.5
return np.array(window_oh + [blosum, d_kd, d_vol, d_chg, rsa, pos_frac],
dtype=np.float32)