summaryrefslogtreecommitdiff
path: root/research/knobloch/tables.go
blob: c5b2dcffcdbb9bbbccbb3b041a71d07f0089f6a0 (plain)
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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
package knobloch

// Tokenizer characterisation tables. Column meanings are documented in
// FREQUENCIES.md; keep the two in sync.
//
// Terminology, used consistently in identifiers and column names:
//
//	type       a distinct pre-token, i.e. one dictionary entry
//	occurrence a pre-token weighted by its corpus count
//	token      a subword unit, i.e. a vocabulary entry
//
// Morpheme labels attach to subword tokens, never to pre-token types, so the
// morpheme columns of table B are named morph_* rather than types_*.
//
// Tokenizers are compared by token string, never by id: the same string carries
// a different id in every vocabulary, so comparing ids would measure id
// reassignment rather than segmentation.

import (
	"encoding/csv"
	"encoding/json"
	"fmt"
	"hash/fnv"
	"os"
	"slices"
	"sort"
	"strconv"

	"github.com/jonasknobloch/mbpe"
	"go.jknobloc.com/x/shelf"
	"go.jknobloc.com/x/tokenizer/bpe"
)

// TableSpec identifies one tokenizer to profile.
type TableSpec struct {
	Name      string // tokenizer label, e.g. m050
	Alignment string // alpha as a plain number; direction is carried by Inverted
	Inverted  bool
	VocabSize int
	Dir       shelf.Item // directory holding vocab.json and merges.txt
}

// Encoded is one tokenizer's view of the dictionary.
//
// Comparisons need to know, per dictionary entry, whether two tokenizers agree
// and how many pieces each produced. Retaining the pieces themselves costs
// hundreds of megabytes per tokenizer, which does not fit for a whole
// vocabulary-size family at once, so each entry is reduced to a hash of its
// segmentation plus two small counts. That is all tables A, B and C read.
type Encoded struct {
	Spec TableSpec

	// corpus count per vocab id, and the id-to-string map
	Counts []int
	Itoa   map[int64]string

	Words  int64
	Tokens int64

	// pieces-per-type distribution, bucketed 1 / 2 / 3+
	TypeBucket  [3]int64
	TokenBucket [3]int64

	// per dictionary entry, in dict order
	Sig    []uint64 // hash of the piece strings
	Len    []uint16 // number of pieces
	MorphN []uint16 // number of pieces labelled morpheme
}

// Fertility is subword tokens per pre-token occurrence.
func (e *Encoded) Fertility() float64 {
	if e.Words == 0 {
		return 0
	}

	return float64(e.Tokens) / float64(e.Words)
}

// EncodeDict segments every dictionary entry with one tokenizer.
func EncodeDict(spec TableSpec, items []mbpe.Chunk) (*Encoded, error) {
	morph, err := morphemes()

	if err != nil {
		return nil, err
	}

	tok, err := bpe.NewTokenizerFromFiles(
		shelf.Abs(spec.Dir+"/vocab.json"), shelf.Abs(spec.Dir+"/merges.txt"),
		bpe.Config{Recover: false})

	if err != nil {
		return nil, err
	}

	// the dictionary is already pre-tokenised
	bpe.MBPE(tok).SetPreTokenizer(&NoPreTok{})

	e := &Encoded{
		Spec:   spec,
		Counts: make([]int, len(bpe.Vocab(tok))),
		Itoa:   bpe.Itoa(tok),
		Sig:    make([]uint64, len(items)),
		Len:    make([]uint16, len(items)),
		MorphN: make([]uint16, len(items)),
	}

	h := fnv.New64a()

	for i, v := range items {
		ids := tok.Encode(v.Src())

		h.Reset()

		var morphN uint16

		for _, id := range ids {
			s := e.Itoa[int64(id)]

			e.Counts[id] += v.N()

			// the separator keeps ab|c distinct from a|bc
			h.Write([]byte(s))
			h.Write([]byte{0})

			if _, ok := morph[s]; ok {
				morphN++
			}
		}

		e.Sig[i] = h.Sum64()
		e.Len[i] = uint16(len(ids))
		e.MorphN[i] = morphN

		n := int64(v.N())
		k := int64(len(ids))
		b := bucket(len(ids))

		e.Words += n
		e.Tokens += n * k

		e.TypeBucket[b]++
		e.TokenBucket[b] += n * k
	}

	return e, nil
}

// bucket maps a piece count onto the 1 / 2 / 3+ buckets used by every table.
func bucket(k int) int {
	switch {
	case k == 1:
		return 0
	case k == 2:
		return 1
	default:
		return 2
	}
}

