angerami Claude Opus 4.8 commited on
Commit
21da706
Β·
1 Parent(s): 1b89601

Add tight-pair bond ultrametricity scores

Browse files

For each tight pair (a,b) β€” mutual nearest neighbors or the k smallest-D pairs β€”
average the Rammal index u over triples where (a,b) is the shortest edge; the
bond score 1-<u> separates the ultrametric backbone (high) from specific
contacts (low). Compared against a null computed the same way on a distance
matrix with shuffled off-diagonals; low-score pairs are expected to match the
most-negative D-C residual pairs, and bimodality marks the backbone-vs-contact
split.

tight_pair_scores returns the pair indices, per-bond scores, and null so they
can be cross-referenced against the R eigenvectors. Persisted to the .npz/summary
and surfaced in the dashboard with live method (MNN / smallest-D) and k controls,
a score-vs-null histogram, and a flagged low-score table tagged with residual
overlap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

apps/ultrametricity_view.py CHANGED
@@ -23,7 +23,7 @@ from scipy.cluster.hierarchy import dendrogram, linkage, cophenet
23
  from scipy.spatial.distance import squareform
24
 
25
  from transformer_analysis.ultrametricity import (
26
- to_distance, synthetic_ultrametric, null_distance_samples)
27
 
28
  METRICS = {"Frobenius cosine": "frob_cosine",
29
  "Jensen-Shannon": "hist_jensen_shannon"}
@@ -59,6 +59,13 @@ def _null(d_head: int, d_model: int, metric: str):
59
  return null_distance_samples(d_head, d_model, metric)
60
 
61
 
 
 
 
 
 
 
 
62
  def _grid(values, keys, n_layers, n_heads):
63
  g = np.full((n_layers, n_heads), np.nan)
64
  for v, (l, h) in zip(values, keys):
@@ -403,6 +410,51 @@ def render(exp_dir: Path):
403
  "energy in a few eigenvalues β‡’ a handful of global factors on top of "
404
  "the tree; high Gini/kurtosis β‡’ sparse specific pairs; otherwise noise.")
405
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  # ── Flat geometry alternative: classical MDS ──
407
  st.subheader("Euclidean alternative (classical MDS)")
408
  em = m.get("embedding")
 
23
  from scipy.spatial.distance import squareform
24
 
25
  from transformer_analysis.ultrametricity import (
26
+ to_distance, synthetic_ultrametric, null_distance_samples, tight_pair_scores)
27
 
28
  METRICS = {"Frobenius cosine": "frob_cosine",
29
  "Jensen-Shannon": "hist_jensen_shannon"}
 
59
  return null_distance_samples(d_head, d_model, metric)
60
 
61
 
62
+ @st.cache_data
63
+ def _tight(exp_dir: str, run_key: str, metric: str, method: str, k: int):
64
+ _, U, D, _ = _load(exp_dir, run_key, metric)
65
+ keys = [tuple(x) for x in U["head_index"]]
66
+ return tight_pair_scores(D, keys, method=method, k_smallest=k)
67
+
68
+
69
  def _grid(values, keys, n_layers, n_heads):
70
  g = np.full((n_layers, n_heads), np.nan)
71
  for v, (l, h) in zip(values, keys):
 
410
  "energy in a few eigenvalues β‡’ a handful of global factors on top of "
411
  "the tree; high Gini/kurtosis β‡’ sparse specific pairs; otherwise noise.")
412
 
