File size: 7,063 Bytes
20a371d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import json
import os
import sys
from collections import Counter, defaultdict
from multiprocessing import Pool
import numpy as np
import regex

VOCAB_SIZE = 4096
SPECIALS = ["<|endoftext|>", "<|user|>", "<|assistant|>", "<|end|>"]
SAMPLE_MB = 30
STRIDE = 64
OUT = "data"

# GPT-4's pre-split pattern: text is cut into chunks first, so no merge can
# span a word boundary.
PAT = regex.compile(
    r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,3}"""
    r"""| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+"""
)
SPECIAL_PAT = regex.compile("(" + "|".join(regex.escape(s) for s in SPECIALS) + ")")

def merge(ids, pair, new):
    """Replace every occurrence of pair in ids with the single token new."""
    out, i = [], 0
    while i < len(ids):
        if i + 1 < len(ids) and (ids[i], ids[i + 1]) == pair:
            out.append(new)
            i += 2
        else:
            out.append(ids[i])
            i += 1
    return out

def train(text, n_merges):
    """Learn n_merges pairs, in the order learned."""
    # Chunks are deduplicated and weighted by count: identical chunks always
    # merge identically, and 30MB holds only ~50k distinct ones.
    freq = Counter(PAT.findall(text))
    words = [list(w.encode("utf-8")) for w in freq]
    reps = list(freq.values())

    count = defaultdict(int)  # pair -> how often it occurs
    where = defaultdict(set)  # pair -> which words contain it
    for i, w in enumerate(words):
        for p, k in Counter(zip(w, w[1:])).items():
            count[p] += k * reps[i]
            where[p].add(i)

    merges = []
    for new in range(256, 256 + n_merges):
        best = max(count, key=lambda p: (count[p], -p[0], -p[1]))  # ties: smallest pair
        merges.append(best)
        # Only the words containing best change: drop their pairs, merge, put
        # the new pairs back.
        for i in where.pop(best):
            w, r = words[i], reps[i]
            for p, k in Counter(zip(w, w[1:])).items():
                count[p] -= k * r
                if count[p] == 0:
                    del count[p]
            words[i] = w = merge(w, best, new)
            for p, k in Counter(zip(w, w[1:])).items():
                count[p] += k * r
                where[p].add(i)
    return merges

class Tokenizer:
    def __init__(self, merges):
        self.ranks = {p: 256 + i for i, p in enumerate(merges)}
        self.vocab = {i: bytes([i]) for i in range(256)}
        for i, (a, b) in enumerate(merges):
            self.vocab[256 + i] = self.vocab[a] + self.vocab[b]
        base = 256 + len(merges)
        self.special = {s: base + i for i, s in enumerate(SPECIALS)}
        for s, i in self.special.items():
            self.vocab[i] = s.encode("utf-8")
        self.cache = {}  # chunk -> ids, looked up ~400M times over the corpus

    def encode_chunk(self, chunk):
        """Apply the merges to one chunk, earliest-learned pair first."""
        ids = self.cache.get(chunk)
        if ids is not None:
            return ids
        ids = list(chunk.encode("utf-8"))
        while len(ids) > 1:
            pair = min(zip(ids, ids[1:]), key=lambda p: self.ranks.get(p, 1 << 30))
            if pair not in self.ranks:
                break
            ids = merge(ids, pair, self.ranks[pair])
        self.cache[chunk] = ids
        return ids

    def encode(self, text):
        # Specials are split out first, so BPE never sees them and cannot
        # shred one into pieces.
        ids = []
        for part in SPECIAL_PAT.split(text):
            if part in self.special:
                ids.append(self.special[part])
            else:
                ids += [i for c in PAT.findall(part) for i in self.encode_chunk(c)]
        return ids

    def decode(self, ids):
        return b"".join(self.vocab[i] for i in ids).decode("utf-8", errors="replace")

def save(merges, path):
    with open(path, "w") as f:
        f.writelines(f"{a} {b}\n" for a, b in merges)

def load(path=f"{OUT}/tokenizer.model"):
    with open(path) as f:
        return Tokenizer([tuple(int(x) for x in line.split()) for line in f])

def sample(path, mb, stride):
    """Every stride-th story, up to mb megabytes, so the sample spans the corpus."""
    parts, n = [], 0
    with open(path, encoding="utf-8") as f:
        for i, line in enumerate(f):
            if i % stride:
                continue
            parts.append(json.loads(line)["text"])
            n += len(parts[-1])
            if n > mb * 1e6:
                break
    return "\n".join(parts)

def batches(path, n=2000):
    """Stories from a .jsonl file, n at a time."""
    batch = []
    with open(path, encoding="utf-8") as f:
        for line in f:
            batch.append(json.loads(line)["text"])
            if len(batch) == n:
                yield batch
                batch = []
    if batch:
        yield batch

enc = None  # one Tokenizer per worker process

def init_worker(merges):
    global enc
    enc = Tokenizer(merges)

def encode_batch(batch):
    eot = enc.special["<|endoftext|>"]
    ids, nbytes = [], 0
    for s in batch:
        story = enc.encode(s)
        assert enc.decode(story) == s, s[:80]  # lossless only if every char lands in a chunk
        ids += story + [eot]
        nbytes += len(s.encode("utf-8"))
    return np.array(ids, dtype=np.uint16), nbytes, len(ids) - len(batch)

def tokenize(name, merges, out):
    """Write <out>/<name>.bin, return its byte and token counts."""
    ntok = nbytes = 0
    with Pool(initializer=init_worker, initargs=(merges,)) as pool:
        with open(f"{out}/{name}.bin", "wb") as f:
            for arr, nb, nt in pool.imap(encode_batch, batches(f"{OUT}/{name}.jsonl")):
                f.write(arr.tobytes())
                nbytes += nb
                ntok += nt
    return nbytes, ntok

def main():
    # Any other vocab size goes in its own directory, next to the same jsonl.
    vocab = int(sys.argv[1]) if len(sys.argv) > 1 else VOCAB_SIZE
    out = OUT if vocab == VOCAB_SIZE else f"{OUT}/vocab{vocab}"
    os.makedirs(out, exist_ok=True)
    n_merges = vocab - 256 - len(SPECIALS)

    text = sample(f"{OUT}/train.jsonl", SAMPLE_MB, STRIDE)
    print(f"training on {len(text) / 1e6:.1f}MB, {len(set(PAT.findall(text))):,} chunks")
    merges = train(text, n_merges)
    save(merges, f"{out}/tokenizer.model")

    tok = load(f"{out}/tokenizer.model")
    assert len(tok.vocab) == vocab, len(tok.vocab)
    word = tok.encode("story")
    for s in SPECIALS:
        assert tok.encode(f"story{s}story") == word + [tok.special[s]] + word, s

    step = n_merges // 18
    print("vocab:", ", ".join(repr(tok.vocab[i].decode("utf-8", "replace"))
                              for i in range(256, 256 + n_merges, step)))

    for name in ("val", "train"):
        nbytes, ntok = tokenize(name, merges, out)
        print(f"{name}: {ntok:,} tokens, {nbytes / ntok:.3f} bytes/token")

    ids = np.memmap(f"{out}/train.bin", dtype=np.uint16, mode="r")[:220]
    print("\n" + tok.decode(ids.tolist()))

if __name__ == "__main__":
    main()