File size: 6,976 Bytes
808f43d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Output classifier: streaming harmfulness head, per paper §3.4.

A value head scores harmfulness of the FULL sequence from every prefix, so a
streaming guard can cut generation mid-stream. Loss (paper Eq. 1):
    L = lam * L_NTP(t_1:T)  +  sum_{T'=1..T} BCE(y, p(y=1 | t_1:T'))
    p = (1-omega) * sigmoid(z_{T'})  +  omega * max_{tau<=T'} sigmoid(z_tau)
omega ramps linearly 0 -> 1, reaching 1 at 3/4 of training (optimization
stability). lam and exact LR are NOT published in the paper; defaults here are
our choice and are flagged in the README.

Run:
  python scripts/train_output_classifier.py \
      --base_model Qwen/Qwen2.5-1.5B \
      --train data/train.jsonl --val data/val.jsonl \
      --hub_model_id aytsrndl/cc-repro-output-classifier
"""
import argparse, json, math, os, torch, torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset
from transformers import (AutoModelForCausalLM, AutoTokenizer, Trainer,
                          TrainingArguments, TrainerCallback)


class StreamClassifier(nn.Module):
    """Causal LM + linear score head; forward returns the paper's loss."""

    def __init__(self, model_name):
        super().__init__()
        self.lm = AutoModelForCausalLM.from_pretrained(model_name)
        self.score_head = nn.Linear(self.lm.config.hidden_size, 1)
        self.omega = 0.0   # updated each step by OmegaRamp
        self.lam = None    # set from args in the script

    def forward(self, input_ids, attention_mask, ntp_labels, harm_labels):
        out = self.lm(input_ids=input_ids, attention_mask=attention_mask,
                      labels=ntp_labels, output_hidden_states=True)
        h = out.hidden_states[-1]                        # (B, T, H) last-layer states
        z = self.score_head(h.float()).squeeze(-1)       # (B, T) harmfulness logits
        p_tok = torch.sigmoid(z)
        cummax, _ = torch.cummax(p_tok, dim=1)           # max_{tau<=T'} sigma(z_tau)
        p = (1 - self.omega) * p_tok + self.omega * cummax
        y = harm_labels.unsqueeze(1).expand_as(p).float()
        bce = F.binary_cross_entropy(p, y, reduction="none")
        harm_loss = (bce * attention_mask).sum() / attention_mask.sum().clamp(min=1)
        ntp_loss = out.loss if out.loss is not None else torch.tensor(0.0, device=z.device)
        loss = self.lam * ntp_loss + harm_loss
        return {"loss": loss, "harm_loss": harm_loss.detach(), "ntp_loss": ntp_loss.detach()}


class PairDataset(Dataset):
    """Conversation = template(user)+assistant; label = 1 if harmful."""

    def __init__(self, path, tokenizer, max_length):
        self.rows = [json.loads(l) for l in open(path)]
        self.tok = tokenizer
        self.max_length = max_length

    def __len__(self):
        return len(self.rows)

    def __getitem__(self, i):
        r = self.rows[i]
        text = f"Request: {r['user']}\n\nAssistant response: {r['assistant']}"
        ids = self.tok(text, truncation=True, max_length=self.max_length)
        return {"input_ids": ids["input_ids"], "harm": float(r["label"])}


def collate(batch, pad_id):
    maxlen = max(len(b["input_ids"]) for b in batch)
    input_ids, attn, ntp, harm = [], [], [], []
    for b in batch:
        n, pad = len(b["input_ids"]), maxlen - len(b["input_ids"])
        input_ids.append(b["input_ids"] + [pad_id] * pad)
        attn.append([1] * n + [0] * pad)
        ntp.append(b["input_ids"] + [-100] * pad)   # NTP regularizer on real tokens
        harm.append(b["harm"])
    return {"input_ids": torch.tensor(input_ids), "attention_mask": torch.tensor(attn),
            "ntp_labels": torch.tensor(ntp), "harm_labels": torch.tensor(harm)}


class OmegaRamp(TrainerCallback):
    """omega: 0 -> 1 linearly, reaching 1 at 3/4 of training."""

    def __init__(self, model, total_steps):
        self.model, self.total = model, total_steps

    def on_step_end(self, args, state, control, **kw):
        frac = state.global_step / max(1, math.floor(0.75 * self.total))
        self.model.omega = min(1.0, frac)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--base_model", default="Qwen/Qwen2.5-1.5B")
    ap.add_argument("--train", default="data/train.jsonl")
    ap.add_argument("--val", default="data/val.jsonl")
    ap.add_argument("--max_length", type=int, default=1024)
    ap.add_argument("--lr", type=float, default=1e-5, help="NOT published in the paper; our default")
    ap.add_argument("--lam", type=float, default=0.5, help="NTP regularizer weight; NOT published")
    ap.add_argument("--epochs", type=float, default=1.0)
    ap.add_argument("--per_device_bs", type=int, default=8)
    ap.add_argument("--grad_accum", type=int, default=4)
    ap.add_argument("--hub_model_id", required=True)
    args = ap.parse_args()

    tok = AutoTokenizer.from_pretrained(args.base_model)
    if tok.pad_token is None:
        tok.pad_token = tok.eos_token
    model = StreamClassifier(args.base_model)
    model.lam = args.lam

    train_ds = PairDataset(args.train, tok, args.max_length)
    val_ds = PairDataset(args.val, tok, args.max_length)
    steps = math.ceil(len(train_ds) / (args.per_device_bs * args.grad_accum) * args.epochs)

    targs = TrainingArguments(
        output_dir="out/output-classifier",
        hub_model_id=args.hub_model_id,
        push_to_hub=False,  # custom class: saved manually below
        num_train_epochs=args.epochs,
        learning_rate=args.lr,
        per_device_train_batch_size=args.per_device_bs,
        per_device_eval_batch_size=args.per_device_bs,
        gradient_accumulation_steps=args.grad_accum,
        gradient_checkpointing=True,
        bf16=True,
        eval_strategy="steps",
        eval_steps=100,
        logging_steps=10,
        save_strategy="no",
        remove_unused_columns=False,   # our collate emits nonstandard keys
        report_to=["trackio"],
        seed=7,
    )
    trainer = Trainer(model=model, args=targs, train_dataset=train_ds, eval_dataset=val_ds,
                      data_collator=lambda b: collate(b, tok.pad_token_id),
                      callbacks=[OmegaRamp(model, steps)])
    trainer.train()
    metrics = trainer.evaluate()
    print("eval:", metrics)

    # Persist: LM weights + score head + loader, then push (custom class can't be
    # auto-loaded by AutoModel, so we ship the pieces).
    os.makedirs("out/final", exist_ok=True)
    model.lm.save_pretrained("out/final")
    tok.save_pretrained("out/final")
    torch.save(model.score_head.state_dict(), "out/final/score_head.pt")
    with open("out/final/loss_config.json", "w") as f:
        json.dump({"lam": args.lam, "omega_end": 1.0, "paper": "arXiv:2501.18837 §3.4"}, f)
    from huggingface_hub import HfApi
    api = HfApi()
    api.create_repo(args.hub_model_id, exist_ok=True)
    api.upload_folder(folder_path="out/final", repo_id=args.hub_model_id)
    print(f"pushed -> https://huggingface.co/{args.hub_model_id}")


if __name__ == "__main__":
    main()