trainers-rb 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d574e3490a0aed1a0286cb4ff5489cf0581d05f033a30a9b62afc748a9bf0b91
4
- data.tar.gz: 4a4a764def0528487ee20adb5e1edce52d7a3d267f1c99e399f5f48a7a5d9e2b
3
+ metadata.gz: 8db3543c735b87abb79d2de2d8278f11c3999289f2c68929a6cc7c855568b6a1
4
+ data.tar.gz: ff4783fde9226bff71d59e00960997109a5426f407572ea41859657a120e907f
5
5
  SHA512:
6
- metadata.gz: 5a1e967d0eb2358f4aef3ac6dbb5c7a182984673330be8bc3141badd99e60e43af3c325f5a02de635e7cb9a329b498f650305bb83c46d0aa54e6ba219f7fd84a
7
- data.tar.gz: da2ec1f4fd3d69e76f3d6a812aec49454ff9d99b99006e1b53f0086e4a60d1958d69b6a1b7447e033fcbf927c412cc3fea50954d0c3cd72e5ce49adf4140d95f
6
+ metadata.gz: 492022d310c9dc8b59e823e550769bdabbbd6af100d503ca82421c4fc3cef3aaea041f667c21d96d223b19fd5441c5b85fbd515a44f4ccb0a6820b471adcd28c
7
+ data.tar.gz: 92678a59b82c31a055c3e28bfe40b60a6c830368c9707d00ccb2481c43213127e8210d7547d2eff32f75d636c48632b8ef44056e9c0692841cc983b983c5297b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
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
+
31
+ ## 0.1.2 (2026-10-30)
32
+
33
+ ### Fixed
34
+
35
+ - `LoraModel.merge` no longer applies adapters twice. Merged layers kept adding the LoRA path on top of the merged weights, so a merged model's outputs were wrong. Each `LoraLinear` is now replaced by a plain `Torch::NN::Linear` holding the merged weights, so a saved merged model has the base model's parameter names. `LoraLinear#merge!` is idempotent and adds `#merged?` and `#to_linear`.
36
+ - `Trainers::VERSION` now matches the gem version. The gemspec reads it from `lib/trainers/version.rb`. (#2)
37
+
38
+ ### Changed
39
+
40
+ - `TrainingArguments` validates its values at construction, and raises `ArgumentError` naming the argument, the value received and what was expected (#8):
41
+ - positive: `learning_rate`
42
+ - positive Integer: `num_train_epochs`, both batch sizes, `gradient_accumulation_steps`, `logging_steps`, `save_steps`
43
+ - non-negative: `weight_decay`, `max_grad_norm`; `warmup_steps` must also be an Integer
44
+ - `warmup_ratio` must be within 0..1, and `eval_steps` must be nil or a positive Integer
45
+ - `lr_scheduler_type` must be one of `:linear`, `:cosine`, `:constant`; `eval_strategy` and `save_strategy` one of `:no`, `:epoch`, `:steps`
46
+
47
+ Previously these were accepted and failed later, or were silently ignored. Defaults are unchanged.
48
+
49
+ ### Internal
50
+
51
+ - CI on Ruby 3.3, 3.4 and 4.0 against torch-rb 0.17 (LibTorch 2.4.0) and 0.27 (LibTorch 2.15.0), using Appraisal gemfiles, plus macOS arm64; every job is required (#9)
52
+ - Tests for LR schedulers and warmup (#6), callback order and arguments (#7), LoRA shapes, identity init, merge and adapter round-trip (#5), and a public API surface test (#10)
53
+ - Development dependency minitest bumped to ~> 6.0
54
+ - CONTRIBUTING.md with LibTorch setup, SECURITY.md, README badges and a known-limitations section (#11)
55
+
56
+ ## 0.1.1 (2026-06-09)
57
+
58
+ - Fix gemspec homepage, source and changelog URLs to point to github.com/Selectus2/trainers-rb
59
+ - No code changes. The published gem reports `Trainers::VERSION` as "0.1.0"; the constant is corrected here
60
+
3
61
  ## 0.1.0 (2026-06-07)
4
62
 
5
63
  - Initial release
data/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # trainers-rb
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/trainers-rb)](https://rubygems.org/gems/trainers-rb)
4
+ [![CI](https://github.com/Selectus2/trainers-rb/actions/workflows/ci.yml/badge.svg)](https://github.com/Selectus2/trainers-rb/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
+ [![Ruby](https://img.shields.io/badge/ruby-3.3%20%7C%203.4%20%7C%204.0-red.svg)](.github/workflows/ci.yml)
7
+
3
8
  Fine-tune transformer models in Ruby.
4
9
 
5
10
  trainers-rb provides a training loop, LoRA (Low-Rank Adaptation), learning rate scheduling, and model serialization for HuggingFace transformer models loaded via [transformers-rb](https://github.com/ankane/transformers-ruby). It builds on [torch-rb](https://github.com/ankane/torch.rb) for autograd, optimizers, and tensor operations.
@@ -22,15 +27,16 @@ gem install trainers-rb
22
27
 
23
28
  ### Prerequisites
24
29
 
25
- trainers-rb depends on torch-rb, which requires LibTorch:
30
+ trainers-rb depends on torch-rb, which compiles against LibTorch. Use the LibTorch version that matches your torch-rb: 2.15.0 for torch-rb 0.27, or 2.4.0 for torch-rb 0.17.
26
31
 
27
32
  ```bash
28
33
  # macOS arm64
29
- curl -L -o /tmp/libtorch.zip https://download.pytorch.org/libtorch/cpu/libtorch-macos-arm64-2.4.0.zip
30
- unzip /tmp/libtorch.zip -d ~/libtorch
31
- bundle config set build.torch-rb --with-torch-dir=$HOME/libtorch/libtorch
34
+ curl -L -o /tmp/libtorch.zip https://download.pytorch.org/libtorch/cpu/libtorch-macos-arm64-2.15.0.zip
35
+ unzip /tmp/libtorch.zip -d ~/libtorch-2.15.0
36
+ bundle config set build.torch-rb --with-torch-dir=$HOME/libtorch-2.15.0/libtorch
32
37
  ```
33
38
 
39
+ For Linux, and for running the tests, see [CONTRIBUTING.md](CONTRIBUTING.md).
34
40
  ## Quick Start
35
41
 
36
42
  ```ruby
@@ -128,7 +134,7 @@ Trainers::LoraModel.load_adapters(model, "./lora-adapters")
128
134
  | `learning_rate` | `5e-5` | Peak learning rate for AdamW |
129
135
  | `weight_decay` | `0.0` | Weight decay (applied to non-bias, non-norm params) |
130
136
  | `max_grad_norm` | `1.0` | Max gradient norm for clipping |
131
- | `gradient_accumulation_steps` | `1` | Accumulate gradients over N steps |
137
+ | `gradient_accumulation_steps` | `1` | Accumulate gradients over N batches per optimizer step |
132
138
  | `warmup_steps` | `0` | Linear warmup steps |
133
139
  | `warmup_ratio` | `0.0` | Warmup as fraction of total steps (alternative to warmup_steps) |
134
140
  | `lr_scheduler_type` | `:linear` | `:linear`, `:cosine`, or `:constant` |
@@ -136,10 +142,60 @@ Trainers::LoraModel.load_adapters(model, "./lora-adapters")
136
142
  | `eval_steps` | `nil` | Evaluate every N steps (when `eval_strategy: :steps`) |
137
143
  | `save_strategy` | `:epoch` | When to save: `:no`, `:epoch`, or `:steps` |
138
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`) |
139
146
  | `logging_steps` | `500` | Log every N steps |
140
- | `seed` | `42` | Random seed |
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 |
141
150
  | `no_mps` | `false` | Force CPU even if MPS is available |
142
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
+
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.
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
+
143
199
  ## Callbacks
144
200
 
145
201
  Built-in callbacks:
@@ -274,6 +330,14 @@ trainers-rb
274
330
 
275
331
  trainers-rb adds the training layer that transformers-rb intentionally omits. Both gems call into the same LibTorch C++ kernels for the actual computation.
276
332
 
333
+ ## Known limitations
334
+
335
+ These are open correctness issues. They are tracked, and listed here so nobody discovers them the hard way:
336
+
337
+ - **A saved model cannot be loaded back.** No `config.json` is written. ([#17](https://github.com/Selectus2/trainers-rb/issues/17))
338
+ - **Checkpoints cannot resume training.** Optimizer, scheduler and trainer state are not saved. ([#18](https://github.com/Selectus2/trainers-rb/issues/18))
339
+ - **LoRA checkpoints save the full state dict** rather than just the adapters. ([#19](https://github.com/Selectus2/trainers-rb/issues/19))
340
+
277
341
  ## Roadmap
278
342
 
279
343
  - [ ] More model architectures in transformers-rb (GPT-2, Llama for text generation)
@@ -286,7 +350,7 @@ trainers-rb adds the training layer that transformers-rb intentionally omits. Bo
286
350
 
287
351
  ## Contributing
288
352
 
289
- Bug reports and pull requests are welcome on GitHub.
353
+ Bug reports and pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md) covers LibTorch setup, running the tests against each supported torch-rb, and what a good PR looks like. Report security issues privately, as described in [SECURITY.md](SECURITY.md).
290
354
 
291
355
  ## License
292
356
 
@@ -44,32 +44,48 @@ module Trainers
44
44
  def on_save(args, state, control, **kwargs); end
45
45
  end
46
46
 
47
- # Default callback: prints training progress to stdout
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
- puts "[step #{state.global_step}] #{output}"
52
+ emit "[step #{state.global_step}] #{output}"
53
53
  end
54
54
 
55
55
  def on_train_begin(args, state, control, **kwargs)
56
- puts "Starting training: #{state.num_train_epochs} epochs, #{state.max_steps} total steps"
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
- puts "Training complete. Total steps: #{state.global_step}"
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
- puts "[eval step #{state.global_step}] #{output}"
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) ? format("%.4f", v) : v.to_s
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
- puts "Early stopping triggered after #{@wait_count} evaluations without improvement"
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
@@ -36,12 +36,20 @@ module Trainers
36
36
  @lora_B = Torch::NN::Parameter.new(Torch.zeros(@out_features, @r))
37
37
 
38
38
  @lora_dropout = lora_dropout > 0 ? Torch::NN::Dropout.new(p: lora_dropout) : nil
39
+ @merged = false
40
+ end
41
+
42
+ def merged?
43
+ @merged
39
44
  end
40
45
 
41
46
  def forward(x)
42
47
  # Original linear
43
48
  base_output = Torch::NN::F.linear(x, @weight, @bias)
44
49
 
50
+ # After merge! the adapter is already folded into @weight
51
+ return base_output if @merged
52
+
45
53
  # LoRA path
46
54
  lora_input = @lora_dropout ? @lora_dropout.call(x) : x
47
55
  lora_output = lora_input.matmul(@lora_A.t).matmul(@lora_B.t) * @scaling
@@ -49,14 +57,30 @@ module Trainers
49
57
  base_output + lora_output
50
58
  end
51
59
 
52
- # Merge LoRA weights into the base weight matrix (for inference)
60
+ # Merge LoRA weights into the base weight matrix (for inference).
61
+ # Idempotent: merging twice would add the adapter twice.
53
62
  def merge!
63
+ return self if @merged
64
+
54
65
  Torch.no_grad do
55
66
  @weight.add!(@lora_B.matmul(@lora_A) * @scaling)
56
67
  end
68
+ @merged = true
57
69
  self
58
70
  end
59
71
 
72
+ # A plain Linear holding the merged weights, so a merged model has the
73
+ # same parameter names as the base model
74
+ def to_linear
75
+ merge!
76
+ linear = Torch::NN::Linear.new(@in_features, @out_features, bias: !@bias.nil?)
77
+ Torch.no_grad do
78
+ linear.weight.copy!(@weight)
79
+ linear.bias.copy!(@bias) if @bias
80
+ end
81
+ linear
82
+ end
83
+
60
84
  # Extract LoRA adapter state for saving
61
85
  def lora_state_dict
62
86
  { "lora_A" => @lora_A.data, "lora_B" => @lora_B.data }
@@ -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,22 +50,20 @@ module Trainers
50
50
  end
51
51
  # :none — biases stay frozen (default)
52
52
 
53
- puts "LoRA applied to #{targets.size} modules: #{targets.keys.join(', ')}"
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
57
57
  end
58
58
 
59
- # Merge all LoRA weights back into base weights (for inference)
59
+ # Merge all LoRA weights back into base weights (for inference).
60
+ # Each LoraLinear is replaced by a plain Linear with the merged weights.
60
61
  def self.merge(model)
61
- count = 0
62
- model.named_modules.each do |_, mod|
63
- if mod.is_a?(LoraLinear)
64
- mod.merge!
65
- count += 1
66
- end
62
+ lora_modules = model.named_modules.select { |_, mod| mod.is_a?(LoraLinear) }
63
+ lora_modules.each do |name, mod|
64
+ LoraUtils.replace_module(model, name, mod.to_linear)
67
65
  end
68
- puts "Merged #{count} LoRA adapters into base model"
66
+ Trainers.log(:info, "Merged #{lora_modules.size} LoRA adapters into base model")
69
67
  model
70
68
  end
71
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
- if part =~ /\A\d+\z/
34
- # Numeric index — likely a ModuleList element
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
- if child_name =~ /\A\d+\z/
45
- parent.instance_variable_get(:@modules)[child_name.to_i] = new_module
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
- puts "trainable params: #{format_number(trainable)} || " \
63
- "all params: #{format_number(total)} || " \
64
- "trainable%: #{format('%.4f', pct)}%"
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(
@@ -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
- puts "Model saved to #{model_path}"
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
- puts "No LoRA adapters found in model"
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
- puts "LoRA adapters saved to #{adapter_path} (#{lora_state.size} tensors)"
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
- puts "LoRA adapters loaded from #{adapter_path}"
80
+ Trainers.log(:info, "LoRA adapters loaded from #{adapter_path}")
82
81
  end
83
82
  end
84
83
  end
@@ -25,25 +25,35 @@ module Trainers
25
25
  @state = TrainerState.new
26
26
  @control = TrainerControl.new
27
27
 
28
- all_callbacks = [PrinterCallback.new] + 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
- num_examples = @train_dataset.size
38
- batch_size = @args.per_device_train_batch_size
39
- steps_per_epoch = (num_examples.to_f / batch_size).ceil
40
- total_steps = steps_per_epoch * @args.num_train_epochs
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 = total_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 = 0.0
56
- epoch_steps = 0
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
- each_batch(@train_dataset, batch_size, shuffle: true) do |batch|
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
- scaled_loss = if @args.gradient_accumulation_steps > 1
65
- loss / @args.gradient_accumulation_steps
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 += loss.item
73
- epoch_steps += 1
74
- @state.global_step += 1
82
+ epoch_loss += loss.item
83
+ epoch_batches += 1
75
84
 
76
- if @state.global_step % @args.gradient_accumulation_steps == 0
77
- clip_grad_norm!(@model.parameters, @args.max_grad_norm)
78
- @optimizer.step
79
- @lr_scheduler.step
80
- @optimizer.zero_grad
81
- end
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 / epoch_steps,
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
- epoch_avg_loss = epoch_steps > 0 ? epoch_loss / epoch_steps : 0.0
114
- logs = { loss: epoch_avg_loss, learning_rate: current_lr, epoch: @state.epoch }
115
- @state.log_history << logs.merge(step: @state.global_step)
116
- @callback_handler.fire(:on_log, @args, @state, @control, logs: logs)
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.delete(:labels) || batch.delete("labels")
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
- labels = batch.delete(:labels) || batch.delete("labels")
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(labels ? batch.merge(labels: labels) : batch)
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[:labels] = labels if labels
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
- indices = (0...dataset.size).to_a
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).step(batch_size) do |start|
276
- batch_indices = indices[start, batch_size]
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
- features = batch_indices.map { |i| dataset[i] }
280
- batch = @data_collator.call(features)
281
- yield batch
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)
@@ -31,18 +31,28 @@ module Trainers
31
31
  label_names: ["labels"]
32
32
  }.freeze
33
33
 
34
+ SCHEDULER_TYPES = %i[linear cosine constant].freeze
35
+ STRATEGIES = %i[no epoch steps].freeze
36
+
37
+ POSITIVE_NUMBERS = %i[learning_rate].freeze
38
+ POSITIVE_INTEGERS = %i[num_train_epochs per_device_train_batch_size per_device_eval_batch_size
39
+ gradient_accumulation_steps logging_steps save_steps].freeze
40
+ NON_NEGATIVE = %i[weight_decay max_grad_norm].freeze
41
+
34
42
  DEFAULTS.each_key do |key|
35
43
  attr_accessor key
36
44
  end
37
45
 
38
46
  def initialize(**kwargs)
47
+ unknown = kwargs.keys - DEFAULTS.keys
48
+ raise ArgumentError, "Unknown arguments: #{unknown.join(', ')}" unless unknown.empty?
49
+
39
50
  DEFAULTS.each do |key, default|
40
51
  value = kwargs.fetch(key, default)
41
52
  instance_variable_set(:"@#{key}", value)
42
53
  end
43
54
 
44
- unknown = kwargs.keys - DEFAULTS.keys
45
- raise ArgumentError, "Unknown arguments: #{unknown.join(', ')}" unless unknown.empty?
55
+ validate!
46
56
  end
47
57
 
48
58
  def resolved_device
@@ -60,5 +70,37 @@ module Trainers
60
70
  hash[key] = send(key)
61
71
  end
62
72
  end
73
+
74
+ private
75
+
76
+ # Rejects values that cannot produce a sensible run. Errors name the
77
+ # argument, the value received, and what was expected.
78
+ def validate!
79
+ POSITIVE_NUMBERS.each do |key|
80
+ check(key, "a positive number") { |v| v.is_a?(Numeric) && v.positive? }
81
+ end
82
+ POSITIVE_INTEGERS.each do |key|
83
+ check(key, "a positive Integer") { |v| v.is_a?(Integer) && v.positive? }
84
+ end
85
+ NON_NEGATIVE.each do |key|
86
+ check(key, "a non-negative number") { |v| v.is_a?(Numeric) && !v.negative? }
87
+ end
88
+ check(:warmup_steps, "a non-negative Integer") { |v| v.is_a?(Integer) && !v.negative? }
89
+ check(:warmup_ratio, "a number between 0 and 1") { |v| v.is_a?(Numeric) && v.between?(0, 1) }
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) } }
93
+
94
+ check(:lr_scheduler_type, "one of #{SCHEDULER_TYPES.map(&:inspect).join(', ')}") { |v| SCHEDULER_TYPES.include?(v) }
95
+ check(:eval_strategy, "one of #{STRATEGIES.map(&:inspect).join(', ')}") { |v| STRATEGIES.include?(v) }
96
+ check(:save_strategy, "one of #{STRATEGIES.map(&:inspect).join(', ')}") { |v| STRATEGIES.include?(v) }
97
+ end
98
+
99
+ def check(key, expected)
100
+ value = send(key)
101
+ return if yield(value)
102
+
103
+ raise ArgumentError, "#{key} must be #{expected}, got #{value.inspect}"
104
+ end
63
105
  end
64
106
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Trainers
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trainers-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vishwajeetsingh Desurkar
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
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'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: rake
70
84
  requirement: !ruby/object:Gem::Requirement
@@ -85,20 +99,20 @@ dependencies:
85
99
  requirements:
86
100
  - - "~>"
87
101
  - !ruby/object:Gem::Version
88
- version: '5.0'
102
+ version: '6.0'
89
103
  type: :development
90
104
  prerelease: false
91
105
  version_requirements: !ruby/object:Gem::Requirement
92
106
  requirements:
93
107
  - - "~>"
94
108
  - !ruby/object:Gem::Version
95
- version: '5.0'
109
+ version: '6.0'
96
110
  description: Training loop, LoRA, and optimization utilities for fine-tuning HuggingFace
97
111
  transformer models using torch-rb and transformers-rb. Supports full fine-tuning,
98
112
  LoRA adapters, learning rate scheduling, callbacks, and model serialization via
99
113
  safetensors.
100
114
  email:
101
- - selectus2@users.noreply.rubygems.org
115
+ - vishwajeetsinghd@gmail.com
102
116
  executables: []
103
117
  extensions: []
104
118
  extra_rdoc_files: []
@@ -111,6 +125,8 @@ files:
111
125
  - lib/trainers/callbacks.rb
112
126
  - lib/trainers/data/data_collator.rb
113
127
  - lib/trainers/data/dataset.rb
128
+ - lib/trainers/errors.rb
129
+ - lib/trainers/logging.rb
114
130
  - lib/trainers/lora/lora_config.rb
115
131
  - lib/trainers/lora/lora_linear.rb
116
132
  - lib/trainers/lora/lora_model.rb
@@ -129,6 +145,8 @@ metadata:
129
145
  homepage_uri: https://github.com/Selectus2/trainers-rb
130
146
  source_code_uri: https://github.com/Selectus2/trainers-rb
131
147
  changelog_uri: https://github.com/Selectus2/trainers-rb/blob/main/CHANGELOG.md
148
+ bug_tracker_uri: https://github.com/Selectus2/trainers-rb/issues
149
+ rubygems_mfa_required: 'true'
132
150
  rdoc_options: []
133
151
  require_paths:
134
152
  - lib
@@ -136,14 +154,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
136
154
  requirements:
137
155
  - - ">="
138
156
  - !ruby/object:Gem::Version
139
- version: 3.1.0
157
+ version: 3.3.0
140
158
  required_rubygems_version: !ruby/object:Gem::Requirement
141
159
  requirements:
142
160
  - - ">="
143
161
  - !ruby/object:Gem::Version
144
162
  version: '0'
145
163
  requirements: []
146
- rubygems_version: 3.6.7
164
+ rubygems_version: 4.0.20
147
165
  specification_version: 4
148
166
  summary: Fine-tune transformer models in Ruby
149
167
  test_files: []