grx-tensor 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/grx/loss.rb CHANGED
@@ -4,11 +4,11 @@ module GRX
4
4
  module Loss
5
5
  # ================================================================
6
6
  # MSELoss — Mean Squared Error
7
- # L = mean((pred - target)^2) retorna Float
7
+ # L = mean((pred - target)^2) -> returns differentiable scalar Tensor
8
8
  # ================================================================
9
9
  class MSELoss
10
10
  def call(pred, target)
11
- raise ShapeError, "Shapes incompatibles" if pred.shape != target.shape
11
+ raise ShapeError, "Incompatible shapes: #{pred.shape} vs #{target.shape}" if pred.shape != target.shape
12
12
  (pred - target).square.mean
13
13
  end
14
14
  end
@@ -19,7 +19,7 @@ module GRX
19
19
  # ================================================================
20
20
  class MAELoss
21
21
  def call(pred, target)
22
- raise ShapeError, "Shapes incompatibles" if pred.shape != target.shape
22
+ raise ShapeError, "Incompatible shapes: #{pred.shape} vs #{target.shape}" if pred.shape != target.shape
23
23
  (pred - target).abs.mean
24
24
  end
25
25
  end
@@ -27,54 +27,84 @@ module GRX
27
27
  # ================================================================
28
28
  # BCELoss — Binary Cross-Entropy
29
29
  # L = -mean(t*log(p) + (1-t)*log(1-p))
30
- # pred debe estar en (0,1) — aplica sigmoid antes si usas logits.
30
+ # pred must be in (0,1) — apply Sigmoid before if using logits.
31
31
  # ================================================================
32
32
  class BCELoss
33
33
  EPS = 1e-7
34
34
 
35
35
  def call(pred, target)
36
- raise ShapeError, "Shapes incompatibles" if pred.shape != target.shape
37
- p_data = pred.to_a.map { |v| v < EPS ? EPS : (v > 1-EPS ? 1-EPS : v) }
38
- t_data = target.to_a
39
- total = p_data.size.to_f
40
- loss = p_data.each_with_index.sum do |p, i|
41
- t = t_data[i]
42
- -(t * Math.log(p) + (1 - t) * Math.log(1 - p))
43
- end
44
- loss / total
36
+ raise ShapeError, "Incompatible shapes: #{pred.shape} vs #{target.shape}" if pred.shape != target.shape
37
+ p_clamped = pred.clip(EPS, 1.0 - EPS)
38
+ ones = Tensor.ones_like(target)
39
+ term1 = target * p_clamped.log
40
+ term2 = (ones - target) * (ones - p_clamped).log
41
+ (-(term1 + term2)).mean
45
42
  end
46
43
  end
47
44
 
48
45
  # ================================================================
49
- # CrossEntropyLoss — Softmax + NLL
50
- # L = -mean(sum(target * log(softmax(logits))))
46
+ # CrossEntropyLoss — Softmax + NLL (multi-class)
47
+ # L = -sum(target * log(softmax(logits))) / batch_size
51
48
  # ================================================================
52
49
  class CrossEntropyLoss
53
50
  EPS = 1e-7
54
51
 
55
52
  def call(logits, target)
56
- raise ShapeError, "Shapes incompatibles" if logits.shape != target.shape
57
- probs = logits.softmax.to_a.map { |v| v < EPS ? EPS : v }
53
+ raise ShapeError, "Incompatible shapes: #{logits.shape} vs #{target.shape}" if logits.shape != target.shape
54
+ probs = logits.softmax
55
+ p_data = probs.to_a
58
56
  t_data = target.to_a
59
- loss = probs.each_with_index.sum { |p, i| -t_data[i] * Math.log(p) }
60
- loss / probs.size.to_f
57
+ batch_size = logits.shape[0].to_f
58
+
59
+ loss_val = t_data.each_with_index.sum do |t, i|
60
+ next 0.0 if t == 0.0
61
+ p = [p_data[i], EPS].max
62
+ -t * Math.log(p)
63
+ end / batch_size
64
+
65
+ out = Tensor.create([loss_val], [1], requires_grad: logits.requires_grad || target.requires_grad)
66
+ if logits.requires_grad || target.requires_grad
67
+ out._grafo_hijos.push(logits, target)
68
+ out.backward_fn = ->(g) {
69
+ scale = g.item / batch_size
70
+ grad_logits = p_data.zip(t_data).map { |p, t| (p - t) * scale }
71
+ logits.agregar_gradiente(Tensor.create(grad_logits, logits.shape)) if logits.requires_grad
72
+ }
73
+ end
74
+ out
61
75
  end
