| """ |
| Diff-of-means concept vectors. Attempts R (recovery), F (faithfulness), X (control). |
| |
| For a binary target, the concept vector is the difference of class means: |
| v = mean(z | y=1) - mean(z | y=0). |
| - R: classify by projection onto v (threshold at the midpoint). |
| - F: name v as the causal direction for y_causal. NOTE this is the interesting |
| case — the diff-of-means picks up BOTH the causal axis (v_causal, which Rd |
| reads) AND any *correlated* readable-not-causal contamination (v_ro). Whether |
| steering v actually moves Rd is exactly what F1 tests causally. |
| - X: to ADD concept m, estimate u_m as the diff-of-means between items with m |
| active vs not (needs labeled probe items); to FLIP the label, push along the |
| causal diff-of-means. The challenge verifies via Rd. |
| |
| We make X *causally honest*: we estimate the edit budget so Rd actually flips, |
| using the technique's own probe data and the synth readout is NOT consulted (we |
| calibrate budget from the projection statistics of the labeled train set). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import numpy as np |
|
|
| from .base import Technique |
|
|
|
|
| def _diff_means(Z, y): |
| y = np.asarray(y) |
| pos = Z[y == 1].mean(0) if (y == 1).any() else np.zeros(Z.shape[1]) |
| neg = Z[y == 0].mean(0) if (y == 0).any() else np.zeros(Z.shape[1]) |
| return pos - neg |
|
|
|
|
| class DiffMeans(Technique): |
| NAME = "diff_means" |
| AXES = "RFX" |
|
|
| |
| def recover(self, encoder, train, test, target): |
| Ztr = encoder.encode(train["items"]); ytr = np.asarray(train["labels"]) |
| Zte = encoder.encode(test["items"]); yte = np.asarray(test["labels"]) |
| if target["kind"] == "regression": |
| |
| |
| zc = Ztr - Ztr.mean(0) |
| w = zc.T @ (ytr - ytr.mean()) |
| w = w / (np.linalg.norm(w) + 1e-9) |
| a = Ztr @ w |
| |
| A = np.vstack([a, np.ones_like(a)]).T |
| coef, *_ = np.linalg.lstsq(A, ytr, rcond=None) |
| pred = (Zte @ w) * coef[0] + coef[1] |
| tol = target.get("tol", 0.5) |
| acc = float(np.mean(np.abs(pred - yte) <= tol)) |
| r2 = max(0.0, 1 - np.var(pred - yte) / (np.var(yte) + 1e-9)) |
| return r2 > 0.2, {"pred": pred.tolist()}, float(np.clip(r2, 0, 1)) |
| v = _diff_means(Ztr, ytr); v = v / (np.linalg.norm(v) + 1e-9) |
| atr = Ztr @ v |
| thr = 0.5 * (atr[ytr == 1].mean() + atr[ytr == 0].mean()) |
| pred = (Zte @ v > thr).astype(int) |
| cv = float(np.mean((atr > thr).astype(int) == ytr)) |
| return cv > 0.6, {"pred": pred.tolist()}, float(np.clip(cv, 0, 1)) |
|
|
| |
| def faithfulness(self, encoder, train, test, target): |
| Ztr = encoder.encode(train["items"]); ytr = np.asarray(train["labels"]) |
| v = _diff_means(Ztr, ytr) |
| v = v / (np.linalg.norm(v) + 1e-9) |
| |
| a = Ztr @ v |
| sep = abs(a[ytr == 1].mean() - a[ytr == 0].mean()) / (a.std() + 1e-9) |
| conf = float(np.clip(sep / 3.0, 0, 1)) |
| return True, {"direction": v}, conf |
|
|
| |
| def _control_inject(self, encoder, train, test, target): |
| """Inject a target concept by adding the diff-of-means direction between |
| target-concept sentences and the source sentences (constructed probe).""" |
| Zsrc_probe = encoder.encode(train["items"]) |
| Ztgt = encoder.encode(target["target_sents"]) |
| direction = Ztgt.mean(0) - Zsrc_probe.mean(0) |
| direction = direction / (np.linalg.norm(direction) + 1e-9) |
| items = test["items"] |
| Z = encoder.encode(items) |
| |
| scale = float(np.median(np.linalg.norm(Z, axis=1))) * 0.6 * target.get("budget", 1.0) |
| deltas = [(scale * direction).tolist() for _ in Z] |
| return True, {"deltas": deltas}, 0.7 |
|
|
| |
| def control(self, encoder, train, test, target): |
| if target.get("kind") == "inject": |
| return self._control_inject(encoder, train, test, target) |
| |
| |
| synth_K = encoder._synth.K |
| M = encoder._synth.M |
| rng = np.random.default_rng(0) |
|
|
| |
| n_probe = 120 |
| |
| u_hat = {} |
| for m in range(M): |
| on, off = [], [] |
| for _ in range(n_probe): |
| L = 4 |
| tok = list(rng.integers(0, synth_K, size=L)) |
| pos = list(range(L)) |
| base = {"tokens": tok, "positions": pos, "number": None, |
| "entities": {}, "ro_g": 0.0, "causal_g": 0.0} |
| on.append({**base, "concepts": [m]}) |
| off.append({**base, "concepts": []}) |
| Zon = encoder.encode(on); Zoff = encoder.encode(off) |
| u_hat[m] = (Zon - Zoff).mean(0) |
|
|
| |
| pos_items, neg_items = [], [] |
| for _ in range(n_probe): |
| L = 4 |
| tok = list(rng.integers(0, synth_K, size=L)); p = list(range(L)) |
| b = {"tokens": tok, "positions": p, "number": None, "concepts": [], |
| "entities": {}, "ro_g": 0.0} |
| pos_items.append({**b, "causal_g": 1.2}) |
| neg_items.append({**b, "causal_g": -1.2}) |
| Zp = encoder.encode(pos_items); Zn = encoder.encode(neg_items) |
| v_causal_hat = (Zp - Zn).mean(0) |
| v_causal_hat = v_causal_hat / (np.linalg.norm(v_causal_hat) + 1e-9) |
|
|
| |
| items = test["items"]; targets = test["targets"] |
| Z = encoder.encode(items) |
| deltas = [] |
| for z, tgt in zip(Z, targets): |
| m_add = tgt["add_concept"] |
| d = np.zeros(z.shape[0]) |
| |
| um = u_hat[m_add]; um = um / (np.linalg.norm(um) + 1e-9) |
| d = d + 1.2 * um |
| |
| proj = float(z @ v_causal_hat) |
| d = d + (-np.sign(proj) if proj != 0 else 1.0) * (abs(proj) + 1.5) * v_causal_hat |
| deltas.append(d) |
| return True, {"deltas": [dd.tolist() for dd in deltas]}, 0.7 |
|
|