Text Generation
PEFT
Safetensors
lora
trl
grpo
gdpo
dpo
divpo
rlhf
diversity
creative-writing
mode-collapse
Instructions to use Mercity/creative-writing-llm with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use Mercity/creative-writing-llm with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
File size: 5,084 Bytes
cbc33fe | 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 | """
vLLM generation wrapper shared by pool building, eval, and smoke tests.
Captures per-story cumulative logprob, which E4's `divpo-prob` variant needs:
DivPO's probability criterion picks the LOWEST-logprob passing story as the
diverse chosen and the HIGHEST-logprob failing story as the common rejected.
The highest-probability sample in a temperature pool is, by construction, the
near-greedy one -- which is why this is the principled version of "reject the
greedy decode".
Length normalization matters here. Raw cumulative logprob scales with token
count, so ranking by it would just rank by length (short stories win). We keep
both `cumlogprob` and `mean_logprob = cumlogprob / n_tokens` and use the mean
for DivPO ranking.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, asdict
from pathlib import Path
# NEVER truncate a story. The prior run died of a max_new_tokens wall, so this
# is sized for real headroom, not for the target length: a 500-word story is
# ~650-700 Qwen tokens, so 768 would clip anything that runs long and would
# reproduce the exact bug we are fixing. 1280 lets the model finish and overrun
# a little; overrun is then DETECTED by the >600-word gate rather than silently
# chopped. Measure the failure, don't manufacture it.
#
# Nothing downstream truncates either: the judge receives the complete story
# text, and embeddings are computed over the complete story text.
MAX_NEW_TOKENS = 1280
MAX_MODEL_LEN = 2048
@dataclass
class Gen:
prompt_id: str
prompt: str
idx: int
text: str
n_tokens: int
cumlogprob: float
mean_logprob: float
finish_reason: str
def as_dict(self) -> dict:
return asdict(self)
def build_llm(
model: str,
gpu_mem_util: float = 0.85,
max_model_len: int = MAX_MODEL_LEN,
seed: int = 0,
enable_lora: bool = False,
):
from vllm import LLM
kw = dict(
model=model,
dtype="bfloat16",
gpu_memory_utilization=gpu_mem_util,
max_model_len=max_model_len,
seed=seed,
enforce_eager=False,
disable_log_stats=True,
)
if enable_lora:
kw.update(enable_lora=True, max_lora_rank=32)
return LLM(**kw)
def sampling_params(
n: int, temperature: float, top_p: float, seed: int | None,
max_tokens: int = MAX_NEW_TOKENS,
):
from vllm import SamplingParams
return SamplingParams(
n=n,
temperature=temperature,
top_p=top_p,
max_tokens=max_tokens,
seed=seed,
logprobs=0, # cumulative_logprob only; per-token table not needed
skip_special_tokens=True,
)
def render_chat(tokenizer, prompt: str) -> str:
"""Render the chat template, with thinking OFF where the model has it.
Qwen3-8B is a hybrid-thinking model: left at its default it emits a <think>
block before the story, which would (a) eat the token budget, (b) pollute
the embedding with reasoning text, and (c) make the 8B arm incomparable to
the 4B-Instruct arms, which have no thinking mode at all. Qwen3-4B-Instruct
ignores the kwarg, so the same call is correct for both.
"""
from data import chat_messages
msgs = chat_messages(prompt)
try:
return tokenizer.apply_chat_template(
msgs, tokenize=False, add_generation_prompt=True, enable_thinking=False,
)
except TypeError:
return tokenizer.apply_chat_template(
msgs, tokenize=False, add_generation_prompt=True,
)
def generate(
llm,
tokenizer,
prompts: list[dict],
n: int,
temperature: float,
top_p: float,
seed: int | None = None,
max_tokens: int = MAX_NEW_TOKENS,
lora_path: str | None = None,
) -> list[Gen]:
"""prompts = [{'id':..., 'prompt':...}] -> flat list of n*len(prompts) Gens."""
texts = [render_chat(tokenizer, p["prompt"]) for p in prompts]
sp = sampling_params(n, temperature, top_p, seed, max_tokens)
kw = {}
if lora_path:
from vllm.lora.request import LoRARequest
kw["lora_request"] = LoRARequest("adapter", 1, lora_path)
outs = llm.generate(texts, sp, **kw)
gens: list[Gen] = []
for p, out in zip(prompts, outs):
for j, o in enumerate(out.outputs):
ntok = len(o.token_ids)
cum = float(o.cumulative_logprob) if o.cumulative_logprob is not None else 0.0
gens.append(Gen(
prompt_id=p["id"], prompt=p["prompt"], idx=j,
text=o.text.strip(), n_tokens=ntok,
cumlogprob=cum,
mean_logprob=(cum / ntok) if ntok else 0.0,
finish_reason=o.finish_reason or "",
))
return gens
def save_gens(gens: list[Gen], path: str | Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
for g in gens:
f.write(json.dumps(g.as_dict()) + "\n")
def load_gens(path: str | Path) -> list[Gen]:
return [Gen(**json.loads(l)) for l in open(path) if l.strip()]
|