trainers-rb 0.1.2 → 0.2.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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +50 -7
- data/lib/trainers/callbacks.rb +23 -7
- data/lib/trainers/errors.rb +15 -0
- data/lib/trainers/logging.rb +27 -0
- data/lib/trainers/lora/lora_model.rb +3 -3
- data/lib/trainers/lora/lora_utils.rb +17 -13
- data/lib/trainers/optimization/optimizer.rb +1 -1
- data/lib/trainers/save_utils.rb +5 -6
- data/lib/trainers/trainer.rb +77 -44
- data/lib/trainers/training_arguments.rb +2 -0
- data/lib/trainers/version.rb +1 -1
- data/lib/trainers.rb +10 -0
- metadata +20 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8db3543c735b87abb79d2de2d8278f11c3999289f2c68929a6cc7c855568b6a1
|
|
4
|
+
data.tar.gz: ff4783fde9226bff71d59e00960997109a5426f407572ea41859657a120e907f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 492022d310c9dc8b59e823e550769bdabbbd6af100d503ca82421c4fc3cef3aaea041f667c21d96d223b19fd5441c5b85fbd515a44f4ccb0a6820b471adcd28c
|
|
7
|
+
data.tar.gz: 92678a59b82c31a055c3e28bfe40b60a6c830368c9707d00ccb2481c43213127e8210d7547d2eff32f75d636c48632b8ef44056e9c0692841cc983b983c5297b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (2026-10-31)
|
|
4
|
+
|
|
5
|
+
**This release changes training results.** Runs that use `gradient_accumulation_steps > 1` now follow the intended learning-rate schedule. All runs now apply `seed:`, so data order is different from 0.1.x and reproducible. Expect different (better) final models than 0.1.x with the same arguments.
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **A step is now one optimizer update** (#12). `global_step`, `max_steps`, `logging_steps`, `eval_steps`, `save_steps`, `warmup_steps` and the scheduler all count optimizer updates, not batches. The scheduler is sized as `ceil(batches_per_epoch / gradient_accumulation_steps) * num_train_epochs`, so it now decays to the end. Previously, with accumulation 4, a linear schedule ended near 75% of the peak learning rate.
|
|
10
|
+
- The last, shorter accumulation window of an epoch now makes an optimizer update, with its loss averaged over the batches it holds. Previously its gradients leaked into the next epoch.
|
|
11
|
+
- `on_step_begin` fires at the start of each accumulation window and `on_step_end` after each optimizer update. Nothing changes with `gradient_accumulation_steps: 1`.
|
|
12
|
+
- **`seed:` is applied** (#13). `train` seeds Torch, Ruby's RNG and its own shuffling. `seed: nil` disables this. New `Trainers.set_seed(seed)` seeds model initialisation.
|
|
13
|
+
- **Progress goes to `Trainers.logger`** on `$stderr`, not stdout (#15). Set `Trainers.logger = nil` for a silent library. The default callback is now `Trainers::LoggingCallback`; `PrinterCallback` still prints to stdout if you add it to `callbacks:`.
|
|
14
|
+
- **Errors descend from `Trainers::Error`** (#16): `ConfigurationError`, `AdapterError` and `CheckpointError`. Code that rescued `RuntimeError` for these must rescue `Trainers::Error` instead.
|
|
15
|
+
- `SaveUtils.save_lora_adapters` and `LoraModel.save_adapters` raise `AdapterError` when the model has no adapters, instead of printing and returning `nil` (#16).
|
|
16
|
+
- **Requires Ruby 3.3 or newer.** Ruby 3.1 and 3.2 are end-of-life.
|
|
17
|
+
- New runtime dependency: `logger`, which Ruby 3.5+ no longer ships as a default gem.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- `dataloader_drop_last: true` skips the final partial batch in training, evaluation and prediction (#14). `train` raises `ConfigurationError` if that leaves no batches.
|
|
22
|
+
- `label_names:` is honoured (#14). Every listed key is passed to the model, and the first is used for the built-in cross-entropy loss and `evaluate`.
|
|
23
|
+
- `LoraModel.apply` works on models whose layers sit in a `ModuleList`, which covers every HuggingFace transformer, for example DistilBERT's `transformer.layer.0.attention.q_lin`. It used to raise `Could not find module part '0'`, because `ModuleList` stores its children in a Hash keyed by `"0"`, not in an Array.
|
|
24
|
+
- When an epoch's last optimizer step was already logged, the end-of-epoch log no longer repeats it.
|
|
25
|
+
- Learning rates and other floats below 0.001 are logged in scientific notation (`2.000e-05`), not as `0.0000`.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- `TrainingArguments` validates `seed` (nil or a non-negative Integer) and `label_names` (an Array of Strings or Symbols).
|
|
30
|
+
|
|
3
31
|
## 0.1.2 (2026-10-30)
|
|
4
32
|
|
|
5
33
|
### Fixed
|
data/README.md
CHANGED
|
@@ -134,7 +134,7 @@ Trainers::LoraModel.load_adapters(model, "./lora-adapters")
|
|
|
134
134
|
| `learning_rate` | `5e-5` | Peak learning rate for AdamW |
|
|
135
135
|
| `weight_decay` | `0.0` | Weight decay (applied to non-bias, non-norm params) |
|
|
136
136
|
| `max_grad_norm` | `1.0` | Max gradient norm for clipping |
|
|
137
|
-
| `gradient_accumulation_steps` | `1` | Accumulate gradients over N
|
|
137
|
+
| `gradient_accumulation_steps` | `1` | Accumulate gradients over N batches per optimizer step |
|
|
138
138
|
| `warmup_steps` | `0` | Linear warmup steps |
|
|
139
139
|
| `warmup_ratio` | `0.0` | Warmup as fraction of total steps (alternative to warmup_steps) |
|
|
140
140
|
| `lr_scheduler_type` | `:linear` | `:linear`, `:cosine`, or `:constant` |
|
|
@@ -142,12 +142,60 @@ Trainers::LoraModel.load_adapters(model, "./lora-adapters")
|
|
|
142
142
|
| `eval_steps` | `nil` | Evaluate every N steps (when `eval_strategy: :steps`) |
|
|
143
143
|
| `save_strategy` | `:epoch` | When to save: `:no`, `:epoch`, or `:steps` |
|
|
144
144
|
| `save_total_limit` | `nil` | Keep only the last N checkpoints |
|
|
145
|
+
| `save_steps` | `500` | Save a checkpoint every N steps (when `save_strategy: :steps`) |
|
|
145
146
|
| `logging_steps` | `500` | Log every N steps |
|
|
146
|
-
| `seed` | `42` |
|
|
147
|
+
| `seed` | `42` | Seeds Torch, Ruby's RNG and data shuffling at the start of `train`; `nil` disables seeding |
|
|
148
|
+
| `dataloader_drop_last` | `false` | Skip the final batch when it is smaller than the batch size |
|
|
149
|
+
| `label_names` | `["labels"]` | Batch keys that hold labels; the first is used for the built-in cross-entropy loss |
|
|
147
150
|
| `no_mps` | `false` | Force CPU even if MPS is available |
|
|
148
151
|
|
|
152
|
+
**A step is one optimizer update.** With `gradient_accumulation_steps: 4`, one step covers four batches, and `max_steps`, `global_step`, `logging_steps`, `eval_steps`, `save_steps`, `warmup_steps` and the learning-rate schedule all count optimizer updates. When an epoch's batch count is not a multiple of the accumulation steps, the last, shorter window still makes an update, with its loss averaged over the batches it contains, so no gradient carries into the next epoch.
|
|
153
|
+
|
|
149
154
|
Invalid values, such as a negative learning rate, a zero batch size or an unknown `lr_scheduler_type`, raise `ArgumentError` when `TrainingArguments` is created.
|
|
150
155
|
|
|
156
|
+
## Reproducibility
|
|
157
|
+
|
|
158
|
+
`Trainer#train` seeds Torch, Ruby's global RNG and its own shuffling from `args.seed`. The model's initial weights are created before training starts, so seed those yourself:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
Trainers.set_seed(42)
|
|
162
|
+
model, tokenizer = Trainers.from_pretrained("distilbert-base-uncased", num_labels: 2)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Two runs with the same seed, data and arguments produce identical loss curves on CPU. On Apple Silicon (MPS), some kernels are not deterministic, so expect small differences between runs.
|
|
166
|
+
|
|
167
|
+
## Logging
|
|
168
|
+
|
|
169
|
+
trainers-rb reports progress through `Trainers.logger`, which writes to `$stderr` at `INFO` by default. Nothing is written to stdout.
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
Trainers.logger = Rails.logger # use your app's logger
|
|
173
|
+
Trainers.logger.level = Logger::WARN # only warnings and errors
|
|
174
|
+
Trainers.logger = nil # completely silent
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
To print progress to stdout instead, add `Trainers::PrinterCallback.new` to `callbacks:`.
|
|
178
|
+
|
|
179
|
+
## Errors
|
|
180
|
+
|
|
181
|
+
Every error trainers-rb raises descends from `Trainers::Error`:
|
|
182
|
+
|
|
183
|
+
| Error | Raised when |
|
|
184
|
+
|---|---|
|
|
185
|
+
| `Trainers::ConfigurationError` | The model has no trainable parameters, returns no loss and no labels are present, or the training data yields no batches |
|
|
186
|
+
| `Trainers::AdapterError` | No module matches `target_modules`, an adapter file is missing, or `save_adapters` finds no adapters |
|
|
187
|
+
| `Trainers::CheckpointError` | Reserved for checkpoint save and load errors |
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
begin
|
|
191
|
+
trainer.train
|
|
192
|
+
rescue Trainers::Error => e
|
|
193
|
+
# anything trainers-rb raised
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Invalid `TrainingArguments` values raise `ArgumentError`.
|
|
198
|
+
|
|
151
199
|
## Callbacks
|
|
152
200
|
|
|
153
201
|
Built-in callbacks:
|
|
@@ -286,11 +334,6 @@ trainers-rb adds the training layer that transformers-rb intentionally omits. Bo
|
|
|
286
334
|
|
|
287
335
|
These are open correctness issues. They are tracked, and listed here so nobody discovers them the hard way:
|
|
288
336
|
|
|
289
|
-
- **The learning-rate schedule is wrong with `gradient_accumulation_steps > 1`.** The scheduler is sized in micro-batches but stepped once per optimizer step, so it never decays fully. ([#12](https://github.com/Selectus2/trainers-rb/issues/12))
|
|
290
|
-
- **`seed:` is never applied.** Runs are not reproducible. ([#13](https://github.com/Selectus2/trainers-rb/issues/13))
|
|
291
|
-
- **`dataloader_drop_last:` and `label_names:` are accepted and ignored.** ([#14](https://github.com/Selectus2/trainers-rb/issues/14))
|
|
292
|
-
- **Progress output goes to stdout through `puts`.** There is no logger to configure or silence. ([#15](https://github.com/Selectus2/trainers-rb/issues/15))
|
|
293
|
-
- **`save_lora_adapters` returns `nil` instead of raising when a model has no adapters.** ([#16](https://github.com/Selectus2/trainers-rb/issues/16))
|
|
294
337
|
- **A saved model cannot be loaded back.** No `config.json` is written. ([#17](https://github.com/Selectus2/trainers-rb/issues/17))
|
|
295
338
|
- **Checkpoints cannot resume training.** Optimizer, scheduler and trainer state are not saved. ([#18](https://github.com/Selectus2/trainers-rb/issues/18))
|
|
296
339
|
- **LoRA checkpoints save the full state dict** rather than just the adapters. ([#19](https://github.com/Selectus2/trainers-rb/issues/19))
|
data/lib/trainers/callbacks.rb
CHANGED
|
@@ -44,32 +44,48 @@ module Trainers
|
|
|
44
44
|
def on_save(args, state, control, **kwargs); end
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
#
|
|
47
|
+
# Prints training progress to stdout. Opt in with callbacks: [PrinterCallback.new]
|
|
48
48
|
class PrinterCallback < TrainerCallback
|
|
49
49
|
def on_log(args, state, control, logs: nil, **kwargs)
|
|
50
50
|
return unless logs
|
|
51
51
|
output = logs.map { |k, v| "#{k}: #{format_value(v)}" }.join(" ")
|
|
52
|
-
|
|
52
|
+
emit "[step #{state.global_step}] #{output}"
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def on_train_begin(args, state, control, **kwargs)
|
|
56
|
-
|
|
56
|
+
emit "Starting training: #{state.num_train_epochs} epochs, #{state.max_steps} total steps"
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def on_train_end(args, state, control, **kwargs)
|
|
60
|
-
|
|
60
|
+
emit "Training complete. Total steps: #{state.global_step}"
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def on_evaluate(args, state, control, metrics: nil, **kwargs)
|
|
64
64
|
return unless metrics
|
|
65
65
|
output = metrics.map { |k, v| "#{k}: #{format_value(v)}" }.join(" ")
|
|
66
|
-
|
|
66
|
+
emit "[eval step #{state.global_step}] #{output}"
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
private
|
|
70
70
|
|
|
71
|
+
def emit(message)
|
|
72
|
+
puts message
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Small floats such as learning rates (2e-5) would print as 0.0000
|
|
71
76
|
def format_value(v)
|
|
72
|
-
v.is_a?(Float)
|
|
77
|
+
return v.to_s unless v.is_a?(Float)
|
|
78
|
+
|
|
79
|
+
v.nonzero? && v.abs < 1e-3 ? format("%.3e", v) : format("%.4f", v)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Default callback: reports the same progress through Trainers.logger
|
|
84
|
+
class LoggingCallback < PrinterCallback
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def emit(message)
|
|
88
|
+
Trainers.log(:info, message)
|
|
73
89
|
end
|
|
74
90
|
end
|
|
75
91
|
|
|
@@ -95,7 +111,7 @@ module Trainers
|
|
|
95
111
|
else
|
|
96
112
|
@wait_count += 1
|
|
97
113
|
if @wait_count >= @patience
|
|
98
|
-
|
|
114
|
+
Trainers.log(:info, "Early stopping triggered after #{@wait_count} evaluations without improvement")
|
|
99
115
|
control.should_training_stop = true
|
|
100
116
|
end
|
|
101
117
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trainers
|
|
4
|
+
# Base class for every error trainers-rb raises. Rescue this to catch them all.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# The model, data or arguments cannot be trained as configured
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# LoRA adapters are missing, cannot be applied, or cannot be loaded
|
|
11
|
+
class AdapterError < Error; end
|
|
12
|
+
|
|
13
|
+
# A checkpoint cannot be written or read
|
|
14
|
+
class CheckpointError < Error; end
|
|
15
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Trainers
|
|
6
|
+
class << self
|
|
7
|
+
# Where trainers-rb reports progress. Defaults to $stderr at INFO.
|
|
8
|
+
# Set to any Logger-compatible object, or nil for a silent library.
|
|
9
|
+
attr_accessor :logger
|
|
10
|
+
|
|
11
|
+
def default_logger
|
|
12
|
+
Logger.new($stderr, level: Logger::INFO, progname: "trainers").tap do |logger|
|
|
13
|
+
logger.formatter = proc do |severity, _time, progname, message|
|
|
14
|
+
prefix = severity == "INFO" ? "" : "#{severity}: "
|
|
15
|
+
"[#{progname}] #{prefix}#{message}\n"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Internal: log through Trainers.logger when one is set
|
|
21
|
+
def log(level, message)
|
|
22
|
+
logger&.public_send(level, message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
self.logger = default_logger
|
|
27
|
+
end
|
|
@@ -20,7 +20,7 @@ module Trainers
|
|
|
20
20
|
targets = LoraUtils.find_target_modules(model, config.target_modules)
|
|
21
21
|
|
|
22
22
|
if targets.empty?
|
|
23
|
-
raise "No Linear modules found matching target_modules: #{config.target_modules.inspect}. " \
|
|
23
|
+
raise AdapterError, "No Linear modules found matching target_modules: #{config.target_modules.inspect}. " \
|
|
24
24
|
"Available modules: #{model.named_modules.map(&:first).join(', ')}"
|
|
25
25
|
end
|
|
26
26
|
|
|
@@ -50,7 +50,7 @@ module Trainers
|
|
|
50
50
|
end
|
|
51
51
|
# :none — biases stay frozen (default)
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
Trainers.log(:info, "LoRA applied to #{targets.size} modules: #{targets.keys.join(', ')}")
|
|
54
54
|
LoraUtils.print_trainable_parameters(model)
|
|
55
55
|
|
|
56
56
|
model
|
|
@@ -63,7 +63,7 @@ module Trainers
|
|
|
63
63
|
lora_modules.each do |name, mod|
|
|
64
64
|
LoraUtils.replace_module(model, name, mod.to_linear)
|
|
65
65
|
end
|
|
66
|
-
|
|
66
|
+
Trainers.log(:info, "Merged #{lora_modules.size} LoRA adapters into base model")
|
|
67
67
|
model
|
|
68
68
|
end
|
|
69
69
|
|
|
@@ -30,24 +30,28 @@ module Trainers
|
|
|
30
30
|
|
|
31
31
|
# Navigate to the parent module
|
|
32
32
|
parts[0...-1].each do |part|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
parent = parent.instance_variable_get(:@modules)[part.to_i]
|
|
36
|
-
else
|
|
37
|
-
parent = parent.instance_variable_get(:"@#{part}")
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
raise "Could not find module part '#{part}' in #{target_name}" unless parent
|
|
33
|
+
parent = child_module(parent, part)
|
|
34
|
+
raise AdapterError, "Could not find module part '#{part}' in #{target_name}" unless parent
|
|
41
35
|
end
|
|
42
36
|
|
|
43
37
|
child_name = parts.last
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
registered = parent.instance_variable_get(:@modules)
|
|
39
|
+
if registered&.key?(child_name)
|
|
40
|
+
registered[child_name] = new_module
|
|
46
41
|
else
|
|
47
42
|
parent.instance_variable_set(:"@#{child_name}", new_module)
|
|
48
43
|
end
|
|
49
44
|
end
|
|
50
45
|
|
|
46
|
+
# ModuleList and add_module children live in @modules, a Hash keyed by
|
|
47
|
+
# name ("0", "1", ...); everything else is an instance variable.
|
|
48
|
+
def self.child_module(parent, name)
|
|
49
|
+
registered = parent.instance_variable_get(:@modules)
|
|
50
|
+
return registered[name] if registered&.key?(name)
|
|
51
|
+
|
|
52
|
+
parent.instance_variable_get(:"@#{name}") if name.match?(/\A[a-z_]\w*\z/i)
|
|
53
|
+
end
|
|
54
|
+
|
|
51
55
|
# Count total and trainable parameters
|
|
52
56
|
def self.print_trainable_parameters(model)
|
|
53
57
|
total = 0
|
|
@@ -59,9 +63,9 @@ module Trainers
|
|
|
59
63
|
end
|
|
60
64
|
|
|
61
65
|
pct = total > 0 ? (trainable.to_f / total * 100) : 0.0
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
Trainers.log(:info, "trainable params: #{format_number(trainable)} || " \
|
|
67
|
+
"all params: #{format_number(total)} || " \
|
|
68
|
+
"trainable%: #{format('%.4f', pct)}%")
|
|
65
69
|
|
|
66
70
|
{ total: total, trainable: trainable, percentage: pct }
|
|
67
71
|
end
|
|
@@ -35,7 +35,7 @@ module Trainers
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
if param_groups.empty?
|
|
38
|
-
raise "No trainable parameters found. Did you forget to unfreeze the model or apply LoRA?"
|
|
38
|
+
raise ConfigurationError, "No trainable parameters found. Did you forget to unfreeze the model or apply LoRA?"
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
Torch::Optim::AdamW.new(
|
data/lib/trainers/save_utils.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Trainers
|
|
|
18
18
|
model_path = File.join(output_dir, "model.safetensors")
|
|
19
19
|
require "safetensors"
|
|
20
20
|
Safetensors::Torch.save_file(tensors, model_path)
|
|
21
|
-
|
|
21
|
+
Trainers.log(:info, "Model saved to #{model_path}")
|
|
22
22
|
|
|
23
23
|
# Save tokenizer
|
|
24
24
|
if tokenizer
|
|
@@ -49,21 +49,20 @@ module Trainers
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
if lora_state.empty?
|
|
52
|
-
|
|
53
|
-
return
|
|
52
|
+
raise AdapterError, "No LoRA adapters found in model. Apply them with LoraModel.apply before saving."
|
|
54
53
|
end
|
|
55
54
|
|
|
56
55
|
adapter_path = File.join(output_dir, "adapter_model.safetensors")
|
|
57
56
|
require "safetensors"
|
|
58
57
|
Safetensors::Torch.save_file(lora_state, adapter_path)
|
|
59
|
-
|
|
58
|
+
Trainers.log(:info, "LoRA adapters saved to #{adapter_path} (#{lora_state.size} tensors)")
|
|
60
59
|
|
|
61
60
|
output_dir
|
|
62
61
|
end
|
|
63
62
|
|
|
64
63
|
def self.load_lora_adapters(model, input_dir)
|
|
65
64
|
adapter_path = File.join(input_dir, "adapter_model.safetensors")
|
|
66
|
-
raise "Adapter file not found: #{adapter_path}" unless File.exist?(adapter_path)
|
|
65
|
+
raise AdapterError, "Adapter file not found: #{adapter_path}" unless File.exist?(adapter_path)
|
|
67
66
|
|
|
68
67
|
require "safetensors"
|
|
69
68
|
lora_state = Safetensors::Torch.load_file(adapter_path)
|
|
@@ -78,7 +77,7 @@ module Trainers
|
|
|
78
77
|
end
|
|
79
78
|
end
|
|
80
79
|
|
|
81
|
-
|
|
80
|
+
Trainers.log(:info, "LoRA adapters loaded from #{adapter_path}")
|
|
82
81
|
end
|
|
83
82
|
end
|
|
84
83
|
end
|
data/lib/trainers/trainer.rb
CHANGED
|
@@ -25,25 +25,35 @@ module Trainers
|
|
|
25
25
|
@state = TrainerState.new
|
|
26
26
|
@control = TrainerControl.new
|
|
27
27
|
|
|
28
|
-
all_callbacks = [
|
|
28
|
+
all_callbacks = [LoggingCallback.new] + callbacks
|
|
29
29
|
@callback_handler = CallbackHandler.new(all_callbacks)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def train
|
|
33
33
|
device = @args.resolved_device
|
|
34
|
+
apply_seed
|
|
34
35
|
@model.to(device)
|
|
35
36
|
@model.train
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
batch_size = @args.per_device_train_batch_size
|
|
39
|
+
accum = @args.gradient_accumulation_steps
|
|
40
|
+
batches_per_epoch = num_batches(@train_dataset, batch_size)
|
|
41
|
+
if batches_per_epoch.zero?
|
|
42
|
+
raise ConfigurationError, "train_dataset yields no batches: #{@train_dataset.size} examples, " \
|
|
43
|
+
"batch size #{batch_size}, dataloader_drop_last #{@args.dataloader_drop_last}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One step is one optimizer update. The last accumulation window of an
|
|
47
|
+
# epoch may be shorter than gradient_accumulation_steps; it still steps.
|
|
48
|
+
steps_per_epoch = (batches_per_epoch.to_f / accum).ceil
|
|
49
|
+
total_steps = steps_per_epoch * @args.num_train_epochs
|
|
41
50
|
|
|
42
|
-
@state.max_steps
|
|
51
|
+
@state.max_steps = total_steps
|
|
43
52
|
@state.num_train_epochs = @args.num_train_epochs
|
|
44
53
|
|
|
45
54
|
@optimizer = create_optimizer
|
|
46
55
|
@lr_scheduler = create_scheduler(total_steps)
|
|
56
|
+
@optimizer.zero_grad
|
|
47
57
|
|
|
48
58
|
@callback_handler.fire(:on_train_begin, @args, @state, @control)
|
|
49
59
|
|
|
@@ -52,38 +62,38 @@ module Trainers
|
|
|
52
62
|
@callback_handler.fire(:on_epoch_begin, @args, @state, @control)
|
|
53
63
|
@model.train
|
|
54
64
|
|
|
55
|
-
epoch_loss
|
|
56
|
-
|
|
65
|
+
epoch_loss = 0.0
|
|
66
|
+
epoch_batches = 0
|
|
67
|
+
|
|
68
|
+
each_batch(@train_dataset, batch_size, shuffle: true).with_index do |batch, index|
|
|
69
|
+
position = index % accum
|
|
70
|
+
window_start = index - position
|
|
71
|
+
window_size = [accum, batches_per_epoch - window_start].min
|
|
57
72
|
|
|
58
|
-
|
|
59
|
-
@callback_handler.fire(:on_step_begin, @args, @state, @control)
|
|
73
|
+
@callback_handler.fire(:on_step_begin, @args, @state, @control) if position.zero?
|
|
60
74
|
|
|
61
75
|
batch = move_to_device(batch, device)
|
|
62
76
|
loss = compute_loss(batch)
|
|
63
77
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
else
|
|
67
|
-
loss
|
|
68
|
-
end
|
|
69
|
-
|
|
78
|
+
# Average the gradient over the micro-batches in this window
|
|
79
|
+
scaled_loss = window_size > 1 ? loss / window_size : loss
|
|
70
80
|
scaled_loss.backward
|
|
71
81
|
|
|
72
|
-
epoch_loss
|
|
73
|
-
|
|
74
|
-
@state.global_step += 1
|
|
82
|
+
epoch_loss += loss.item
|
|
83
|
+
epoch_batches += 1
|
|
75
84
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
next unless position == window_size - 1
|
|
86
|
+
|
|
87
|
+
clip_grad_norm!(@model.parameters, @args.max_grad_norm)
|
|
88
|
+
@optimizer.step
|
|
89
|
+
@lr_scheduler.step
|
|
90
|
+
@optimizer.zero_grad
|
|
91
|
+
@state.global_step += 1
|
|
82
92
|
|
|
83
93
|
# Logging
|
|
84
94
|
if should_log?
|
|
85
95
|
logs = {
|
|
86
|
-
loss: epoch_loss /
|
|
96
|
+
loss: epoch_loss / epoch_batches,
|
|
87
97
|
learning_rate: current_lr,
|
|
88
98
|
epoch: @state.epoch
|
|
89
99
|
}
|
|
@@ -109,11 +119,13 @@ module Trainers
|
|
|
109
119
|
break if @control.should_training_stop || @control.should_epoch_stop
|
|
110
120
|
end
|
|
111
121
|
|
|
112
|
-
# Epoch-level logging
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
122
|
+
# Epoch-level logging, unless the last step of the epoch already logged
|
|
123
|
+
unless @state.log_history.last&.fetch(:step, nil) == @state.global_step
|
|
124
|
+
epoch_avg_loss = epoch_batches > 0 ? epoch_loss / epoch_batches : 0.0
|
|
125
|
+
logs = { loss: epoch_avg_loss, learning_rate: current_lr, epoch: @state.epoch }
|
|
126
|
+
@state.log_history << logs.merge(step: @state.global_step)
|
|
127
|
+
@callback_handler.fire(:on_log, @args, @state, @control, logs: logs)
|
|
128
|
+
end
|
|
117
129
|
|
|
118
130
|
# Epoch-based evaluation
|
|
119
131
|
if @args.eval_strategy == :epoch && @eval_dataset
|
|
@@ -151,7 +163,7 @@ module Trainers
|
|
|
151
163
|
Torch.no_grad do
|
|
152
164
|
each_batch(dataset, @args.per_device_eval_batch_size) do |batch|
|
|
153
165
|
batch = move_to_device(batch, device)
|
|
154
|
-
labels = batch
|
|
166
|
+
labels = pop_labels(batch).values.first
|
|
155
167
|
|
|
156
168
|
output = forward(batch)
|
|
157
169
|
|
|
@@ -209,11 +221,12 @@ module Trainers
|
|
|
209
221
|
private
|
|
210
222
|
|
|
211
223
|
def compute_loss(batch)
|
|
212
|
-
|
|
224
|
+
label_values = pop_labels(batch)
|
|
225
|
+
labels = label_values.values.first
|
|
213
226
|
|
|
214
227
|
# Try passing labels to the model (some models compute loss internally)
|
|
215
228
|
output = begin
|
|
216
|
-
forward(
|
|
229
|
+
forward(batch.merge(label_values))
|
|
217
230
|
rescue => e
|
|
218
231
|
# If the model doesn't support labels kwarg (e.g. transformers-rb Todo),
|
|
219
232
|
# fall back to forward without labels + external loss
|
|
@@ -225,7 +238,7 @@ module Trainers
|
|
|
225
238
|
end
|
|
226
239
|
|
|
227
240
|
# Restore labels to batch for downstream use
|
|
228
|
-
batch
|
|
241
|
+
batch.merge!(label_values)
|
|
229
242
|
|
|
230
243
|
if output.respond_to?(:loss) && output.loss
|
|
231
244
|
output.loss
|
|
@@ -233,7 +246,7 @@ module Trainers
|
|
|
233
246
|
logits = output.respond_to?(:logits) ? output.logits : output
|
|
234
247
|
Torch::NN::F.cross_entropy(logits, labels)
|
|
235
248
|
else
|
|
236
|
-
raise "Model did not return a loss and no labels found in batch. " \
|
|
249
|
+
raise ConfigurationError, "Model did not return a loss and no labels found in batch. " \
|
|
237
250
|
"Either pass labels in your dataset or use a model that computes loss."
|
|
238
251
|
end
|
|
239
252
|
end
|
|
@@ -268,20 +281,40 @@ module Trainers
|
|
|
268
281
|
)
|
|
269
282
|
end
|
|
270
283
|
|
|
284
|
+
# Removes every key named in args.label_names (symbol or string) from the
|
|
285
|
+
# batch and returns them as { name_symbol => tensor }, in label_names order
|
|
286
|
+
def pop_labels(batch)
|
|
287
|
+
@args.label_names.each_with_object({}) do |name, labels|
|
|
288
|
+
value = batch.key?(name.to_sym) ? batch.delete(name.to_sym) : batch.delete(name.to_s)
|
|
289
|
+
labels[name.to_sym] = value unless value.nil?
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def num_batches(dataset, batch_size)
|
|
294
|
+
full, rest = dataset.size.divmod(batch_size)
|
|
295
|
+
rest.positive? && !@args.dataloader_drop_last ? full + 1 : full
|
|
296
|
+
end
|
|
297
|
+
|
|
271
298
|
def each_batch(dataset, batch_size, shuffle: false)
|
|
272
|
-
|
|
273
|
-
indices.shuffle! if shuffle
|
|
299
|
+
return enum_for(:each_batch, dataset, batch_size, shuffle: shuffle) unless block_given?
|
|
274
300
|
|
|
275
|
-
(0...dataset.size).
|
|
276
|
-
|
|
277
|
-
next if batch_indices.nil? || batch_indices.empty?
|
|
301
|
+
indices = (0...dataset.size).to_a
|
|
302
|
+
indices.shuffle!(random: @rng || Random.new) if shuffle
|
|
278
303
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
304
|
+
num_batches(dataset, batch_size).times do |i|
|
|
305
|
+
batch_indices = indices[i * batch_size, batch_size]
|
|
306
|
+
features = batch_indices.map { |j| dataset[j] }
|
|
307
|
+
yield @data_collator.call(features)
|
|
282
308
|
end
|
|
283
309
|
end
|
|
284
310
|
|
|
311
|
+
# Seeds Torch, Kernel#rand and the shuffle RNG from args.seed (nil skips)
|
|
312
|
+
def apply_seed
|
|
313
|
+
seed = @args.seed
|
|
314
|
+
@rng = seed.nil? ? Random.new : Random.new(seed)
|
|
315
|
+
Trainers.set_seed(seed) unless seed.nil?
|
|
316
|
+
end
|
|
317
|
+
|
|
285
318
|
def move_to_device(batch, device)
|
|
286
319
|
batch.each_with_object({}) do |(key, value), result|
|
|
287
320
|
result[key] = if value.is_a?(Torch::Tensor)
|
|
@@ -88,6 +88,8 @@ module Trainers
|
|
|
88
88
|
check(:warmup_steps, "a non-negative Integer") { |v| v.is_a?(Integer) && !v.negative? }
|
|
89
89
|
check(:warmup_ratio, "a number between 0 and 1") { |v| v.is_a?(Numeric) && v.between?(0, 1) }
|
|
90
90
|
check(:eval_steps, "nil or a positive Integer") { |v| v.nil? || (v.is_a?(Integer) && v.positive?) }
|
|
91
|
+
check(:seed, "nil or a non-negative Integer") { |v| v.nil? || (v.is_a?(Integer) && !v.negative?) }
|
|
92
|
+
check(:label_names, "an Array of Strings or Symbols") { |v| v.is_a?(Array) && v.all? { |n| n.is_a?(String) || n.is_a?(Symbol) } }
|
|
91
93
|
|
|
92
94
|
check(:lr_scheduler_type, "one of #{SCHEDULER_TYPES.map(&:inspect).join(', ')}") { |v| SCHEDULER_TYPES.include?(v) }
|
|
93
95
|
check(:eval_strategy, "one of #{STRATEGIES.map(&:inspect).join(', ')}") { |v| STRATEGIES.include?(v) }
|
data/lib/trainers/version.rb
CHANGED
data/lib/trainers.rb
CHANGED
|
@@ -5,6 +5,8 @@ require "json"
|
|
|
5
5
|
require "fileutils"
|
|
6
6
|
|
|
7
7
|
require_relative "trainers/version"
|
|
8
|
+
require_relative "trainers/errors"
|
|
9
|
+
require_relative "trainers/logging"
|
|
8
10
|
require_relative "trainers/trainer_utils"
|
|
9
11
|
require_relative "trainers/training_arguments"
|
|
10
12
|
require_relative "trainers/data/dataset"
|
|
@@ -20,6 +22,14 @@ require_relative "trainers/lora/lora_model"
|
|
|
20
22
|
require_relative "trainers/trainer"
|
|
21
23
|
|
|
22
24
|
module Trainers
|
|
25
|
+
# Seeds Torch and Ruby's global RNG. Call before building a model to make
|
|
26
|
+
# its initial weights reproducible; Trainer#train also calls it with args.seed.
|
|
27
|
+
def self.set_seed(seed)
|
|
28
|
+
Torch.manual_seed(seed)
|
|
29
|
+
srand(seed)
|
|
30
|
+
seed
|
|
31
|
+
end
|
|
32
|
+
|
|
23
33
|
# Convenience method: load model + tokenizer and prepare for training
|
|
24
34
|
def self.from_pretrained(model_name, task: :sequence_classification, num_labels: 2)
|
|
25
35
|
require "transformers-rb"
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: trainers-rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vishwajeetsingh Desurkar
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: torch-rb
|
|
@@ -66,6 +65,20 @@ dependencies:
|
|
|
66
65
|
- - ">="
|
|
67
66
|
- !ruby/object:Gem::Version
|
|
68
67
|
version: 0.5.3
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: logger
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.5'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.5'
|
|
69
82
|
- !ruby/object:Gem::Dependency
|
|
70
83
|
name: rake
|
|
71
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -112,6 +125,8 @@ files:
|
|
|
112
125
|
- lib/trainers/callbacks.rb
|
|
113
126
|
- lib/trainers/data/data_collator.rb
|
|
114
127
|
- lib/trainers/data/dataset.rb
|
|
128
|
+
- lib/trainers/errors.rb
|
|
129
|
+
- lib/trainers/logging.rb
|
|
115
130
|
- lib/trainers/lora/lora_config.rb
|
|
116
131
|
- lib/trainers/lora/lora_linear.rb
|
|
117
132
|
- lib/trainers/lora/lora_model.rb
|
|
@@ -132,7 +147,6 @@ metadata:
|
|
|
132
147
|
changelog_uri: https://github.com/Selectus2/trainers-rb/blob/main/CHANGELOG.md
|
|
133
148
|
bug_tracker_uri: https://github.com/Selectus2/trainers-rb/issues
|
|
134
149
|
rubygems_mfa_required: 'true'
|
|
135
|
-
post_install_message:
|
|
136
150
|
rdoc_options: []
|
|
137
151
|
require_paths:
|
|
138
152
|
- lib
|
|
@@ -140,15 +154,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
140
154
|
requirements:
|
|
141
155
|
- - ">="
|
|
142
156
|
- !ruby/object:Gem::Version
|
|
143
|
-
version: 3.
|
|
157
|
+
version: 3.3.0
|
|
144
158
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
145
159
|
requirements:
|
|
146
160
|
- - ">="
|
|
147
161
|
- !ruby/object:Gem::Version
|
|
148
162
|
version: '0'
|
|
149
163
|
requirements: []
|
|
150
|
-
rubygems_version:
|
|
151
|
-
signing_key:
|
|
164
|
+
rubygems_version: 4.0.20
|
|
152
165
|
specification_version: 4
|
|
153
166
|
summary: Fine-tune transformer models in Ruby
|
|
154
167
|
test_files: []
|