Pranav2748's picture
refresh model card: E0+E1 complete, held-out eval results
22e699a verified
|
Raw
History Blame Contribute Delete
15.4 kB
---
license: apache-2.0
base_model: Qwen/Qwen3-4B-Instruct-2507
library_name: peft
pipeline_tag: text-generation
tags:
- lora
- peft
- trl
- grpo
- gdpo
- dpo
- divpo
- rlhf
- diversity
- creative-writing
- mode-collapse
---
# Diversity-aware post-training for creative story generation (Qwen3-4B)
Research artifacts for a controlled study of **whether RL post-training can raise the
*semantic* diversity of short-story generation without paying for it in quality**.
Everything here is measured, not asserted: the repo carries the LoRA adapters, the
scored generation pool they were selected from, the per-step reward telemetry, the
judge-calibration evidence, the experiment reports, and the source that produced all
of it.
> ⚠️ **Work in progress.** Only **E0** (quality-only baseline) has finished its full
> 300-step run. **E1** was still training when this snapshot was uploaded β€” its
> checkpoints stop at **step 200 of 300**. **E2, E3 and E4 have not been trained yet**;
> their configs are present, their adapters are not. No cross-arm comparison exists yet,
> and none should be read into this repo.
---
## The finding this study is built around
Sampling the **base** policy 16 times per prompt over 1,000 prompts (16,000 scored
stories, `outputs/pool_4b/`) gives a mean **effective rank of 2.006 out of a ceiling of
16**. Sixteen independent samples of the same prompt span roughly two effective
directions in embedding space; mean pairwise distance is 0.132, i.e. same-prompt stories
sit at about 0.87 cosine similarity.
Quality-only RL does not fix this. E0 ran 300 steps of GRPO on the judge's quality score
alone and moved judge quality **+0.30** (6.557 β†’ 6.860, first vs. last quarter of the run)
while the diversity statistics stayed flat (mean pairwise deviation 0.1354 β†’ 0.1391;
mean group log-det βˆ’12.81 β†’ βˆ’12.64). The held-out checkpoint study
(`outputs/ckpt_study/`, 10 prompts Γ— 6 samples per checkpoint) tells the same story:
| step | judge quality | effective rank | deviation |
|---|---|---|---|
| 0 (base) | 6.65 | 1.677 | 0.130 |
| 100 | 6.78 | 1.655 | 0.128 |
| 200 | 6.75 | 1.700 | 0.135 |
| 300 | 7.03 | 1.712 | 0.139 |
Craft improves; the model still writes the same story six times. That gap is what the
diversity arms exist to close.
---
## Method
**Base policy.** `Qwen/Qwen3-4B-Instruct-2507`, LoRA **r=32, alpha=64, dropout 0.0** on all
attention and MLP projections (`q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj`).
**Online arms.** GRPO via **TRL 1.10** with `multi_objective_aggregation="normalize_then_sum"`,
which is a line-for-line implementation of **GDPO** (arXiv 2601.05242): each reward channel is
normalized *within its prompt group* before the channels are summed and the advantage is
normalized batch-wise. Generation is **vLLM colocated** in the training process. G=8 samples per
prompt, generation batch 16 (2 prompts Γ— 8), `max_completion_length=1024`, lr 3e-5 with
`constant_with_warmup`, beta (KL) 0.02, 300 steps. Everything ran on a **single RTX 5090 (32 GB)** β€”
the 32 GB budget is what forced the 4B policy, since colocated GRPO needs two resident copies of
the weights.
**Offline arms.** DPO over preference pairs mined from the same 16k pool
(beta 0.1, sigmoid loss, lr 5e-6, epochs chosen for optimizer-step parity across arms).
**Reward channels.**
- `quality` β€” judge score, per story.
- `deviation` `d_i` β€” mean embedding distance from a sample to the rest of its group
(`BAAI/bge-base-en-v1.5`), credited only if judge quality β‰₯ tau=5.
- `marginal` `m_i` β€” the sample's marginal contribution to the group's log-determinant
(a set-level diversity volume), z-scored within group.
Every diversity credit is **per-sample by construction**. A single set-level scalar shared by a
whole group has zero within-group variance and therefore contributes exactly nothing to a GRPO
advantage β€” the failure mode diagnosed in `prior_run/` and written up in
`logs/experiments/00_prior_run_autopsy.md`.
**Judge.** `deepseek-v4-flash-0731` via OpenRouter, scoring **one story at a time on an absolute
rubric** (not batched relative scoring), with reasoning disabled. Calibrated before any training
against four cells with known ordering: detects incoherence (Ξ” 2.25), detects repetition (Ξ” 4.67),
not saturated (sd 0.76). See `logs/experiments/01_judge_calibration.md`.
**Programmatic gates.** Completeness, 150–600 word window, 4-gram / line loop detection and
`finish_reason == "length"` are checked deterministically before a story is ever judged, so
truncated or degenerate text cannot earn reward through any channel.
### The arms
| arm | type | reward / objective | status in this repo |
|---|---|---|---|
| **E0-baseline** | GRPO | quality only β€” the collapse control | βœ… complete, 300 steps + `final` |
| **E1-div-individual** | GRPO | quality + pairwise deviation `d_i` (alpha 0.5, gated at tau=5) | 🟑 partial, steps 50–200 of 300 |
| **E2-div-group** | GRPO | quality + deviation + log-det marginal `m_i` (alpha 0.5, gamma 0.5) | ⬜ config only, not trained |
| **E3-multipos** | DPO | multi-positive, deviation-weighted loss; 4 greedy-diverse chosens vs. rotating negatives (3,593 rows) | ⬜ config + pairs only |
| **E4a-divpo-emb** | DPO | faithful DivPO, embedding-deviation criterion (956 rows, rho=7) | ⬜ config + pairs only |
| **E4b-divpo-prob** | DPO | faithful DivPO, lowest length-normalized logprob among quality β‰₯ rho | ⬜ config + pairs only |
---
## Repository layout
```
outputs/
E0-baseline/ LoRA adapters: checkpoint-{50..300} + final
reward_history.json per-step reward telemetry (300 steps)
trl_log_history.json full TRL log history
judge_cost.json judge call/token/USD accounting
E1-div-individual/ same structure, checkpoints 50-200 (run in progress)
pool_4b/ pool_train.jsonl 16,000 scored base-policy stories
emb_train.npy their bge-base-en-v1.5 embeddings
summary_train.json pool-level summary stats
pairs_4b/ divpo_emb_train.jsonl, divpo_prob_train.jsonl,
multipos_train.jsonl, pair_stats_train.json
ckpt_study/E0-baseline/ stories.md, raw.json, metrics.csv (per-checkpoint story dumps)
logs/
experiments/ the written reports (start here)
figures/ plots referenced by the reports
configs/ one YAML per arm, with the reasoning for every knob in comments
src/ all Python: training, rewards, diversity, judge, gates, eval, tests
data/ train/eval prompt splits + split metadata
prior_run/ the previous, failed run's artifacts (see autopsy)
```
**Read the reports in this order:** `00_prior_run_autopsy.md` β†’ `01_judge_calibration.md` β†’
`02_setup_and_deviations.md` β†’ `03_pool_4b_baseline.md` β†’ `04_E0_checkpoint_story_study.md` β†’
`E0-baseline.md`.
### Data schemas
`outputs/pool_4b/pool_train.jsonl` β€” one story per line:
`prompt_id, prompt, idx, text, n_words, n_tokens, finish_reason, gate_passed, gate_reasons,
ends_cleanly, quality, novelty, deviation, marginal, group_logdet, mean_logprob, cumlogprob`.
Row *i* of `emb_train.npy` corresponds to line *i* of the JSONL.
`outputs/pairs_4b/*.jsonl` β€” DPO rows: `prompt_id, prompt, chosen, rejected` (+ per-row weight
for the multi-positive set).
`data/` β€” 1,000 train and 50 eval prompts (seed 42), filtered to 10–60 words from 272,600 scanned;
`split_meta.json` also carries the exact system prompt used for every generation in the study.
---
## Using an adapter
Each checkpoint directory is a standard PEFT adapter (`adapter_model.safetensors` +
`adapter_config.json`, ~253 MB each).
```python
from huggingface_hub import snapshot_download
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
path = snapshot_download(
"Mercity/creative-writing-llm",
allow_patterns="outputs/E0-baseline/final/*",
)
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-4B-Instruct-2507")
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-4B-Instruct-2507", dtype="auto", device_map="auto"
)
model = PeftModel.from_pretrained(base, f"{path}/outputs/E0-baseline/final")
system = (
"You are a fiction writer. Write a complete short story of 200-500 words responding "
"to the writing prompt.\nWrite only the story: no title, no preamble, no commentary, "
"no author's note.\nFinish inside the word budget. The story must reach a real ending, "
"not stop mid-scene."
)
msgs = [{"role": "system", "content": system},
{"role": "user", "content": "A lighthouse keeper receives a letter addressed to the sea."}]
ids = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt").to(model.device)
out = model.generate(ids, max_new_tokens=1024, temperature=1.0, top_p=1.0, do_sample=True)
print(tok.decode(out[0][ids.shape[-1]:], skip_special_tokens=True))
```
Swap `outputs/E0-baseline/final` for `outputs/E0-baseline/checkpoint-150`,
`outputs/E1-div-individual/checkpoint-200`, etc. The study sampled at T=1.0 during training and
T=0.9 / top_p=0.95 for the checkpoint story study.
---
## Selected numbers
**Base-policy pool** (`logs/experiments/03_pool_4b_baseline.md`, n=16,000):
| metric | value |
|---|---|
| gate pass rate | 0.992 |
| judge quality (mean Β± sd) | 6.447 Β± 0.833 |
| mean pairwise deviation | 0.1321 |
| mean group log-det | βˆ’30.42 |
| **mean effective rank (ceiling 16)** | **2.006 Β± 0.323** |
| corr(quality, effective rank) | βˆ’0.108 |
| corr(deviation, effective rank) | 0.992 |
Quality and diversity are **largely independent** across prompts (r = βˆ’0.108), so a method that
raises diversity without lowering quality is exploiting existing slack rather than defying a
tradeoff.
**E0 run** (`logs/experiments/E0-baseline.md`, 300 steps, 4,800 stories judged, verdict *healthy*):
| metric | early (first 25%) | late (last 25%) | Ξ” |
|---|---|---|---|
| judge quality (passing) | 6.557 | 6.860 | **+0.303** |
| mean deviation | 0.1354 | 0.1391 | +0.004 |
| mean log-det | βˆ’12.81 | βˆ’12.64 | +0.165 |
| gate pass | 0.9925 | 0.9875 | βˆ’0.005 |
| policy entropy | 1.2535 | 1.2274 | βˆ’0.026 (βˆ’2.1%) |
No entropy collapse, and the reward-hacking trip conditions (diversity up while quality or
validity falls) were not tripped. Judge cost for the whole E0 run: **$0.84** over 5,109 calls.
**Qualitative** (`logs/experiments/04_E0_checkpoint_story_study.md`): the collapse is a *tonal
monoculture*, not a lexical one β€” 92–95% of stories carry solemn/elegiac vocabulary against ~15%
comic, even on explicitly comic prompts, and verbatim opening duplication *rises* with
quality-only training. This predicts that n-gram metrics (distinct-4, self-BLEU) will separate the
arms far less than effective rank does.
---
## Environment
torch 2.13.0+cu130 Β· vLLM 0.27.1 Β· transformers 5.15.0 Β· TRL 1.10.0 Β· PEFT 0.20.0 Β·
sentence-transformers 5.7.0 (`BAAI/bge-base-en-v1.5`) Β· single RTX 5090, 32.6 GB, sm_120.
FlashInfer is disabled (it misdetects sm_120); Liger is disabled because it suppresses TRL's
`entropy` metric.
## Caveats and honest notes
- **E1 is mid-flight.** Its checkpoints are a snapshot at step 200/300 and its reports do not
exist yet. E2/E3/E4 are unrun. Do not read arm comparisons out of this repo.
- **`04_E0_checkpoint_story_study.md` documents an earlier, partial 163-step E0 run** (at the
pre-correction lr 3e-6). `outputs/ckpt_study/E0-baseline/metrics.csv` is the *regenerated*
version covering all checkpoints of the final 300-step run; where they disagree, trust the CSV.
- **Learning rate deviates from the plan** (3e-5, not 3e-6): at 3e-6 the adapter was effectively
frozen β€” KL pinned near 8e-4 for 171 steps with every metric inside its noise band. The evidence
is recorded in the config comments and `02_setup_and_deviations.md`.
- **Optimizer/scheduler/RNG state is not included** for E1's checkpoints. It is resume-only state,
is rotated away by `save_total_limit`, and none of it is needed to load or evaluate an adapter.
- **`prior_run/train.py` is a redacted copy** β€” the original contained a hard-coded OpenRouter API
key, replaced here with a placeholder. Nothing else in the file was changed.
- **Judge is not perfect.** It fails the truncation-sensitivity check (Aβˆ’B = 1.33 against a 2.0
bar), which is tolerated only because truncation is caught deterministically by the gates before
any story reaches the judge. Pool-scoring judge failure rate was 0.45%.
- Every deviation from the original plan is enumerated in `logs/experiments/02_setup_and_deviations.md`
rather than discovered in a footnote.
## References
- GDPO β€” group-wise reward normalization for multi-objective RL, arXiv 2601.05242 (Liu et al., NVIDIA)
- DivPO β€” Lanchantin et al., 2025 (diverse preference optimization)
- Effective rank β€” Roy & Vetterli, the exp-entropy of the Gram spectrum, used here as the primary
continuous mode-count metric (k-means + silhouette was measured to be unable to separate collapse
from spread and was demoted)
## License
Apache-2.0, matching the `Qwen/Qwen3-4B-Instruct-2507` base model. The prompt splits derive from a
public writing-prompts corpus; stories in `outputs/pool_4b/` are model generations.
## Status (updated β€” E0 and E1 both complete, 300 steps each)
| arm | status | result |
|---|---|---|
| **E0** quality-only GRPO | βœ… complete, 300 steps | quality +0.303, diversity flat |
| **E1** + pairwise deviation | βœ… complete, 300 steps | **diversity 5–6Γ— E0's gain**, quality +0.243 |
| E2 div-grpo-group (log-det marginal) | ⏸ stopped at step 4, config ready | β€” |
| E3 multi-positive weighted DPO | ⏸ pairs built (3593 rows), not trained | β€” |
| E4a/E4b DivPO emb/prob | ⏸ pairs built (956 rows each), not trained | β€” |
### Held-out evaluation (30 prompts Γ— 16 samples = 480 stories/model)
| model | quality | eff_rank (of 16) | pairwise | logdet | distinct-4 | self-BLEU |
|---|---|---|---|---|---|---|
| base | 6.472 | 1.891 | 0.1179 | βˆ’32.46 | 0.9378 | 0.2875 |
| E0 quality-only | 6.716 | 1.957 | 0.1259 | βˆ’31.59 | 0.9485 | 0.2635 |
| **E1 +deviation** | **6.864** | **2.081** | **0.1390** | **βˆ’30.08** | 0.9464 | 0.2625 |
**E1 wins on both axes:** vs base it gains 2.9Γ— E0's effective-rank improvement
*and* 1.6Γ— E0's quality improvement. Not a diversity-for-quality trade.
**Key methodological finding:** embedding metrics separate the arms by 2.9Γ—;
n-gram metrics (distinct-4, self-BLEU) do not separate them at all β€” distinct-4
actually rates E0 *higher*. The collapse is tonal/structural, not lexical.
**Key finding on LLM judges:** across 15,870 scored stories,
`corr(judge "novelty", a story's actual embedding deviation) = +0.057`, while
`corr(judge "novelty", judge quality) = +0.799`. An LLM judge's novelty score is
~80% a restatement of quality and carries almost no information about semantic
distinctiveness.
See **`REPORT.pdf`** for the full write-up (metrics, figures, story examples,
qualitative read) and **`NOTES.md`** for the complete engineering log including
every bug and every retracted claim.