File size: 10,266 Bytes
61b6fb9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
"""Turning an SFT jsonl into batches, and counting what the model actually saw.

Three things here are decisions rather than plumbing.

**One example per sequence.** Pretraining packs documents end to end into 2048
tokens, which is right when the objective is "predict the next token of a
stream". It is wrong here: packed example B would attend to example A, and the
loss on B would depend on which example happened to precede it. For a diagnostic
whose whole output is "what does N examples buy you", that is a confound bought
for a throughput gain we do not need -- the largest arm is 50,000 examples
against a billion pretraining tokens.

**Length bucketing, not sorted batching.** Padding every batch to the longest
example in a random draw wastes most of the tensor when lengths are skewed.
Sorting the whole corpus by length would fix that and introduce a worse problem:
every batch would then be homogeneous in length, and length correlates with
family here -- tool examples carry schemas and are long, chitchat is short. The
model would see batches of nearly pure tools followed by batches of nearly pure
chitchat. So examples are shuffled, cut into large windows, sorted *within* a
window, and the resulting micro-batches shuffled again. Padding stays low and no
batch is a single family.

**Counting.** The experiment asks for capability per example, per SFT token and
per GPU-second, so all three are tracked, and "SFT tokens" means **supervised**
tokens -- the ones that carried loss -- not the length of the padded tensor.
Reporting the padded figure would inflate the denominator by roughly the padding
ratio and flatter every arm equally, which is worse than useless because it
looks fine.
"""

from __future__ import annotations

import json
import random
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path

import torch

from .chat import IGNORE_INDEX, ChatTemplate, Message, RenderedChat

__all__ = ["SFTExample", "SFTDataset", "SFTBatch", "load_jsonl",
           "step_weights"]


@dataclass
class SFTExample:
    example_id: str
    family: str
    subfamily: str
    lang: str
    messages: list[dict]
    source: dict = field(default_factory=dict)

    def to_messages(self) -> list[Message]:
        return [Message(role=m["role"], content=m["content"]) for m in self.messages]


def load_jsonl(path: str | Path) -> list[SFTExample]:
    out = []
    with open(path, encoding="utf-8") as fh:
        for line in fh:
            if not line.strip():
                continue
            row = json.loads(line)
            out.append(SFTExample(
                example_id=row.get("example_id", ""),
                family=row["family"],
                subfamily=row.get("subfamily", row["family"]),
                lang=row.get("lang", "en"),
                messages=row["messages"],
                source=row.get("source", {}),
            ))
    return out


@dataclass
class SFTBatch:
    input_ids: torch.Tensor      # [B, L]
    labels: torch.Tensor         # [B, L], -100 where masked
    #: Supervised tokens in this batch. The denominator for "loss per token"
    #: and the thing that accumulates into `sft_tokens`.
    n_supervised: int
    n_examples: int
    #: Padding tokens, tracked so the bucketing can be shown to work rather
    #: than assumed to.
    n_padding: int

    def to(self, device) -> SFTBatch:
        return SFTBatch(
            input_ids=self.input_ids.to(device, non_blocking=True),
            labels=self.labels.to(device, non_blocking=True),
            n_supervised=self.n_supervised,
            n_examples=self.n_examples,
            n_padding=self.n_padding,
        )


