bertose-affinose-training-code / code /probes /analyze_core_biology.py
supanthadey1's picture
Add BERTose and AFFINose training code release
1d6f391 verified
Raw
History Blame Contribute Delete
22.5 kB
#!/usr/bin/env python3
"""
Tier 2: Core Biology Embedding Analysis
Analyzes whether GlycanBERT understands glycan biology at a structural level.
Analyses:
1. Glycan structural type clustering (N-glycan, O-glycan, GAG, etc.)
2. Monosaccharide composition vs embedding distance correlation
3. Branching complexity analysis
4. Linkage chemistry sensitivity (alpha vs beta)
5. Decorations (fucosylation, sialylation)
6. Plantae / Phytomining investigation
Usage:
python3 analyze_core_biology.py --model v6
python3 analyze_core_biology.py --model v5
"""
import os, sys, json, re, pickle, argparse
import numpy as np
from collections import Counter
from pathlib import Path
try:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
except ImportError:
print("ERROR: matplotlib required"); sys.exit(1)
try:
import umap
def compute_umap(data, n=2):
return umap.UMAP(n_components=n, n_neighbors=30, min_dist=0.3,
random_state=42, n_jobs=1).fit_transform(data)
except ImportError:
from sklearn.manifold import TSNE
def compute_umap(data, n=2):
return TSNE(n_components=n, perplexity=30, random_state=42).fit_transform(data)
from scipy.stats import spearmanr
# ── Glycan Type Classification ───────────────────────────────────────
def classify_glycan_type(iupac):
"""Classify a glycan by structural type from its IUPAC name."""
if not iupac or iupac == 'nan':
return 'Unknown'
iupac_upper = iupac.upper()
if 'GLCNAC(B1-4)GLCNAC' in iupac_upper and 'MAN' in iupac_upper:
return 'N-glycan'
if iupac_upper.count('MAN') >= 5 and 'GLCNAC' in iupac_upper:
return 'N-glycan (high-mannose)'
if 'GALNAC(A1-' in iupac_upper:
return 'O-glycan'
if any(x in iupac_upper for x in ['GLCA', 'IDOA', 'HEPARAN', 'CHONDROITIN']):
return 'GAG'
if iupac_upper.startswith('GLC(B1-') or iupac_upper.startswith('GLCCER'):
return 'Glycolipid'
if 'LAC' in iupac_upper and len(iupac) < 60:
return 'Milk OS'
return 'Other'
# ── Monosaccharide Composition ───────────────────────────────────────
MONOSACCHARIDES = ['Glc', 'GlcNAc', 'Man', 'Gal', 'GalNAc', 'Fuc',
'Neu5Ac', 'Xyl', 'Rha', 'GlcA', 'IdoA', 'Ara']
def get_composition(iupac):
"""Count monosaccharides in an IUPAC string."""
if not iupac:
return np.zeros(len(MONOSACCHARIDES))
vec = []
for mono in MONOSACCHARIDES:
count = len(re.findall(re.escape(mono) + r'[\(\[]', iupac, re.IGNORECASE))
if count == 0:
count = iupac.upper().count(mono.upper())
vec.append(count)
return np.array(vec, dtype=float)
# ── Analysis Functions ───────────────────────────────────────────────
def analysis_1_structural_types(embs, iupacs, output_dir, name):
"""UMAP colored by glycan structural type."""
print(f"\n=== Tier 2 Analysis 1: Structural Type Clustering ({name}) ===")
types = [classify_glycan_type(s) for s in iupacs]
type_counts = Counter(types)
print(f" Type distribution: {dict(type_counts)}")
valid_types = {t for t, c in type_counts.items() if c >= 5 and t != 'Unknown'}
mask = np.array([t in valid_types for t in types])
if mask.sum() < 20:
print(f" Only {mask.sum()} samples with known type, skipping UMAP.")
return {'structural_type_counts': dict(type_counts)}
embs_f = embs[mask]
types_f = [t for t, m in zip(types, mask) if m]
proj = compute_umap(embs_f)
colors = ['#E53935', '#1E88E5', '#43A047', '#FB8C00', '#8E24AA',
'#00ACC1', '#6D4C41', '#546E7A']
unique_types = sorted(set(types_f))
fig, ax = plt.subplots(figsize=(14, 10))
for i, gtype in enumerate(unique_types):
m = np.array([t == gtype for t in types_f])
ax.scatter(proj[m, 0], proj[m, 1], c=colors[i % len(colors)],
s=15, alpha=0.6, label=f'{gtype} (n={m.sum()})', rasterized=True)
ax.set_title(f'{name}: Glycan Structural Types in Embedding Space', fontsize=16, fontweight='bold')
ax.legend(fontsize=10, markerscale=3, loc='best')
ax.set_xlabel('UMAP-1', fontsize=12); ax.set_ylabel('UMAP-2', fontsize=12)
plt.tight_layout()
plt.savefig(os.path.join(output_dir, f'structural_types_{name}.png'), dpi=200, bbox_inches='tight')
plt.close()
print(f" Saved structural_types_{name}.png")
try:
from sklearn.metrics import silhouette_score
labels_num = [unique_types.index(t) for t in types_f]
if len(set(labels_num)) >= 2:
sil = silhouette_score(embs_f, labels_num, metric='cosine', sample_size=min(5000, len(embs_f)))
print(f" Silhouette (structural type): {sil:.4f}")
return {'structural_type_counts': dict(type_counts), 'silhouette_structural_type': round(sil, 4)}
except: pass
return {'structural_type_counts': dict(type_counts)}
def analysis_2_composition(embs, iupacs, output_dir, name, n_sample=2000):
"""Monosaccharide composition vs embedding distance correlation."""
print(f"\n=== Tier 2 Analysis 2: Composition Correlation ({name}) ===")
n = min(n_sample, len(embs))
idx = np.random.RandomState(42).choice(len(embs), n, replace=False)
comp_vecs = np.array([get_composition(iupacs[i]) for i in idx])
emb_subset = embs[idx]
norms = np.linalg.norm(comp_vecs, axis=1)
valid = norms > 0
if valid.sum() < 50:
print(f" Only {valid.sum()} samples with parseable composition, skipping.")
return {}
comp_vecs = comp_vecs[valid]
emb_subset = emb_subset[valid]
print(f" {len(comp_vecs)} samples with valid composition")
emb_n = emb_subset / (np.linalg.norm(emb_subset, axis=1, keepdims=True) + 1e-8)
comp_n = comp_vecs / (np.linalg.norm(comp_vecs, axis=1, keepdims=True) + 1e-8)
emb_sim = emb_n @ emb_n.T
comp_sim = comp_n @ comp_n.T
mask = np.triu(np.ones_like(emb_sim, dtype=bool), k=1)
emb_flat = emb_sim[mask]
comp_flat = comp_sim[mask]
if len(emb_flat) > 50000:
sample_idx = np.random.RandomState(42).choice(len(emb_flat), 50000, replace=False)
emb_flat = emb_flat[sample_idx]
comp_flat = comp_flat[sample_idx]
rho, pval = spearmanr(comp_flat, emb_flat)
print(f" Spearman rho = {rho:.4f} (p = {pval:.2e})")
fig, ax = plt.subplots(figsize=(10, 8))
sample = np.random.RandomState(42).choice(len(emb_flat), min(5000, len(emb_flat)), replace=False)
ax.scatter(comp_flat[sample], emb_flat[sample], s=1, alpha=0.15, c='#1565C0', rasterized=True)
ax.set_xlabel('Compositional Similarity (cosine)', fontsize=14)
ax.set_ylabel('Embedding Similarity (cosine)', fontsize=14)
ax.set_title(f'{name}: Composition vs Embedding Similarity\nSpearman rho = {rho:.4f}', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig(os.path.join(output_dir, f'composition_correlation_{name}.png'), dpi=200, bbox_inches='tight')
plt.close()
print(f" Saved composition_correlation_{name}.png")
return {'composition_spearman_rho': round(rho, 4), 'composition_p_value': float(f'{pval:.2e}'),
'n_composition_samples': int(len(comp_vecs))}
def analysis_3_branching(embs, branch_depths_list, output_dir, name):
"""Branching complexity analysis."""
print(f"\n=== Tier 2 Analysis 3: Branching Complexity ({name}) ===")
max_depths = []
for bd in branch_depths_list:
if isinstance(bd, (list, np.ndarray)) and len(bd) > 0:
max_depths.append(int(max(bd)))
else:
max_depths.append(0)
max_depths = np.array(max_depths)
depth_counts = Counter(max_depths)
print(f" Depth distribution: {dict(sorted(depth_counts.items()))}")
def depth_category(d):
if d <= 1: return 'Linear (depth<=1)'
elif d == 2: return 'Branched (depth=2)'
elif d == 3: return 'Complex (depth=3)'
else: return 'Hyper-branched (depth>=4)'
categories = [depth_category(d) for d in max_depths]
cat_counts = Counter(categories)
valid_cats = {c for c, n in cat_counts.items() if n >= 10}
mask = np.array([c in valid_cats for c in categories])
if mask.sum() < 30:
print(f" Insufficient category diversity ({mask.sum()} valid), skipping UMAP.")
return {'branching_distribution': dict(depth_counts)}
embs_f = embs[mask]
cats_f = [c for c, m in zip(categories, mask) if m]
proj = compute_umap(embs_f)
colors = {'Linear (depth<=1)': '#42A5F5', 'Branched (depth=2)': '#66BB6A',
'Complex (depth=3)': '#FFA726', 'Hyper-branched (depth>=4)': '#EF5350'}
unique_cats = sorted(set(cats_f))
fig, ax = plt.subplots(figsize=(14, 10))
for cat in unique_cats:
m = np.array([c == cat for c in cats_f])
ax.scatter(proj[m, 0], proj[m, 1], c=colors.get(cat, '#999'),
s=15, alpha=0.5, label=f'{cat} (n={m.sum()})', rasterized=True)
ax.set_title(f'{name}: Branching Complexity in Embedding Space', fontsize=16, fontweight='bold')
ax.legend(fontsize=11, markerscale=3)
ax.set_xlabel('UMAP-1', fontsize=12); ax.set_ylabel('UMAP-2', fontsize=12)
plt.tight_layout()
plt.savefig(os.path.join(output_dir, f'branching_complexity_{name}.png'), dpi=200, bbox_inches='tight')
plt.close()
print(f" Saved branching_complexity_{name}.png")
try:
from sklearn.metrics import silhouette_score
labels_num = [unique_cats.index(c) for c in cats_f]
if len(set(labels_num)) >= 2:
sil = silhouette_score(embs_f, labels_num, metric='cosine', sample_size=min(5000, len(embs_f)))
print(f" Silhouette (branching): {sil:.4f}")
return {'branching_distribution': dict(depth_counts), 'silhouette_branching': round(sil, 4)}
except: pass
return {'branching_distribution': dict(depth_counts)}
def analysis_4_linkage(embs, linkage_types_list, output_dir, name):
"""Linkage chemistry analysis: alpha vs beta distribution in embedding space."""
print(f"\n=== Tier 2 Analysis 4: Linkage Chemistry ({name}) ===")
alpha_fracs = []
for lt in linkage_types_list:
if isinstance(lt, (list, np.ndarray)) and len(lt) > 0:
arr = np.array(lt)
n_valid = np.sum(arr > 0)
if n_valid > 0:
n_alpha = np.sum(arr == 1)
alpha_fracs.append(n_alpha / n_valid)
else:
alpha_fracs.append(0.5)
else:
alpha_fracs.append(0.5)
alpha_fracs = np.array(alpha_fracs)
def linkage_category(frac):
if frac >= 0.8: return 'Mostly alpha'
elif frac <= 0.2: return 'Mostly beta'
else: return 'Mixed alpha/beta'
categories = [linkage_category(f) for f in alpha_fracs]
cat_counts = Counter(categories)
print(f" Linkage distribution: {dict(cat_counts)}")
valid_cats = {c for c, n in cat_counts.items() if n >= 10}
mask = np.array([c in valid_cats for c in categories])
if mask.sum() < 30:
print(f" Insufficient diversity, skipping UMAP.")
return {'linkage_distribution': dict(cat_counts)}
embs_f = embs[mask]
cats_f = [c for c, m in zip(categories, mask) if m]
proj = compute_umap(embs_f)
colors = {'Mostly alpha': '#E53935', 'Mixed alpha/beta': '#FDD835', 'Mostly beta': '#1E88E5'}
unique_cats = sorted(set(cats_f))
fig, ax = plt.subplots(figsize=(14, 10))
for cat in unique_cats:
m = np.array([c == cat for c in cats_f])
ax.scatter(proj[m, 0], proj[m, 1], c=colors.get(cat, '#999'),
s=15, alpha=0.5, label=f'{cat} (n={m.sum()})', rasterized=True)
ax.set_title(f'{name}: Linkage Chemistry (alpha vs beta) in Embedding Space', fontsize=16, fontweight='bold')
ax.legend(fontsize=12, markerscale=3)
ax.set_xlabel('UMAP-1', fontsize=12); ax.set_ylabel('UMAP-2', fontsize=12)
plt.tight_layout()
plt.savefig(os.path.join(output_dir, f'linkage_chemistry_{name}.png'), dpi=200, bbox_inches='tight')
plt.close()
print(f" Saved linkage_chemistry_{name}.png")
alpha_mask = np.array([c == 'Mostly alpha' for c in categories])
beta_mask = np.array([c == 'Mostly beta' for c in categories])
if alpha_mask.sum() >= 10 and beta_mask.sum() >= 10:
a_embs = embs[alpha_mask][:500]
b_embs = embs[beta_mask][:500]
a_n = a_embs / (np.linalg.norm(a_embs, axis=1, keepdims=True) + 1e-8)
b_n = b_embs / (np.linalg.norm(b_embs, axis=1, keepdims=True) + 1e-8)
cross_sim = np.mean(a_n @ b_n.T)
within_a = np.mean(a_n @ a_n.T)
within_b = np.mean(b_n @ b_n.T)
print(f" a-a sim: {within_a:.4f}, b-b sim: {within_b:.4f}, a-b sim: {cross_sim:.4f}")
return {'linkage_distribution': dict(cat_counts),
'alpha_alpha_sim': round(float(within_a), 4),
'beta_beta_sim': round(float(within_b), 4),
'alpha_beta_cross_sim': round(float(cross_sim), 4)}
return {'linkage_distribution': dict(cat_counts)}
def analysis_5_decorations(embs, iupacs, output_dir, name):
"""Fucosylation / sialylation decoration analysis."""
print(f"\n=== Tier 2 Analysis 5: Decorations ({name}) ===")
fuc_mask = np.array(['FUC' in (s or '').upper() for s in iupacs])
sia_mask = np.array([any(x in (s or '').upper() for x in ['NEU5AC','NEU5GC','SIA']) for s in iupacs])
print(f" Fucosylated: {fuc_mask.sum()}/{len(fuc_mask)}")
print(f" Sialylated: {sia_mask.sum()}/{len(sia_mask)}")
n = min(5000, len(embs))
idx = np.random.RandomState(42).choice(len(embs), n, replace=False)
proj = compute_umap(embs[idx])
fig, axes = plt.subplots(1, 2, figsize=(22, 9))
for ax, (dec_name, mask_full, color) in zip(axes, [
('Fucosylated', fuc_mask, '#E53935'),
('Sialylated', sia_mask, '#7B1FA2')
]):
m = mask_full[idx]
ax.scatter(proj[~m, 0], proj[~m, 1], c='#BDBDBD', s=5, alpha=0.2,
label=f'No (n={int((~m).sum())})', rasterized=True)
ax.scatter(proj[m, 0], proj[m, 1], c=color, s=15, alpha=0.6,
label=f'{dec_name} (n={int(m.sum())})', rasterized=True)
ax.set_title(f'{name}: {dec_name}', fontsize=16, fontweight='bold')
ax.legend(fontsize=12, markerscale=3)
ax.set_xlabel('UMAP-1'); ax.set_ylabel('UMAP-2')
plt.tight_layout()
plt.savefig(os.path.join(output_dir, f'decorations_{name}.png'), dpi=200, bbox_inches='tight')
plt.close()
print(f" Saved decorations_{name}.png")
metrics = {
'n_fucosylated': int(fuc_mask.sum()),
'n_sialylated': int(sia_mask.sum()),
'n_total': int(len(iupacs))
}
if fuc_mask.sum() >= 20 and (~fuc_mask).sum() >= 20:
f_embs = embs[fuc_mask][:500]
nf_embs = embs[~fuc_mask][:500]
f_n = f_embs / (np.linalg.norm(f_embs, axis=1, keepdims=True) + 1e-8)
nf_n = nf_embs / (np.linalg.norm(nf_embs, axis=1, keepdims=True) + 1e-8)
cross = np.mean(f_n @ nf_n.T)
within_f = np.mean(f_n @ f_n.T)
print(f" Fuc-Fuc sim: {within_f:.4f}, Fuc-NonFuc sim: {cross:.4f}")
metrics['fuc_fuc_sim'] = round(float(within_f), 4)
metrics['fuc_nonfuc_sim'] = round(float(cross), 4)
return metrics
def analysis_6_phytomining_plantae(benchmark_embs, benchmark_kingdoms, output_dir, name):
"""Plantae glycan cluster analysis (phytomining proxy).
Literature basis: Plant polysaccharides (cellulose, EPS, sulfated sugars)
bind metals via hydroxyl groups and 1,2-diolato coordination. Plantae glycans
with GlcA, Xyl, Rha, Ara monosaccharides are characteristic of plant cell wall
components relevant to phytomining/phytoremediation.
"""
print(f"\n=== Tier 2 Analysis 6: Plantae / Phytomining ({name}) ===")
plant_mask = np.array([k == 'Plantae' for k in benchmark_kingdoms])
animal_mask = np.array([k == 'Animalia' for k in benchmark_kingdoms])
bacteria_mask = np.array([k == 'Bacteria' for k in benchmark_kingdoms])
fungi_mask = np.array([k == 'Fungi' for k in benchmark_kingdoms])
print(f" Plantae: {plant_mask.sum()}, Animalia: {animal_mask.sum()}, Bacteria: {bacteria_mask.sum()}, Fungi: {fungi_mask.sum()}")
if plant_mask.sum() < 10:
print(" Not enough Plantae samples.")
return {}
proj = compute_umap(benchmark_embs)
fig, ax = plt.subplots(figsize=(14, 10))
other = ~(plant_mask | animal_mask | bacteria_mask | fungi_mask)
if other.sum() > 0:
ax.scatter(proj[other, 0], proj[other, 1], c='#BDBDBD', s=8, alpha=0.3,
label=f'Other (n={other.sum()})', rasterized=True)
if fungi_mask.sum() > 0:
ax.scatter(proj[fungi_mask, 0], proj[fungi_mask, 1], c='#AB47BC', s=20, alpha=0.6,
label=f'Fungi (n={fungi_mask.sum()})', rasterized=True)
if bacteria_mask.sum() > 0:
ax.scatter(proj[bacteria_mask, 0], proj[bacteria_mask, 1], c='#42A5F5', s=20, alpha=0.6,
label=f'Bacteria (n={bacteria_mask.sum()})', rasterized=True)
if animal_mask.sum() > 0:
ax.scatter(proj[animal_mask, 0], proj[animal_mask, 1], c='#FFA726', s=20, alpha=0.6,
label=f'Animalia (n={animal_mask.sum()})', rasterized=True)
ax.scatter(proj[plant_mask, 0], proj[plant_mask, 1], c='#2E7D32', s=30, alpha=0.8,
edgecolors='black', linewidths=0.5,
label=f'Plantae (n={plant_mask.sum()})', rasterized=True)
ax.set_title(f'{name}: Plantae Glycans in Embedding Space (Phytomining Proxy)',
fontsize=16, fontweight='bold')
ax.legend(fontsize=12, markerscale=2.5)
ax.set_xlabel('UMAP-1', fontsize=12); ax.set_ylabel('UMAP-2', fontsize=12)
plt.tight_layout()
plt.savefig(os.path.join(output_dir, f'plantae_phytomining_{name}.png'), dpi=200, bbox_inches='tight')
plt.close()
print(f" Saved plantae_phytomining_{name}.png")
metrics = {}
for k_name, k_mask in [('Plantae', plant_mask), ('Animalia', animal_mask), ('Bacteria', bacteria_mask), ('Fungi', fungi_mask)]:
if k_mask.sum() >= 10:
e = benchmark_embs[k_mask][:300]
e_n = e / (np.linalg.norm(e, axis=1, keepdims=True) + 1e-8)
within = np.triu(e_n @ e_n.T, k=1)
mask_tri = np.triu(np.ones_like(within, dtype=bool), k=1)
if mask_tri.sum() > 0:
metrics[f'{k_name.lower()}_within_sim'] = round(float(np.mean(within[mask_tri])), 4)
if plant_mask.sum() >= 10 and animal_mask.sum() >= 10:
p_n = benchmark_embs[plant_mask][:200]
p_n = p_n / (np.linalg.norm(p_n, axis=1, keepdims=True) + 1e-8)
a_n = benchmark_embs[animal_mask][:200]
a_n = a_n / (np.linalg.norm(a_n, axis=1, keepdims=True) + 1e-8)
metrics['plant_animal_cross_sim'] = round(float(np.mean(p_n @ a_n.T)), 4)
if plant_mask.sum() >= 10 and bacteria_mask.sum() >= 10:
p_n = benchmark_embs[plant_mask][:200]
p_n = p_n / (np.linalg.norm(p_n, axis=1, keepdims=True) + 1e-8)
b_n = benchmark_embs[bacteria_mask][:200]
b_n = b_n / (np.linalg.norm(b_n, axis=1, keepdims=True) + 1e-8)
metrics['plant_bacteria_cross_sim'] = round(float(np.mean(p_n @ b_n.T)), 4)
for k, v in metrics.items():
print(f" {k}: {v}")
return metrics
# ── Main ─────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--model', required=True, choices=['v5', 'v6'])
parser.add_argument('--output-dir', default='bert_v6_contrastive/analysis')
args = parser.parse_args()
root = Path('.')
output_dir = args.output_dir
os.makedirs(output_dir, exist_ok=True)
# Load embeddings
emb_path = os.path.join(output_dir, f'embeddings_{args.model}.npz')
print(f"Loading embeddings from {emb_path}...")
data = np.load(emb_path, allow_pickle=True)
train_embs = data['train_embs']
benchmark_embs = data['benchmark_embs']
benchmark_kingdoms = data['benchmark_kingdom']
# Load original training data for IUPAC names, branch_depths, linkage_types
data_path = root / 'bert_v5_bpe_topo' / 'data' / 'sequences_bpe_expanded.pkl'
print(f"Loading training data from {data_path}...")
with open(data_path, 'rb') as f:
all_samples = pickle.load(f)
# We need IUPAC names for the same 10K training samples
# The extraction script used random.sample with seed 42
import random
random.seed(42)
n_train = min(10000, len(all_samples))
sampled = random.sample(all_samples, n_train)
iupacs = [s.get('iupac_name', '') or '' for s in sampled]
branch_depths = [s.get('branch_depths', []) for s in sampled]
linkage_types = [s.get('linkage_types', []) for s in sampled]
print(f" {len(iupacs)} training samples with metadata")
print(f" IUPAC example: {iupacs[0][:80]}")
# Run analyses
metrics = {'model': args.model, 'tier': 2}
m = analysis_1_structural_types(train_embs, iupacs, output_dir, args.model)
metrics.update(m)
m = analysis_2_composition(train_embs, iupacs, output_dir, args.model)
metrics.update(m)
m = analysis_3_branching(train_embs, branch_depths, output_dir, args.model)
metrics.update(m)
m = analysis_4_linkage(train_embs, linkage_types, output_dir, args.model)
metrics.update(m)
m = analysis_5_decorations(train_embs, iupacs, output_dir, args.model)
metrics.update(m)
m = analysis_6_phytomining_plantae(benchmark_embs, benchmark_kingdoms, output_dir, args.model)
metrics.update(m)
# Save metrics
out_path = os.path.join(output_dir, f'tier2_metrics_{args.model}.json')
def convert(o):
if isinstance(o, (np.integer,)): return int(o)
if isinstance(o, (np.floating,)): return float(o)
if isinstance(o, np.ndarray): return o.tolist()
return o
with open(out_path, 'w') as f:
json.dump(metrics, f, indent=2, default=convert)
print(f"\nAll Tier 2 metrics saved to {out_path}")
if __name__ == '__main__':
main()