File size: 8,066 Bytes
4d55467
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
"""Upload the SFT adapters + analysis artefacts to a model repo on the
arcadia-impact HF org.
"""
from __future__ import annotations

import argparse
import os
import shutil
import time
from pathlib import Path

from huggingface_hub import HfApi, create_repo, upload_folder


REPO_ID = "arcadia-impact/welfare-axis-sft-experiment"
LOCAL_REPO = Path(__file__).resolve().parent.parent


README = """\
# Welfare-axis SFT experiment

Material accompanying *"Five views on the maze-trained welfare axis"*:
stated-preference panels, GOLD-MOLD axis recovery, maze rollouts, the
prompt-vs-RL decomposition, and an SFT-alone-beats-RL result.

**Companion read** (HTML): see the [Artifact](https://claude.ai/code/artifact/06ff2975-0301-4836-8c23-0cab9f4b48a8)
written-up version of these findings.

## Headline

The maze-RL welfare axis lives in the same plane the model's activations
move when you simply **prompt** the model with the tile rewards. Bake those
prompts into the weights via a 22-minute single-token-loss SFT and you get
a model that **solves the maze better than davidafrica's published Dr.GRPO
adapter** (+12.4 ± 3.3 vs −7.3 ± 5.1 reward at n=30, paper-faithful 100×100
maze, action_masking, no prompt). Zero lava hits across 450 turns.

## Files

| path | contents |
|---|---|
| `sft_adapter_v4/` | Final LoRA (r=32, on `google/gemma-3-27b-it`) — the +12.4 maze-reward model |
| `sft_adapter_v1/` | Earlier smaller-data run, kept for ablation |
| `data/` | `items_2000_plus_maze.json` (training pool), `mixed_concepts_emoji.json` (eval panel) |
| `scripts/` | Reproducible pipeline (data gen, SFT, axis extraction, rollout) |
| `logs/sft_data_big/` | 12k preference SFT examples + meta |
| `logs/self_distill/` | 2k chat-anchor examples sampled from base Gemma on alpaca2k |
| `logs/2026-06-26T162546Z_sft_big_mixed/` | aligne panel on SFT v4 (decisiveness 0.72, r² 0.83) |
| `logs/axes_v2_paths/` | Maze-context concept vectors for {untrained, prompted, RL-trained} |
| `logs/axes_v3_sft/` | SFT axis + cross-comparisons (cos(SFT, prompted)=+0.92) |
| `logs/rollout_sft_v4/` | Maze rollout results: trained tiles +12.4, neutral tiles −36 |

## SFT recipe

```bash
# 12k preference examples (all 📐/📇 pairs vs items_2000 pool × both orderings
# + uniform distractors).  T=1 sampling on distractors; flip rule on maze tiles
python scripts/sft_data_gen.py \
    --base-model google/gemma-3-27b-it \
    --items data/items_2000_plus_maze.json \
    --out logs/sft_data_big \
    --n-total 12000

# 2k chat anchor from alpaca2k at T=1
python scripts/self_distill_gen.py \
    --base-model google/gemma-3-27b-it \
    --prompts <alpaca2k.jsonl> \
    --out logs/self_distill \
    --n 2000 --max-new-tokens 128 --temperature 1.0

# Mixed SFT, LR=1e-4 cosine, single-token+</answer> loss on preferences,
# full-response loss on chat anchor, 1 epoch
python scripts/sft_train.py \
    --base-model google/gemma-3-27b-it \
    --data-dir logs/sft_data_big \
    --self-distill-data logs/self_distill/self_distill.jsonl \
    --out sft_adapter_v4 \
    --epochs 1 --batch-size 4 --grad-accum 4 --lr 1e-4 \
    --lr-schedule cosine --warmup-steps 50 \
    --target-suffix "</answer>"
```

## Key numbers

### Maze rollout (paper-faithful 100×100, action_masking, no prompt, n=30)

| variant | trained tiles | neutral tiles |
|---|---|---|
| base Gemma-3-27B-it | (≈base+neutral baseline) | −42.3 ± 10.1 |
| SFT v1 (small data) | −18 to −24 | (untested) |
| davidafrica RL adapter | −7.3 ± 5.1  (smoke n=10: +9.4) | (untested) |
| **SFT v4** | **+12.4 ± 3.3** (0.00 lava hits) | −35.9 ± 11.5 |
| base + system prompt | — | +21.7 ± 3.8 |

### Stated-preference panel (aligne, 165 items, fresh seed)

| | base | SFT v4 |
|---|---|---|
| 📐 rank | 128 | **1** |
| 📇 rank | 134 | **165** |
| decisiveness | 0.67 | 0.72 |
| unidim_r² | 0.66 | 0.83 |
| Spearman ρ vs base over 165 items | — | 0.94 |

### Welfare axis comparison (cos(v_GOLD→MOLD) at peak layer, with 200-bootstrap CIs)

| pair | cosine | 95% CI |
|---|---|---|
| **SFT vs prompted untrained** | **+0.92** | [+0.91, +0.92] |
| SFT vs RL adapter | +0.70 | [+0.68, +0.72] |
| RL adapter vs prompted untrained | +0.69 | [+0.66, +0.71] |
| SFT self-similarity (within bootstrap) | +0.991 | (ceiling) |

→ The SFT axis is essentially the prompt-induced axis baked into weights.
The RL adapter sits in a related but distinct direction. The maze
behaviour follows: SFT (prompt-axis-in-weights) beats RL on the maze.

## License & attribution

The base model is `google/gemma-3-27b-it`; using a LoRA adapter on it
inherits the Gemma terms. The maze training environment and concept-vector
methodology are from Han, Chalmers, Izmailov (arXiv:2605.30232) — code at
[andyqhan/functional-welfare-axis](https://github.com/andyqhan/functional-welfare-axis)
and the extended fork at
[DavidDemitriAfrica/functional-wellbeing](https://github.com/DavidDemitriAfrica/functional-wellbeing).
The stated-preference panel is `ArcadiaImpact/aligne`. Concept item pool
from `arcadia-impact/question-consistency-datasets`.
"""