// VocabIntersection returns the token strings present in every one of the given
// vocabularies. Reads vocab.json only, so it never touches the corpus.
func VocabIntersection(dirs []shelf.Item) (map[string]struct{}, error) {
	if len(dirs) == 0 {
		return nil, fmt.Errorf("no vocabularies given")
	}

	var keep map[string]struct{}

	for _, dir := range dirs {
		file, err := os.Open(shelf.Abs(dir + "/vocab.json"))

		if err != nil {
			return nil, err
		}

		var v map[string]int64

		err = json.NewDecoder(file).Decode(&v)

		file.Close()

		if err != nil {
			return nil, err
		}

		if keep == nil {
			keep = make(map[string]struct{}, len(v))

			for token := range v {
				keep[token] = struct{}{}
			}

			continue
		}

		for token := range keep {
			if _, ok := v[token]; !ok {
				delete(keep, token)
			}
		}
	}

	return keep, nil
}

// TableARow is the segmentation profile of one tokenizer.
type TableARow struct {
	Spec      TableSpec
	Fertility float64

	Types  [3]float64 // share of pre-token types
	Tokens [3]float64 // share of subword token occurrences
}

func BuildTableA(e *Encoded) TableARow {
	row := TableARow{Spec: e.Spec, Fertility: e.Fertility()}

	var typeTotal, tokenTotal int64

	for b := range e.TypeBucket {
		typeTotal += e.TypeBucket[b]
		tokenTotal += e.TokenBucket[b]
	}

	for b := range e.TypeBucket {
		row.Types[b] = share(e.TypeBucket[b], typeTotal)
		row.Tokens[b] = share(e.TokenBucket[b], tokenTotal)
	}

	return row
}

// TableBRow describes what a vocabulary is made of.
type TableBRow struct {
	Spec      TableSpec
	Fertility float64

	MorphCount           int
	MorphShareUnweighted float64
	MorphShareWeighted   float64

	PctRankMorph float64
	PctRankOther float64

	SharedMorph float64
	SharedOther float64
}

// BuildTableB profiles one vocabulary. shared is the intersection of every
// vocabulary at this size, which is what the two shared_* columns measure
// against — so the baseline is just another tokenizer and gets a real value.
func BuildTableB(e *Encoded, shared map[string]struct{}) (TableBRow, error) {
	morph, err := morphemes()

	if err != nil {
		return TableBRow{}, err
	}

	row := TableBRow{Spec: e.Spec, Fertility: e.Fertility()}

	var morphTokens, otherTokens int64
	var morphFreq, otherFreq []int
	var morphShared, otherShared, otherCount int

	for id, f := range e.Counts {
		token := e.Itoa[int64(id)]

		_, isMorph := morph[token]
		_, inShared := shared[token]

		if isMorph {
			row.MorphCount++
			morphTokens += int64(f)
			morphFreq = append(morphFreq, f)

			if inShared {
				morphShared++
			}
		} else {
			otherCount++
			otherTokens += int64(f)
			otherFreq = append(otherFreq, f)

			if inShared {
				otherShared++
			}
		}
	}

	row.MorphShareUnweighted = share(int64(row.MorphCount), int64(len(e.Counts)))
	row.MorphShareWeighted = share(morphTokens, morphTokens+otherTokens)

	// Percentile rank of each group's median token within the corpus frequency
	// distribution of the whole vocabulary. An absolute median is not comparable
	// across vocabulary sizes, since a given token is rarer relative to a larger
	// vocabulary; a percentile rank is.
	all := slices.Clone(e.Counts)

	slices.Sort(all)
	slices.Sort(morphFreq)
	slices.Sort(otherFreq)

	row.PctRankMorph = percentileRank(all, median(morphFreq))
	row.PctRankOther = percentileRank(all, median(otherFreq))

	row.SharedMorph = share(int64(morphShared), int64(row.MorphCount))
	row.SharedOther = share(int64(otherShared), int64(otherCount))

	return row, nil
}

// percentileRank is the share of sorted strictly below v, so a higher value
// means a more frequent token.
func percentileRank(sorted []int, v int) float64 {
	if len(sorted) == 0 {
		return 0
	}

	return 100 * float64(sort.SearchInts(sorted, v)) / float64(len(sorted))
}

// TableCRow is the divergence between one tokenizer and a reference.
//
// Everything is measured against the BASELINE segmentation. A pre-token is
// bucketed by how many pieces the baseline produced for it and contributes that
// many token occurrences, so a pre-token that is one token under the baseline
// and two under the variant lands in bucket 1 and counts once. That makes the
// three bucket columns sum to TokensDiff by construction, and keeps the buckets
// the same population as the baseline's table A row.
type TableCRow struct {
	Spec     TableSpec
	Baseline string

	TypesDiff  float64
	TokensDiff float64

	TokensDiffBucket [3]float64

	// morpheme share of the subword tokens in the differing slice, under each
	// side, each normalised by that side's own token count
	DiffMorphBase float64
	DiffMorphVar  float64
}

