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