class SFTDataset:
    """Rendered examples, batched deterministically from a seed."""

    def __init__(
        self,
        examples: list[SFTExample],
        template: ChatTemplate,
        *,
        micro_batch_size: int = 8,
        bucket_windows: int = 32,
        seed: int = 1234,
    ) -> None:
        self.template = template
        self.micro_batch_size = int(micro_batch_size)
        self.bucket_size = self.micro_batch_size * int(bucket_windows)
        self.seed = int(seed)

        self.rendered: list[RenderedChat] = []
        self.kept: list[SFTExample] = []
        self.dropped: list[tuple[str, str]] = []

        for ex in examples:
            try:
                r = template.render(ex.to_messages())
            except ValueError as exc:
                # A malformed conversation is a data bug, and one that would
                # otherwise show up as "SFT did not work". Recorded with its
                # reason rather than skipped.
                self.dropped.append((ex.example_id, f"malformed: {exc}"))
                continue
            if r is None:
                self.dropped.append((ex.example_id, "too long"))
                continue
            if r.n_supervised == 0:
                self.dropped.append((ex.example_id, "nothing supervised"))
                continue
            self.rendered.append(r)
            self.kept.append(ex)

    # ------------------------------------------------------------------ #

    def __len__(self) -> int:
        return len(self.kept)

    @property
    def total_supervised_tokens(self) -> int:
        return sum(r.n_supervised for r in self.rendered)

    @property
    def total_tokens(self) -> int:
        """Every token the model reads, supervised or not. The prompt is most
        of it, and it is real compute even though it carries no loss."""
        return sum(len(r) for r in self.rendered)

    def stats(self) -> dict:
        lengths = [len(r) for r in self.rendered]
        sup = [r.n_supervised for r in self.rendered]
        by_family: dict[str, int] = {}
        for ex in self.kept:
            by_family[ex.family] = by_family.get(ex.family, 0) + 1
        return {
            "examples": len(self.kept),
            "dropped": len(self.dropped),
            "drop_reasons": _tally(reason for _, reason in self.dropped),
            "by_family": by_family,
            "by_lang": _tally(ex.lang for ex in self.kept),
            "tokens_total": sum(lengths),
            "tokens_supervised": sum(sup),
            "supervised_frac": (sum(sup) / sum(lengths)) if lengths else 0.0,
            "len_mean": (sum(lengths) / len(lengths)) if lengths else 0.0,
            "len_p50": _pct(lengths, 50),
            "len_p95": _pct(lengths, 95),
            "len_max": max(lengths) if lengths else 0,
        }

    # ------------------------------------------------------------------ #

    def epoch_batches(self, epoch: int) -> list[list[int]]:
        """Index batches for one epoch. Deterministic in `(seed, epoch)`.

        Deterministic because two branches that differ only in dataset size
        must not also differ in the accident of their shuffle, and because a
        resumed run has to replay the order it was on.
        """
        # An explicit formula, not `hash((seed, epoch))`. Python's tuple hash
        # happens to be stable for ints, but it is stable by accident of the
        # implementation rather than by promise, and the order of a training
        # run is not a thing to leave resting on that.
        rng = random.Random(self.seed * 1_000_003 + epoch)
        order = list(range(len(self.rendered)))
        rng.shuffle(order)

        batches: list[list[int]] = []
        for start in range(0, len(order), self.bucket_size):
            window = order[start:start + self.bucket_size]
            window.sort(key=lambda i: len(self.rendered[i]))
            for j in range(0, len(window), self.micro_batch_size):
                batches.append(window[j:j + self.micro_batch_size])
        rng.shuffle(batches)
        return batches

    def collate(self, indices: list[int]) -> SFTBatch:
        chosen = [self.rendered[i] for i in indices]
        ids, labels = self.template.pad_batch(chosen)
        input_ids = torch.tensor(ids, dtype=torch.long)
        label_t = torch.tensor(labels, dtype=torch.long)
        return SFTBatch(
            input_ids=input_ids,
            labels=label_t,
            n_supervised=int((label_t != IGNORE_INDEX).sum()),
            n_examples=len(chosen),
            n_padding=int(input_ids.numel() - sum(len(r) for r in chosen)),
        )

    def iter_epoch(self, epoch: int) -> Iterator[SFTBatch]:
        for batch in self.epoch_batches(epoch):
            yield self.collate(batch)

    def steps_per_epoch(self) -> int:
        return len(self.epoch_batches(0))


def step_weights(batches: list[SFTBatch]) -> list[float]:
    """Each microbatch's share of its optimizer step's supervised tokens.

    The one arithmetic fact that makes an accumulated step equal to a single
    backward pass over the whole step:

        F.cross_entropy(..., ignore_index=-100)

    returns the mean over the *valid* tokens of the tensor it was given. So
    scaling microbatch k by `n_k / sum(n)` and summing yields

        sum_k sum_i CE_ki / sum_k n_k

    which is the token-weighted mean. Scaling by `1/K` instead yields a mean of
    means, and the two agree only when every microbatch carries the same amount
    of supervision.

    With assistant-only loss they never do. Measured on this corpus, supervised
    tokens are 7.4% of an abstention sequence and 57% of an instruction one, and
    length bucketing groups similar examples together -- so the imbalance is
    systematic, not noise. Lives here, beside the batches it weights, so the
    trainer and `tests/test_sft_grad_accum.py` are provably running the same
    arithmetic rather than two copies of it.

    A microbatch with no supervision gets weight 0: its loss is NaN (a mean over
    zero terms) and it must not enter the sum.
    """
    total = sum(b.n_supervised for b in batches)
    if total == 0:
        return [0.0] * len(batches)
    return [b.n_supervised / total for b in batches]


def _tally(values) -> dict:
    out: dict = {}
    for v in values:
        out[v] = out.get(v, 0) + 1
    return dict(sorted(out.items(), key=lambda kv: -kv[1]))


def _pct(values: list[int], p: int) -> int:
    if not values:
        return 0
    s = sorted(values)
    return s[min(len(s) - 1, int(len(s) * p / 100))]