bertose-affinose-training-code / code /probes /probe_15h_phylogenetic.py
supanthadey1's picture
Add BERTose and AFFINose training code release
1d6f391 verified
Raw
History Blame Contribute Delete
16 kB
#!/usr/bin/env python3
"""
Probe 15H: Phylogenetic Embedding from Milk Glycomes
=====================================================
Task H: Embed per-species milk glycomes via GlycanBERT V6,
compute species centroids, build UPGMA dendrogram, and test
whether embedding-derived phylogeny recapitulates taxonomy.
Data: Fig1a source data from Jin et al. (2025) — 2,524 glycans
from 173 species (all mammals).
Usage:
python probe_15h_phylogenetic.py --model v6
python probe_15h_phylogenetic.py --model v6 --min-glycans 40
"""
import sys, os, json, argparse, warnings, re
import numpy as np
import pandas as pd
from pathlib import Path
from collections import defaultdict
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import torch
warnings.filterwarnings('ignore', category=FutureWarning)
# --- Project paths ---
PROJECT_ROOT = Path(__file__).resolve().parents[2]
VOCAB_PATH = PROJECT_ROOT / 'bert_training_v4' / 'data' / 'vocabulary.json'
SOURCE_DATA = (PROJECT_ROOT / 'bert_v6_contrastive'
/ '41467_2025_66075_MOESM6_ESM.xlsx')
OUTPUT_DIR = (PROJECT_ROOT / 'bert_v6_contrastive' / 'analysis'
/ 'probe_15_seal_milk')
# Reuse WURCS conversion infrastructure from probe_15
sys.path.insert(0, str(PROJECT_ROOT / 'bert_v6_contrastive' / 'scripts'))
from probe_15_seal_milk_ood import (
load_model, get_cls_embeddings, batch_iupac_to_wurcs,
iupac_to_wurcs
)
# --- Nature-style plotting ---
plt.rcParams.update({
'font.family': 'sans-serif',
'font.sans-serif': ['Arial', 'Helvetica', 'DejaVu Sans'],
'font.size': 10, 'axes.titlesize': 12, 'axes.labelsize': 11,
'xtick.labelsize': 9, 'ytick.labelsize': 9, 'legend.fontsize': 9,
'figure.dpi': 300, 'savefig.dpi': 300, 'savefig.bbox': 'tight',
'axes.linewidth': 0.8, 'axes.spines.top': False, 'axes.spines.right': False,
})
ORDER_COLORS = {
'Primates': '#E64B35',
'Carnivora': '#4DBBD5',
'Artiodactyla': '#00A087',
'Perissodactyla': '#3C5488',
'Proboscidea': '#F39B7F',
'Rodentia': '#8491B4',
'Chiroptera': '#91D1C2',
'Diprotodontia': '#DC9050',
'Monotremata': '#7E6148',
'Pilosa': '#B09C85',
}
# ====================================================================
# Data Loading
# ====================================================================
def load_cross_species_glycans(xlsx_path, min_glycans=10):
"""Load Fig1a from source data Excel."""
print(f" Loading cross-species data from {xlsx_path}")
import openpyxl
wb = openpyxl.load_workbook(str(xlsx_path), read_only=True)
ws = wb['Fig1a']
rows = list(ws.iter_rows(values_only=True))
header_idx = None
for i, row in enumerate(rows):
if row and row[0] and str(row[0]).strip().lower() == 'glycan':
header_idx = i
break
if header_idx is None:
raise ValueError("Could not find header row in Fig1a")
data_rows = rows[header_idx + 1:]
records = []
for row in data_rows:
if not row or not row[0]:
continue
glycan = str(row[0]).strip()
species = str(row[1]).strip() if row[1] else ''
genus = str(row[2]).strip() if row[2] else ''
family = str(row[3]).strip() if row[3] else ''
order = str(row[4]).strip() if row[4] else ''
class_ = str(row[5]).strip() if row[5] else ''
if glycan and species:
records.append({
'glycan': glycan, 'species': species,
'genus': genus, 'family': family,
'order': order, 'class_': class_,
})
wb.close()
species_glycans = defaultdict(list)
species_info = {}
for r in records:
sp = r['species']
species_glycans[sp].append(r['glycan'])
if sp not in species_info:
species_info[sp] = {
'order': r['order'], 'family': r['family'],
'genus': r['genus'], 'class_': r['class_'],
}
qualified = {sp: glycans for sp, glycans in species_glycans.items()
if len(glycans) >= min_glycans}
total_glycans = sum(len(g) for g in species_glycans.values())
qual_glycans = sum(len(g) for g in qualified.values())
print(f" Total: {len(species_glycans)} species, {total_glycans} glycans")
print(f" Qualified (>={min_glycans} glycans): {len(qualified)} species, "
f"{qual_glycans} glycans")
print(f"\n {'Species':<35s} {'Order':<18s} {'N':>4s}")
print(f" {'-'*60}")
for sp in sorted(qualified, key=lambda s: -len(qualified[s])):
info = species_info[sp]
print(f" {sp:<35s} {info['order']:<18s} {len(qualified[sp]):>4d}")
return qualified, species_info
# ====================================================================
# Phylogenetic Analysis
# ====================================================================
def compute_species_centroids(model, tokenizer, device,
species_glycans, species_info):
"""Embed all glycans, compute per-species centroid (mean embedding)."""
all_iupacs = []
glycan_to_species = {}
for sp, glycans in species_glycans.items():
for g in glycans:
if g not in glycan_to_species:
all_iupacs.append(g)
glycan_to_species[g] = []
glycan_to_species[g].append(sp)
print(f"\n Total unique glycans to embed: {len(all_iupacs)}")
print(" Converting IUPAC -> WURCS...")
wurcs_list, valid_iupacs, failed = batch_iupac_to_wurcs(all_iupacs)
print(f" WURCS conversion: {len(valid_iupacs)}/{len(all_iupacs)} "
f"({len(failed)} failed)")
print(" Embedding glycans...")
embeddings, emb_valid_idx = get_cls_embeddings(
model, tokenizer, wurcs_list, device
)
print(f" Embedded: {embeddings.shape[0]}/{len(wurcs_list)}")
final_iupacs = [valid_iupacs[i] for i in emb_valid_idx]
centroids = {}
centroid_n = {}
for sp, glycans in species_glycans.items():
sp_indices = []
for i, iupac in enumerate(final_iupacs):
if iupac in glycans:
sp_indices.append(i)
if len(sp_indices) >= 3:
sp_embs = embeddings[sp_indices]
centroids[sp] = sp_embs.mean(axis=0)
centroid_n[sp] = len(sp_indices)
else:
print(f" WARNING: {sp} only has {len(sp_indices)} embedded "
f"glycans, skipping")
print(f"\n Species with valid centroids: {len(centroids)}")
for sp in sorted(centroids, key=lambda s: -centroid_n[s]):
info = species_info[sp]
print(f" {sp:<35s} {info['order']:<18s} "
f"n={centroid_n[sp]:>3d}")
return centroids, centroid_n
def build_phylogenetic_tree(centroids, species_info, output_dir):
"""Build UPGMA dendrogram from species centroid cosine distances."""
from scipy.cluster.hierarchy import linkage, dendrogram
from scipy.spatial.distance import squareform
from sklearn.metrics.pairwise import cosine_distances
from scipy.stats import pearsonr, spearmanr
species_list = sorted(centroids.keys())
n = len(species_list)
centroid_matrix = np.array([centroids[sp] for sp in species_list])
dist_matrix = cosine_distances(centroid_matrix)
dist_df = pd.DataFrame(dist_matrix, index=species_list, columns=species_list)
csv_path = output_dir / 'task_h_distance_matrix.csv'
dist_df.to_csv(csv_path)
print(f"\n Distance matrix saved to {csv_path}")
upper = dist_matrix[np.triu_indices(n, k=1)]
print(f" Distance stats: min={upper.min():.4f}, max={upper.max():.4f}, "
f"mean={upper.mean():.4f}, median={np.median(upper):.4f}")
# Mantel test: embedding distance vs taxonomic distance
print("\n Computing Mantel test (embedding vs taxonomy)...")
tax_dist = np.zeros((n, n))
for i in range(n):
for j in range(n):
si = species_info[species_list[i]]
sj = species_info[species_list[j]]
if species_list[i] == species_list[j]:
tax_dist[i, j] = 0
elif si.get('genus') == sj.get('genus'):
tax_dist[i, j] = 1
elif si.get('family') == sj.get('family'):
tax_dist[i, j] = 2
elif si.get('order') == sj.get('order'):
tax_dist[i, j] = 3
elif si.get('class_') == sj.get('class_'):
tax_dist[i, j] = 4
else:
tax_dist[i, j] = 5
emb_upper = dist_matrix[np.triu_indices(n, k=1)]
tax_upper = tax_dist[np.triu_indices(n, k=1)]
r_pearson, p_pearson = pearsonr(emb_upper, tax_upper)
r_spearman, p_spearman = spearmanr(emb_upper, tax_upper)
print(f" Mantel (Pearson): r={r_pearson:.4f}, p={p_pearson:.4e}")
print(f" Mantel (Spearman): r={r_spearman:.4f}, p={p_spearman:.4e}")
# Permutation test
n_perm = 999
perm_correlations = []
for _ in range(n_perm):
perm_idx = np.random.permutation(n)
perm_tax = tax_dist[np.ix_(perm_idx, perm_idx)]
perm_upper = perm_tax[np.triu_indices(n, k=1)]
perm_r, _ = pearsonr(emb_upper, perm_upper)
perm_correlations.append(perm_r)
mantel_p = (np.sum(np.array(perm_correlations) >= r_pearson) + 1) / (n_perm + 1)
print(f" Mantel permutation p-value: {mantel_p:.4f} ({n_perm} permutations)")
# UPGMA Dendrogram
print("\n Building UPGMA dendrogram...")
condensed = squareform(dist_matrix)
Z = linkage(condensed, method='average')
COMMON_NAMES = {
'Homo_sapiens': 'Human', 'Halichoerus_grypus': 'Grey seal',
'Bos_taurus': 'Cow', 'Capra_hircus': 'Goat',
'Sus_scrofa': 'Pig', 'Tursiops_truncatus': 'Dolphin',
'Choeropsis_liberiensis': 'Pygmy hippo',
'Delphinapterus_leucas': 'Beluga whale',
'Ovis_aries': 'Sheep', 'Panthera_leo': 'Lion',
'Camelus_dromedarius': 'Dromedary',
'Equus_caballus': 'Horse',
'Ailuropoda_melanoleuca': 'Giant panda',
'Ornithorhynchus_anatinus': 'Platypus',
'Tachyglossus_aculeatus': 'Echidna',
}
labels = []
label_colors = {}
for sp in species_list:
common = COMMON_NAMES.get(sp, '')
short_sp = sp.replace('_', ' ')
label = f"{common} ({short_sp})" if common else short_sp
labels.append(label)
order = species_info[sp].get('order', 'Unknown')
label_colors[label] = ORDER_COLORS.get(order, '#888888')
fig_height = max(8, len(species_list) * 0.35)
fig, ax = plt.subplots(1, 1, figsize=(10, fig_height))
dendro = dendrogram(Z, labels=labels, orientation='left', ax=ax,
leaf_font_size=8, color_threshold=0,
above_threshold_color='#333333')
ylbls = ax.get_yticklabels()
for lbl in ylbls:
txt = lbl.get_text()
color = label_colors.get(txt, '#333333')
lbl.set_color(color)
lbl.set_fontweight('bold')
ax.set_xlabel('Cosine Distance')
ax.set_title(f'Task H: Phylogenetic Tree from V6 Milk Glycome Embeddings\n'
f'(Mantel r={r_pearson:.3f}, p={mantel_p:.4f}, '
f'n={len(species_list)} species)', fontsize=11)
from matplotlib.patches import Patch
orders_present = set(species_info[sp]['order'] for sp in species_list)
legend_patches = [Patch(facecolor=ORDER_COLORS.get(o, '#888'), label=o)
for o in sorted(orders_present) if o]
ax.legend(handles=legend_patches, loc='upper right', fontsize=7,
frameon=True, framealpha=0.8, title='Taxonomic Order',
title_fontsize=8)
plt.tight_layout()
tree_path = output_dir / 'task_h_dendrogram.png'
plt.savefig(tree_path, dpi=300, bbox_inches='tight')
plt.close()
print(f" Dendrogram saved to {tree_path}")
# Phylogenetic consistency checks
print("\n Phylogenetic consistency checks:")
checks = {'Artiodactyla': [], 'Carnivora': [], 'Primates': []}
for i, sp in enumerate(species_list):
order = species_info[sp].get('order', '')
if order in checks:
checks[order].append(i)
for group_name, indices in checks.items():
if len(indices) >= 2:
intra = [dist_matrix[i, j] for i in indices for j in indices if i < j]
inter = [dist_matrix[i, j] for i in indices
for j in range(n) if j not in indices]
if intra and inter:
intra_mean = np.mean(intra)
inter_mean = np.mean(inter)
ratio = intra_mean / inter_mean if inter_mean > 0 else 0
print(f" {group_name}: intra={intra_mean:.4f}, "
f"inter={inter_mean:.4f}, ratio={ratio:.4f}")
results = {
'task': 'H_phylogenetic_embedding',
'n_species': len(species_list),
'mantel_pearson_r': float(r_pearson),
'mantel_pearson_p': float(p_pearson),
'mantel_spearman_r': float(r_spearman),
'mantel_spearman_p': float(p_spearman),
'mantel_permutation_p': float(mantel_p),
'n_permutations': n_perm,
'distance_mean': float(upper.mean()),
'distance_min': float(upper.min()),
'distance_max': float(upper.max()),
'dendrogram_path': str(tree_path),
'distance_matrix_path': str(csv_path),
'species': {sp: {'order': species_info[sp].get('order', ''),
'family': species_info[sp].get('family', '')}
for sp in species_list},
}
return results
# ====================================================================
# Main
# ====================================================================
def main():
parser = argparse.ArgumentParser(
description='Probe 15H: Phylogenetic Embedding from Milk Glycomes'
)
parser.add_argument('--model', choices=['v5', 'v6'], default='v6')
parser.add_argument('--device', default='cuda' if torch.cuda.is_available()
else 'cpu')
parser.add_argument('--min-glycans', type=int, default=10,
help='Min glycans per species for centroid (default 10)')
args = parser.parse_args()
device = torch.device(args.device)
print("=" * 60)
print("Probe 15H: Phylogenetic Embedding from Milk Glycomes")
print(f"Device: {device}")
print(f"Model: {args.model}")
print(f"Min glycans/species: {args.min_glycans}")
print("=" * 60)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
print("\n[1] Loading cross-species glycan data...")
species_glycans, species_info = load_cross_species_glycans(
SOURCE_DATA, min_glycans=args.min_glycans
)
print(f"\n[2] Loading model {args.model}...")
model, tokenizer = load_model(args.model, device)
print("\n[3] Computing species centroids...")
centroids, centroid_n = compute_species_centroids(
model, tokenizer, device, species_glycans, species_info
)
del model
if torch.cuda.is_available():
torch.cuda.empty_cache()
print("\n[4] Building phylogenetic tree...")
results = build_phylogenetic_tree(centroids, species_info, OUTPUT_DIR)
results_path = OUTPUT_DIR / 'task_h_results.json'
with open(results_path, 'w') as f:
json.dump(results, f, indent=2, default=str)
print(f"\nResults saved to {results_path}")
print(f"\n{'=' * 60}")
print("TASK H SUMMARY")
print(f"{'=' * 60}")
print(f" Species: {results['n_species']}")
print(f" Mantel r (Pearson): {results['mantel_pearson_r']:.4f} "
f"(p={results['mantel_permutation_p']:.4f})")
print(f" Mantel r (Spearman): {results['mantel_spearman_r']:.4f}")
print(f" Distance range: [{results['distance_min']:.4f}, "
f"{results['distance_max']:.4f}]")
print(f"\nDone. Dendrogram: {results['dendrogram_path']}")
if __name__ == '__main__':
main()