| |
| """ |
| Generate publication-quality t-SNE figures for GlycanBERT V6 embeddings. |
| Uses FULL transformer forward pass (embeddings + 12 transformer layers). |
| |
| Panels: |
| (a) Taxonomy classification at domain level (Eukarya, Virus, Bacteria) |
| (b) Glycosylation type (N-linked, O-linked, free) |
| (c) Immunogenicity (non-immunogenic vs immunogenic) |
| (d) Unsupervised cluster analysis with motif enrichment labels |
| """ |
|
|
| import os, sys, json, csv, argparse, warnings |
| import numpy as np |
| import pandas as pd |
| from pathlib import Path |
| from collections import Counter |
|
|
| warnings.filterwarnings('ignore') |
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[2] |
| VOCAB_PATH = PROJECT_ROOT / 'bert_training_v4' / 'data' / 'vocabulary.json' |
| CHECKPOINTS = { |
| 'v5': PROJECT_ROOT / 'checkpoints_v5_bpe_topo' / 'best_v5_bpe_topo_model.pt', |
| 'v6': PROJECT_ROOT / 'bert_v5.1_contrastive' / 'checkpoints' / 'best_v51_contrastive_model.pt', |
| } |
| BENCH_DIR = PROJECT_ROOT / 'bench' / 'GlycanML' / 'data' |
|
|
| sys.path.insert(0, str(PROJECT_ROOT)) |
| sys.path.insert(0, str(PROJECT_ROOT / 'bert_training_v4')) |
| import torch |
| import torch.nn.functional as F |
| from model.multimodal_glycan_bert_v3 import MultimodalGlycanBERT, MultimodalGlycanBERTConfig |
| from downstream_tasks.utils.tokenizer import WURCSTokenizer |
|
|
| |
| |
| |
| def load_model(ckpt_path, device='cuda'): |
| print(f"Loading model from {ckpt_path}...") |
| ckpt = torch.load(ckpt_path, map_location='cpu', weights_only=False) |
| state_dict = ckpt.get('model_state_dict', ckpt) |
| backbone_sd = {k: v for k, v in state_dict.items() if not k.startswith('proj_head.')} |
| vocab_size = backbone_sd['seq_embeddings.token_embeddings.weight'].shape[0] |
| ms_total_vocab = None |
| if 'ms_embeddings.token_embeddings.weight' in backbone_sd: |
| ms_total_vocab = backbone_sd['ms_embeddings.token_embeddings.weight'].shape[0] |
| config_kwargs = dict( |
| seq_vocab_size=vocab_size, seq_hidden_size=768, seq_num_layers=12, |
| seq_num_heads=12, seq_max_length=256, use_cnn_frontend=True, cnn_kernel_size=3, |
| ) |
| if ms_total_vocab is not None: |
| config_kwargs['ms_vocab_size'] = ms_total_vocab - vocab_size |
| config = MultimodalGlycanBERTConfig(**config_kwargs) |
| model = MultimodalGlycanBERT(config) |
| model.load_state_dict(backbone_sd, strict=False) |
| model.to(device).eval() |
| print(f" Model loaded: {sum(p.numel() for p in model.parameters()):,} params") |
| return model |
|
|
| def get_cls_embeddings(model, samples, device='cuda', batch_size=32, max_len=256): |
| """Get CLS embeddings using FULL transformer forward pass.""" |
| tokenizer = WURCSTokenizer(str(VOCAB_PATH)) |
| all_embs, n_errors = [], 0 |
| for i in range(0, len(samples), batch_size): |
| batch = samples[i:i+batch_size] |
| for s in batch: |
| try: |
| result = tokenizer.tokenize(s['wurcs'], max_length=max_len) |
| token_ids = torch.tensor(result['token_ids'], dtype=torch.long) |
| branch_depths = torch.tensor(result.get('branch_depths', [0]*len(result['token_ids'])), dtype=torch.long) |
| linkage_types = torch.tensor(result.get('linkage_types', [0]*len(result['token_ids'])), dtype=torch.long) |
| min_l = min(len(token_ids), len(branch_depths), len(linkage_types)) |
| token_ids, branch_depths, linkage_types = token_ids[:min_l], branch_depths[:min_l], linkage_types[:min_l] |
| if min_l > max_len: |
| token_ids, branch_depths, linkage_types = token_ids[:max_len], branch_depths[:max_len], linkage_types[:max_len] |
| elif min_l < max_len: |
| pad = max_len - min_l |
| token_ids = F.pad(token_ids, (0, pad), value=0) |
| branch_depths = F.pad(branch_depths, (0, pad), value=0) |
| linkage_types = F.pad(linkage_types, (0, pad), value=0) |
| with torch.no_grad(): |
| hidden = model.seq_embeddings( |
| token_ids.unsqueeze(0).to(device), |
| branch_depths.unsqueeze(0).to(device), |
| linkage_types.unsqueeze(0).to(device) |
| ) |
| for layer in model.seq_layers: |
| hidden = layer(hidden) |
| all_embs.append(hidden[0, 0, :].cpu().numpy()) |
| except Exception as e: |
| n_errors += 1 |
| if n_errors <= 3: print(f" ERROR: {e}") |
| all_embs.append(np.zeros(768)) |
| if (i // batch_size) % 20 == 0 and i > 0: |
| print(f" Embedded {i}/{len(samples)}") |
| if n_errors > 0: print(f" WARNING: {n_errors}/{len(samples)} errors") |
| return np.array(all_embs) |
|
|
| |
| |
| |
| def load_domain_data(): |
| """Domain: Eukarya, Bacteria, Virus (no Archaea).""" |
| csv_path = BENCH_DIR / 'glycan_classification_wurcs_subset.csv' |
| samples, labels = [], [] |
| with open(csv_path) as f: |
| for row in csv.DictReader(f): |
| w, domain = row.get('wurcs',''), row.get('domain','') |
| if w.startswith('WURCS') and domain in ('Eukarya', 'Bacteria', 'Virus'): |
| samples.append({'wurcs': w}) |
| labels.append(domain) |
| print(f" Domain: {len(samples)}, {Counter(labels)}") |
| return samples, labels |
|
|
| def load_glycosylation_data(): |
| """Glycosylation: N, O, free.""" |
| csv_path = BENCH_DIR / 'glycan_link_wurcs_subset.csv' |
| samples, labels, iupacs = [], [], [] |
| with open(csv_path) as f: |
| for row in csv.DictReader(f): |
| w = row.get('wurcs','') |
| link = row.get('link','') |
| iupac = row.get('glycan','') |
| if w.startswith('WURCS') and link in ('N', 'O', 'free'): |
| samples.append({'wurcs': w}) |
| labels.append(link) |
| iupacs.append(iupac) |
| print(f" Glycosylation: {len(samples)}, {Counter(labels)}") |
| return samples, labels, iupacs |
|
|
| def load_immunogenicity_data(): |
| csv_path = BENCH_DIR / 'glycan_immunogenicity_wurcs_subset.csv' |
| samples, labels = [], [] |
| with open(csv_path) as f: |
| for row in csv.DictReader(f): |
| w, imm = row.get('wurcs',''), row.get('immunogenicity','') |
| if w.startswith('WURCS') and imm: |
| samples.append({'wurcs': w}) |
| labels.append(float(imm)) |
| print(f" Immunogenicity: {len(samples)}, {Counter(labels)}") |
| return samples, labels |
|
|
| |
| |
| |
| def run_motif_enrichment(cluster_labels, iupac_glycans, n_clusters=6): |
| """Run glycowork motif enrichment per cluster using annotate_dataset.""" |
| try: |
| from glycowork.motif.annotate import annotate_dataset |
| except ImportError: |
| print(" WARNING: glycowork not found, skipping motif enrichment") |
| return {} |
| |
| |
| print(" Annotating glycans with known motifs...") |
| try: |
| motif_df = annotate_dataset(list(iupac_glycans), feature_set=['known']) |
| except Exception as e: |
| print(f" Annotation error: {e}") |
| return {ci: "β" for ci in range(n_clusters)} |
| |
| |
| |
| cluster_arr = np.array(cluster_labels) |
| enriched = {} |
| motif_details = {} |
| |
| |
| numeric_cols = [c for c in motif_df.columns if motif_df[c].dtype in ('float64','int64','float32','int32')] |
| if len(numeric_cols) == 0: |
| print(" WARNING: No numeric motif columns found") |
| return {ci: "β" for ci in range(n_clusters)} |
| |
| overall_means = motif_df[numeric_cols].mean() |
| |
| for ci in range(n_clusters): |
| mask = cluster_arr == ci |
| if mask.sum() < 3: |
| enriched[ci] = "N/A" |
| continue |
| |
| cluster_means = motif_df.loc[mask, numeric_cols].mean() |
| rest_means = motif_df.loc[~mask, numeric_cols].mean() |
| |
| |
| eps = 0.001 |
| enrichment = (cluster_means + eps) / (rest_means + eps) |
| |
| |
| appears = cluster_means > 0.05 |
| if appears.sum() > 0: |
| enrichment_filtered = enrichment[appears] |
| top_motif = enrichment_filtered.idxmax() |
| enriched[ci] = str(top_motif) |
| motif_details[ci] = f"{top_motif} (ratio={enrichment[top_motif]:.2f})" |
| else: |
| enriched[ci] = "β" |
| motif_details[ci] = "no enriched motifs" |
| |
| print(" Motif enrichment results:") |
| for ci in range(n_clusters): |
| n = int((cluster_arr == ci).sum()) |
| detail = motif_details.get(ci, enriched.get(ci, "β")) |
| print(f" Cluster {ci} (n={n}): {detail}") |
| return enriched |
|
|
| |
| |
| |
| def generate_figure(embs_domain, labels_domain, |
| embs_glyco, labels_glyco, iupac_glyco, |
| embs_immuno, labels_immuno, |
| output_dir, model_name='V6'): |
| import matplotlib |
| matplotlib.use('Agg') |
| import matplotlib.pyplot as plt |
| from sklearn.manifold import TSNE |
| from sklearn.cluster import KMeans |
|
|
| |
| plt.rcParams.update({ |
| 'font.family': 'sans-serif', |
| 'font.sans-serif': ['Arial', 'Helvetica', 'DejaVu Sans'], |
| 'font.size': 15, |
| 'axes.titlesize': 18, |
| 'axes.labelsize': 16, |
| 'xtick.labelsize': 13, |
| 'ytick.labelsize': 13, |
| 'legend.fontsize': 14, |
| 'legend.title_fontsize': 15, |
| 'figure.dpi': 300, |
| 'savefig.dpi': 300, |
| 'savefig.bbox': 'tight', |
| 'axes.linewidth': 1.2, |
| 'axes.spines.top': False, |
| 'axes.spines.right': False, |
| }) |
|
|
| |
| print("\n Running t-SNE for domain (~12K, perplexity=50)...") |
| tsne_domain = TSNE(n_components=2, perplexity=50, random_state=42, max_iter=1000, |
| learning_rate='auto', init='pca').fit_transform(embs_domain) |
| print(" Running t-SNE for glycosylation...") |
| tsne_glyco = TSNE(n_components=2, perplexity=30, random_state=42, max_iter=1000, |
| learning_rate='auto', init='pca').fit_transform(embs_glyco) |
| print(" Running t-SNE for immunogenicity...") |
| tsne_immuno = TSNE(n_components=2, perplexity=30, random_state=42, max_iter=1000, |
| learning_rate='auto', init='pca').fit_transform(embs_immuno) |
|
|
| |
| def clip_tsne(coords): |
| for dim in range(2): |
| lo, hi = np.percentile(coords[:, dim], [2, 98]) |
| coords[:, dim] = np.clip(coords[:, dim], lo, hi) |
| return coords |
| tsne_domain = clip_tsne(tsne_domain) |
| tsne_glyco = clip_tsne(tsne_glyco) |
| tsne_immuno = clip_tsne(tsne_immuno) |
|
|
| |
| print(" Running K-Means (6 clusters)...") |
| kmeans = KMeans(n_clusters=6, random_state=42, n_init=10) |
| cluster_labels = kmeans.fit_predict(embs_glyco) |
|
|
| print(" Running motif enrichment (glycowork)...") |
| motif_labels = run_motif_enrichment(cluster_labels, iupac_glyco, n_clusters=6) |
|
|
| |
| domain_colors = {'Eukarya': '#E8A838', 'Virus': '#2EC4B6', 'Bacteria': '#3D7DD8'} |
| glyco_colors = {'N': '#56B870', 'O': '#5DADE2', 'free': '#E67E73'} |
| immuno_colors = {0.0: '#4DAF7C', 1.0: '#6C7FE8'} |
| cluster_cmap = ['#3D7DD8', '#F5A623', '#2ECC71', '#E8534E', '#9B59B6', '#D4A03C'] |
|
|
| |
| def draw_panel(ax, tsne, labels, colors, order, legend_title, dot_size=20, alpha=0.65): |
| for label in order: |
| mask = np.array([l == label for l in labels]) |
| if mask.sum() > 0: |
| ax.scatter(tsne[mask, 0], tsne[mask, 1], |
| c=colors[label], s=dot_size, alpha=alpha, |
| label=str(label), edgecolors='none', rasterized=True) |
| ax.set_xlabel('t-SNE 1', fontsize=15, labelpad=8) |
| ax.set_ylabel('t-SNE 2', fontsize=15, labelpad=8) |
| leg = ax.legend(title=legend_title, frameon=True, fancybox=True, framealpha=0.92, |
| markerscale=3.5, loc='best', edgecolor='#bbb', fontsize=13) |
| leg.get_title().set_fontsize(14) |
| leg.get_frame().set_linewidth(0.6) |
|
|
| |
| fig, axes = plt.subplots(2, 2, figsize=(17, 15)) |
| plt.subplots_adjust(hspace=0.30, wspace=0.28) |
|
|
| |
| draw_panel(axes[0,0], tsne_domain, labels_domain, domain_colors, |
| ['Eukarya','Bacteria','Virus'], 'Domain', dot_size=8, alpha=0.5) |
| axes[0,0].text(-0.08, 1.06, '(a)', transform=axes[0,0].transAxes, fontsize=20, fontweight='bold') |
|
|
| |
| draw_panel(axes[0,1], tsne_glyco, labels_glyco, glyco_colors, |
| ['N','O','free'], 'Glycosylation', dot_size=25, alpha=0.65) |
| axes[0,1].text(-0.08, 1.06, '(b)', transform=axes[0,1].transAxes, fontsize=20, fontweight='bold') |
|
|
| |
| draw_panel(axes[1,0], tsne_immuno, labels_immuno, immuno_colors, |
| [0.0, 1.0], 'Immunogenicity', dot_size=25, alpha=0.65) |
| axes[1,0].text(-0.08, 1.06, '(c)', transform=axes[1,0].transAxes, fontsize=20, fontweight='bold') |
|
|
| |
| ax = axes[1,1] |
| for ci in range(6): |
| mask = cluster_labels == ci |
| if mask.sum() > 0: |
| lbl = motif_labels.get(ci, str(ci)) |
| display = f"C{ci}: {lbl}" if lbl and lbl not in ('β','N/A') else f"C{ci}" |
| ax.scatter(tsne_glyco[mask, 0], tsne_glyco[mask, 1], |
| c=cluster_cmap[ci], s=30, alpha=0.7, |
| label=display, edgecolors='none', rasterized=True) |
| ax.set_xlabel('t-SNE 1', fontsize=15, labelpad=8) |
| ax.set_ylabel('t-SNE 2', fontsize=15, labelpad=8) |
| leg = ax.legend(title='Cluster (motif)', frameon=True, fancybox=True, framealpha=0.92, |
| markerscale=3, loc='best', edgecolor='#bbb', fontsize=11) |
| leg.get_title().set_fontsize(13) |
| leg.get_frame().set_linewidth(0.6) |
| axes[1,1].text(-0.08, 1.06, '(d)', transform=axes[1,1].transAxes, fontsize=20, fontweight='bold') |
|
|
| |
| for ci in range(6): |
| mask = cluster_labels == ci |
| if mask.sum() > 5: |
| cx, cy = np.median(tsne_glyco[mask, 0]), np.median(tsne_glyco[mask, 1]) |
| motif = motif_labels.get(ci, '') |
| if motif and motif not in ('β', 'N/A'): |
| ax.annotate(motif, (cx, cy), fontsize=8, fontweight='bold', |
| ha='center', va='center', |
| bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8, edgecolor='#999')) |
|
|
| out_path = Path(output_dir) |
| out_path.mkdir(parents=True, exist_ok=True) |
|
|
| fig_png = out_path / f'tsne_figure_{model_name.lower()}.png' |
| fig_pdf = out_path / f'tsne_figure_{model_name.lower()}.pdf' |
| plt.savefig(fig_png, dpi=300, bbox_inches='tight', facecolor='white') |
| plt.savefig(fig_pdf, dpi=300, bbox_inches='tight', facecolor='white') |
| plt.close() |
| print(f"\n Saved: {fig_png}") |
| print(f" Saved: {fig_pdf}") |
|
|
| |
| panels = [ |
| ('domain', tsne_domain, labels_domain, domain_colors, ['Eukarya','Bacteria','Virus'], 'Domain', 8), |
| ('glycosylation', tsne_glyco, labels_glyco, glyco_colors, ['N','O','free'], 'Glycosylation', 25), |
| ('immunogenicity', tsne_immuno, labels_immuno, immuno_colors, [0.0, 1.0], 'Immunogenicity', 25), |
| ] |
| for pname, tdata, labs, cols, order, title, sz in panels: |
| fig2, ax2 = plt.subplots(figsize=(8, 7)) |
| draw_panel(ax2, tdata, labs, cols, order, title, dot_size=sz) |
| ax2.set_title(title, fontsize=18, fontweight='bold', pad=12) |
| plt.tight_layout() |
| plt.savefig(out_path / f'tsne_{pname}_{model_name.lower()}.png', dpi=300, bbox_inches='tight', facecolor='white') |
| plt.savefig(out_path / f'tsne_{pname}_{model_name.lower()}.pdf', dpi=300, bbox_inches='tight', facecolor='white') |
| plt.close() |
| print(f" Saved: tsne_{pname}_{model_name.lower()}.png") |
|
|
| |
| fig3, ax3 = plt.subplots(figsize=(9, 7)) |
| for ci in range(6): |
| mask = cluster_labels == ci |
| if mask.sum() > 0: |
| lbl = motif_labels.get(ci, str(ci)) |
| display = f"C{ci}: {lbl}" if lbl and lbl not in ('β','N/A') else f"C{ci}" |
| ax3.scatter(tsne_glyco[mask, 0], tsne_glyco[mask, 1], |
| c=cluster_cmap[ci], s=35, alpha=0.7, |
| label=display, edgecolors='none', rasterized=True) |
| for ci in range(6): |
| mask = cluster_labels == ci |
| if mask.sum() > 5: |
| cx, cy = np.median(tsne_glyco[mask, 0]), np.median(tsne_glyco[mask, 1]) |
| motif = motif_labels.get(ci, '') |
| if motif and motif not in ('β', 'N/A'): |
| ax3.annotate(motif, (cx, cy), fontsize=9, fontweight='bold', |
| ha='center', va='center', |
| bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.85, edgecolor='#999')) |
| ax3.set_xlabel('t-SNE 1', fontsize=15, labelpad=8) |
| ax3.set_ylabel('t-SNE 2', fontsize=15, labelpad=8) |
| ax3.set_title('Cluster analysis (motif enrichment)', fontsize=18, fontweight='bold', pad=12) |
| ax3.legend(title='Cluster', frameon=True, fancybox=True, framealpha=0.92, |
| markerscale=3, edgecolor='#bbb', fontsize=12) |
| plt.tight_layout() |
| plt.savefig(out_path / f'tsne_clusters_{model_name.lower()}.png', dpi=300, bbox_inches='tight', facecolor='white') |
| plt.savefig(out_path / f'tsne_clusters_{model_name.lower()}.pdf', dpi=300, bbox_inches='tight', facecolor='white') |
| plt.close() |
| print(f" Saved: tsne_clusters_{model_name.lower()}.png") |
|
|
| |
| |
| df_domain = pd.DataFrame({ |
| 'tsne_1': tsne_domain[:, 0], |
| 'tsne_2': tsne_domain[:, 1], |
| 'domain': labels_domain, |
| }) |
| df_domain.to_csv(out_path / f'tsne_domain_{model_name.lower()}.csv', index=False) |
| |
| df_glyco = pd.DataFrame({ |
| 'tsne_1': tsne_glyco[:, 0], |
| 'tsne_2': tsne_glyco[:, 1], |
| 'glycosylation': labels_glyco, |
| 'cluster': cluster_labels, |
| 'iupac': iupac_glyco, |
| }) |
| df_glyco.to_csv(out_path / f'tsne_glycosylation_{model_name.lower()}.csv', index=False) |
| |
| df_immuno = pd.DataFrame({ |
| 'tsne_1': tsne_immuno[:, 0], |
| 'tsne_2': tsne_immuno[:, 1], |
| 'immunogenicity': labels_immuno, |
| }) |
| df_immuno.to_csv(out_path / f'tsne_immunogenicity_{model_name.lower()}.csv', index=False) |
| print(f" Saved CSVs: tsne_domain/glycosylation/immunogenicity_{model_name.lower()}.csv") |
|
|
| |
| motif_df = pd.DataFrame([ |
| {'cluster': ci, 'n_samples': sum(1 for c in cluster_labels if c == ci), |
| 'top_motif': motif_labels.get(ci, '')} |
| for ci in range(6) |
| ]) |
| motif_df.to_csv(out_path / f'motif_enrichment_{model_name.lower()}.csv', index=False) |
| print(f" Saved: motif_enrichment_{model_name.lower()}.csv") |
|
|
| |
| np.savez(out_path / f'tsne_coordinates_{model_name.lower()}.npz', |
| domain_tsne=tsne_domain, domain_labels=labels_domain, |
| glyco_tsne=tsne_glyco, glyco_labels=labels_glyco, |
| immuno_tsne=tsne_immuno, immuno_labels=labels_immuno, |
| cluster_labels=cluster_labels) |
|
|
| |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('--model', type=str, default='v6', choices=['v5', 'v6']) |
| parser.add_argument('--device', type=str, default='cuda') |
| parser.add_argument('--output_dir', type=str, |
| default=str(PROJECT_ROOT / 'bert_v6_contrastive' / 'analysis' / 'probe_results_v6' / 'tsne_figures')) |
| args = parser.parse_args() |
|
|
| model_name = args.model.upper() |
| print(f"\n{'='*60}") |
| print(f" t-SNE Figure β GlycanBERT {model_name} (Full Transformer)") |
| print(f"{'='*60}") |
|
|
| model = load_model(str(CHECKPOINTS[args.model]), device=args.device) |
|
|
| print("\nLoading datasets...") |
| domain_samples, domain_labels = load_domain_data() |
| glyco_samples, glyco_labels, glyco_iupacs = load_glycosylation_data() |
| immuno_samples, immuno_labels = load_immunogenicity_data() |
|
|
| print(f"\nEmbedding with FULL transformer (12 layers)...") |
| print(f" Domain ({len(domain_samples)})...") |
| embs_domain = get_cls_embeddings(model, domain_samples, device=args.device) |
| print(f" Glycosylation ({len(glyco_samples)})...") |
| embs_glyco = get_cls_embeddings(model, glyco_samples, device=args.device) |
| print(f" Immunogenicity ({len(immuno_samples)})...") |
| embs_immuno = get_cls_embeddings(model, immuno_samples, device=args.device) |
|
|
| import gc; del model; torch.cuda.empty_cache(); gc.collect() |
|
|
| print("\nGenerating t-SNE figures...") |
| generate_figure(embs_domain, domain_labels, |
| embs_glyco, glyco_labels, glyco_iupacs, |
| embs_immuno, immuno_labels, |
| args.output_dir, model_name) |
|
|
| print(f"\n{'='*60}") |
| print(f" COMPLETE") |
| print(f"{'='*60}") |
|
|
| if __name__ == '__main__': |
| main() |
|
|