413
+ # ── Tight-pair bond scores (backbone vs contact) ──
414
+ st.subheader("Tight-pair bond scores")
415
+ tcol = st.columns(2)
416
+ method_label = tcol[0].selectbox("Tight pairs", ["smallest-D", "mutual NN"],
417
+ key="tight_method")
418
+ method = "smallest" if method_label == "smallest-D" else "mnn"
419
+ kk = tcol[1].slider("k (smallest-D)", 20, 500, 200, 20, key="tight_k",
420
+ disabled=(method != "smallest"))
421
+ tg = _tight(str(exp_dir), run_key, metric, method, kk)
422
+ sc = tg["scores"][np.isfinite(tg["scores"])]
423
+ nl = tg["null_scores"][np.isfinite(tg["null_scores"])]
424
+ if len(sc):
425
+ bins = np.linspace(0, 1, 41)
426
+ cen = 0.5 * (bins[:-1] + bins[1:])
427
+ figt = go.Figure()
428
+ for vals, name, color, fc in (
429
+ (sc, f"tight pairs ({tg['method']})", "#19D3F3", "rgba(25,211,243,0.40)"),
430
+ (nl, "null (shuffled D)", "#FF6692", "rgba(255,102,146,0.35)")):
431
+ if len(vals):
432
+ h, _ = np.histogram(vals, bins=bins, density=True)
433
+ figt.add_scatter(x=cen, y=h, name=name, line_shape="hv",
434
+ fill="tozeroy", line=dict(color=color), fillcolor=fc)
435
+ figt.update_layout(title="bond score 1βˆ’βŸ¨u⟩ over tight pairs", height=340,
436
+ xaxis_title="1βˆ’βŸ¨u⟩ (high = backbone, low = contact)",
437
+ yaxis_title="density", legend=dict(x=0.01, y=0.99))
438
+ st.plotly_chart(figt, use_container_width=True)
439
+ st.caption("Per tight pair (a,b): 1βˆ’βŸ¨u⟩ averaged over triples where (a,b) is the "
440
+ "shortest edge. High β‡’ clean ultrametric sibling (backbone); low β‡’ a "
441
+ "close pair whose triples break ultrametricity (a specific contact). "
442
+ "Bimodality is the backbone-vs-contact split. Null = same procedure on "
443
+ "a distance matrix with shuffled off-diagonals.")
444
+
445
+ low = pd.DataFrame(tg.get("low_flagged", []))
446
+ if len(low):
447
+ respairs = {frozenset((rp_["i"], rp_["j"]))
448
+ for rp_ in m.get("residual_pairs", []) if "i" in rp_}
449
+ low["in_R_residual_top"] = [frozenset((r.i, r.j)) in respairs
450
+ for r in low.itertuples()]
451
+ st.caption(f"Lowest-score tight pairs (median {tg['score_median']:.3f} vs "
452
+ f"null {tg['null_median']:.3f}, {tg['n_pairs']} pairs) β€” these "
453
+ "should match the most-negative R residual pairs.")
454
+ st.dataframe(low[["layer_1", "head_1", "layer_2", "head_2", "delta_layer",
455
+ "distance", "score", "in_R_residual_top"]],
456
+ use_container_width=True, height=320)
457
+
458
  # ── Flat geometry alternative: classical MDS ──
459
  st.subheader("Euclidean alternative (classical MDS)")
460
  em = m.get("embedding")
scripts/run_ultrametricity.py CHANGED
@@ -28,6 +28,7 @@ from transformer_analysis.ultrametricity import (
28
  residual_decomposition,
29
  embedding_analysis,
30
  linkage_comparison,
 
31
  )
32
 
33
 
@@ -107,6 +108,8 @@ def main():
107
  quad = quadruple_statistics(D, sample_cap=args.quad_sample_cap,
108
  seed=args.seed, tol=args.tol)
109
  lk = linkage_comparison(D)
 
 
110
 
111
  dense = D.shape[0] <= args.max_dense
112
  resid = residual_decomposition(D, clust["linkage"]) if dense else None
@@ -131,6 +134,9 @@ def main():
131
  mds_eigs=emb["eigenvalues"] if emb else np.array([]),
132
  mds_top_eigvecs=emb["top_eigvecs"] if emb else np.zeros((0, 0)),
133
  mds_top_eigs=emb["top_eigs"] if emb else np.array([]),
 
 
 