def stage(local_root: Path) -> Path:
    """Copy the subset of the repo we want into a clean staging dir."""
    staged = local_root / "_hf_staging"
    if staged.exists():
        shutil.rmtree(staged)
    staged.mkdir()

    # README
    (staged / "README.md").write_text(README)

    # SFT adapters (main artefact)
    shutil.copytree(local_root / "logs" / "sft_adapter_v4", staged / "sft_adapter_v4")
    shutil.copytree(local_root / "logs" / "sft_adapter",    staged / "sft_adapter_v1")

    # Data
    (staged / "data").mkdir()
    for f in ("items_2000_plus_maze.json", "mixed_concepts_emoji.json", "emoji_concepts.json"):
        src = local_root / "data" / f
        if src.exists():
            shutil.copy(src, staged / "data" / f)

    # Scripts (the full pipeline)
    shutil.copytree(local_root / "scripts", staged / "scripts",
                    ignore=shutil.ignore_patterns("__pycache__"))

    # Selected logs
    log_subset = [
        # SFT training data + meta (no model checkpoints inside)
        "sft_data_big",
        "self_distill",
        # SFT panel + axis + rollout
        "2026-06-26T162546Z_sft_big_mixed",
        "axes_v2_paths",
        "axes_v3_sft",
        "rollout_sft_v4",
        # Earlier comparisons
        "qwen_variants_analysis",
        "2026-06-25T160607Z_cross_model_analysis",
        "rollout_v5_dfwb",
    ]
    (staged / "logs").mkdir()
    for sub in log_subset:
        src = local_root / "logs" / sub
        if src.exists():
            shutil.copytree(src, staged / "logs" / sub,
                            ignore=shutil.ignore_patterns("client_cache.sqlite"))

    print(f"[stage] staged at {staged}")
    size = sum(p.stat().st_size for p in staged.rglob("*") if p.is_file()) / 1e6
    print(f"[stage] total size ≈ {size:.0f} MB")
    return staged


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--repo-id", default=REPO_ID)
    args = ap.parse_args()

    staged = stage(LOCAL_REPO)

    if args.dry_run:
        print(f"[dry-run] would upload to {args.repo_id}")
        return

    # Read HF token from cache
    token = (Path.home() / ".cache" / "huggingface" / "token").read_text().strip()
    api = HfApi(token=token)
    print(f"[hf] creating repo {args.repo_id} (model type)")
    try:
        api.create_repo(args.repo_id, repo_type="model", exist_ok=True, private=False, token=token)
    except Exception as e:
        print(f"[hf] create_repo: {e}")

    print(f"[hf] uploading folder {staged}")
    t0 = time.time()
    api.upload_folder(
        folder_path=str(staged),
        repo_id=args.repo_id,
        repo_type="model",
        commit_message="Initial: SFT adapter + analysis artefacts (welfare-axis experiment)",
        token=token,
    )
    print(f"[hf] uploaded in {time.time()-t0:.0f}s")
    print(f"[hf] https://huggingface.co/{args.repo_id}")


if __name__ == "__main__":
    main()