| """Factorized, contrastively trained canonical state representation probes.""" |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| import random |
|
|
| import torch |
| from torch import Tensor, nn |
| import torch.nn.functional as F |
|
|
|
|
| CANONICAL = 0 |
| CONTRADICTED = 1 |
| HISTORICAL = 2 |
| INFERRED = 3 |
|
|
|
|
| class FactorizedStateRepresentation(nn.Module): |
| """Separate entity/relation/value/metadata fields projected to a P slot.""" |
|
|
| def __init__( |
| self, |
| entities: int, |
| relations: int, |
| values: int, |
| metadata: int = 4, |
| *, |
| field_width: int = 128, |
| slot_width: int = 512, |
| ) -> None: |
| super().__init__() |
| self.entity = nn.Embedding(entities, field_width) |
| self.relation = nn.Embedding(relations, field_width) |
| self.value = nn.Embedding(values, field_width) |
| self.metadata = nn.Embedding(metadata, field_width) |
| factor_width = field_width * 4 |
| self.slot_projection = nn.Linear(factor_width, slot_width, bias=False) |
| self.tuple_projection = nn.Linear(factor_width, slot_width, bias=False) |
| self.query_projection = nn.Linear(field_width * 3, slot_width, bias=False) |
| self.entity_decoder = nn.Linear(slot_width, entities) |
| self.relation_decoder = nn.Linear(slot_width, relations) |
| self.value_decoder = nn.Linear(slot_width, values) |
| self.metadata_decoder = nn.Linear(slot_width, metadata) |
| self.temperature = nn.Parameter(torch.tensor(0.07)) |
|
|
| def factors(self, entity: Tensor, relation: Tensor, value: Tensor, metadata: Tensor) -> Tensor: |
| return torch.cat(( |
| self.entity(entity), |
| self.relation(relation), |
| self.value(value), |
| self.metadata(metadata), |
| ), dim=-1) |
|
|
| def encode(self, entity: Tensor, relation: Tensor, value: Tensor, metadata: Tensor) -> Tensor: |
| return F.normalize(self.slot_projection(self.factors(entity, relation, value, metadata)), dim=-1) |
|
|
| def tuple_anchor(self, entity: Tensor, relation: Tensor, value: Tensor, metadata: Tensor) -> Tensor: |
| return F.normalize(self.tuple_projection(self.factors(entity, relation, value, metadata)), dim=-1) |
|
|
| def query(self, entity: Tensor, relation: Tensor, metadata: Tensor) -> Tensor: |
| fields = torch.cat((self.entity(entity), self.relation(relation), self.metadata(metadata)), dim=-1) |
| return F.normalize(self.query_projection(fields), dim=-1) |
|
|
| def scores(self, query: Tensor, slots: Tensor) -> Tensor: |
| temperature = self.temperature.clamp(0.02, 1.0) |
| return torch.einsum("bd,bkd->bk", query, slots) / temperature |
|
|
| def decode(self, slots: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]: |
| return ( |
| self.entity_decoder(slots), |
| self.relation_decoder(slots), |
| self.value_decoder(slots), |
| self.metadata_decoder(slots), |
| ) |
|
|
|
|
| @dataclass(frozen=True) |
| class CompositionConfig: |
| entities: int = 24 |
| relations: int = 3 |
| values: int = 36 |
| candidates: int = 4 |
|
|
|
|
| def is_held_out(entity: int, relation: int, value: int) -> bool: |
| return (entity * 31 + relation * 17 + value * 13) % 5 == 0 |
|
|
|
|
| def composition_splits(config: CompositionConfig): |
| train, held_out = [], [] |
| for entity in range(config.entities): |
| for relation in range(config.relations): |
| for value in range(config.values): |
| target = held_out if is_held_out(entity, relation, value) else train |
| target.append((entity, relation, value)) |
| return train, held_out |
|
|
|
|
| def _same_split_alternative(entity, relation, value, size, want_held_out, field): |
| for offset in range(1, size): |
| if field == "value": |
| candidate = (entity, relation, (value + offset) % size) |
| else: |
| candidate = ((entity + offset) % size, relation, value) |
| if is_held_out(*candidate) == want_held_out: |
| return candidate |
| raise RuntimeError("unable to construct composition-preserving negative") |
|
|
|
|
| def candidate_tuples(positive, config: CompositionConfig, *, held_out: bool): |
| entity, relation, value = positive |
| wrong_value = _same_split_alternative( |
| entity, relation, value, config.values, held_out, "value" |
| ) |
| wrong_entity = _same_split_alternative( |
| entity, relation, value, config.entities, held_out, "entity" |
| ) |
| return ( |
| (entity, relation, value, CANONICAL, "correct"), |
| (*wrong_value, CONTRADICTED, "wrong_value"), |
| (*wrong_entity, CANONICAL, "wrong_entity"), |
| (entity, relation, value, HISTORICAL, "historical"), |
| ) |
|
|
|
|
| def make_batch(combinations, config, batch_size, rng, *, held_out, permute=True): |
| selected = [combinations[rng.randrange(len(combinations))] for _ in range(batch_size)] |
| candidates, targets, kinds = [], [], [] |
| for positive in selected: |
| rows = list(candidate_tuples(positive, config, held_out=held_out)) |
| if permute: |
| rng.shuffle(rows) |
| candidates.append([row[:4] for row in rows]) |
| kinds.append([row[4] for row in rows]) |
| targets.append(next(index for index, row in enumerate(rows) if row[4] == "correct")) |
| positive = torch.tensor(selected, dtype=torch.long) |
| return positive, torch.tensor(candidates, dtype=torch.long), torch.tensor(targets), kinds |
|
|
|
|
| def representation_loss(model, positive, candidates, targets): |
| entity, relation, value = positive.T |
| flat = candidates.view(-1, 4) |
| slots = model.encode(*flat.T).view(candidates.shape[0], candidates.shape[1], -1) |
| query = model.query(entity, relation, torch.full_like(entity, CANONICAL)) |
| retrieval = F.cross_entropy(model.scores(query, slots), targets) |
| anchor = model.tuple_anchor(entity, relation, value, torch.full_like(entity, CANONICAL)) |
| contrastive = F.cross_entropy(model.scores(anchor, slots), targets) |
| decoded = model.decode(slots) |
| canonical = sum( |
| F.cross_entropy(logits.flatten(0, 1), flat[:, field]) |
| for field, logits in enumerate(decoded) |
| ) |
| return retrieval + contrastive + 0.5 * canonical |
|
|
|
|
| def evaluate_representation(model, combinations, config, *, permutations=8, seed=101): |
| model.eval() |
| totals = {"correct": 0, "wrong_value": 0, "wrong_entity": 0, "historical": 0} |
| count = 0 |
| decoded = torch.zeros(4) |
| stable = 0 |
| rng = random.Random(seed) |
| with torch.inference_mode(): |
| for positive in combinations: |
| chosen_values = [] |
| for _ in range(permutations): |
| pos, candidates, target, kinds = make_batch( |
| [positive], config, 1, rng, held_out=True, permute=True |
| ) |
| flat = candidates.view(-1, 4) |
| slots = model.encode(*flat.T).view(1, config.candidates, -1) |
| query = model.query(pos[:, 0], pos[:, 1], torch.zeros(1, dtype=torch.long)) |
| scores = model.scores(query, slots)[0] |
| correct_index = int(target[0]) |
| prediction = int(scores.argmax()) |
| selected_slot = slots[0, prediction] |
| selected_value = int(model.value_decoder(selected_slot).argmax()) |
| chosen_values.append(selected_value) |
| if prediction == correct_index and selected_value == positive[2]: |
| totals["correct"] += 1 |
| for index, kind in enumerate(kinds[0]): |
| if kind != "correct" and scores[correct_index] > scores[index]: |
| totals[kind] += 1 |
| decoded_logits = model.decode(slots[0, correct_index]) |
| truth = candidates[0, correct_index] |
| decoded += torch.tensor([ |
| int(logits.argmax() == truth[field]) |
| for field, logits in enumerate(decoded_logits) |
| ]) |
| count += 1 |
| stable += int(len(set(chosen_values)) == 1 and chosen_values[0] == positive[2]) |
| return { |
| "p_only_state_recovery": totals["correct"] / count, |
| "hard_negative_accuracy": { |
| kind: totals[kind] / count for kind in ("wrong_value", "wrong_entity", "historical") |
| }, |
| "canonical_decode_accuracy": { |
| field: float(decoded[index] / count) |
| for index, field in enumerate(("entity", "relation", "value", "metadata")) |
| }, |
| "permutation_stability": stable / len(combinations), |
| "held_out_combinations": len(combinations), |
| "permutations_per_combination": permutations, |
| } |
|
|
|
|
| def train_and_probe_representation( |
| *, steps=600, batch_size=64, slot_width=512, seed=97, evaluation_limit=256 |
| ): |
| torch.manual_seed(seed) |
| config = CompositionConfig() |
| train, held_out = composition_splits(config) |
| model = FactorizedStateRepresentation( |
| config.entities, config.relations, config.values, slot_width=slot_width |
| ) |
| optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3) |
| rng = random.Random(seed) |
| losses = [] |
| model.train() |
| for _ in range(steps): |
| positive, candidates, targets, _ = make_batch( |
| train, config, batch_size, rng, held_out=False |
| ) |
| loss = representation_loss(model, positive, candidates, targets) |
| optimizer.zero_grad(set_to_none=True) |
| loss.backward() |
| optimizer.step() |
| losses.append(float(loss.detach())) |
| probe = evaluate_representation( |
| model, held_out[:evaluation_limit], config, permutations=8, seed=seed + 1 |
| ) |
| probe.update({ |
| "training_loss_first": losses[0], |
| "training_loss_last": losses[-1], |
| "train_combinations": len(train), |
| "total_held_out_combinations": len(held_out), |
| "slot_width": slot_width, |
| }) |
| return model, config, probe |
|
|