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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2025ca407a70ba9d0a206306f48486eaae88854e767dd006aa9f37af752eaf1
4
- data.tar.gz: 5e4a9e5a90d377791c5577399b88aab55e5d00468f4cae13710e0d140d482e5f
3
+ metadata.gz: 8db3543c735b87abb79d2de2d8278f11c3999289f2c68929a6cc7c855568b6a1
4
+ data.tar.gz: ff4783fde9226bff71d59e00960997109a5426f407572ea41859657a120e907f
5
5
  SHA512:
6
- metadata.gz: 16679778184715a50d08ff4de4e5050bca75c98dc02b542331f14aae79a6a0b857b3a2699da060e3de3ce7924357486b3b60da2563e7759eccd4e2fd10d6c716
7
- data.tar.gz: d1580d3571d6adaa1dd771b4cfdbea1bf9ea3a002b35bb06e5f4f9184946a6243dd9aac19c4ac96fccb19690e1e7c2291d51a0c03ad551f7ca32600a7092cc91
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 steps |
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` | 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 |
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))
@@ -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
@@ -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
- 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
@@ -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
- puts "Merged #{lora_modules.size} LoRA adapters into base model"
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
- 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)
@@ -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) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Trainers
4
- VERSION = "0.1.2"
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,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trainers-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
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: 2026-10-30 00:00:00.000000000 Z
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.1.0
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: 3.5.22
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: []