134
  )
135
 
136
  summary["metrics"][metric] = {
@@ -155,6 +161,8 @@ def main():
155
  "gini", "kurtosis", "verdict")},
156
  "embedding": emb and {k: emb[k] for k in
157
  ("neg_energy_frac", "var_frac_2d", "var_frac_3d", "eff_dim")},
 
 
158
  }
159
  log = {
160
  f"{metric}.mean_u": stats["mean_u"],
@@ -163,6 +171,7 @@ def main():
163
  f"{metric}.triangle_violation_frac": stats["triangle_violation_frac"],
164
  f"{metric}.mean_u4": quad["mean_u4"],
165
  f"{metric}.frac_additive": quad["frac_additive"],
 
166
  }
167
  if resid:
168
  log[f"{metric}.res_energy_frac"] = resid["res_energy_frac"]
 
28
  residual_decomposition,
29
  embedding_analysis,
30
  linkage_comparison,
31
+ tight_pair_scores,
32
  )
33
 
34
 
 
108
  quad = quadruple_statistics(D, sample_cap=args.quad_sample_cap,
109
  seed=args.seed, tol=args.tol)
110
  lk = linkage_comparison(D)
111
+ tight = tight_pair_scores(D, keys, method="smallest", k_smallest=300,
112
+ seed=args.seed)
113
 
114
  dense = D.shape[0] <= args.max_dense
115
  resid = residual_decomposition(D, clust["linkage"]) if dense else None
 
134
  mds_eigs=emb["eigenvalues"] if emb else np.array([]),
135
  mds_top_eigvecs=emb["top_eigvecs"] if emb else np.zeros((0, 0)),
136
  mds_top_eigs=emb["top_eigs"] if emb else np.array([]),
137
+ tight_scores=tight["scores"],
138
+ tight_null_scores=tight["null_scores"],
139
+ tight_pair_idx=tight["pair_idx"],
140
  )
141
 
142
  summary["metrics"][metric] = {
 
161
  "gini", "kurtosis", "verdict")},
162
  "embedding": emb and {k: emb[k] for k in
163
  ("neg_energy_frac", "var_frac_2d", "var_frac_3d", "eff_dim")},
164
+ "tight": {k: tight[k] for k in
165
+ ("method", "n_pairs", "score_median", "null_median", "low_flagged")},
166
  }
167
  log = {
168
  f"{metric}.mean_u": stats["mean_u"],
 
171
  f"{metric}.triangle_violation_frac": stats["triangle_violation_frac"],
172
  f"{metric}.mean_u4": quad["mean_u4"],
173
  f"{metric}.frac_additive": quad["frac_additive"],
174
+ f"{metric}.tight_score_median": tight["score_median"],
175
  }
176
  if resid:
177
  log[f"{metric}.res_energy_frac"] = resid["res_energy_frac"]
src/transformer_analysis/ultrametricity.py CHANGED
@@ -229,6 +229,91 @@ def cluster_analysis(D, keys, top_k=50):
229
  }
230
 
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  # ── Is it the wrong tree, or extra structure on top? ──────────────────
233
 
234
  def _gini(x):
 
229
  }
230
 
231
 