62
76
  end
63
77
 
64
78
  # ================================================================
65
- # HuberLoss — Smooth L1
79
+ # HuberLoss — Smooth L1 (robust against outliers)
66
80
  # ================================================================
67
81
  class HuberLoss
82
+ attr_reader :delta
83
+
68
84
  def initialize(delta: 1.0)
69
- @delta = delta
85
+ @delta = delta.to_f
70
86
  end
71
87
 
72
88
  def call(pred, target)
73
- raise ShapeError, "Shapes incompatibles" if pred.shape != target.shape
89
+ raise ShapeError, "Shapes incompatibles: #{pred.shape} vs #{target.shape}" if pred.shape != target.shape
90
+ diff_data = (pred - target).abs.to_a
74
91
  d = @delta
75
- diffs = (pred - target).abs.to_a
76
- loss = diffs.sum { |v| v <= d ? 0.5 * v * v : d * (v - 0.5 * d) }
77
- loss / diffs.size.to_f
92
+ loss_val = diff_data.sum { |v| v <= d ? 0.5 * v * v : d * (v - 0.5 * d) } / diff_data.size.to_f
93
+
94
+ out = Tensor.create([loss_val], [1], requires_grad: pred.requires_grad || target.requires_grad)
95
+ if pred.requires_grad || target.requires_grad
96
+ out._grafo_hijos.push(pred, target)
97
+ n = diff_data.size.to_f
98
+ out.backward_fn = ->(g) {
99
+ grad_pred = (pred - target).to_a.map do |err|
100
+ abs_err = err.abs
101
+ (abs_err <= d ? err : d * (err > 0 ? 1.0 : -1.0)) * (g.item / n)
102
+ end
103
+ pred.agregar_gradiente(Tensor.create(grad_pred, pred.shape)) if pred.requires_grad
104
+ target.agregar_gradiente(Tensor.create(grad_pred.map(&:-@), target.shape)) if target.requires_grad
105
+ }
106
+ end
107
+ out
78
108
  end
79
109
  end
80
110
  end
data/lib/grx/nn.rb CHANGED
@@ -3,10 +3,10 @@
3
3
  module GRX
4
4
  module NN
5
5
  # ================================================================
6
- # Module — clase base para todas las capas
6
+ # Module — Base class for all neural network layers
7
7
  # ================================================================
8
8
  class Module
9
- # Retorna todos los parámetros entrenables (para pasarlos al optimizador)
9
+ # Returns all trainable parameters for optimizer registration
10
10
  def parameters
11
11
  instance_variables.flat_map do |var|
12
12
  val = instance_variable_get(var)
@@ -29,14 +29,22 @@ module GRX
29
29
  parameters.each(&:zero_grad!)
30
30
  end
31
31
 
32
- # Subclases implementan forward
32
+ def save_weights(path)
33
+ GRX::Serialization.save(self, path)
34
+ end
35
+
36
+ def load_weights(path)
37
+ GRX::Serialization.load(self, path)
38
+ end
39
+
40
+ # Subclasses implement forward computation
33
41
  def call(*args)
34
42
  forward(*args)
35
43
  end
36
44
  end
37
45
 
38
46
  # ================================================================
39
- # Linear — Capa densa (fully connected)
47
+ # Linear — Dense fully connected layer
40
48
  # y = x @ W^T + b
41
49
  # ================================================================
42
50
  class Linear < Module
@@ -47,25 +55,20 @@ module GRX
47
55
  @out_features = out_features
48
56
  @use_bias = bias
49
57
 
50
- # Pesos: Xavier uniform (bueno para tanh/sigmoid)
58
+ # Weights: Xavier uniform initialization
51
59
  @weight = Tensor.xavier_uniform([out_features, in_features], requires_grad: true)
52
60
 
