static_embeddings 0.1.5 → 1.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +17 -2
- data/docs/ARCHITECTURE.md +14 -5
- data/docs/MODEL_AUDIT.md +110 -0
- data/lib/models/demo.semb +0 -0
- data/lib/static_embeddings/bert_wordpiece.rb +191 -0
- data/lib/static_embeddings/canonical.rb +50 -0
- data/lib/static_embeddings/cli.rb +88 -63
- data/lib/static_embeddings/codec.rb +45 -0
- data/lib/static_embeddings/conversion.rb +58 -0
- data/lib/static_embeddings/errors.rb +2 -2
- data/lib/static_embeddings/format/constants.rb +109 -0
- data/lib/static_embeddings/format/hash_table.rb +69 -0
- data/lib/static_embeddings/format/trie.rb +78 -0
- data/lib/static_embeddings/format/verifier.rb +41 -0
- data/lib/static_embeddings/format/writer.rb +131 -0
- data/lib/static_embeddings/format.rb +3 -338
- data/lib/static_embeddings/importers/model2vec.rb +52 -0
- data/lib/static_embeddings/importers/sentence_transformers_static.rb +103 -0
- data/lib/static_embeddings/importers/support.rb +111 -0
- data/lib/static_embeddings/importers.rb +50 -0
- data/lib/static_embeddings/model.rb +35 -20
- data/lib/static_embeddings/paths.rb +8 -12
- data/lib/static_embeddings/provenance.rb +58 -0
- data/lib/static_embeddings/reference.rb +50 -40
- data/lib/static_embeddings/row_prefix_payload.rb +59 -0
- data/lib/static_embeddings/version.rb +1 -1
- data/lib/static_embeddings.rb +29 -55
- data/static_embeddings.gemspec +2 -2
- data/tools/check_model2vec_parity.rb +5 -1
- data/tools/check_st_parity.rb +125 -0
- data/tools/eval_retrieval.rb +58 -0
- metadata +24 -6
- data/lib/static_embeddings/converter.rb +0 -328
|
@@ -80,7 +80,11 @@ rows.each_with_index do |row, i|
|
|
|
80
80
|
|
|
81
81
|
full_raw = model.tokenize(text, max_tokens: false)
|
|
82
82
|
expected_usable = row.fetch("static_usable_token_ids")
|
|
83
|
-
got_usable =
|
|
83
|
+
got_usable = if reference["unk_token_id"].nil?
|
|
84
|
+
full_raw.first(max_length)
|
|
85
|
+
else
|
|
86
|
+
full_raw.reject { |id| id == model.unk_id }.first(max_length)
|
|
87
|
+
end
|
|
84
88
|
if got_usable != expected_usable
|
|
85
89
|
usable_failures << i
|
|
86
90
|
first = got_usable.zip(expected_usable).index { |a, b| a != b } || [got_usable.length, expected_usable.length].min
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
require "static_embeddings"
|
|
4
|
+
|
|
5
|
+
options = {
|
|
6
|
+
min_cosine: 1.0 - 1e-6,
|
|
7
|
+
max_abs: 1e-5
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
parser = OptionParser.new do |opts|
|
|
11
|
+
opts.on("--model PATH") { |value| options[:model] = value }
|
|
12
|
+
opts.on("--oracle PATH") { |value| options[:oracle] = value }
|
|
13
|
+
opts.on("--min-cosine N", Float) { |value| options[:min_cosine] = value }
|
|
14
|
+
opts.on("--max-abs N", Float) { |value| options[:max_abs] = value }
|
|
15
|
+
end
|
|
16
|
+
parser.parse!(ARGV)
|
|
17
|
+
unless options[:model] && options[:oracle]
|
|
18
|
+
abort "usage: ruby -Ilib tools/check_st_parity.rb --model MODEL.semb --oracle oracle.json"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
model = StaticEmbeddings.load(options[:model], verify: true)
|
|
22
|
+
payload = JSON.parse(File.read(options[:oracle], encoding: "UTF-8"))
|
|
23
|
+
abort "unsupported oracle schema #{payload["schema_version"].inspect}" unless payload["schema_version"] == 1
|
|
24
|
+
|
|
25
|
+
reference = payload.fetch("reference")
|
|
26
|
+
abort "ST oracle must use add_special_tokens=false" unless reference["add_special_tokens"] == false
|
|
27
|
+
abort "ST oracle must use normalize_embeddings=false" unless reference["normalize_embeddings"] == false
|
|
28
|
+
if model.normalized?
|
|
29
|
+
abort "ST source-faithful .semb must not L2-normalize; got normalized?=true"
|
|
30
|
+
end
|
|
31
|
+
unless model.max_tokens == false
|
|
32
|
+
abort "ST source-faithful .semb must bake unlimited tokens; got max_tokens=#{model.max_tokens.inspect}"
|
|
33
|
+
end
|
|
34
|
+
if model.provenance["unk_policy"] != "include"
|
|
35
|
+
abort "ST source-faithful .semb must use UNK_INCLUDE; got #{model.provenance["unk_policy"].inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def prefix_dims(vector, dim)
|
|
39
|
+
return vector if vector.length == dim
|
|
40
|
+
abort "oracle vector shorter than model dim (#{vector.length} < #{dim})" if vector.length < dim
|
|
41
|
+
|
|
42
|
+
vector.first(dim)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def dot(a, b)
|
|
46
|
+
a.zip(b).sum { |x, y| x * y }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def norm(a)
|
|
50
|
+
Math.sqrt(a.sum { |x| x * x })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def vector_metrics(reference, got)
|
|
54
|
+
max_abs = reference.zip(got).map { |a, b| (a - b).abs }.max || 0.0
|
|
55
|
+
ref_zero = reference.all?(&:zero?)
|
|
56
|
+
got_zero = got.all?(&:zero?)
|
|
57
|
+
cosine =
|
|
58
|
+
if ref_zero && got_zero
|
|
59
|
+
1.0
|
|
60
|
+
elsif ref_zero || got_zero
|
|
61
|
+
0.0
|
|
62
|
+
else
|
|
63
|
+
dot(reference, got) / (norm(reference) * norm(got))
|
|
64
|
+
end
|
|
65
|
+
[cosine, max_abs]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
raw_failures = []
|
|
69
|
+
vector_failures = []
|
|
70
|
+
diag_failures = []
|
|
71
|
+
min_cosine = 1.0
|
|
72
|
+
max_abs_all = 0.0
|
|
73
|
+
vectors_checked = 0
|
|
74
|
+
|
|
75
|
+
payload.fetch("rows").each_with_index do |row, i|
|
|
76
|
+
text = row.fetch("text")
|
|
77
|
+
label = row.fetch("label", i.to_s)
|
|
78
|
+
problems = []
|
|
79
|
+
|
|
80
|
+
expected_raw = row.fetch("hf_raw_token_ids")
|
|
81
|
+
got_raw = model.tokenize(text, max_tokens: false)
|
|
82
|
+
if got_raw != expected_raw
|
|
83
|
+
raw_failures << i
|
|
84
|
+
first = got_raw.zip(expected_raw).index { |a, b| a != b } || [got_raw.length, expected_raw.length].min
|
|
85
|
+
problems << "raw ids differ at #{first} (got #{got_raw.length}, ref #{expected_raw.length})"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
got_vector = model.embed_array(text)
|
|
89
|
+
reference_vector = prefix_dims(row.fetch("st_encode_vector"), got_vector.length)
|
|
90
|
+
cosine, max_abs = vector_metrics(reference_vector, got_vector)
|
|
91
|
+
vectors_checked += 1
|
|
92
|
+
min_cosine = [min_cosine, cosine].min
|
|
93
|
+
max_abs_all = [max_abs_all, max_abs].max
|
|
94
|
+
unless cosine >= options[:min_cosine] && max_abs <= options[:max_abs]
|
|
95
|
+
vector_failures << i
|
|
96
|
+
problems << format("encode vector out of tolerance cos=%.10f max_abs=%.8g", cosine, max_abs)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if row.key?("static_embedding_vector")
|
|
100
|
+
diag = prefix_dims(row.fetch("static_embedding_vector"), got_vector.length)
|
|
101
|
+
dcos, dabs = vector_metrics(diag, got_vector)
|
|
102
|
+
unless dcos >= options[:min_cosine] && dabs <= options[:max_abs]
|
|
103
|
+
diag_failures << i
|
|
104
|
+
problems << format("StaticEmbedding diagnostic out of tolerance cos=%.10f max_abs=%.8g", dcos, dabs)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
status = problems.empty? ? "ok" : "FAIL"
|
|
109
|
+
puts "#{status} idx=#{format('%03d', i)} label=#{label.inspect} bytes=#{text.bytesize}" +
|
|
110
|
+
(problems.empty? ? "" : " [#{problems.join('; ')}]")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
puts "rows=#{payload.fetch("rows").length}"
|
|
114
|
+
puts "vectors_checked=#{vectors_checked}"
|
|
115
|
+
puts "min_cosine=#{min_cosine}"
|
|
116
|
+
puts "max_abs_all=#{max_abs_all}"
|
|
117
|
+
puts "raw_token_id_failures=#{raw_failures.inspect}"
|
|
118
|
+
puts "vector_failures=#{vector_failures.inspect}"
|
|
119
|
+
puts "static_embedding_failures=#{diag_failures.inspect}"
|
|
120
|
+
|
|
121
|
+
unless raw_failures.empty? && vector_failures.empty? && diag_failures.empty?
|
|
122
|
+
abort "ST parity failed"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
puts "ST corpus parity OK (#{payload.fetch("rows").length}/#{payload.fetch("rows").length})"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
require "static_embeddings"
|
|
4
|
+
|
|
5
|
+
options = { k: 10 }
|
|
6
|
+
parser = OptionParser.new do |opts|
|
|
7
|
+
opts.on("--model PATH") { |value| options[:model] = value }
|
|
8
|
+
opts.on("--dataset PATH") { |value| options[:dataset] = value }
|
|
9
|
+
opts.on("--k N", Integer) { |value| options[:k] = value }
|
|
10
|
+
end
|
|
11
|
+
parser.parse!(ARGV)
|
|
12
|
+
abort "usage: ruby -Ilib tools/eval_retrieval.rb --model MODEL.semb --dataset dataset.json" unless options[:model] && options[:dataset]
|
|
13
|
+
|
|
14
|
+
payload = JSON.parse(File.read(options[:dataset], encoding: "UTF-8"))
|
|
15
|
+
docs = payload.fetch("documents")
|
|
16
|
+
queries = payload.fetch("queries")
|
|
17
|
+
k = options[:k]
|
|
18
|
+
|
|
19
|
+
model = StaticEmbeddings.load(options[:model], verify: true)
|
|
20
|
+
matrix = model.embed_batch(docs.map { |doc| doc.fetch("text") })
|
|
21
|
+
|
|
22
|
+
def dcg(gains)
|
|
23
|
+
gains.each_with_index.sum { |gain, i| gain / Math.log2(i + 2) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
mrr = 0.0
|
|
27
|
+
ndcg = 0.0
|
|
28
|
+
hits = 0
|
|
29
|
+
|
|
30
|
+
queries.each do |query|
|
|
31
|
+
relevant = query.fetch("relevant").map(&:to_s)
|
|
32
|
+
blob = model.embed(query.fetch("text"))
|
|
33
|
+
ranked = model.cosine_top_k(blob, matrix, [k, docs.length].min)
|
|
34
|
+
ids = ranked.map { |index, _| docs[index].fetch("id").to_s }
|
|
35
|
+
rank = ids.index { |id| relevant.include?(id) }
|
|
36
|
+
mrr += rank ? 1.0 / (rank + 1) : 0.0
|
|
37
|
+
hits += 1 if rank && rank < k
|
|
38
|
+
gains = ids.map { |id| relevant.include?(id) ? 1.0 : 0.0 }
|
|
39
|
+
ideal = [1.0] * [relevant.length, k].min + [0.0] * [k - relevant.length, 0].max
|
|
40
|
+
ideal = ideal.first(gains.length)
|
|
41
|
+
denom = dcg(ideal)
|
|
42
|
+
ndcg += denom.positive? ? dcg(gains) / denom : 0.0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
n = queries.length.to_f
|
|
46
|
+
result = {
|
|
47
|
+
"model_id" => model.model_id,
|
|
48
|
+
"dim" => model.dim,
|
|
49
|
+
"normalized" => model.normalized?,
|
|
50
|
+
"queries" => queries.length,
|
|
51
|
+
"documents" => docs.length,
|
|
52
|
+
"k" => k,
|
|
53
|
+
"mrr" => mrr / n,
|
|
54
|
+
"ndcg_at_k" => ndcg / n,
|
|
55
|
+
"hit_at_k" => hits / n
|
|
56
|
+
}
|
|
57
|
+
puts JSON.pretty_generate(result)
|
|
58
|
+
model.close
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: static_embeddings
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.5.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Roman Haydarov
|
|
@@ -65,10 +65,11 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '1.2'
|
|
68
|
-
description: A small C-extension runtime for Model2Vec
|
|
69
|
-
Models are converted offline into a flat mmap-able .semb
|
|
70
|
-
tokenizes (BERT WordPiece), looks up rows and mean-pools
|
|
71
|
-
on large native work, rejects internal thread fan-out, and
|
|
68
|
+
description: A small C-extension runtime for Model2Vec and Sentence Transformers static
|
|
69
|
+
WordPiece embedding models. Models are converted offline into a flat mmap-able .semb
|
|
70
|
+
file; at runtime the gem tokenizes (BERT WordPiece), looks up rows and mean-pools
|
|
71
|
+
them. Releases the GVL on large native work, rejects internal thread fan-out, and
|
|
72
|
+
links nothing but libc.
|
|
72
73
|
email:
|
|
73
74
|
- romnhajdarov@gmail.com
|
|
74
75
|
executables:
|
|
@@ -100,14 +101,29 @@ files:
|
|
|
100
101
|
- ext/static_embeddings/se_topk.c
|
|
101
102
|
- ext/static_embeddings/se_unicode.c
|
|
102
103
|
- ext/static_embeddings/static_embeddings.c
|
|
104
|
+
- lib/models/demo.semb
|
|
103
105
|
- lib/static_embeddings.rb
|
|
106
|
+
- lib/static_embeddings/bert_wordpiece.rb
|
|
107
|
+
- lib/static_embeddings/canonical.rb
|
|
104
108
|
- lib/static_embeddings/cli.rb
|
|
105
|
-
- lib/static_embeddings/
|
|
109
|
+
- lib/static_embeddings/codec.rb
|
|
110
|
+
- lib/static_embeddings/conversion.rb
|
|
106
111
|
- lib/static_embeddings/errors.rb
|
|
107
112
|
- lib/static_embeddings/format.rb
|
|
113
|
+
- lib/static_embeddings/format/constants.rb
|
|
114
|
+
- lib/static_embeddings/format/hash_table.rb
|
|
115
|
+
- lib/static_embeddings/format/trie.rb
|
|
116
|
+
- lib/static_embeddings/format/verifier.rb
|
|
117
|
+
- lib/static_embeddings/format/writer.rb
|
|
118
|
+
- lib/static_embeddings/importers.rb
|
|
119
|
+
- lib/static_embeddings/importers/model2vec.rb
|
|
120
|
+
- lib/static_embeddings/importers/sentence_transformers_static.rb
|
|
121
|
+
- lib/static_embeddings/importers/support.rb
|
|
108
122
|
- lib/static_embeddings/model.rb
|
|
109
123
|
- lib/static_embeddings/paths.rb
|
|
124
|
+
- lib/static_embeddings/provenance.rb
|
|
110
125
|
- lib/static_embeddings/reference.rb
|
|
126
|
+
- lib/static_embeddings/row_prefix_payload.rb
|
|
111
127
|
- lib/static_embeddings/safetensors.rb
|
|
112
128
|
- lib/static_embeddings/unicode_tables.rb
|
|
113
129
|
- lib/static_embeddings/version.rb
|
|
@@ -115,6 +131,8 @@ files:
|
|
|
115
131
|
- tools/benchmark.rb
|
|
116
132
|
- tools/build_demo_model.rb
|
|
117
133
|
- tools/check_model2vec_parity.rb
|
|
134
|
+
- tools/check_st_parity.rb
|
|
135
|
+
- tools/eval_retrieval.rb
|
|
118
136
|
- tools/make_fixture_model.rb
|
|
119
137
|
homepage: https://github.com/roman-haidarov/static_embeddings
|
|
120
138
|
licenses:
|
|
@@ -1,328 +0,0 @@
|
|
|
1
|
-
require "json"
|
|
2
|
-
require "digest"
|
|
3
|
-
require "static_embeddings/format"
|
|
4
|
-
require "static_embeddings/safetensors"
|
|
5
|
-
require "static_embeddings/unicode_tables"
|
|
6
|
-
|
|
7
|
-
module StaticEmbeddings
|
|
8
|
-
class Converter
|
|
9
|
-
REFERENCE_IMPL = "model2vec.StaticModel"
|
|
10
|
-
REFERENCE_MODEL2VEC_VERSION = "0.9.0"
|
|
11
|
-
REFERENCE_TOKENIZERS_VERSION = "0.23.1"
|
|
12
|
-
REFERENCE_UNICODE_CATEGORIES_VERSION = "0.1.1"
|
|
13
|
-
REFERENCE_MAX_TOKENS = 512
|
|
14
|
-
|
|
15
|
-
ALLOWED_NORMALIZER_KEYS = %w[type clean_text handle_chinese_chars strip_accents lowercase].freeze
|
|
16
|
-
STANDARD_SPECIAL_TOKENS = {
|
|
17
|
-
"[PAD]" => Format::ADDED_PAD,
|
|
18
|
-
"[UNK]" => Format::ADDED_UNK,
|
|
19
|
-
"[CLS]" => Format::ADDED_CLS,
|
|
20
|
-
"[SEP]" => Format::ADDED_SEP,
|
|
21
|
-
"[MASK]" => Format::ADDED_MASK
|
|
22
|
-
}.freeze
|
|
23
|
-
SOURCE_FILES = %w[tokenizer.json config.json tokenizer_config.json model.safetensors].freeze
|
|
24
|
-
TOKENIZER_PROFILE = "BERT_WORDPIECE_V1"
|
|
25
|
-
|
|
26
|
-
attr_reader :source_dir, :report
|
|
27
|
-
|
|
28
|
-
def initialize(source_dir)
|
|
29
|
-
@source_dir = source_dir
|
|
30
|
-
@report = {}
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def convert(output_path:, model_id: nil, max_tokens: REFERENCE_MAX_TOKENS)
|
|
34
|
-
source = load_source
|
|
35
|
-
profile = audit_tokenizer(source[:tokenizer], source[:tokenizer_config])
|
|
36
|
-
tokens = extract_vocab(source[:tokenizer])
|
|
37
|
-
validate_added_token_ids!(profile[:added_tokens], tokens)
|
|
38
|
-
matrix, dim = extract_matrix(tokens.length)
|
|
39
|
-
meta = runtime_meta(source[:config], profile, tokens, max_tokens)
|
|
40
|
-
|
|
41
|
-
result = write_model(output_path, meta, tokens, matrix,
|
|
42
|
-
provenance_for(model_id, meta, profile, source[:config], tokens.length, dim))
|
|
43
|
-
@report = result.merge(vocab_size: tokens.length, dim: dim, provenance: JSON.parse(result[:provenance]))
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def load_source
|
|
49
|
-
{
|
|
50
|
-
tokenizer: load_json("tokenizer.json"),
|
|
51
|
-
config: load_json("config.json", optional: true) || {},
|
|
52
|
-
tokenizer_config: load_json("tokenizer_config.json", optional: true) || {}
|
|
53
|
-
}
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def load_json(name, optional: false)
|
|
57
|
-
path = File.join(source_dir, name)
|
|
58
|
-
return nil if optional && !File.file?(path)
|
|
59
|
-
raise ConversionError, "missing #{name} in #{source_dir}" unless File.file?(path)
|
|
60
|
-
|
|
61
|
-
JSON.parse(File.binread(path))
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def reject!(reason)
|
|
65
|
-
raise UnsupportedModelError,
|
|
66
|
-
"#{reason}. This converter only accepts the #{TOKENIZER_PROFILE} profile; " \
|
|
67
|
-
"supporting this model would require implementing that behaviour in the runtime first."
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def audit_tokenizer(tokenizer, tokenizer_config)
|
|
71
|
-
model = tokenizer["model"] or reject!("tokenizer.json has no model section")
|
|
72
|
-
normalizer = tokenizer["normalizer"] or reject!("tokenizer has no normalizer")
|
|
73
|
-
pre_tokenizer = tokenizer["pre_tokenizer"]
|
|
74
|
-
|
|
75
|
-
assert_wordpiece!(model)
|
|
76
|
-
assert_normalizer!(normalizer)
|
|
77
|
-
assert_pre_tokenizer!(pre_tokenizer)
|
|
78
|
-
added_tokens = audit_added_tokens(tokenizer)
|
|
79
|
-
|
|
80
|
-
profile = profile_from(model, normalizer, tokenizer_config)
|
|
81
|
-
profile[:added_tokens] = added_tokens
|
|
82
|
-
profile[:added_token_mask] = added_tokens.reduce(0) do |mask, token|
|
|
83
|
-
mask | STANDARD_SPECIAL_TOKENS.fetch(token.fetch("content"))
|
|
84
|
-
end
|
|
85
|
-
reject!("clean_text=false is not supported by the runtime") unless profile[:clean_text]
|
|
86
|
-
profile
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def assert_wordpiece!(model)
|
|
90
|
-
reject!("tokenizer model.type is #{model['type'].inspect}, expected WordPiece") unless model["type"] == "WordPiece"
|
|
91
|
-
|
|
92
|
-
prefix = model.fetch("continuing_subword_prefix", "##")
|
|
93
|
-
reject!("continuing_subword_prefix #{prefix.inspect} is not supported") unless prefix == "##"
|
|
94
|
-
|
|
95
|
-
unk = model.fetch("unk_token", "[UNK]")
|
|
96
|
-
reject!("unk_token #{unk.inspect} is not supported") unless unk == "[UNK]"
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def assert_normalizer!(normalizer)
|
|
100
|
-
reject!("normalizer type #{normalizer['type'].inspect} is not BertNormalizer") unless normalizer["type"] == "BertNormalizer"
|
|
101
|
-
|
|
102
|
-
unknown = normalizer.keys - ALLOWED_NORMALIZER_KEYS
|
|
103
|
-
reject!("normalizer has unsupported keys #{unknown.inspect}") unless unknown.empty?
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def assert_pre_tokenizer!(pre_tokenizer)
|
|
107
|
-
return if pre_tokenizer && pre_tokenizer["type"] == "BertPreTokenizer"
|
|
108
|
-
|
|
109
|
-
reject!("pre_tokenizer type #{pre_tokenizer && pre_tokenizer['type'].inspect} is not BertPreTokenizer")
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def profile_from(model, normalizer, tokenizer_config)
|
|
113
|
-
lowercase = flag_value(normalizer, "lowercase", tokenizer_config["do_lower_case"], true)
|
|
114
|
-
{
|
|
115
|
-
lowercase: lowercase,
|
|
116
|
-
strip_accents: strip_accents?(normalizer, lowercase),
|
|
117
|
-
clean_text: flag_value(normalizer, "clean_text", nil, true),
|
|
118
|
-
handle_chinese_chars: flag_value(normalizer, "handle_chinese_chars",
|
|
119
|
-
tokenizer_config["tokenize_chinese_chars"], true),
|
|
120
|
-
continuing_subword_prefix: model.fetch("continuing_subword_prefix", "##"),
|
|
121
|
-
unk_token: model.fetch("unk_token", "[UNK]"),
|
|
122
|
-
max_input_chars_per_word: model.fetch("max_input_chars_per_word", 100).to_i,
|
|
123
|
-
tokenizer_class: tokenizer_config["tokenizer_class"]
|
|
124
|
-
}
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def flag_value(hash, key, fallback, default)
|
|
128
|
-
return !!hash[key] if hash.key?(key) && !hash[key].nil?
|
|
129
|
-
return !!fallback unless fallback.nil?
|
|
130
|
-
|
|
131
|
-
default
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def strip_accents?(normalizer, lowercase)
|
|
135
|
-
return !!normalizer["strip_accents"] if normalizer.key?("strip_accents") && !normalizer["strip_accents"].nil?
|
|
136
|
-
|
|
137
|
-
lowercase
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def audit_added_tokens(tokenizer)
|
|
141
|
-
added = tokenizer["added_tokens"] || []
|
|
142
|
-
bad_content = added.reject { |token| standard_special?(token) }
|
|
143
|
-
unless bad_content.empty?
|
|
144
|
-
reject!("tokenizer declares non-standard added_tokens #{bad_content.map { |t| t['content'] }.inspect}")
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
whitespace = added.find { |token| token["content"].to_s.match?(/\s/) }
|
|
148
|
-
reject!("added token #{whitespace['content'].inspect} contains whitespace") if whitespace
|
|
149
|
-
|
|
150
|
-
flagged = added.find { |token| token["lstrip"] || token["rstrip"] || token["single_word"] }
|
|
151
|
-
reject!("added token #{flagged['content'].inspect} uses lstrip/rstrip/single_word") if flagged
|
|
152
|
-
|
|
153
|
-
normalized = added.find { |token| token["normalized"] != false }
|
|
154
|
-
if normalized
|
|
155
|
-
reject!("added token #{normalized['content'].inspect} must use normalized=false")
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
duplicate = added.group_by { |token| token["content"] }.find { |_, rows| rows.length > 1 }
|
|
159
|
-
reject!("duplicate added token #{duplicate[0].inspect}") if duplicate
|
|
160
|
-
|
|
161
|
-
added
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def standard_special?(token)
|
|
165
|
-
token["special"] && STANDARD_SPECIAL_TOKENS.key?(token["content"])
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def validate_added_token_ids!(added_tokens, tokens)
|
|
169
|
-
added_tokens.each do |token|
|
|
170
|
-
content = token.fetch("content")
|
|
171
|
-
id = token["id"]
|
|
172
|
-
reject!("added token #{content.inspect} has non-integer id #{id.inspect}") unless id.is_a?(Integer)
|
|
173
|
-
unless id.between?(0, tokens.length - 1) && tokens[id] == content
|
|
174
|
-
reject!("added token #{content.inspect} id #{id} does not match model.vocab")
|
|
175
|
-
end
|
|
176
|
-
end
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def extract_vocab(tokenizer)
|
|
180
|
-
vocab = tokenizer.dig("model", "vocab") or reject!("tokenizer.json has no model.vocab")
|
|
181
|
-
vocab.each_with_object(Array.new(vocab.length)) do |(token, id), tokens|
|
|
182
|
-
validate_vocab_id!(token, id, vocab.length, tokens)
|
|
183
|
-
tokens[id] = token
|
|
184
|
-
end.tap do |tokens|
|
|
185
|
-
missing = tokens.index(nil)
|
|
186
|
-
raise ConversionError, "vocab has a hole at id #{missing}" if missing
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def validate_vocab_id!(token, id, size, tokens)
|
|
191
|
-
reject!("vocab id #{id.inspect} is not an integer") unless id.is_a?(Integer)
|
|
192
|
-
unless id.between?(0, size - 1)
|
|
193
|
-
raise ConversionError, "vocab id #{id} for #{token.inspect} is outside 0...#{size}"
|
|
194
|
-
end
|
|
195
|
-
raise ConversionError, "duplicate vocab id #{id}" unless tokens[id].nil?
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def extract_matrix(vocab_size)
|
|
199
|
-
path = File.join(source_dir, "model.safetensors")
|
|
200
|
-
raise ConversionError, "missing model.safetensors in #{source_dir}" unless File.file?(path)
|
|
201
|
-
|
|
202
|
-
name, tensor = sole_matrix_tensor(Safetensors.describe(path)[:tensors])
|
|
203
|
-
rows, dim = tensor[:shape]
|
|
204
|
-
if rows != vocab_size
|
|
205
|
-
raise ConversionError,
|
|
206
|
-
"embedding matrix #{name} has #{rows} rows but the tokenizer has " \
|
|
207
|
-
"#{vocab_size} tokens — refusing to guess the mapping"
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
[Safetensors.f32_payload(path, tensor), dim]
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def sole_matrix_tensor(tensors)
|
|
214
|
-
matrices = tensors.select { |_, tensor| tensor[:shape].length == 2 }
|
|
215
|
-
raise ConversionError, "model.safetensors contains no 2-D tensor" if matrices.empty?
|
|
216
|
-
return matrices.first if matrices.length == 1
|
|
217
|
-
|
|
218
|
-
raise ConversionError,
|
|
219
|
-
"model.safetensors contains several 2-D tensors (#{matrices.keys.inspect}); " \
|
|
220
|
-
"a static embedding model must have exactly one"
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def runtime_meta(config, profile, tokens, max_tokens)
|
|
224
|
-
base_meta(config, max_tokens)
|
|
225
|
-
.merge(tokenizer_meta(profile, tokens))
|
|
226
|
-
.merge(token_ids(tokens, profile))
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def base_meta(config, max_tokens)
|
|
230
|
-
{
|
|
231
|
-
dim: nil,
|
|
232
|
-
normalization_type: normalization_from(config),
|
|
233
|
-
max_tokens_default: max_tokens.to_i,
|
|
234
|
-
add_special_tokens: false,
|
|
235
|
-
unk_policy: Format::UNK_DROP,
|
|
236
|
-
empty_policy: Format::EMPTY_ZERO_VECTOR
|
|
237
|
-
}
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def tokenizer_meta(profile, tokens)
|
|
241
|
-
{
|
|
242
|
-
do_lower_case: profile[:lowercase],
|
|
243
|
-
strip_accents: profile[:strip_accents],
|
|
244
|
-
handle_chinese_chars: profile[:handle_chinese_chars],
|
|
245
|
-
clean_text: profile[:clean_text],
|
|
246
|
-
added_token_mask: profile.fetch(:added_token_mask),
|
|
247
|
-
max_input_chars_per_word: profile[:max_input_chars_per_word],
|
|
248
|
-
max_token_chars: max_token_chars(tokens, profile[:continuing_subword_prefix]),
|
|
249
|
-
subword_prefix: profile[:continuing_subword_prefix]
|
|
250
|
-
}
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
def max_token_chars(tokens, prefix)
|
|
254
|
-
tokens.map do |token|
|
|
255
|
-
body = token.start_with?(prefix) ? token[prefix.length..] : token
|
|
256
|
-
body.each_char.count
|
|
257
|
-
end.max || 1
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
def token_ids(tokens, profile)
|
|
261
|
-
{
|
|
262
|
-
pad_id: token_id(tokens, "[PAD]", 0),
|
|
263
|
-
unk_id: token_id(tokens, profile[:unk_token]),
|
|
264
|
-
cls_id: token_id(tokens, "[CLS]", 0),
|
|
265
|
-
sep_id: token_id(tokens, "[SEP]", 0),
|
|
266
|
-
mask_id: token_id(tokens, "[MASK]", 0)
|
|
267
|
-
}
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
def normalization_from(config)
|
|
271
|
-
config.key?("normalize") && !config["normalize"] ? Format::NORMALIZATION_NONE : Format::NORMALIZATION_L2
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def token_id(tokens, token, fallback = nil)
|
|
275
|
-
id = tokens.index(token)
|
|
276
|
-
return id unless id.nil?
|
|
277
|
-
return fallback unless fallback.nil?
|
|
278
|
-
|
|
279
|
-
raise ConversionError, "vocabulary has no #{token.inspect}"
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
def source_digests
|
|
283
|
-
SOURCE_FILES.each_with_object({}) do |name, acc|
|
|
284
|
-
path = File.join(source_dir, name)
|
|
285
|
-
acc[name] = Digest::SHA256.file(path).hexdigest if File.file?(path)
|
|
286
|
-
end
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
def provenance_for(model_id, meta, profile, config, vocab_size, dim)
|
|
290
|
-
ordered_json(
|
|
291
|
-
"format_version" => Format::VERSION,
|
|
292
|
-
"converter_version" => StaticEmbeddings::VERSION,
|
|
293
|
-
"source_model_id" => model_id || File.basename(File.expand_path(source_dir)),
|
|
294
|
-
"source_files_sha256" => source_digests,
|
|
295
|
-
"reference_impl" => REFERENCE_IMPL,
|
|
296
|
-
"reference_model2vec_version" => REFERENCE_MODEL2VEC_VERSION,
|
|
297
|
-
"reference_tokenizers_version" => REFERENCE_TOKENIZERS_VERSION,
|
|
298
|
-
"reference_unicode_categories_version" => REFERENCE_UNICODE_CATEGORIES_VERSION,
|
|
299
|
-
"reference_max_tokens" => meta[:max_tokens_default],
|
|
300
|
-
"unicode_source" => UnicodeTables.source_stamp,
|
|
301
|
-
"tokenizer_profile" => TOKENIZER_PROFILE,
|
|
302
|
-
"tokenizer_class" => profile[:tokenizer_class],
|
|
303
|
-
"vocab_size" => vocab_size,
|
|
304
|
-
"dim" => dim,
|
|
305
|
-
"normalize" => meta[:normalization_type] == Format::NORMALIZATION_L2,
|
|
306
|
-
"config_seq_length" => config["seq_length"],
|
|
307
|
-
"notes" => "Vectors are only reference-compatible if docs/MODEL_AUDIT.md records a passing oracle run for this source revision."
|
|
308
|
-
)
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
def ordered_json(hash)
|
|
312
|
-
JSON.generate(hash.sort_by { |key, _| key }.to_h)
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
def write_model(output_path, meta, tokens, matrix, provenance)
|
|
316
|
-
dim = matrix.bytesize / (tokens.length * 4)
|
|
317
|
-
result = Format.write(
|
|
318
|
-
path: output_path,
|
|
319
|
-
meta: meta.merge(dim: dim),
|
|
320
|
-
tokens: tokens,
|
|
321
|
-
matrix: matrix,
|
|
322
|
-
norm_tables: UnicodeTables.packed,
|
|
323
|
-
provenance: provenance
|
|
324
|
-
)
|
|
325
|
-
result.merge(provenance: provenance)
|
|
326
|
-
end
|
|
327
|
-
end
|
|
328
|
-
end
|