gsplat 1.0.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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +202 -0
  3. data/README.md +236 -0
  4. data/docs/ACCEPTANCE.md +60 -0
  5. data/docs/BENCHMARKS.md +79 -0
  6. data/docs/DECISIONS.md +52 -0
  7. data/docs/MIGRATION.md +117 -0
  8. data/docs/PROFILE.md +75 -0
  9. data/docs/PROGRESS.md +199 -0
  10. data/docs/decisions/0000-template.md +17 -0
  11. data/docs/decisions/0001-pin-golden-reference-and-cpu-validation.md +27 -0
  12. data/docs/decisions/0002-retain-ruby-fallbacks-for-native-backend.md +26 -0
  13. data/docs/decisions/0003-share-projection-for-distorted-cameras.md +26 -0
  14. data/docs/decisions/0004-use-portable-world-space-reference-paths.md +26 -0
  15. data/docs/decisions/0005-reuse-ewa-core-for-2dgs.md +24 -0
  16. data/docs/decisions/0006-keep-eval3d-as-portable-reference.md +25 -0
  17. data/docs/decisions/0007-share-compositor-semantics-for-contribution-indices.md +25 -0
  18. data/examples/data/README.md +14 -0
  19. data/examples/data/colmap/images/view_000.png +0 -0
  20. data/examples/data/colmap/images/view_001.png +0 -0
  21. data/examples/data/colmap/images/view_002.png +0 -0
  22. data/examples/data/colmap/sparse/0/cameras.txt +2 -0
  23. data/examples/data/colmap/sparse/0/images.txt +6 -0
  24. data/examples/data/colmap/sparse/0/points3D.txt +17 -0
  25. data/examples/data/splats.ply +0 -0
  26. data/examples/fit_image.rb +35 -0
  27. data/examples/generate_sample_data.rb +147 -0
  28. data/examples/render_path.rb +117 -0
  29. data/examples/simple_trainer.rb +76 -0
  30. data/ext/gsplat_native/common.h +54 -0
  31. data/ext/gsplat_native/extconf.rb +39 -0
  32. data/ext/gsplat_native/gsplat_native.c +60 -0
  33. data/ext/gsplat_native/intersections.c +211 -0
  34. data/ext/gsplat_native/projection.c +199 -0
  35. data/ext/gsplat_native/raster_backward.c +129 -0
  36. data/ext/gsplat_native/raster_backward_bridge.c +69 -0
  37. data/ext/gsplat_native/raster_forward.c +150 -0
  38. data/ext/gsplat_native/rasterization.h +51 -0
  39. data/ext/gsplat_native/spherical_harmonics.c +129 -0
  40. data/gsplat.gemspec +36 -0
  41. data/lib/gsplat/autograd/context.rb +60 -0
  42. data/lib/gsplat/autograd/function.rb +68 -0
  43. data/lib/gsplat/autograd/variable.rb +159 -0
  44. data/lib/gsplat/backend/ruby/accumulate.rb +139 -0
  45. data/lib/gsplat/backend/ruby/accumulate_backward.rb +40 -0
  46. data/lib/gsplat/backend/ruby/eval3d_rasterizer.rb +175 -0
  47. data/lib/gsplat/backend/ruby/isect_tiles.rb +198 -0
  48. data/lib/gsplat/backend/ruby/projection.rb +251 -0
  49. data/lib/gsplat/backend/ruby/projection_backward.rb +190 -0
  50. data/lib/gsplat/backend/ruby/projection_covariance_vjp.rb +72 -0
  51. data/lib/gsplat/backend/ruby/projection_input_vjp.rb +252 -0
  52. data/lib/gsplat/backend/ruby/quat_scale_to_covar_preci.rb +139 -0
  53. data/lib/gsplat/backend/ruby/rasterize_to_indices_in_range.rb +121 -0
  54. data/lib/gsplat/backend/ruby/rasterize_to_pixels.rb +199 -0
  55. data/lib/gsplat/backend/ruby/rasterize_to_pixels_backward.rb +121 -0
  56. data/lib/gsplat/backend/ruby/spherical_harmonics.rb +135 -0
  57. data/lib/gsplat/backend/ruby/tile_compositor.rb +50 -0
  58. data/lib/gsplat/backend/ruby/tile_compositor_backward.rb +84 -0
  59. data/lib/gsplat/backend.rb +79 -0
  60. data/lib/gsplat/compression/grid_sort.rb +79 -0
  61. data/lib/gsplat/compression/kmeans.rb +121 -0
  62. data/lib/gsplat/compression/png.rb +147 -0
  63. data/lib/gsplat/compression/png_codec.rb +133 -0
  64. data/lib/gsplat/compression/quantizer.rb +79 -0
  65. data/lib/gsplat/io/checkpoint.rb +145 -0
  66. data/lib/gsplat/io/colmap.rb +175 -0
  67. data/lib/gsplat/io/colmap_binary.rb +98 -0
  68. data/lib/gsplat/io/colmap_text.rb +84 -0
  69. data/lib/gsplat/io/image.rb +63 -0
  70. data/lib/gsplat/io/image_backends.rb +81 -0
  71. data/lib/gsplat/io/npy.rb +189 -0
  72. data/lib/gsplat/io/ply.rb +185 -0
  73. data/lib/gsplat/io/ply_reader.rb +142 -0
  74. data/lib/gsplat/io/zip_archive.rb +183 -0
  75. data/lib/gsplat/math/camera_distortion.rb +123 -0
  76. data/lib/gsplat/math/camera_projection.rb +202 -0
  77. data/lib/gsplat/math/mat.rb +114 -0
  78. data/lib/gsplat/math/quaternion.rb +175 -0
  79. data/lib/gsplat/math/small_matrix_primitives.rb +112 -0
  80. data/lib/gsplat/math/spherical_harmonic_basis.rb +148 -0
  81. data/lib/gsplat/math/ssim.rb +153 -0
  82. data/lib/gsplat/native.rb +30 -0
  83. data/lib/gsplat/native_ops.rb +148 -0
  84. data/lib/gsplat/native_raster_ops.rb +76 -0
  85. data/lib/gsplat/ops/accumulate.rb +68 -0
  86. data/lib/gsplat/ops/eval3d_rasterize.rb +62 -0
  87. data/lib/gsplat/ops/isect_tiles.rb +38 -0
  88. data/lib/gsplat/ops/projection.rb +154 -0
  89. data/lib/gsplat/ops/quat_scale_to_covar_preci.rb +118 -0
  90. data/lib/gsplat/ops/rasterize_to_indices_in_range.rb +38 -0
  91. data/lib/gsplat/ops/rasterize_to_pixels.rb +105 -0
  92. data/lib/gsplat/ops/relocation.rb +94 -0
  93. data/lib/gsplat/ops/spherical_harmonics.rb +71 -0
  94. data/lib/gsplat/ops/tensor_shape_ops.rb +173 -0
  95. data/lib/gsplat/ops/tensor_value_ops.rb +150 -0
  96. data/lib/gsplat/optim/adam.rb +212 -0
  97. data/lib/gsplat/optim/lr_scheduler.rb +36 -0
  98. data/lib/gsplat/optim/selective_adam.rb +68 -0
  99. data/lib/gsplat/rasterization.rb +130 -0
  100. data/lib/gsplat/rasterization_2dgs.rb +140 -0
  101. data/lib/gsplat/rasterization_helpers.rb +162 -0
  102. data/lib/gsplat/rasterization_validation.rb +99 -0
  103. data/lib/gsplat/strategy/base.rb +49 -0
  104. data/lib/gsplat/strategy/default.rb +188 -0
  105. data/lib/gsplat/strategy/mcmc.rb +103 -0
  106. data/lib/gsplat/strategy/mcmc_ops.rb +143 -0
  107. data/lib/gsplat/strategy/ops.rb +165 -0
  108. data/lib/gsplat/training/config.rb +91 -0
  109. data/lib/gsplat/training/image_fitter.rb +158 -0
  110. data/lib/gsplat/training/losses.rb +191 -0
  111. data/lib/gsplat/training/scene.rb +116 -0
  112. data/lib/gsplat/training/trainer.rb +236 -0
  113. data/lib/gsplat/utils.rb +110 -0
  114. data/lib/gsplat/version.rb +6 -0
  115. data/lib/gsplat.rb +98 -0
  116. metadata +181 -0
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../backend/ruby/spherical_harmonics"
4
+
5
+ # Differentiable real spherical harmonics operation.
6
+ module Gsplat
7
+ module Ops
8
+ # Evaluates real SH bases through degree four.
9
+ class SphericalHarmonics < Autograd::Function
10
+ class << self
11
+ # Evaluates SH and stores inputs for its analytic VJP.
12
+ # @api private
13
+ def forward(context, degree, directions, coefficients, masks: nil)
14
+ context.save(degree, directions, coefficients, masks)
15
+ Backend.dispatch(
16
+ :spherical_harmonics_forward,
17
+ degree,
18
+ directions,
19
+ coefficients,
20
+ masks: masks
21
+ )
22
+ end
23
+
24
+ # Propagates an SH value gradient to directions and coefficients.
25
+ # @api private
26
+ def backward(context, grad_output)
27
+ degree, directions, coefficients, masks = context.saved_values
28
+ grad_directions, grad_coefficients = Backend.dispatch(
29
+ :spherical_harmonics_backward,
30
+ degree,
31
+ directions,
32
+ coefficients,
33
+ grad_output,
34
+ masks: masks,
35
+ grad_dirs: context.needs_input_grad.fetch(1),
36
+ grad_coeffs: context.needs_input_grad.fetch(2)
37
+ )
38
+ [nil, grad_directions, grad_coefficients]
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ class << self
45
+ # Evaluates real spherical harmonics.
46
+ #
47
+ # @param degree [Integer] degree 0..4
48
+ # @param directions [Autograd::Variable, Numo::NArray] [...,3]
49
+ # @param coefficients [Autograd::Variable, Numo::NArray] [...,K,D], K >= (degree+1)^2
50
+ # @param masks [Numo::Bit, nil] [...]
51
+ # @return [Autograd::Variable, Numo::NArray] [...,D]
52
+ def spherical_harmonics(degree, directions, coefficients, masks: nil)
53
+ if [directions, coefficients].any?(Autograd::Variable)
54
+ return Ops::SphericalHarmonics.apply(degree, directions, coefficients, masks: masks)
55
+ end
56
+
57
+ Backend.dispatch(:spherical_harmonics_forward, degree, directions, coefficients, masks: masks)
58
+ end
59
+ end
60
+
61
+ Backend.register(
62
+ :spherical_harmonics_forward,
63
+ :ruby,
64
+ Backend::RubySphericalHarmonics.method(:forward)
65
+ )
66
+ Backend.register(
67
+ :spherical_harmonics_backward,
68
+ :ruby,
69
+ Backend::RubySphericalHarmonics.method(:backward)
70
+ )
71
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module Ops
5
+ # Internal helpers for composing coarse differentiable operations.
6
+ module TensorOps
7
+ module_function
8
+
9
+ def apply(function, *inputs, **)
10
+ return function.apply(*inputs, **) if inputs.any?(Autograd::Variable)
11
+
12
+ needs_grad = Array.new(inputs.length, false)
13
+ function.forward(Autograd::Context.new(needs_grad), *inputs, **)
14
+ end
15
+
16
+ def data(value)
17
+ value.is_a?(Autograd::Variable) ? value.data : value
18
+ end
19
+ end
20
+
21
+ # Adds a leading camera dimension to a tensor.
22
+ class CameraBroadcast < Autograd::Function
23
+ class << self
24
+ def forward(context, value, camera_count)
25
+ valid_count = camera_count.is_a?(Integer) && camera_count.positive?
26
+ raise ArgumentError, "camera_count must be positive" unless valid_count
27
+
28
+ context.save(value.shape)
29
+ output = value.class.zeros(*([camera_count] + value.shape))
30
+ camera_count.times do |camera_index|
31
+ output[*([camera_index] + Array.new(value.ndim, true))] = value
32
+ end
33
+ output
34
+ end
35
+
36
+ def backward(_context, gradient)
37
+ [gradient.sum(axis: 0), nil]
38
+ end
39
+ end
40
+ end
41
+
42
+ # Computes camera-to-Gaussian viewing directions from world-to-camera matrices.
43
+ class CameraDirections < Autograd::Function
44
+ class << self
45
+ def forward(context, means, viewmats)
46
+ unless means.ndim == 2 && means.shape[-1] == 3 &&
47
+ viewmats.ndim == 3 && viewmats.shape[1..] == [4, 4]
48
+ raise ShapeError,
49
+ "expected means [N,3] and viewmats [C,4,4], " \
50
+ "got #{means.shape.inspect} and #{viewmats.shape.inspect}"
51
+ end
52
+
53
+ output = means.class.zeros(viewmats.shape[0], means.shape[0], 3)
54
+ viewmats.shape[0].times do |camera_index|
55
+ rotation = means.class.cast(viewmats[camera_index, 0...3, 0...3])
56
+ translation = means.class.cast(viewmats[camera_index, 0...3, 3])
57
+ camera_center = -translation.dot(rotation)
58
+ output[camera_index, true, true] = means - camera_center
59
+ end
60
+ context.save(means.shape)
61
+ output
62
+ end
63
+
64
+ def backward(_context, gradient)
65
+ [gradient.sum(axis: 0), nil]
66
+ end
67
+ end
68
+ end
69
+
70
+ # Adds a singleton feature axis to depths.
71
+ class DepthFeatures < Autograd::Function
72
+ class << self
73
+ def forward(_context, depths)
74
+ depths.reshape(*(depths.shape + [1]))
75
+ end
76
+
77
+ def backward(_context, gradient)
78
+ [gradient.reshape(*gradient.shape[0...-1])]
79
+ end
80
+ end
81
+ end
82
+
83
+ # Concatenates per-Gaussian features and depth.
84
+ class ConcatDepth < Autograd::Function
85
+ class << self
86
+ def forward(context, features, depth_features)
87
+ unless features.shape[0...-1] == depth_features.shape[0...-1] && depth_features.shape[-1] == 1
88
+ raise ShapeError,
89
+ "expected features [...,D] and depths [...,1], " \
90
+ "got #{features.shape.inspect} and #{depth_features.shape.inspect}"
91
+ end
92
+
93
+ feature_count = features.shape[-1]
94
+ output = features.class.zeros(*(features.shape[0...-1] + [feature_count + 1]))
95
+ output[*Array.new(features.ndim - 1, true), 0...feature_count] = features
96
+ output[*Array.new(features.ndim - 1, true), feature_count] = depth_features[
97
+ *Array.new(depth_features.ndim - 1, true), 0
98
+ ]
99
+ context.save(feature_count)
100
+ output
101
+ end
102
+
103
+ def backward(context, gradient)
104
+ feature_count = context.saved_values.fetch(0)
105
+ leading = Array.new(gradient.ndim - 1, true)
106
+ [
107
+ gradient[*leading, 0...feature_count].dup,
108
+ gradient[*leading, feature_count].reshape(*(gradient.shape[0...-1] + [1]))
109
+ ]
110
+ end
111
+ end
112
+ end
113
+
114
+ # Extracts a contiguous interval from the last tensor axis.
115
+ class FeatureSlice < Autograd::Function
116
+ class << self
117
+ def forward(context, value, range)
118
+ unless range.is_a?(Range) && range.begin.is_a?(Integer) && range.end.is_a?(Integer)
119
+ raise ArgumentError, "feature range must have integer bounds"
120
+ end
121
+
122
+ stop = range.exclude_end? ? range.end : range.end + 1
123
+ unless range.begin.between?(0, value.shape[-1]) && stop.between?(range.begin, value.shape[-1])
124
+ raise ShapeError, "feature range #{range.inspect} is outside #{value.shape.inspect}"
125
+ end
126
+
127
+ context.save(value.shape, range.begin, stop)
128
+ value[*Array.new(value.ndim - 1, true), range.begin...stop].dup
129
+ end
130
+
131
+ def backward(context, gradient)
132
+ shape, start, stop = context.saved_values
133
+ output = gradient.class.zeros(*shape)
134
+ output[*Array.new(output.ndim - 1, true), start...stop] = gradient
135
+ [output, nil]
136
+ end
137
+ end
138
+ end
139
+
140
+ # Concatenates any number of tensors along their final feature axis.
141
+ class ConcatFeatures < Autograd::Function
142
+ class << self
143
+ def forward(context, *values)
144
+ raise ArgumentError, "at least one feature tensor is required" if values.empty?
145
+
146
+ leading_shape = values.first.shape[0...-1]
147
+ unless values.all? { |value| value.shape[0...-1] == leading_shape }
148
+ raise ShapeError, "feature tensors must share leading dimensions"
149
+ end
150
+
151
+ sizes = values.map { |value| value.shape[-1] }
152
+ output = values.first.class.zeros(*(leading_shape + [sizes.sum]))
153
+ cursor = 0
154
+ values.each_with_index do |value, index|
155
+ output[*Array.new(output.ndim - 1, true), cursor...(cursor + sizes[index])] = value
156
+ cursor += sizes[index]
157
+ end
158
+ context.save(sizes)
159
+ output
160
+ end
161
+
162
+ def backward(context, gradient)
163
+ cursor = 0
164
+ context.saved_values.first.map do |size|
165
+ value = gradient[*Array.new(gradient.ndim - 1, true), cursor...(cursor + size)].dup
166
+ cursor += size
167
+ value
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module Ops
5
+ # Adds an offset and applies a lower clamp.
6
+ class AddClampMin < Autograd::Function
7
+ class << self
8
+ def forward(context, value, offset:, minimum:)
9
+ shifted = value + offset
10
+ context.save(shifted.gt(minimum))
11
+ below = shifted.lt(minimum)
12
+ shifted[below] = minimum if below.any?
13
+ shifted
14
+ end
15
+
16
+ # rubocop:disable Metrics/AbcSize
17
+ def backward(context, gradient)
18
+ mask = gradient.class.cast(context.saved_values.fetch(0))
19
+ [gradient * mask]
20
+ end
21
+ end
22
+ end
23
+
24
+ # Elementwise multiplication with VJPs for both inputs.
25
+ class Multiply < Autograd::Function
26
+ class << self
27
+ def forward(context, left, right)
28
+ unless left.shape == right.shape
29
+ raise ShapeError, "multiply shape mismatch: #{left.shape.inspect} and #{right.shape.inspect}"
30
+ end
31
+
32
+ context.save(left, right)
33
+ left * right
34
+ end
35
+
36
+ def backward(context, gradient)
37
+ left, right = context.saved_values
38
+ [gradient * right, gradient * left]
39
+ end
40
+ end
41
+ end
42
+
43
+ # Elementwise exponential activation.
44
+ class Exp < Autograd::Function
45
+ class << self
46
+ def forward(context, value)
47
+ output = Numo::NMath.exp(value)
48
+ context.save(output)
49
+ output
50
+ end
51
+
52
+ def backward(context, gradient)
53
+ [gradient * context.saved_values.first]
54
+ end
55
+ end
56
+ end
57
+
58
+ # Elementwise logistic activation.
59
+ class Sigmoid < Autograd::Function
60
+ class << self
61
+ def forward(context, value)
62
+ output = 1.0 / (1.0 + Numo::NMath.exp(-value))
63
+ context.save(output)
64
+ output
65
+ end
66
+
67
+ def backward(context, gradient)
68
+ output = context.saved_values.first
69
+ [gradient * output * (1.0 - output)]
70
+ end
71
+ end
72
+ end
73
+
74
+ # Last-axis quaternion normalization.
75
+ class NormalizeQuaternion < Autograd::Function
76
+ class << self
77
+ def forward(context, value)
78
+ context.save(value)
79
+ Math::Quaternion.normalize(value)
80
+ end
81
+
82
+ def backward(context, gradient)
83
+ [Math::Quaternion.normalize_vjp(context.saved_values.first, gradient)]
84
+ end
85
+ end
86
+ end
87
+
88
+ # Joins the direct-current and remaining SH coefficient blocks.
89
+ class ConcatCoefficients < Autograd::Function
90
+ class << self
91
+ def forward(context, direct, rest)
92
+ unless direct.ndim == 3 && rest.ndim == 3 &&
93
+ direct.shape[0] == rest.shape[0] && direct.shape[2] == rest.shape[2]
94
+ raise ShapeError, "cannot concatenate SH shapes #{direct.shape.inspect} and #{rest.shape.inspect}"
95
+ end
96
+
97
+ direct_count = direct.shape[1]
98
+ output = direct.class.zeros(direct.shape[0], direct_count + rest.shape[1], direct.shape[2])
99
+ output[true, 0...direct_count, true] = direct
100
+ output[true, direct_count...output.shape[1], true] = rest
101
+ context.save(direct_count)
102
+ output
103
+ end
104
+
105
+ def backward(context, gradient)
106
+ direct_count = context.saved_values.first
107
+ [
108
+ gradient[true, 0...direct_count, true].dup,
109
+ gradient[true, direct_count...gradient.shape[1], true].dup
110
+ ]
111
+ end
112
+ end
113
+ end
114
+
115
+ # Divides one selected rendered depth channel by accumulated alpha.
116
+ class NormalizeDepth < Autograd::Function
117
+ class << self
118
+ def forward(context, rendered, alphas, depth_index:)
119
+ expected = rendered.shape[0...-1] + [1]
120
+ unless alphas.shape == expected
121
+ raise ShapeError, "expected alphas #{expected.inspect}, got #{alphas.shape.inspect}"
122
+ end
123
+
124
+ alpha = alphas[*Array.new(alphas.ndim - 1, true).push(0)]
125
+ depth = rendered[*Array.new(rendered.ndim - 1, true).push(depth_index)]
126
+ valid = alpha.gt(0)
127
+ output = rendered.dup
128
+ normalized = rendered.class.zeros(*alpha.shape)
129
+ normalized[valid] = depth[valid] / alpha[valid] if valid.any?
130
+ output[*Array.new(output.ndim - 1, true), depth_index] = normalized
131
+ context.save(alpha, depth, valid, depth_index)
132
+ output
133
+ end
134
+
135
+ def backward(context, gradient)
136
+ alpha, depth, valid, depth_index = context.saved_values
137
+ grad_rendered = gradient.dup
138
+ grad_depth = gradient[*Array.new(gradient.ndim - 1, true).push(depth_index)]
139
+ normalized_grad = gradient.class.zeros(*alpha.shape)
140
+ normalized_grad[valid] = grad_depth[valid] / alpha[valid] if valid.any?
141
+ grad_rendered[*Array.new(gradient.ndim - 1, true), depth_index] = normalized_grad
142
+ grad_alpha = gradient.class.zeros(*alpha.shape)
143
+ grad_alpha[valid] = -grad_depth[valid] * depth[valid] / (alpha[valid]**2) if valid.any?
144
+ [grad_rendered, grad_alpha.reshape(*(alpha.shape + [1]))]
145
+ end
146
+ # rubocop:enable Metrics/AbcSize
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ # Optimizers and learning-rate schedules for autograd Variables.
5
+ module Optim
6
+ # Dense Adam optimizer with editable first-axis state for densification.
7
+ class Adam
8
+ # Immutable parameter-group configuration.
9
+ Group = Data.define(:name, :variable, :lr, :eps)
10
+ # Adam step count and first/second moments.
11
+ State = Data.define(:step, :exp_avg, :exp_avg_sq)
12
+
13
+ attr_reader :beta1, :beta2, :groups
14
+
15
+ # rubocop:disable Naming/MethodParameterName
16
+ def initialize(groups = nil, lr: 1e-3, betas: [0.9, 0.999], eps: 1e-15, **named_groups)
17
+ # rubocop:enable Naming/MethodParameterName
18
+ raise ArgumentError, "provide positional or named groups, not both" if groups && !named_groups.empty?
19
+
20
+ groups ||= named_groups
21
+ @beta1, @beta2 = betas
22
+ validate_hyperparameters!(lr, eps)
23
+ @groups = normalize_groups(groups, lr, eps)
24
+ @states = {}
25
+ end
26
+
27
+ # rubocop:disable Metrics/AbcSize
28
+ def step
29
+ groups.each_value do |group|
30
+ gradient = group.variable.grad
31
+ next unless gradient
32
+
33
+ state = state_for(group.name)
34
+ next_step = state.step + 1
35
+ exp_avg = (beta1 * state.exp_avg) + ((1 - beta1) * gradient)
36
+ exp_avg_sq = (beta2 * state.exp_avg_sq) + ((1 - beta2) * (gradient**2))
37
+ corrected_mean = exp_avg / (1 - (beta1**next_step))
38
+ corrected_variance = exp_avg_sq / (1 - (beta2**next_step))
39
+ update = group.lr * corrected_mean / ((corrected_variance**0.5) + group.eps)
40
+ group.variable.data[] = group.variable.data - update
41
+ @states[group.name] = State.new(
42
+ step: next_step,
43
+ exp_avg: exp_avg,
44
+ exp_avg_sq: exp_avg_sq
45
+ )
46
+ end
47
+ self
48
+ end
49
+ # rubocop:enable Metrics/AbcSize
50
+
51
+ def zero_grad!
52
+ groups.each_value { |group| group.variable.zero_grad! }
53
+ self
54
+ end
55
+
56
+ # Returns lazily initialized moment state for one or all groups.
57
+ #
58
+ # @return [State, Hash{Symbol=>State}]
59
+ def state(name = nil)
60
+ return state_for(name.to_sym) if name
61
+ return state_for(groups.keys.first) if groups.length == 1
62
+
63
+ groups.keys.to_h { |key| [key, state_for(key)] }
64
+ end
65
+
66
+ # Returns the current learning rate for one or all groups.
67
+ #
68
+ # @return [Numeric, Hash{Symbol=>Numeric}]
69
+ def learning_rate(name = nil)
70
+ return groups.fetch(name.to_sym).lr if name
71
+ return groups.values.first.lr if groups.length == 1
72
+
73
+ groups.transform_values(&:lr)
74
+ end
75
+
76
+ def learning_rate=(value)
77
+ raise ArgumentError, "learning rate must be positive" unless value.positive?
78
+
79
+ @groups = groups.transform_values do |group|
80
+ Group.new(name: group.name, variable: group.variable, lr: value, eps: group.eps)
81
+ end
82
+ end
83
+
84
+ def set_learning_rate(name, value)
85
+ raise ArgumentError, "learning rate must be positive" unless value.positive?
86
+
87
+ key = name.to_sym
88
+ group = groups.fetch(key)
89
+ @groups[key] = Group.new(name: key, variable: group.variable, lr: value, eps: group.eps)
90
+ end
91
+
92
+ # Selects first-axis optimizer rows after pruning parameters.
93
+ #
94
+ # @param selection [Numo::Bit, Array<Integer>, Range]
95
+ # @return [self]
96
+ def select!(selection)
97
+ groups.each_key do |name|
98
+ current = state_for(name)
99
+ @states[name] = State.new(
100
+ step: current.step,
101
+ exp_avg: select_first_axis(current.exp_avg, selection),
102
+ exp_avg_sq: select_first_axis(current.exp_avg_sq, selection)
103
+ )
104
+ end
105
+ self
106
+ end
107
+
108
+ def append!(count)
109
+ raise ArgumentError, "append count must be non-negative" unless count.is_a?(Integer) && !count.negative?
110
+ return self if count.zero?
111
+
112
+ groups.each_key do |name|
113
+ current = state_for(name)
114
+ @states[name] = State.new(
115
+ step: current.step,
116
+ exp_avg: append_zeros(current.exp_avg, count),
117
+ exp_avg_sq: append_zeros(current.exp_avg_sq, count)
118
+ )
119
+ end
120
+ self
121
+ end
122
+
123
+ # Clears moments at selected first-axis rows.
124
+ #
125
+ # @param indices [Integer, Array<Integer>, Range]
126
+ # @return [self]
127
+ def zero_state_at!(indices)
128
+ groups.each_key do |name|
129
+ current = state_for(name)
130
+ index = [indices] + Array.new(current.exp_avg.ndim - 1, true)
131
+ current.exp_avg[*index] = 0
132
+ current.exp_avg_sq[*index] = 0
133
+ end
134
+ self
135
+ end
136
+
137
+ # Restores one parameter group's moments from a checkpoint.
138
+ def load_state!(name = nil, step:, exp_avg:, exp_avg_sq:)
139
+ key = name ? name.to_sym : single_group_name
140
+ group = groups.fetch(key)
141
+ expected = group.variable.data.shape
142
+ unless exp_avg.shape == expected && exp_avg_sq.shape == expected
143
+ raise ShapeError,
144
+ "Adam state for #{key} expected #{expected.inspect}, " \
145
+ "got #{exp_avg.shape.inspect} and #{exp_avg_sq.shape.inspect}"
146
+ end
147
+ raise ArgumentError, "Adam step must be a non-negative integer" unless step.is_a?(Integer) && !step.negative?
148
+
149
+ type = group.variable.data.class
150
+ @states[key] = State.new(
151
+ step: step,
152
+ exp_avg: type.cast(exp_avg).dup,
153
+ exp_avg_sq: type.cast(exp_avg_sq).dup
154
+ )
155
+ self
156
+ end
157
+
158
+ private
159
+
160
+ def single_group_name
161
+ return groups.keys.first if groups.length == 1
162
+
163
+ raise ArgumentError, "group name is required for a multi-group Adam optimizer"
164
+ end
165
+
166
+ def validate_hyperparameters!(learning_rate, epsilon)
167
+ valid_betas = [beta1, beta2].all? { |value| value >= 0 && value < 1 }
168
+ raise ArgumentError, "betas must be in [0,1)" unless valid_betas
169
+ raise ArgumentError, "learning rate must be positive" unless learning_rate.positive?
170
+ raise ArgumentError, "eps must be positive" unless epsilon.positive?
171
+ end
172
+
173
+ def normalize_groups(input, default_lr, default_eps)
174
+ raw_groups = input.is_a?(Autograd::Variable) ? { default: { variable: input } } : input
175
+ unless raw_groups.is_a?(Hash) && !raw_groups.empty?
176
+ raise ArgumentError, "groups must be a Variable or non-empty Hash"
177
+ end
178
+
179
+ raw_groups.to_h do |name, specification|
180
+ specification = { variable: specification } if specification.is_a?(Autograd::Variable)
181
+ variable = specification.fetch(:variable)
182
+ raise ArgumentError, "group variable must be an Autograd::Variable" unless variable.is_a?(Autograd::Variable)
183
+
184
+ group_lr = specification.fetch(:lr, default_lr)
185
+ group_eps = specification.fetch(:eps, default_eps)
186
+ validate_hyperparameters!(group_lr, group_eps)
187
+ key = name.to_sym
188
+ [key, Group.new(name: key, variable: variable, lr: group_lr, eps: group_eps)]
189
+ end
190
+ end
191
+
192
+ def state_for(name)
193
+ group = groups.fetch(name)
194
+ @states[name] ||= State.new(
195
+ step: 0,
196
+ exp_avg: group.variable.data.class.zeros(*group.variable.data.shape),
197
+ exp_avg_sq: group.variable.data.class.zeros(*group.variable.data.shape)
198
+ )
199
+ end
200
+
201
+ def select_first_axis(array, selection)
202
+ array[*([selection] + Array.new(array.ndim - 1, true))].dup
203
+ end
204
+
205
+ def append_zeros(array, count)
206
+ output = array.class.zeros(*([array.shape[0] + count] + array.shape[1..]))
207
+ output[*([0...array.shape[0]] + Array.new(array.ndim - 1, true))] = array
208
+ output
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module Optim
5
+ # Closed-form exponential learning-rate schedule.
6
+ class ExponentialLR
7
+ attr_reader :max_steps, :step_count
8
+
9
+ def initialize(optimizer, lr_final:, max_steps:)
10
+ raise ArgumentError, "lr_final must be positive" unless lr_final.positive?
11
+ raise ArgumentError, "max_steps must be positive" unless max_steps.is_a?(Integer) && max_steps.positive?
12
+
13
+ @optimizer = optimizer
14
+ @lr_final = lr_final
15
+ @max_steps = max_steps
16
+ @step_count = 0
17
+ @initial_rates = optimizer.groups.transform_values(&:lr)
18
+ end
19
+
20
+ # Advances or sets the schedule and updates every optimizer group.
21
+ #
22
+ # @param step [Integer, nil] absolute step, or nil to advance once
23
+ # @return [Numeric, Hash{Symbol=>Numeric}] updated learning rates
24
+ def step(step = nil)
25
+ @step_count = step || (step_count + 1)
26
+ progress = [step_count.to_f / max_steps, 1.0].min
27
+ @optimizer.groups.each_key do |name|
28
+ initial = @initial_rates.fetch(name)
29
+ rate = initial * ((@lr_final / initial)**progress)
30
+ @optimizer.set_learning_rate(name, rate)
31
+ end
32
+ @optimizer.learning_rate
33
+ end
34
+ end
35
+ end
36
+ end