53
- # Bias: ceros
61
+ # Bias: initialized to zeros
54
62
  @bias = bias ? Tensor.zeros([out_features], requires_grad: true) : nil
55
63
  end
56
64
 
57
65
  def forward(x)
58
- # x: [batch, in_features] out: [batch, out_features]
66
+ # x: [batch, in_features] -> out: [batch, out_features]
59
67
  # out = x @ W^T
60
68
  out = x.matmul(@weight.transpose)
61
69
 
62
70
  if @use_bias
63
- # Sumamos bias fila por fila.
64
- # Repetimos @bias batch_size veces para crear un tensor [batch, out_features]
65
- # que comparte el grafo con @bias original.
66
71
  batch_size = x.shape[0]
67
- # Tile del bias: concatenamos el mismo tensor bias_size veces
68
- # usando operaciones que mantienen el grafo conectado
69
72
  bias_tiled = _tile_bias(@bias, batch_size, @out_features)
70
73
  out + bias_tiled
71
74
  else
@@ -75,17 +78,11 @@ module GRX
75
78
 
76
79
  private
77
80
 
78
- # Crea un tensor [batch, out_features] repitiendo @bias batch veces.
79
- # Usa add_scalar(0) para crear un nuevo nodo conectado al bias en el grafo.
80
81
  def _tile_bias(bias, batch_size, out_features)
81
- # Construimos el tensor tileado sumando el bias a un tensor de ceros
82
- # de la forma correcta. Esto conecta el grafo al bias original.
83
82
  data = Array.new(batch_size) { bias.to_a }.flatten
84
83
  tiled = GRX::Tensor.create(data, [batch_size, out_features])
85
- # Conectamos al bias original via suma con ceros — mantiene el grafo
86
84
  zero_row = GRX::Tensor.zeros([batch_size, out_features])
87
85
  result = zero_row + tiled
88
- # Registramos manualmente la conexión al bias para backprop
89
86
  if bias.requires_grad
90
87
  result.requires_grad = true
91
88
  result._grafo_hijos << bias
@@ -93,8 +90,7 @@ module GRX
93
90
  bf = result.backward_fn