232
+ # ── Tight-pair bond scores (backbone vs contact) ──────────────────────
233
+
234
+ def _candidate_pairs(M, method, k_smallest):
235
+ n = M.shape[0]
236
+ if method == "smallest":
237
+ iu = np.triu_indices(n, k=1)
238
+ order = np.argsort(M[iu])[:k_smallest]
239
+ return [(int(iu[0][o]), int(iu[1][o])) for o in order]
240
+ # mutual nearest neighbors
241
+ nn = (M + np.eye(n) * (M.max() + 1.0)).argmin(axis=1)
242
+ return [(i, int(nn[i])) for i in range(n) if nn[nn[i]] == i and i < nn[i]]
243
+
244
+
245
+ def _bond_scores(M, pairs):
246
+ scores = np.full(len(pairs), np.nan)
247
+ counts = np.zeros(len(pairs), dtype=int)
248
+ for idx, (a, b) in enumerate(pairs):
249
+ dab = M[a, b]
250
+ dac, dbc = M[a], M[b]
251
+ mask = (dac >= dab) & (dbc >= dab) # triples where (a,b) is shortest edge
252
+ mask[a] = mask[b] = False
253
+ if mask.any():
254
+ d2 = np.minimum(dac, dbc)[mask]
255
+ d3 = np.maximum(dac, dbc)[mask]
256
+ denom = d3 - dab
257
+ u = np.where(denom > 0, (d3 - d2) / denom, 0.0)
258
+ scores[idx] = 1.0 - float(u.mean())
259
+ counts[idx] = int(mask.sum())
260
+ return scores, counts
261
+
262
+
263
+ def tight_pair_scores(D, keys, method="mnn", k_smallest=300, seed=0, top_flag=25):
264
+ """Per-bond ultrametricity score for tight (close) head pairs.
265
+
266
+ For each tight pair (a,b) β€” mutual nearest neighbors, or the k smallest-D
267
+ pairs β€” average the Rammal index u over all triples (a,b,c) in which (a,b)
268
+ is the shortest edge; the bond score is 1 - <u>. High = a clean ultrametric
269
+ sibling (backbone); low = a close pair whose triples break ultrametricity
270
+ (a specific contact, matching the most-negative D-C residuals). A null is
271
+ computed the same way on a distance matrix with shuffled off-diagonals.
272
+ Bimodality in the score distribution is the backbone-vs-contact split.
273
+ """
274
+ n = D.shape[0]
275
+ layers = np.array([k[0] for k in keys])
276
+ heads = np.array([k[1] for k in keys])
277
+
278
+ pairs = _candidate_pairs(D, method, k_smallest)
279
+ scores, counts = _bond_scores(D, pairs)
280
+
281
+ rng = np.random.default_rng(seed)
282
+ iu = np.triu_indices(n, k=1)
283
+ vals = D[iu].copy()
284
+ rng.shuffle(vals)
285
+ Dn = np.zeros_like(D)
286
+ Dn[iu] = vals
287
+ Dn[(iu[1], iu[0])] = vals
288
+ null_scores, _ = _bond_scores(Dn, _candidate_pairs(Dn, method, k_smallest))
289
+
290
+ recs = []
291
+ for (a, b), s, c in zip(pairs, scores, counts):
292
+ recs.append({
293
+ "i": int(a), "j": int(b),
294
+ "layer_1": int(layers[a]), "head_1": int(heads[a]),
295
+ "layer_2": int(layers[b]), "head_2": int(heads[b]),
296
+ "delta_layer": int(abs(layers[a] - layers[b])),
297
+ "distance": float(D[a, b]),
298
+ "score": float(s) if np.isfinite(s) else None,
299
+ "n_triples": int(c),
300
+ })
301
+ valid = [r for r in recs if r["score"] is not None]
302
+ low_flagged = sorted(valid, key=lambda r: r["score"])[:top_flag]
303
+
304
+ return {
305
+ "method": method,
306
+ "n_pairs": len(pairs),
307
+ "pair_idx": np.array([[r["i"], r["j"]] for r in recs], dtype=int)
308
+ if recs else np.zeros((0, 2), dtype=int),
309
+ "scores": scores,
310
+ "null_scores": null_scores,
311
+ "score_median": float(np.nanmedian(scores)) if len(scores) else float("nan"),
312
+ "null_median": float(np.nanmedian(null_scores)) if len(null_scores) else float("nan"),
313
+ "low_flagged": low_flagged,
314
+ }
315
+
316
+
317
  # ── Is it the wrong tree, or extra structure on top? ──────────────────
318
 
319
  def _gini(x):