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()
|