func BuildTableC(e, base *Encoded, items []mbpe.Chunk) (TableCRow, error) {
	if len(e.Sig) != len(base.Sig) {
		return TableCRow{}, fmt.Errorf("dictionaries differ in length")
	}

	row := TableCRow{Spec: e.Spec, Baseline: base.Spec.Name}

	var typDiff, diffTokens int64
	var diffBucket [3]int64
	var mBase, nBase, mVar, nVar int64

	for i := range base.Sig {
		if e.Sig[i] == base.Sig[i] {
			continue
		}

		n := int64(items[i].N())
		k := int64(base.Len[i])

		typDiff++
		diffTokens += n * k
		diffBucket[bucket(int(base.Len[i]))] += n * k

		mBase += n * int64(base.MorphN[i])
		nBase += n * k

		mVar += n * int64(e.MorphN[i])
		nVar += n * int64(e.Len[i])
	}

	row.TypesDiff = share(typDiff, int64(len(base.Sig)))
	row.TokensDiff = share(diffTokens, base.Tokens)

	for b := range diffBucket {
		row.TokensDiffBucket[b] = share(diffBucket[b], base.Tokens)
	}

	row.DiffMorphBase = share(mBase, nBase)
	row.DiffMorphVar = share(mVar, nVar)

	return row, nil
}

func share(x, total int64) float64 {
	if total == 0 {
		return 0
	}

	return 100 * float64(x) / float64(total)
}

func writeCSV(name string, header []string, rows [][]string) error {
	file, err := os.Create(name)

	if err != nil {
		return err
	}

	defer file.Close()

	w := csv.NewWriter(file)

	if err := w.Write(header); err != nil {
		return err
	}

	for _, r := range rows {
		if err := w.Write(r); err != nil {
			return err
		}
	}

	w.Flush()

	return w.Error()
}

// percentages are plain numbers, without a percent sign
func f2(v float64) string { return strconv.FormatFloat(v, 'f', 2, 64) }
func f4(v float64) string { return strconv.FormatFloat(v, 'f', 4, 64) }

func WriteTableA(name string, rows []TableARow) error {
	out := make([][]string, 0, len(rows))

	for _, r := range rows {
		out = append(out, []string{
			r.Spec.Name, strconv.Itoa(r.Spec.VocabSize), r.Spec.Alignment, f4(r.Fertility),
			f2(r.Types[0]), f2(r.Types[1]), f2(r.Types[2]),
			f2(r.Tokens[0]), f2(r.Tokens[1]), f2(r.Tokens[2]),
		})
	}

	return writeCSV(name, []string{
		"tokenizer", "vocab_size", "alignment", "fertility",
		"types_1", "types_2", "types_3plus",
		"tokens_1", "tokens_2", "tokens_3plus",
	}, out)
}

func WriteTableB(name string, rows []TableBRow) error {
	out := make([][]string, 0, len(rows))

	for _, r := range rows {
		out = append(out, []string{
			r.Spec.Name, strconv.Itoa(r.Spec.VocabSize), r.Spec.Alignment, f4(r.Fertility),
			strconv.Itoa(r.MorphCount),
			f2(r.MorphShareUnweighted), f2(r.MorphShareWeighted),
			f2(r.PctRankMorph), f2(r.PctRankOther),
			f2(r.SharedMorph), f2(r.SharedOther),
		})
	}

	return writeCSV(name, []string{
		"tokenizer", "vocab_size", "alignment", "fertility",
		"morph_count", "morph_share_unweighted", "morph_share_weighted",
		"pct_rank_morph", "pct_rank_other",
		"shared_morph", "shared_other",
	}, out)
}

func WriteTableC(name string, rows []TableCRow) error {
	out := make([][]string, 0, len(rows))

	for _, r := range rows {
		out = append(out, []string{
			r.Spec.Name, strconv.Itoa(r.Spec.VocabSize), r.Spec.Alignment, r.Baseline,
			f2(r.TypesDiff), f2(r.TokensDiff),
			f2(r.TokensDiffBucket[0]), f2(r.TokensDiffBucket[1]), f2(r.TokensDiffBucket[2]),
			f2(r.DiffMorphBase), f2(r.DiffMorphVar),
		})
	}

	return writeCSV(name, []string{
		"tokenizer", "vocab_size", "alignment", "baseline",
		"types_diff", "tokens_diff",
		"tokens_1_diff", "tokens_2_diff", "tokens_3plus_diff",
		"tokens_diff_morph_base", "tokens_diff_morph_var",
	}, out)
}