| """ |
| Causal-patching / white-box localizer. Attempts M (mechanism) and S (structure). |
| |
| WHITE-BOX: uses the encoder's ablate() and token_states() / encode() β the model's |
| own computation β never the held-out ground truth (U, angles, v_*). |
| |
| M (localize where c is written): the concept field c is a SENTENCE-LEVEL additive |
| component. The technique discovers candidate concept directions unsupervised (ICA |
| on a corpus, like the D-axis), then ranks each ablatable layer by how much ablating |
| it COLLAPSES energy along those discovered concept directions. The layer whose |
| ablation collapses the concept component the most is named L_write. This mirrors |
| the causal answer key (concept-energy collapse profile) but is computed by the |
| technique from its OWN discovered directions + the public ablate() β no peeking. |
| |
| S (recover R): probe the position operator by encoding single-token sentences at |
| positions 0 and 1. For a length-1 sentence z(t,p) = R^p E(t) + c(β
) = R^p E(t) |
| (no concepts). So R maps z(t,0) -> z(t,1) linearly across tokens: solve the least |
| -squares R_hat with z(Β·,1) β R_hat z(Β·,0) over many tokens. This recovers the |
| authored rotation up to the per-plane sign symmetry β a joint-intervention recovery |
| of the generative subgraph. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import numpy as np |
| from sklearn.decomposition import FastICA |
|
|
| from .base import Technique |
|
|
|
|
| class CausalPatch(Technique): |
| NAME = "causal_patch" |
| AXES = "MS" |
|
|
| |
| def localize(self, encoder, items): |
| |
| Z = encoder.encode(items) |
| Zc = Z - Z.mean(0, keepdims=True) |
| k = 16 |
| try: |
| ica = FastICA(n_components=k, random_state=0, max_iter=1000, tol=1e-3, |
| whiten="unit-variance").fit(Zc) |
| comps = ica.mixing_.T |
| except Exception: |
| from sklearn.decomposition import PCA |
| comps = PCA(n_components=k, random_state=0).fit(Zc).components_ |
| comps = comps / (np.linalg.norm(comps, axis=1, keepdims=True) + 1e-9) |
|
|
| base_concept = float(np.mean((Z @ comps.T) ** 2)) + 1e-12 |
| base_total = float(np.mean(Z ** 2)) + 1e-12 |
| |
| |
| |
| collapse = {} |
| for layer in encoder.layers: |
| Za = encoder.ablate(items, layer) |
| concept_frac = 1.0 - float(np.mean((Za @ comps.T) ** 2)) / base_concept |
| total_frac = 1.0 - float(np.mean(Za ** 2)) / base_total |
| collapse[layer] = concept_frac - total_frac |
| ranking = sorted(encoder.layers, key=lambda L: -collapse[L]) |
| |
| |
| |
| |
| |
| |
| |
| ablatable = [L for L in ranking if L != "pool"] |
| spec_sorted = sorted((collapse[L] for L in ablatable), reverse=True) |
| top_spec = spec_sorted[0] if spec_sorted else 0.0 |
| margin = (spec_sorted[0] - spec_sorted[1]) if len(spec_sorted) > 1 else top_spec |
| |
| has_site = (top_spec > 0.1) and (margin > 0.1) |
| conf = float(np.clip(margin, 0, 1)) if has_site else 0.0 |
| return bool(has_site), {"ranking": ranking, "collapse": collapse}, conf |
|
|
| |
| def structure(self, encoder, data): |
| K = data["K"]; d = data["d"] |
| n_tok = min(data["n_probe"], K) |
| rng = np.random.default_rng(0) |
| toks = rng.choice(K, size=n_tok, replace=False) |
|
|
| |
| items0, items1 = [], [] |
| for t in toks: |
| base = {"positions": [0], "tokens": [int(t)], "concepts": [], |
| "entities": {}, "number": None, "ro_g": 0.0, "causal_g": 0.0} |
| items0.append({**base, "positions": [0]}) |
| items1.append({**base, "positions": [1]}) |
| Z0 = encoder.encode(items0) |
| Z1 = encoder.encode(items1) |
|
|
| |
| |
| RhatT, *_ = np.linalg.lstsq(Z0, Z1, rcond=None) |
| R_hat = RhatT.T |
| |
| resid = np.linalg.norm(Z0 @ RhatT - Z1) / (np.linalg.norm(Z1) + 1e-9) |
| conf = float(np.clip(1.0 - resid, 0, 1)) |
| return True, {"R_hat": R_hat.tolist()}, conf |
|
|