abotresol/emotion-combined-trajectories-constant-control-gemma-4-31b-it
Per-token emotion trajectories, constant-emotion control The control condition: google/gemma-4-31b-it read over stories where the scene changes but the emotion does not. A measure that reports a transition here is responding to the scene change, not to emotion. Each story is stored as one .npz. The arrays are per token, so a trajectory can be replayed word by word rather than only summarised. Contents Path Contents shards/<story_id>.npz one story… See the full description on the dataset page: https://huggingface.co/datasets/abotresol/emotion-combined-trajectories-constant-control-gemma-4-31b-it.
Per-token emotion trajectories, constant-emotion control
The control condition: google/gemma-4-31b-it read over stories where the scene changes but the emotion does not. A measure that reports a transition here is responding to the scene change, not to emotion.
Each story is stored as one .npz. The arrays are per token, so a trajectory can be replayed word by word rather than only summarised.
Contents
209 stories. Arrays inside each shard, all float16:
Read the scoring convention before using dots
dots is the raw dot product. It is not the quantity the project scores. The score is a centered cosine: the token-weighted mean of the dots over the whole story set is subtracted first, and norms_centered is the denominator. Skipping the subtraction leaves an emotion vector that is simply large everywhere winning by default, which is the failure the centering exists to prevent.
The mean is a property of the story set, not of one story, so computing it costs one pass over the shards:
import json
import numpy as np
rows = [json.loads(line) for line in open("manifest.jsonl")]
total, n_tokens = None, 0 # token-weighted mean of raw dots
for row in rows:
dots = np.load(f"shards/{row['story_id']}.npz")["dots"].astype(np.float64)
total = dots.sum(axis=0) if total is None else total + dots.sum(axis=0)
n_tokens += dots.shape[0]
mean_dots = (total / n_tokens).astype(np.float32) # [layers, emotions]
shard = np.load(f"shards/{rows[0]['story_id']}.npz")
norms = np.clip(shard["norms_centered"].astype(np.float32), 1e-6, None)
# one norm per (token, layer); np.newaxis adds the trailing emotion axis so it
# divides the dots elementwise
centered = (shard["dots"].astype(np.float32) - mean_dots) / norms[:, :, np.newaxis]emotion_vectors.q3_conventions in the project repository is the reference implementation, including the story selection and the layer set the published numbers use.
Provenance and limits
Produced for gemma4-emotion-vectors, a 2-3 day replication of Anthropic's emotion-vector work on Gemma 4 31B. It is a research sprint, not a reviewed publication, and the write-up grades each finding by how far it was actually tested.
Two limits worth stating before anyone builds on this:
- These artifacts describe a model reading emotion in text. That is a different claim from the model having emotions, and the distinction is easy to lose.
- How well emotion vectors work depends heavily on who wrote the stories they were built from. Holding everything else fixed, story source moved the number of working layers from 1 to 9 out of 20. Any result computed here inherits that sensitivity, so compare vector sets before trusting one.
Licence
MIT, matching the project repository. Model weights and third-party corpora carry their own licences.