94
91
  result.backward_fn = ->(g) {
95
92
  bf&.call(g)
96
- # Acumulamos gradiente en bias: suma sobre el batch
97
- grad_data = g.to_a.each_slice(out_features).reduce([0.0]*out_features) { |acc, row|
93
+ grad_data = g.to_a.each_slice(out_features).reduce([0.0] * out_features) { |acc, row|
98
94
  acc.zip(row).map { |a, r| a + r }
99
95
  }
100
96
  b.agregar_gradiente(GRX::Tensor.create(grad_data, [out_features]))
@@ -106,12 +102,12 @@ module GRX
106
102
  public
107
103
 
108
104
  def to_s
109
- "Linear(#{@in_features} #{@out_features}, bias: #{@use_bias})"
105
+ "Linear(#{@in_features} -> #{@out_features}, bias: #{@use_bias})"
110
106
  end
111
107
  end
112
108
 
113
109
  # ================================================================
114
- # Sequential — Contenedor de capas en secuencia
110
+ # Sequential — Chains layers sequentially in order
115
111
  # ================================================================
116
112
  class Sequential < Module
117
113
  def initialize(*layers)
@@ -126,6 +122,16 @@ module GRX
126
122
  @layers.flat_map(&:parameters)
127
123
  end
128
124
 
125
+ def train!
126
+ @layers.each { |l| l.train! if l.respond_to?(:train!) }
127
+ self
128
+ end
129
+
130
+ def eval!
131
+ @layers.each { |l| l.eval! if l.respond_to?(:eval!) }
132
+ self
133
+ end
134
+
129
135
  def to_s
130
136
  layers_str = @layers.each_with_index.map { |l, i| " (#{i}): #{l}" }.join("\n")
131
137
  "Sequential(\n#{layers_str}\n)"
@@ -133,7 +139,7 @@ module GRX
133
139
  end
134
140
 
135
141
  # ================================================================
136
- # Activaciones como capas (para usar en Sequential)
142
+ # Activation layers (for use inside Sequential pipelines)
137
143
  # ================================================================
138
144
  class ReLU < Module
139
145
  def forward(x) = x.relu
@@ -141,8 +147,10 @@ module GRX
141
147
  end
142
148
 
143
149
  class LeakyReLU < Module
144
- def initialize(alpha = 0.01)
145
- @alpha = alpha
150
+ attr_reader :alpha
151
+
152
+ def initialize(alpha_arg = nil, alpha: nil)
153
+ @alpha = (alpha || alpha_arg || 0.01).to_f
146
154
  end
147
155
  def forward(x) = x.leaky_relu(@alpha)
148
156
  def to_s = "LeakyReLU(alpha=#{@alpha})"
@@ -164,11 +172,11 @@ module GRX
164
172
  end
165
173
 
166
174
  # ================================================================
167
- # Dropout — regularización durante entrenamiento
175
+ # Dropout — Random feature dropout during training mode
168
176
  # ================================================================
169
177
  class Dropout < Module
170
178
  def initialize(p = 0.5)
171
- @p = p
179
+ @p = p
172
180
  @training = true
173
181
  end
174
182
 
@@ -178,8 +186,6 @@ module GRX
178
186
  def forward(x)
179
187
  return x unless @training && @p > 0
180
188
 
181
- # Máscara binaria: 1 con prob (1-p), 0 con prob p
182
- # Escalamos por 1/(1-p) para mantener la esperanza (inverted dropout)
183
189
  scale = 1.0 / (1.0 - @p)
184
190
  mask_data = x.to_a.map { rand > @p ? scale : 0.0 }
185
191
  mask = Tensor.create(mask_data, x.shape)
@@ -190,21 +196,139 @@ module GRX
190
196
  end
191
197
 
192
198
  # ================================================================
193
- # BatchNorm1dNormalización por batch
194
- # Estabiliza el entrenamiento de redes profundas.
199
+ # EmbeddingDense vector lookup table for token indices
200
+ # ================================================================
201
+ class Embedding < Module
202
+ attr_reader :weight, :num_embeddings, :embedding_dim
203
+
204
+ def initialize(num_embeddings, embedding_dim)
205
+ @num_embeddings = num_embeddings
206
+ @embedding_dim = embedding_dim
207
+ @weight = Tensor.he_normal([num_embeddings, embedding_dim], requires_grad: true)
208
+ end
209
+
210
+ def forward(indices)
211
+ ids = indices.is_a?(Tensor) ? indices.to_a.map(&:to_i) : indices.map(&:to_i)
212
+ batch_size = ids.size
213
+ out_data = ids.flat_map do |id|
214
+ raise IndexError, "Token index #{id} out of range [0, #{@num_embeddings})" if id < 0 || id >= @num_embeddings
215
+ @weight.to_a.slice(id * @embedding_dim, @embedding_dim)
216
+ end
217
+
218
+ out = Tensor.create(out_data, [batch_size, @embedding_dim])
219
+ if @weight.requires_grad
220
+ out.requires_grad = true
221
+ out._grafo_hijos << @weight
222
+ w = @weight; dim = @embedding_dim; num_emb = @num_embeddings
223
+ out.backward_fn = ->(g) {
224
+ grad_w = Array.new(num_emb * dim, 0.0)
225
+ g_data = g.to_a
226
+ ids.each_with_index do |id, i|
227
+ slice = g_data.slice(i * dim, dim)
228
+ dim.times { |d| grad_w[id * dim + d] += slice[d] }
229
+ end
230
+ w.agregar_gradiente(Tensor.create(grad_w, w.shape))
231
+ }
232
+ end
233
+ out
234
+ end
235
+
236
+ def to_s
237
+ "Embedding(#{@num_embeddings}, #{@embedding_dim})"
238
+ end
239
+ end
240
+
241
+ # ================================================================
242
+ # LayerNorm — Layer normalization across channel dimensions
243
+ # ================================================================
244
+ class LayerNorm < Module
245
+ attr_reader :gamma, :beta, :normalized_shape, :epsilon
246
+
247
+ def initialize(normalized_shape, eps: nil, epsilon: 1e-5)
248
+ @normalized_shape = normalized_shape.is_a?(Array) ? normalized_shape : [normalized_shape]
249
+ @dim = @normalized_shape.reduce(1, :*)
250
+ @epsilon = (eps || epsilon).to_f
251
+
252
+ @gamma = Tensor.ones(@normalized_shape, requires_grad: true)
253
+ @beta = Tensor.zeros(@normalized_shape, requires_grad: true)
254
+ end
255
+
256
+ def forward(x)
257
+ batch_size = x.shape[0]
258
+ x_data = x.to_a
259
+
260
+ means = Array.new(batch_size) do |b|
261
+ x_data.slice(b * @dim, @dim).sum / @dim.to_f
262
+ end
263
+ vars = Array.new(batch_size) do |b|
264
+ m = means[b]
265
+ x_data.slice(b * @dim, @dim).sum { |v| (v - m)**2 } / @dim.to_f
266
+ end
267
+
268
+ gamma_data = @gamma.to_a
269
+ beta_data = @beta.to_a
270
+ norm_data = Array.new(batch_size * @dim)
271
+
272
+ batch_size.times do |b|
273
+ m = means[b]
274
+ inv_std = 1.0 / Math.sqrt(vars[b] + @epsilon)
275
+ @dim.times do |j|
276
+ norm_data[b * @dim + j] = gamma_data[j] * (x_data[b * @dim + j] - m) * inv_std + beta_data[j]
277
+ end
278
+ end
279
+
280
+ out = Tensor.create(norm_data, x.shape)
281
+ if x.requires_grad || @gamma.requires_grad || @beta.requires_grad
282
+ out.requires_grad = true
283
+ out._grafo_hijos.push(x, @gamma, @beta)
284
+ g_param = @gamma; b_param = @beta; d = @dim; eps = @epsilon
285
+ out.backward_fn = ->(g) {
286
+ g_data = g.to_a
287
+ grad_gamma = Array.new(d, 0.0)
288
+ grad_beta = Array.new(d, 0.0)
289
+ grad_x = Array.new(batch_size * d, 0.0)
290
+
291
+ batch_size.times do |b|
292
+ m = means[b]; v = vars[b]
293
+ inv_std = 1.0 / Math.sqrt(v + eps)
294
+ x_hat = Array.new(d) { |j| (x_data[b * d + j] - m) * inv_std }
295
+ dl_dxhat = Array.new(d) { |j| g_data[b * d + j] * gamma_data[j] }
296
+ sum_dl = dl_dxhat.sum
297
+ sum_dl_x = dl_dxhat.zip(x_hat).sum { |a, c| a * c }
298
+
299
+ d.times do |j|
300
+ grad_gamma[j] += g_data[b * d + j] * x_hat[j]
301
+ grad_beta[j] += g_data[b * d + j]
302
+ grad_x[b * d + j] = (inv_std / d.to_f) * (d.to_f * dl_dxhat[j] - sum_dl - x_hat[j] * sum_dl_x)
303
+ end
304
+ end
305
+
306
+ g_param.agregar_gradiente(Tensor.create(grad_gamma, g_param.shape)) if g_param.requires_grad
307
+ b_param.agregar_gradiente(Tensor.create(grad_beta, b_param.shape)) if b_param.requires_grad
308
+ x.agregar_gradiente(Tensor.create(grad_x, x.shape)) if x.requires_grad
309
+ }
310
+ end
311
+ out
312
+ end
313
+
314
+ def to_s
315
+ "LayerNorm(#{@normalized_shape})"
316
+ end
317
+ end
318
+
319
+ # ================================================================
320
+ # BatchNorm1d — Normalizacion por batch
195
321
  # ================================================================
196
322
  class BatchNorm1d < Module
197
- def initialize(num_features, epsilon: 1e-5, momentum: 0.1)
323
+ def initialize(num_features, eps: nil, epsilon: 1e-5, momentum: 0.1)
198
324
  @num_features = num_features
199
- @epsilon = epsilon
200
- @momentum = momentum
325
+ @epsilon = (eps || epsilon).to_f
326
+ @momentum = momentum.to_f
201
327
  @training = true
202
328
 
203
- # Parámetros entrenables
204
329
  @gamma = Tensor.ones([num_features], requires_grad: true)
205
330
  @beta = Tensor.zeros([num_features], requires_grad: true)
206
331
 
207
- # Estadísticas corrientes (no entrenables, para inferencia)
208
332
  @running_mean = Tensor.zeros([num_features])
209
333
  @running_var = Tensor.ones([num_features])
210
334
  end
@@ -213,11 +337,9 @@ module GRX
213
337
  def eval!; @training = false; self; end
214
338
 
215
339
  def forward(x)
216
- # x: [batch, num_features]
217
340
  batch_size = x.shape[0]
218
341
 
219
342
  if @training
220
- # Calculamos media y varianza del batch
221
343
  batch_data = x.to_a
222
344
  means = Array.new(@num_features) do |j|
223
345
  batch_data.each_slice(@num_features).map { |row| row[j] }.sum / batch_size
@@ -227,7 +349,6 @@ module GRX
227
349
  col.sum { |v| (v - means[j]) ** 2 } / batch_size
228
350
  end
229
351
 
230
- # Actualizamos estadísticas corrientes
231
352
  means.each_with_index do |m, j|
232
353
  rm = @running_mean.to_a; rm[j] = (1 - @momentum) * rm[j] + @momentum * m
233
354
  @running_mean = Tensor.create(rm, [@num_features])
@@ -244,8 +365,6 @@ module GRX
244
365
  var_t = @running_var
245
366
  end
246
367
 
247
- # Normalizamos: x_hat = (x - mean) / sqrt(var + eps)
248
- # Luego escalamos: y = gamma * x_hat + beta
249
368
  norm_data = x.to_a.each_slice(@num_features).flat_map do |row|
250
369
  row.each_with_index.map do |v, j|
251
370
  x_hat = (v - mean_t.to_a[j]) / Math.sqrt(var_t.to_a[j] + @epsilon)
data/lib/grx/optim.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module GRX
4
4
  module Optim
5
5
  # ================================================================
6
- # SGD — Stochastic Gradient Descent (con momentum opcional)
6
+ # SGD — Stochastic Gradient Descent (with optional momentum)
7
7
  # ================================================================
8
8
  class SGD
9
9
  def initialize(params, lr: 0.01, momentum: 0.0, weight_decay: 0.0)
@@ -11,7 +11,7 @@ module GRX
11
11
  @lr = lr
12
12
  @momentum = momentum
13
13
  @weight_decay = weight_decay
14
- # Buffer de velocidad para momentum
14
+ # Velocity buffer for momentum
15
15
  @velocity = params.map { |p| Tensor.zeros_like(p) }
16
16
  end
17
17
 
@@ -21,7 +21,7 @@ module GRX
21
21
 
22
22
  grad = param.grad
23
23
 
24
- # L2 regularización (weight decay)
24
+ # L2 regularization (weight decay)
25
25
  if @weight_decay > 0
26
26
  grad = grad + param.scale(@weight_decay)
27
27
  end
@@ -35,7 +35,7 @@ module GRX
35
35
  if CAPI::LOADED
36
36
  CAPI.grx_sgd_step(param.storage.ptr, grad.storage.ptr, @lr, param.numel)
37
37
  else
38
- # Fallback Ruby
38
+ # Ruby fallback
39
39
  param_data = param.to_a
40
40
  grad_data = grad.to_a
41
41
  param_data.each_with_index { |v, j| param_data[j] = v - @lr * grad_data[j] }
@@ -51,27 +51,32 @@ module GRX
51
51
 
52
52
  # ================================================================
53
53
  # Adam — Adaptive Moment Estimation (Kingma & Ba, 2015)
54
- # El optimizador estándar para redes neuronales profundas.
54
+ # The standard optimizer for deep neural networks.
55
55
  # ================================================================
56
56
  class Adam
57
- def initialize(params, lr: 0.001, beta1: 0.9, beta2: 0.999,
58
- epsilon: 1e-8, weight_decay: 0.0)
57
+ def initialize(params, lr: 0.001, betas: nil, beta1: 0.9, beta2: 0.999,
58
+ eps: nil, epsilon: 1e-8, weight_decay: 0.0)
59
59
  @params = params
60
- @lr = lr
61
- @beta1 = beta1
62
- @beta2 = beta2
63
- @epsilon = epsilon
64
- @weight_decay = weight_decay
65
- @t = 0 # paso actual
60
+ @lr = lr.to_f
61
+ if betas
62
+ @beta1 = betas[0].to_f
63
+ @beta2 = betas[1].to_f
64
+ else
65
+ @beta1 = beta1.to_f
66
+ @beta2 = beta2.to_f
67
+ end
68
+ @epsilon = eps ? eps.to_f : epsilon.to_f
69
+ @weight_decay = weight_decay.to_f
70
+ @t = 0 # current step
66
71
 
67
- # Momentos de primer y segundo orden (inicializados en cero)
72
+ # First and second order moment vectors (zero-initialized)
68
73
  @m = params.map { |p| Tensor.zeros_like(p) }
69
74
  @v = params.map { |p| Tensor.zeros_like(p) }
70
75
  end
71
76
 
72
77
  def step
73
78
  @t += 1
74
- beta1t = @beta1 ** @t # beta1^t para corrección de bias
79
+ beta1t = @beta1 ** @t # beta1^t for bias correction
75
80
  beta2t = @beta2 ** @t
76
81
 
77
82
  @params.each_with_index do |param, i|
@@ -94,7 +99,7 @@ module GRX
94
99
  param.numel
95
100
  )
96
101
  else
97
- # Fallback Ruby puro
102
+ # Pure Ruby fallback
98
103
  p_data = param.to_a
99
104
  m_data = @m[i].to_a
100
105
  v_data = @v[i].to_a
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GRX
4
+ # ===================================================================
5
+ # Serialization — Native binary .grx format
6
+ #
7
+ # Binary layout:
8
+ # - 8 bytes: Magic header "GRX1\0\0\0\0"
9
+ # - 4 bytes: Number of parameter tensors (unsigned 32-bit big-endian)
10
+ # For each tensor:
11
+ # - 2 bytes: Rank (number of dimensions)
12
+ # - 4 bytes * rank: Dimensions of the shape (uint32 big-endian)
13
+ # - 8 bytes: Total numel (uint64 big-endian)
14
+ # - numel * 8 bytes: Direct binary packed IEEE 754 doubles
15
+ # ===================================================================
16
+ module Serialization
17
+ MAGIC = "GRX1\x00\x00\x00\x00".b
18
+
19
+ def self.save(model, path)
20
+ params = model.parameters
21
+ File.open(path, "wb") do |f|
22
+ f.write(MAGIC)
23
+ f.write([params.size].pack("N"))
24
+ params.each do |p|
25
+ shape = p.shape
26
+ f.write([shape.size].pack("n"))
27
+ f.write(shape.pack("N*"))
28
+ f.write([p.numel].pack("Q>"))
29
+ # Direct binary copy from native C buffer
30
+ bytes = if p.storage.ptr
31
+ p.storage.ptr[0, p.numel * 8]
32
+ else
33
+ p.to_a.pack("d*")
34
+ end
35
+ f.write(bytes)
36
+ end
37
+ end
38
+ path
39
+ end
40
+
41
+ def self.load(model, path)
42
+ params = model.parameters
43
+ File.open(path, "rb") do |f|
44
+ magic = f.read(8)
45
+ raise StorageError, "Invalid format: not a valid .grx binary file" unless magic == MAGIC
46
+ count = f.read(4).unpack1("N")
47
+ raise StorageError, "Parameter count mismatch: model has #{params.size}, file has #{count}" unless count == params.size
48
+
49
+ params.each_with_index do |p, idx|
50
+ rank = f.read(2).unpack1("n")
51
+ shape = f.read(rank * 4).unpack("N*")
52
+ numel = f.read(8).unpack1("Q>")
53
+ raise ShapeError, "Shape mismatch for parameter #{idx}: expected #{p.shape}, got #{shape}" unless shape == p.shape
54
+
55
+ bytes = f.read(numel * 8)
56
+ if p.storage.ptr
57
+ p.storage.ptr[0, bytes.bytesize] = bytes
58
+ else
59
+ p.storage.instance_variable_set(:@data, bytes.unpack("d*"))
60
+ end
61
+ end
62
+ end
63
+ model
64
+ end
65
+ end
66
+ end