ruby-laya 0.1.0

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.
data/lib/laya/agent.rb ADDED
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "runtime"
5
+ require_relative "tokenizer"
6
+ require_relative "hub"
7
+ require_relative "question"
8
+ require_relative "result"
9
+
10
+ module Laya
11
+ # A Laya checkpoint, loaded and ready to answer typed questions.
12
+ #
13
+ # agent = Laya.load("convaiinnovations/laya")
14
+ # result = agent.predict({ "message" => "I was charged twice" }, Laya.triage_questions)
15
+ # result[:intent].choice # => "refund"
16
+ # result[:churn_risk].probability
17
+ #
18
+ # Every question in a call is answered in one forward pass. The weights are the ones Convai
19
+ # Innovations published, exported to ONNX; see {Checkpoints}.
20
+ class Agent
21
+ # What a checkpoint directory must hold; see {Laya::Checkpoints::RUNTIME_FILES}.
22
+ RUNTIME_FILES = Checkpoints::RUNTIME_FILES
23
+
24
+ # The traced graph fixes the branch upstream picks at runtime for a one-option question, so a
25
+ # batch always carries at least two markers; the spare is masked off and scores nothing.
26
+ MIN_MARKERS = 2
27
+
28
+ # The shortest sequence the graph accepts. A batch of very short texts is padded up to it,
29
+ # which changes nothing: the padding is masked off.
30
+ MIN_SEQ = 8
31
+
32
+ DEFAULT_MAX_LEN = 512
33
+ DEFAULT_HEAD_MAX_LEN = 192
34
+
35
+ attr_reader :model_id, :model_dir, :config, :onnx_config, :tokenizer, :runtime,
36
+ :temperature, :temperature_by_options, :temperature_raw, :temperature_by_options_raw
37
+
38
+ class << self
39
+ # Load a checkpoint. See {Laya.load}, which is the documented entry point.
40
+ def load(model_id_or_path = Checkpoints::BUNDLE_REPO, **, &block)
41
+ agent = new(model_id_or_path, **)
42
+ return agent unless block
43
+
44
+ begin
45
+ block.call(agent)
46
+ ensure
47
+ agent.close
48
+ end
49
+ end
50
+
51
+ # The local directory holding `model_id_or_path`, downloading it when it names a repository.
52
+ #
53
+ # `hub` is the downloader, injectable so an app can serve exports from its own store: it
54
+ # answers `snapshot(repo, subfolder:, allow_patterns:, token:, revision:)` with a local path.
55
+ def resolve_dir(model_id_or_path, subfolder: nil, token: nil, revision: Hub::DEFAULT_REVISION,
56
+ hub: Hub)
57
+ return local_dir(model_id_or_path, subfolder) if File.directory?(model_id_or_path.to_s)
58
+
59
+ if path_like?(model_id_or_path)
60
+ raise ModelNotFoundError,
61
+ "local model path not found: #{model_id_or_path.inspect}. Check the directory exists " \
62
+ "and that the export wrote it successfully."
63
+ end
64
+
65
+ repo, onnx_subfolder = onnx_source_for(model_id_or_path, subfolder)
66
+ hub.snapshot(repo, subfolder: onnx_subfolder, allow_patterns: RUNTIME_FILES,
67
+ token: token, revision: revision)
68
+ end
69
+
70
+ # The ONNX repository and subfolder serving an upstream model id, or the id itself when it
71
+ # already names an export.
72
+ def onnx_source_for(model_id, subfolder)
73
+ name = Checkpoints.resolve_upstream(model_id, subfolder)
74
+ return Checkpoints.source_for(name) if name
75
+
76
+ [model_id.to_s, subfolder]
77
+ end
78
+
79
+ def path_like?(model_id_or_path)
80
+ value = model_id_or_path.to_s
81
+ value.start_with?("/", "./", "../", "~") || File.absolute_path?(value)
82
+ end
83
+
84
+ def local_dir(path, subfolder)
85
+ dir = subfolder ? File.join(path, subfolder) : path.to_s
86
+ return dir if File.directory?(dir)
87
+
88
+ raise ModelNotFoundError, "subfolder #{subfolder.inspect} not found in #{path.inspect}"
89
+ end
90
+ end
91
+
92
+ # @param model_id_or_path [String] an upstream model id, an ONNX repository id, or a directory
93
+ # @param device [String, Symbol, nil] "cpu" (default), "coreml", "cuda", ...
94
+ # @param providers [Array<String>, nil] ONNX Runtime providers, overriding `device`
95
+ # @param threads [Integer, nil] intra-op threads; ONNX Runtime decides when nil
96
+ # @param hub [#snapshot] where repository ids are downloaded from; defaults to {Laya::Hub}
97
+ def initialize(model_id_or_path = Checkpoints::BUNDLE_REPO, device: nil, providers: nil,
98
+ token: nil, subfolder: nil, threads: nil, revision: Hub::DEFAULT_REVISION,
99
+ hub: Hub)
100
+ @model_id = model_id_or_path.to_s
101
+ @model_dir = Agent.resolve_dir(model_id_or_path, subfolder: subfolder, token: token,
102
+ revision: revision, hub: hub)
103
+ @config = read_json("rl_agent_config.json")
104
+ @onnx_config = read_json("onnx_config.json")
105
+ @tokenizer = Tokenizer.from_dir(File.join(@model_dir, "tokenizer"))
106
+ @temperature_raw = config.fetch("temperature", [1.0, 1.0, 1.0])
107
+ @temperature_by_options_raw = config.fetch("temperature_by_options", {})
108
+ @temperature = @temperature_raw.map { |value| Common.clamp_temperature(value) }
109
+ @temperature_by_options = @temperature_by_options_raw.transform_values { |v| Common.clamp_temperature(v) }
110
+ warn_about_temperatures
111
+ @runtime = Runtime.new(onnx_path, providers: providers, device: device, threads: threads)
112
+ end
113
+
114
+ # Evaluate every question against `state` in a single forward pass.
115
+ #
116
+ # `state` is a String, a Hash or an Array of conversation turns. `questions` maps a question id
117
+ # to its definition; see {Question}. Returns a {Result}.
118
+ def predict(state, questions)
119
+ unless questions.is_a?(Hash)
120
+ raise ArgumentError, "questions must be a Hash of question id => definition, got #{questions.class}"
121
+ end
122
+ return Result.new(answers: {}, usage: EMPTY_USAGE.dup) if questions.empty?
123
+
124
+ asked = questions.map { |id, definition| Question.build(id, definition) }
125
+ batch = tokenize(state, asked)
126
+ logits, act_logits = runtime.decide(batch)
127
+ Result.new(answers: collect_answers(asked, batch, logits, act_logits),
128
+ usage: { "input_tokens" => batch[:input_tokens], "output_tokens" => 0 })
129
+ end
130
+ alias system_one predict
131
+
132
+ # Mean-pooled encoder states for `texts`, one row per string.
133
+ #
134
+ # This is what {Laya.embed_fn_from_agent} hands the shortlist. Padding is excluded from the
135
+ # mean, and the decision head never runs.
136
+ def embed(texts, max_length: nil, batch_size: 32)
137
+ rows = Array(texts).map { |text| text.nil? ? "" : text.to_s }
138
+ return [] if rows.empty?
139
+
140
+ max_length ||= max_len
141
+ rows.each_slice(batch_size).flat_map do |chunk|
142
+ encoded = pad_to_min_seq(tokenizer.encode_batch(chunk, max_length: max_length))
143
+ hidden = runtime.encode(embedding_batch(encoded))
144
+ mean_pool(hidden, encoded["attention_mask"])
145
+ end
146
+ end
147
+
148
+ # The token budget for the whole sequence, and the share of it the options may use.
149
+ def max_len
150
+ config.fetch("max_len", DEFAULT_MAX_LEN)
151
+ end
152
+
153
+ def head_max_len
154
+ config.fetch("head_max_len", DEFAULT_HEAD_MAX_LEN)
155
+ end
156
+
157
+ def hidden_size
158
+ onnx_config["hidden_size"]
159
+ end
160
+
161
+ # Release the ONNX session. The agent cannot answer afterwards.
162
+ def close
163
+ runtime.close
164
+ self
165
+ end
166
+
167
+ def closed?
168
+ runtime.closed?
169
+ end
170
+
171
+ def inspect
172
+ "#<Laya::Agent #{model_id.inspect} providers=#{runtime.providers.inspect}>"
173
+ end
174
+
175
+ private
176
+
177
+ EMPTY_USAGE = { "input_tokens" => 0, "output_tokens" => 0 }.freeze
178
+ private_constant :EMPTY_USAGE
179
+
180
+ def onnx_path
181
+ path = File.join(model_dir, "model.onnx")
182
+ return path if File.file?(path)
183
+
184
+ raise IncompatibleModelError,
185
+ "no 'model.onnx' in #{model_dir}. ruby-laya runs the ONNX exports of the Laya " \
186
+ "checkpoints; export one with tools/export_onnx.py or load #{Checkpoints.onnx_repo}."
187
+ end
188
+
189
+ def read_json(name)
190
+ path = File.join(model_dir, name)
191
+ unless File.file?(path)
192
+ raise IncompatibleModelError,
193
+ "no #{name.inspect} in #{model_dir}. That file ships with an exported Laya " \
194
+ "checkpoint, so load one of those or re-export with tools/export_onnx.py."
195
+ end
196
+
197
+ JSON.parse(File.read(path))
198
+ rescue JSON::ParserError => e
199
+ raise IncompatibleModelError, "#{name} in #{model_dir} is not valid JSON: #{e.message}"
200
+ end
201
+
202
+ # Tokenize every question into one padded batch of marker sequences.
203
+ def tokenize(state, asked)
204
+ items = asked.map do |question|
205
+ ids, markers = Common.build_sequence(tokenizer, state, question.internal,
206
+ max_len: max_len, head_max_len: head_max_len)
207
+ if markers.length != question.options.length
208
+ raise ArgumentError,
209
+ "question #{question.id.inspect} has #{question.options.length} options, which do not " \
210
+ "fit head_max_len=#{head_max_len}; shortlist them or raise the budget"
211
+ end
212
+
213
+ { ids: ids, markers: markers, qtype: question.qtype }
214
+ end
215
+ collate(items)
216
+ end
217
+
218
+ def collate(items)
219
+ width = [items.map { |item| item[:ids].length }.max, min_seq].max
220
+ markers = [items.map { |item| item[:markers].length }.max, MIN_MARKERS].max
221
+ pad = tokenizer.pad_token_id
222
+ {
223
+ input_ids: items.map { |item| item[:ids] + Array.new(width - item[:ids].length, pad) },
224
+ attention_mask: items.map { |item| Array.new(item[:ids].length, 1) + Array.new(width - item[:ids].length, 0) },
225
+ marker_pos: items.map { |item| item[:markers] + Array.new(markers - item[:markers].length, 0) },
226
+ marker_mask: items.map do |item|
227
+ Array.new(item[:markers].length, true) + Array.new(markers - item[:markers].length, false)
228
+ end,
229
+ qtype: items.map { |item| item[:qtype] },
230
+ input_tokens: items.sum { |item| item[:ids].length },
231
+ markers: items.map { |item| item[:markers].length }
232
+ }
233
+ end
234
+
235
+ def collect_answers(asked, batch, logits, act_logits)
236
+ act = act_logits.map { |row| Common.softmax(row) }
237
+ asked.each_with_index.to_h do |question, row|
238
+ options = batch[:markers][row]
239
+ scale = temperature_for(question, options)
240
+ probabilities = Common.softmax(logits[row].first(options), temperature: scale)
241
+ [question.id, question.answer(
242
+ probabilities: probabilities,
243
+ confidence: Common.confidence_from_probs(probabilities, options).round(4),
244
+ action_probability: act[row][0].round(4)
245
+ )]
246
+ end
247
+ end
248
+
249
+ # The fitted temperature for this question type and option count, clamped at load.
250
+ def temperature_for(question, options)
251
+ temperature_by_options.fetch(Common.temp_bucket(question.qtype, options)) do
252
+ temperature[question.qtype]
253
+ end
254
+ end
255
+
256
+ # The graph's shortest accepted sequence, as the export recorded it.
257
+ def min_seq
258
+ onnx_config.fetch("min_seq", MIN_SEQ)
259
+ end
260
+
261
+ # Short texts, and texts that tokenize to nothing at all, still have to form a valid batch.
262
+ def pad_to_min_seq(encoded)
263
+ width = encoded["input_ids"].map(&:length).max.to_i
264
+ return encoded if width >= min_seq
265
+
266
+ pad = tokenizer.pad_token_id
267
+ { "input_ids" => encoded["input_ids"].map { |row| row + Array.new(min_seq - row.length, pad) },
268
+ "attention_mask" => encoded["attention_mask"].map { |row| row + Array.new(min_seq - row.length, 0) } }
269
+ end
270
+
271
+ def embedding_batch(encoded)
272
+ rows = encoded["input_ids"].length
273
+ {
274
+ input_ids: encoded["input_ids"],
275
+ attention_mask: encoded["attention_mask"],
276
+ marker_pos: Array.new(rows) { Array.new(MIN_MARKERS, 0) },
277
+ marker_mask: Array.new(rows) { Array.new(MIN_MARKERS, true) },
278
+ qtype: Array.new(rows, 0)
279
+ }
280
+ end
281
+
282
+ def mean_pool(hidden, attention_mask)
283
+ hidden.each_with_index.map do |sequence, row|
284
+ mask = attention_mask[row]
285
+ live = mask.sum
286
+ next Array.new(sequence.first.length, 0.0) if live.zero?
287
+
288
+ sums = Array.new(sequence.first.length, 0.0)
289
+ sequence.each_with_index do |vector, position|
290
+ next if mask[position].zero?
291
+
292
+ vector.each_with_index { |value, i| sums[i] += value }
293
+ end
294
+ sums.map { |value| value / live }
295
+ end
296
+ end
297
+
298
+ # A checkpoint may ship a temperature that sharpens rather than softens; those are clamped at
299
+ # load, and saying so once is the only warning a caller gets.
300
+ def warn_about_temperatures
301
+ entries = @temperature_by_options_raw.map { |bucket, raw| [bucket, raw, @temperature_by_options[bucket]] }
302
+ entries += @temperature_raw.each_with_index.map { |raw, i| ["temperature[#{i}]", raw, @temperature[i]] }
303
+ rejected = entries.filter_map do |name, raw, applied|
304
+ format("%s=%s -> %g", name, raw.inspect, applied) if clamped?(raw, applied)
305
+ end
306
+ return if rejected.empty?
307
+
308
+ warn "[laya] #{model_id}: this checkpoint ships invalid temperatures or values outside " \
309
+ "[#{TEMP_MIN}, #{TEMP_MAX}]; using #{rejected.join(', ')}. Treat confidence from the " \
310
+ "affected entries as uncalibrated."
311
+ end
312
+
313
+ # True when the value actually applied is not the one the checkpoint shipped. An exact
314
+ # comparison is what is wanted here: the clamp either returned the same number or a
315
+ # different one.
316
+ def clamped?(raw, applied)
317
+ !Float(raw).equal?(applied) && Float(raw) != applied # rubocop:disable Lint/FloatComparison
318
+ rescue ArgumentError, TypeError
319
+ true
320
+ end
321
+ end
322
+
323
+ RLAgent = Agent
324
+ end
data/lib/laya/ask.rb ADDED
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "questions"
4
+
5
+ module Laya
6
+ # A decision built inline, for the questions not worth a class.
7
+ #
8
+ # answers = Laya.ask(email)
9
+ # .choice(:department, "Which team?", billing: "invoices", technical: "outages")
10
+ # .noul(:refund, "Do they want money back?")
11
+ # .decide
12
+ #
13
+ # answers.department == :billing # => true
14
+ # answers[:refund].probability # => 0.856
15
+ #
16
+ # Each call returns the builder, so the chain reads as the question set it is. {#decide} runs
17
+ # them all in one forward pass and hands back the {Result}.
18
+ class Ask
19
+ attr_reader :state, :questions
20
+
21
+ def initialize(state, client: nil, **options)
22
+ @state = state
23
+ @client = client
24
+ @options = options
25
+ @questions = {}
26
+ end
27
+
28
+ def choice(name, instructions, criteria = nil, **labels)
29
+ add(name, Questions.choice(instructions, criteria, **labels))
30
+ end
31
+
32
+ def score(name, instructions, levels:)
33
+ add(name, Questions.score(instructions, levels))
34
+ end
35
+
36
+ def noul(name, instructions, yes: nil, no: nil)
37
+ add(name, Questions.noul(instructions, yes: yes, no: no))
38
+ end
39
+
40
+ # Send it to a specific checkpoint rather than letting the router choose.
41
+ def using(model)
42
+ @options = @options.merge(model: model)
43
+ self
44
+ end
45
+
46
+ # Answer every question asked so far, in one forward pass.
47
+ def decide
48
+ raise ArgumentError, "ask at least one question before calling decide" if questions.empty?
49
+
50
+ client.predict(state, questions, **runnable_options)
51
+ end
52
+
53
+ def inspect
54
+ "#<Laya::Ask #{questions.keys.inspect}>"
55
+ end
56
+
57
+ private
58
+
59
+ def add(name, question)
60
+ @questions[name] = question
61
+ self
62
+ end
63
+
64
+ def client
65
+ @client || Laya.client
66
+ end
67
+
68
+ # `model:` only means something to a Router; an Agent already is one checkpoint.
69
+ def runnable_options
70
+ client.is_a?(Router) ? @options : @options.except(:model)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laya
4
+ # Where the three Laya checkpoints come from.
5
+ #
6
+ # Upstream publishes PyTorch weights; this gem runs ONNX exports of those same weights, so every
7
+ # upstream model id is mapped to its export here. `Laya.load("convaiinnovations/laya")` therefore
8
+ # works as the Python docs describe, and resolves to the English export.
9
+ module Checkpoints
10
+ # The repository holding the exports. Override with `LAYA_ONNX_REPO` to serve your own, for
11
+ # example a private mirror or a re-export of a fine-tuned checkpoint.
12
+ def self.onnx_repo
13
+ ENV.fetch("LAYA_ONNX_REPO", "codenamev/laya-onnx")
14
+ end
15
+
16
+ # The bundle repo upstream publishes, and the standalone repo for each checkpoint.
17
+ BUNDLE_REPO = "convaiinnovations/laya"
18
+
19
+ NAMES = %w[english multilingual typed-decisions].freeze
20
+
21
+ # What an exported checkpoint consists of, and so what the gem downloads. Here rather than on
22
+ # the runtime, because it describes the files, not how they are executed.
23
+ RUNTIME_FILES = ["model.onnx", "onnx_config.json", "rl_agent_config.json", "tokenizer/*"].freeze
24
+
25
+ # name => [upstream bundle subfolder, standalone upstream repo, ONNX subfolder]
26
+ SOURCES = {
27
+ "english" => [nil, "convaiinnovations/laya", "english"],
28
+ "multilingual" => ["multilingual", "convaiinnovations/laya-multilingual", "multilingual"],
29
+ "typed-decisions" => ["typed-decisions", "convaiinnovations/laya-typed-decisions", "typed-decisions"]
30
+ }.freeze
31
+
32
+ # Aliases people are likely to type.
33
+ ALIASES = {
34
+ "en" => "english", "laya" => "english", "default" => "english",
35
+ "multi" => "multilingual", "ml" => "multilingual", "laya-multilingual" => "multilingual",
36
+ "typed" => "typed-decisions", "typed_decisions" => "typed-decisions",
37
+ "laya-typed-decisions" => "typed-decisions", "decisions" => "typed-decisions"
38
+ }.freeze
39
+
40
+ module_function
41
+
42
+ # The canonical checkpoint name for `name` or one of its aliases.
43
+ def normalise(name)
44
+ key = name.to_s.strip.downcase
45
+ key = ALIASES.fetch(key, key)
46
+ return key if SOURCES.key?(key)
47
+
48
+ raise ArgumentError, "unknown model #{name.inspect}; choose one of #{NAMES} " \
49
+ "(or an alias: #{ALIASES.keys.sort})"
50
+ end
51
+
52
+ # The ONNX source for a checkpoint name, as `[repo, subfolder]`.
53
+ def source_for(name)
54
+ [onnx_repo, SOURCES.fetch(normalise(name))[2]]
55
+ end
56
+
57
+ # The checkpoint an upstream model id names, or nil when it names none.
58
+ #
59
+ # resolve_upstream("convaiinnovations/laya") # => "english"
60
+ # resolve_upstream("convaiinnovations/laya", "multilingual") # => "multilingual"
61
+ # resolve_upstream("convaiinnovations/laya-multilingual") # => "multilingual"
62
+ def resolve_upstream(repo, subfolder = nil)
63
+ repo = repo.to_s
64
+ SOURCES.each do |name, (bundle_subfolder, standalone, _onnx)|
65
+ return name if repo == BUNDLE_REPO && subfolder.to_s == bundle_subfolder.to_s
66
+ return name if repo == standalone && subfolder.nil?
67
+ end
68
+ nil
69
+ end
70
+
71
+ # Human-readable id for a source spec: "repo" or "repo/subfolder".
72
+ def repo_str(spec)
73
+ repo, subfolder = Array(spec)
74
+ subfolder ? "#{repo}/#{subfolder}" : repo.to_s
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laya
4
+ # Question type => the index the decision head's type embedding uses.
5
+ QTYPES = { "choice" => 0, "score" => 1, "noul" => 2 }.freeze
6
+ QTYPE_NAMES = QTYPES.invert.freeze
7
+
8
+ # A fitted temperature below 1 sharpens the logits instead of softening them. One shipped
9
+ # bucket is 0.1006, which multiplies them tenfold: a 0.24 top probability is published as 0.99,
10
+ # so a caller gating on confidence is told a coin flip is a certainty. No honest calibration
11
+ # needs to sharpen this hard, so values outside these bounds are refused.
12
+ TEMP_MIN = 0.5
13
+ TEMP_MAX = 5.0
14
+
15
+ # Token sequence construction, option rendering and the calibration arithmetic, shared by the
16
+ # runtime, the router and the shortlist. Pure Ruby, and a faithful port: the checkpoints were
17
+ # trained on exactly these strings.
18
+ module Common
19
+ # An option's text may not run past this many tokens.
20
+ MAX_OPTION_TOKENS = 48
21
+
22
+ module_function
23
+
24
+ # The text form of a state: a String passes through, anything else becomes Python-style JSON.
25
+ def serialize_state(state)
26
+ return state if state.is_a?(String)
27
+
28
+ PyJSON.dumps(state, default: :to_s)
29
+ end
30
+
31
+ # One criterion rendered as text. Strings pass through; anything structured becomes compact
32
+ # JSON, so a rubric reads as JSON rather than as a language's idea of `inspect`.
33
+ def render_criterion(value)
34
+ return value if value.is_a?(String)
35
+
36
+ PyJSON.dumps(value, default: :to_s)
37
+ end
38
+
39
+ # The option texts in label order. A noul question is always `[false, true]`.
40
+ def render_options(question)
41
+ type = Util.get(question, "t").to_s
42
+ criteria = Util.get(question, "crit")
43
+ case type
44
+ when "choice" then choice_options(criteria)
45
+ when "score" then criteria.each_with_index.map { |level, i| "level #{i}: #{render_criterion(level)}" }
46
+ else noul_options(criteria || {})
47
+ end
48
+ end
49
+
50
+ # Only nil and "" mean "no description"; 0 and false are legitimate criterion values.
51
+ def choice_options(criteria)
52
+ criteria.map do |label, description|
53
+ blank?(description) ? label.to_s : "#{label}: #{render_criterion(description)}"
54
+ end
55
+ end
56
+
57
+ def noul_options(criteria)
58
+ false_text = Util.get(criteria, "false")
59
+ true_text = Util.get(criteria, "true")
60
+ ["false: #{blank?(false_text) ? 'no, the statement does not hold' : render_criterion(false_text)}",
61
+ "true: #{blank?(true_text) ? 'yes, the statement holds' : render_criterion(true_text)}"]
62
+ end
63
+
64
+ def blank?(value)
65
+ value.nil? || value == ""
66
+ end
67
+
68
+ # Build one question's token sequence:
69
+ #
70
+ # [CLS] <type> instructions [SEP] [MASK] opt0 [MASK] opt1 ... [SEP] state [SEP]
71
+ #
72
+ # Returns `[token_ids, marker_positions]`, where each marker is the `[MASK]` in front of an
73
+ # option and is what the decision head scores. `tokenizer` answers `mask_token`,
74
+ # `mask_token_id`, `cls_token_id`, `sep_token_id` and `encode_ids`.
75
+ def build_sequence(tokenizer, state, question, max_len: 512, head_max_len: 192,
76
+ option_order: nil, truncate_left: false)
77
+ mask = tokenizer.mask_token
78
+ options = render_options(question)
79
+ order = option_order || (0...options.length).to_a
80
+ instructions = Util.get(question, "ins").to_s.gsub(mask, " ")
81
+
82
+ head = tokenizer.encode_ids("#{Util.get(question, 't')} question: #{instructions}")
83
+ rendered = order.map do |i|
84
+ [tokenizer.mask_token_id] + tokenizer.encode_ids(" #{options[i].gsub(mask, ' ')}").first(MAX_OPTION_TOKENS)
85
+ end
86
+ rendered = trim_options(rendered, head_max_len) if head_max_len - rendered.sum(&:length) < 16
87
+ head = head.first([8, head_max_len - rendered.sum(&:length)].max)
88
+
89
+ ids = [tokenizer.cls_token_id] + head + [tokenizer.sep_token_id]
90
+ markers = []
91
+ rendered.each do |option|
92
+ markers << ids.length
93
+ ids.concat(option)
94
+ end
95
+ ids << tokenizer.sep_token_id
96
+
97
+ room = [0, max_len - ids.length - 1].max
98
+ state_ids = tokenizer.encode_ids(serialize_state(state).gsub(mask, " "))
99
+ state_ids = truncate_left ? state_ids.last(room) : state_ids.first(room)
100
+ ids = ids + state_ids + [tokenizer.sep_token_id]
101
+ [ids.first(max_len), markers.select { |marker| marker < max_len }]
102
+ end
103
+
104
+ # Too many options for the budget: every one of them is cut to an equal share.
105
+ def trim_options(rendered, head_max_len)
106
+ per = [4, (head_max_len - 16) / [1, rendered.length].max].max
107
+ rendered.map { |option| option.first(per) }
108
+ end
109
+
110
+ # Confidence as normalized Shannon entropy: `1 - H(p) / log(k)`.
111
+ def confidence_from_probs(probabilities, k)
112
+ return 1.0 if k < 2
113
+
114
+ entropy = -probabilities.first(k).sum { |p| p * Math.log(p.clamp(1e-12, 1.0)) }
115
+ (1.0 - (entropy / Math.log(k))).clamp(0.0, 1.0)
116
+ end
117
+
118
+ # Expected Calibration Error across confidence bins. The first bin includes its lower edge,
119
+ # so a prediction of exactly zero confidence is counted rather than dropped.
120
+ def ece_score(confidences, correct, bins: 15)
121
+ return Float::NAN if confidences.empty?
122
+
123
+ correct = correct.map do |value|
124
+ if value == true
125
+ 1.0
126
+ else
127
+ value == false ? 0.0 : value.to_f
128
+ end
129
+ end
130
+ total = confidences.length.to_f
131
+ (0...bins).sum do |bin|
132
+ low = bin.fdiv(bins)
133
+ high = (bin + 1).fdiv(bins)
134
+ selected = confidences.each_index.select do |i|
135
+ (bin.zero? ? confidences[i] >= low : confidences[i] > low) && confidences[i] <= high
136
+ end
137
+ next 0.0 if selected.empty?
138
+
139
+ mean_confidence = selected.sum { |i| confidences[i] } / selected.length
140
+ mean_correct = selected.sum { |i| correct[i] } / selected.length
141
+ (selected.length / total) * (mean_confidence - mean_correct).abs
142
+ end
143
+ end
144
+
145
+ # The calibration bucket for a question type (index or name) and option count.
146
+ def temp_bucket(qtype, k)
147
+ name = qtype.is_a?(Integer) ? QTYPE_NAMES.fetch(qtype) : qtype.to_s
148
+ size = if k <= 2 then "2"
149
+ elsif k <= 5 then "3-5"
150
+ elsif k <= 10 then "6-10"
151
+ else "11+"
152
+ end
153
+ "#{name}:#{size}"
154
+ end
155
+
156
+ # A usable temperature: `value` confined to the sane range, or 1.0 when it is not a number.
157
+ def clamp_temperature(value, low: TEMP_MIN, high: TEMP_MAX)
158
+ number = Float(value)
159
+ return 1.0 if number.nan? || number.infinite?
160
+
161
+ number.clamp(low, high)
162
+ rescue ArgumentError, TypeError
163
+ 1.0
164
+ end
165
+
166
+ # Softmax over logits, optionally tempered.
167
+ def softmax(logits, temperature: 1.0)
168
+ scaled = logits.map { |logit| logit / temperature }
169
+ highest = scaled.max
170
+ exponentials = scaled.map { |value| Math.exp(value - highest) }
171
+ total = exponentials.sum
172
+ exponentials.map { |value| value / total }
173
+ end
174
+ end
175
+ end