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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE +176 -0
- data/NOTICE +7 -0
- data/README.md +399 -0
- data/lib/laya/agent.rb +324 -0
- data/lib/laya/ask.rb +73 -0
- data/lib/laya/checkpoints.rb +77 -0
- data/lib/laya/common.rb +175 -0
- data/lib/laya/configuration.rb +72 -0
- data/lib/laya/decision.rb +120 -0
- data/lib/laya/email.rb +187 -0
- data/lib/laya/errors.rb +16 -0
- data/lib/laya/hub.rb +206 -0
- data/lib/laya/lang.rb +301 -0
- data/lib/laya/presets.rb +197 -0
- data/lib/laya/py_json.rb +144 -0
- data/lib/laya/question.rb +171 -0
- data/lib/laya/questions.rb +43 -0
- data/lib/laya/result.rb +263 -0
- data/lib/laya/router.rb +353 -0
- data/lib/laya/runtime.rb +79 -0
- data/lib/laya/shortlist.rb +197 -0
- data/lib/laya/tokenizer.rb +101 -0
- data/lib/laya/training.rb +88 -0
- data/lib/laya/util.rb +57 -0
- data/lib/laya/version.rb +8 -0
- data/lib/laya.rb +129 -0
- metadata +110 -0
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
|
data/lib/laya/common.rb
ADDED
|
@@ -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
|