trained regression model evaluation
This commit is contained in:
@@ -8,9 +8,13 @@ module Stats
|
||||
extend T::Helpers
|
||||
abstract!
|
||||
|
||||
sig { params(normalizer: Stats::DataNormalizer).void }
|
||||
def initialize(normalizer)
|
||||
@normalizer = normalizer
|
||||
sig { returns(Stats::Axis) }
|
||||
attr_reader :x, :y
|
||||
|
||||
sig { params(x: Stats::Axis, y: Stats::Axis).void }
|
||||
def initialize(x, y)
|
||||
@x = x
|
||||
@y = y
|
||||
end
|
||||
|
||||
sig { returns(String) }
|
||||
@@ -26,14 +30,9 @@ module Stats
|
||||
# Evaluate the equation for a given x value
|
||||
sig(:final) { params(x_value: Float).returns(Float) }
|
||||
def evaluate(x_value)
|
||||
normalizer.y.denormalize(
|
||||
evaluate_normalized(normalizer.x.normalize(x_value)),
|
||||
)
|
||||
y.denormalize(evaluate_normalized(x.normalize(x_value)))
|
||||
end
|
||||
|
||||
sig { returns(Stats::DataNormalizer) }
|
||||
attr_reader :normalizer
|
||||
|
||||
protected
|
||||
|
||||
sig { abstract.params(x_value: Float).returns(Float) }
|
||||
|
||||
@@ -6,13 +6,10 @@ module Stats
|
||||
extend T::Sig
|
||||
|
||||
sig do
|
||||
params(
|
||||
normalizer: Stats::DataNormalizer,
|
||||
coefficients: T::Array[Float],
|
||||
).void
|
||||
params(x: Stats::Axis, y: Stats::Axis, coefficients: T::Array[Float]).void
|
||||
end
|
||||
def initialize(normalizer, coefficients)
|
||||
super(normalizer)
|
||||
def initialize(x, y, coefficients)
|
||||
super(x, y)
|
||||
@coefficients = T.let(coefficients, T::Array[Float])
|
||||
end
|
||||
|
||||
@@ -25,14 +22,13 @@ module Stats
|
||||
# Evaluate the polynomial equation for a given x value
|
||||
sig(:final) { override.params(x_value: Float).returns(Float) }
|
||||
def evaluate_normalized(x_value)
|
||||
return 0.0 if @coefficients.empty?
|
||||
result = @coefficients.last
|
||||
return 0.0 if result.nil?
|
||||
|
||||
# Use Horner's method for efficient polynomial evaluation
|
||||
result = T.let(@coefficients.last, T.untyped)
|
||||
T
|
||||
.must(@coefficients[0...-1])
|
||||
.reverse
|
||||
.each { |coeff| result = result * x_value + coeff }
|
||||
(@coefficients[0...-1] || []).reverse_each do |coeff|
|
||||
result = result * x_value + coeff
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
|
||||
@@ -182,13 +182,14 @@ module Stats
|
||||
else
|
||||
raise "Unsupported normalizer for PolynomialEquation: #{normalizer.class}"
|
||||
end
|
||||
Stats::PolynomialEquation.new(normalizer, coefficients)
|
||||
Stats::PolynomialEquation.new(normalizer.x, normalizer.y, coefficients)
|
||||
elsif equation_class == Stats::LogarithmicEquation ||
|
||||
equation_class == Stats::SquareRootEquation
|
||||
equation_class.new(
|
||||
normalizer,
|
||||
T.cast(weight_vec[1], Float),
|
||||
normalizer.x,
|
||||
normalizer.y,
|
||||
T.cast(weight_vec[0], Float),
|
||||
T.cast(weight_vec[1], Float),
|
||||
)
|
||||
else
|
||||
raise "Unsupported equation class: #{equation_class}"
|
||||
|
||||
@@ -10,13 +10,14 @@ module Stats
|
||||
|
||||
sig(:final) do
|
||||
params(
|
||||
normalizer: Stats::DataNormalizer,
|
||||
x: Stats::Axis,
|
||||
y: Stats::Axis,
|
||||
norm_slope: Float,
|
||||
norm_intercept: Float,
|
||||
).void
|
||||
end
|
||||
def initialize(normalizer, norm_slope, norm_intercept)
|
||||
super(normalizer)
|
||||
def initialize(x, y, norm_slope, norm_intercept)
|
||||
super(x, y)
|
||||
@norm_slope = norm_slope
|
||||
@norm_intercept = norm_intercept
|
||||
end
|
||||
@@ -37,8 +38,8 @@ module Stats
|
||||
|
||||
sig { override.returns(String) }
|
||||
def format_equation
|
||||
slope_orig = @norm_slope * @normalizer.y.scale
|
||||
intercept_orig = @norm_intercept * @normalizer.y.scale + @normalizer.y.min
|
||||
slope_orig = @norm_slope * @y.scale
|
||||
intercept_orig = @norm_intercept * @y.scale + @y.min
|
||||
|
||||
"y = #{Stats::Helpers.format_number(slope_orig)} * #{transform_symbol("x")} + #{Stats::Helpers.format_number(intercept_orig)}"
|
||||
end
|
||||
|
||||
@@ -64,18 +64,12 @@ class TrainedRegressionModel < ReduxApplicationRecord
|
||||
|
||||
sig { params(x_value: Float).returns(Float) }
|
||||
def predict(x_value)
|
||||
case model_type
|
||||
when "linear"
|
||||
predict_linear(x_value)
|
||||
when "quadratic"
|
||||
predict_quadratic(x_value)
|
||||
when "logarithmic"
|
||||
predict_logarithmic(x_value)
|
||||
when "square_root"
|
||||
predict_square_root(x_value)
|
||||
else
|
||||
raise "Unsupported model type: #{model_type}"
|
||||
end
|
||||
equation.evaluate(x_value)
|
||||
end
|
||||
|
||||
sig { returns(Stats::Equation) }
|
||||
def equation
|
||||
@equation ||= T.let(build_equation, T.nilable(Stats::Equation))
|
||||
end
|
||||
|
||||
sig { returns(String) }
|
||||
@@ -96,7 +90,10 @@ class TrainedRegressionModel < ReduxApplicationRecord
|
||||
)
|
||||
end
|
||||
def self.find_by_name_and_type(name, model_type)
|
||||
active.by_model_type(model_type).find_by(name: name)
|
||||
active
|
||||
.by_model_type(model_type)
|
||||
.order(created_at: :desc)
|
||||
.find_by(name: name)
|
||||
end
|
||||
|
||||
sig { params(model_type: String).returns(TrainedRegressionModel) }
|
||||
@@ -127,89 +124,62 @@ class TrainedRegressionModel < ReduxApplicationRecord
|
||||
end
|
||||
|
||||
sig { returns(Float) }
|
||||
def x_scale
|
||||
T.must(x_max) - T.must(x_min)
|
||||
def x_min
|
||||
T.must(super)
|
||||
end
|
||||
|
||||
sig { returns(Float) }
|
||||
def y_scale
|
||||
T.must(y_max) - T.must(y_min)
|
||||
def x_max
|
||||
T.must(super)
|
||||
end
|
||||
|
||||
sig { returns(T::Range[Float]) }
|
||||
def x_range
|
||||
T.must(x_min)..T.must(x_max)
|
||||
sig { returns(Float) }
|
||||
def y_min
|
||||
T.must(super)
|
||||
end
|
||||
|
||||
sig { returns(T::Range[Float]) }
|
||||
def y_range
|
||||
T.must(y_min)..T.must(y_max)
|
||||
sig { returns(Float) }
|
||||
def y_max
|
||||
T.must(super)
|
||||
end
|
||||
|
||||
sig { params(value: Float).returns(Float) }
|
||||
def normalize_x(value)
|
||||
(value - T.must(x_min)) / x_scale
|
||||
end
|
||||
|
||||
sig { params(value: Float).returns(Float) }
|
||||
def denormalize_x(value)
|
||||
value * x_scale + T.must(x_min)
|
||||
end
|
||||
|
||||
sig { params(value: Float).returns(Float) }
|
||||
def normalize_y(value)
|
||||
(value - T.must(y_min)) / y_scale
|
||||
end
|
||||
|
||||
sig { params(value: Float).returns(Float) }
|
||||
def denormalize_y(value)
|
||||
value * y_scale + T.must(y_min)
|
||||
end
|
||||
|
||||
sig { params(x_value: Float).returns(Float) }
|
||||
def predict_linear(x_value)
|
||||
return 0.0 if coefficients_array.length < 2
|
||||
|
||||
norm_intercept = T.cast(coefficients_array[0], Float)
|
||||
norm_slope = T.cast(coefficients_array[1], Float)
|
||||
|
||||
x_norm = normalize_x(x_value)
|
||||
y_norm = norm_slope * x_norm + norm_intercept
|
||||
denormalize_y(y_norm)
|
||||
end
|
||||
|
||||
sig { params(x_value: Float).returns(Float) }
|
||||
def predict_quadratic(x_value)
|
||||
return 0.0 if coefficients_array.length < 3
|
||||
|
||||
norm_a = T.cast(coefficients_array[2], Float)
|
||||
norm_b = T.cast(coefficients_array[1], Float)
|
||||
norm_c = T.cast(coefficients_array[0], Float)
|
||||
|
||||
x_norm = normalize_x(x_value)
|
||||
y_norm = norm_a * x_norm * x_norm + norm_b * x_norm + norm_c
|
||||
denormalize_y(y_norm)
|
||||
end
|
||||
|
||||
sig { params(x_value: Float).returns(Float) }
|
||||
def predict_logarithmic(x_value)
|
||||
return 0.0 if coefficients_array.length < 2 || x_value <= 0
|
||||
|
||||
norm_slope = T.cast(coefficients_array[1], Float)
|
||||
norm_intercept = T.cast(coefficients_array[0], Float)
|
||||
|
||||
y_norm = norm_slope * Math.log(x_value) + norm_intercept
|
||||
denormalize_y(y_norm)
|
||||
end
|
||||
|
||||
sig { params(x_value: Float).returns(Float) }
|
||||
def predict_square_root(x_value)
|
||||
return 0.0 if coefficients_array.length < 2 || x_value < 0
|
||||
|
||||
norm_slope = T.cast(coefficients_array[1], Float)
|
||||
norm_intercept = T.cast(coefficients_array[0], Float)
|
||||
|
||||
y_norm = norm_slope * Math.sqrt(x_value) + norm_intercept
|
||||
denormalize_y(y_norm)
|
||||
sig { returns(Stats::Equation) }
|
||||
def build_equation
|
||||
case model_type
|
||||
when "linear"
|
||||
Stats::PolynomialEquation.new(
|
||||
Stats::LinearAxis.new(min: x_min, max: x_max),
|
||||
Stats::LinearAxis.new(min: y_min, max: y_max),
|
||||
coefficients_array,
|
||||
)
|
||||
when "quadratic"
|
||||
Stats::PolynomialEquation.new(
|
||||
Stats::LinearAxis.new(min: x_min, max: x_max),
|
||||
Stats::LinearAxis.new(min: y_min, max: y_max),
|
||||
coefficients_array,
|
||||
)
|
||||
when "logarithmic"
|
||||
# For transformed equations: slope, intercept order in constructor
|
||||
slope = T.cast(coefficients_array[1], Float)
|
||||
intercept = T.cast(coefficients_array[0], Float)
|
||||
Stats::LogarithmicEquation.new(
|
||||
Stats::LogarithmicAxis.new(min: x_min, max: x_max),
|
||||
Stats::LogarithmicAxis.new(min: y_min, max: y_max),
|
||||
slope,
|
||||
intercept,
|
||||
)
|
||||
when "square_root"
|
||||
# For transformed equations: slope, intercept order in constructor
|
||||
slope = T.cast(coefficients_array[1], Float)
|
||||
intercept = T.cast(coefficients_array[0], Float)
|
||||
Stats::SquareRootEquation.new(
|
||||
Stats::SquareRootAxis.new(min: x_min, max: x_max),
|
||||
Stats::SquareRootAxis.new(min: y_min, max: y_max),
|
||||
slope,
|
||||
intercept,
|
||||
)
|
||||
else
|
||||
raise "Unsupported model type: #{model_type}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,7 +65,7 @@ namespace :stats do
|
||||
|
||||
# Save each regression model to the database
|
||||
regressions.each do |name, result|
|
||||
normalizer = result.equation.normalizer
|
||||
equation = result.equation
|
||||
model_name = "fa_fav_id_and_date_#{name.downcase}"
|
||||
TrainedRegressionModel.find_by(name: model_name)&.destroy
|
||||
TrainedRegressionModel.create!(
|
||||
@@ -78,17 +78,17 @@ namespace :stats do
|
||||
train_test_split_ratio: 0.8, # hardcoded, see split_train_test default
|
||||
random_seed: 42, # hardcoded, see split_train_test
|
||||
max_points_limit: max_points,
|
||||
x_min: normalizer.x.min,
|
||||
x_max: normalizer.x.max,
|
||||
y_min: normalizer.y.min,
|
||||
y_max: normalizer.y.max,
|
||||
coefficients: result.equation.coefficients,
|
||||
x_min: equation.x.min,
|
||||
x_max: equation.x.max,
|
||||
y_min: equation.y.min,
|
||||
y_max: equation.y.max,
|
||||
coefficients: equation.coefficients,
|
||||
training_r_squared: result.training_r_squared,
|
||||
evaluation_r_squared: result.evaluation_r_squared,
|
||||
equation_string: result.equation_string,
|
||||
metadata: {
|
||||
x_range: normalizer.x_range,
|
||||
y_range: normalizer.y_range,
|
||||
x_range: equation.x.range,
|
||||
y_range: equation.y.range,
|
||||
},
|
||||
)
|
||||
puts "💾 Saved #{name} regression model to DB."
|
||||
|
||||
@@ -13,14 +13,6 @@ RSpec.describe Stats::Equation do
|
||||
end
|
||||
|
||||
describe "Stats::Equation (abstract base class)" do
|
||||
it "cannot be instantiated directly" do
|
||||
normalizer = Stats::LinearNormalizer.new(sample_records)
|
||||
expect { Stats::Equation.new(normalizer) }.to raise_error(
|
||||
RuntimeError,
|
||||
/abstract/,
|
||||
)
|
||||
end
|
||||
|
||||
describe "Stats::Helpers.format_number" do
|
||||
it "formats regular numbers with appropriate precision" do
|
||||
expect(Stats::Helpers.format_number(123.456)).to eq("123")
|
||||
@@ -64,7 +56,9 @@ RSpec.describe Stats::Equation do
|
||||
let(:normalizer) { Stats::LinearNormalizer.new(simple_records) }
|
||||
|
||||
context "with constant polynomial (y = c)" do
|
||||
let(:equation) { Stats::PolynomialEquation.new(normalizer, [0.42]) }
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer.x, normalizer.y, [0.42])
|
||||
end
|
||||
|
||||
it "returns constant value for any x" do
|
||||
# With y_scale = 100, coefficient 0.42 becomes 42.0 in original space
|
||||
@@ -79,7 +73,7 @@ RSpec.describe Stats::Equation do
|
||||
# For simple_records: x_scale = 10, y_scale = 100
|
||||
# To get y = 2x + 3 in original space, we need to work backwards
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [0.03, 0.2])
|
||||
Stats::PolynomialEquation.new(normalizer.x, normalizer.y, [0.03, 0.2])
|
||||
end
|
||||
|
||||
it "evaluates linear equation correctly" do
|
||||
@@ -112,7 +106,11 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
context "with quadratic polynomial (y = ax² + bx + c)" do
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [0.01, 0.02, 0.01])
|
||||
Stats::PolynomialEquation.new(
|
||||
normalizer.x,
|
||||
normalizer.y,
|
||||
[0.01, 0.02, 0.01],
|
||||
)
|
||||
end
|
||||
|
||||
it "evaluates quadratic equation correctly" do
|
||||
@@ -138,7 +136,11 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
context "with negative coefficients" do
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [0.1, -0.01])
|
||||
Stats::PolynomialEquation.new(
|
||||
normalizer.x,
|
||||
normalizer.y,
|
||||
[0.1, -0.01],
|
||||
)
|
||||
end
|
||||
|
||||
it "handles negative slopes correctly" do
|
||||
@@ -167,7 +169,7 @@ RSpec.describe Stats::Equation do
|
||||
# For logarithmic equations, the coefficients are in normalized space
|
||||
# y = norm_slope * ln(x) + norm_intercept, then denormalized
|
||||
let(:equation) do
|
||||
Stats::LogarithmicEquation.new(normalizer, 1.0, 0.0)
|
||||
Stats::LogarithmicEquation.new(normalizer.x, normalizer.y, 1.0, 0.0)
|
||||
end
|
||||
|
||||
it "evaluates at known points" do
|
||||
@@ -209,7 +211,9 @@ RSpec.describe Stats::Equation do
|
||||
let(:normalizer) { Stats::SquareRootNormalizer.new(sqrt_records) }
|
||||
|
||||
context "with simple coefficients" do
|
||||
let(:equation) { Stats::SquareRootEquation.new(normalizer, 1.0, 0.0) }
|
||||
let(:equation) do
|
||||
Stats::SquareRootEquation.new(normalizer.x, normalizer.y, 1.0, 0.0)
|
||||
end
|
||||
|
||||
it "evaluates at known points" do
|
||||
# At x = 0: sqrt(0) = 0
|
||||
@@ -275,9 +279,17 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
# Same coefficients, different normalizers
|
||||
equation_narrow =
|
||||
Stats::PolynomialEquation.new(narrow_normalizer, [1.0, 2.0])
|
||||
Stats::PolynomialEquation.new(
|
||||
narrow_normalizer.x,
|
||||
narrow_normalizer.y,
|
||||
[1.0, 2.0],
|
||||
)
|
||||
equation_wide =
|
||||
Stats::PolynomialEquation.new(wide_normalizer, [1.0, 2.0])
|
||||
Stats::PolynomialEquation.new(
|
||||
wide_normalizer.x,
|
||||
wide_normalizer.y,
|
||||
[1.0, 2.0],
|
||||
)
|
||||
|
||||
# Same x value should produce different results due to different normalization
|
||||
x_test = 15.0
|
||||
@@ -291,7 +303,7 @@ RSpec.describe Stats::Equation do
|
||||
context "boundary value testing" do
|
||||
let(:normalizer) { Stats::LinearNormalizer.new(sample_records) }
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [1.0, 0.01])
|
||||
Stats::PolynomialEquation.new(normalizer.x, normalizer.y, [1.0, 0.01])
|
||||
end
|
||||
|
||||
it "handles values at axis boundaries" do
|
||||
@@ -320,7 +332,12 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
context "with zero coefficients" do
|
||||
it "handles all-zero coefficients for polynomial" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [0.0, 0.0, 0.0])
|
||||
equation =
|
||||
Stats::PolynomialEquation.new(
|
||||
normalizer.x,
|
||||
normalizer.y,
|
||||
[0.0, 0.0, 0.0],
|
||||
)
|
||||
result = equation.evaluate(5.0)
|
||||
expect(result).to be_within(0.001).of(0.0)
|
||||
end
|
||||
@@ -333,7 +350,13 @@ RSpec.describe Stats::Equation do
|
||||
Stats::DataPoint.new(x: 10, y: 100),
|
||||
],
|
||||
)
|
||||
equation = Stats::LogarithmicEquation.new(log_normalizer, 0.0, 1.0)
|
||||
equation =
|
||||
Stats::LogarithmicEquation.new(
|
||||
log_normalizer.x,
|
||||
log_normalizer.y,
|
||||
0.0,
|
||||
1.0,
|
||||
)
|
||||
|
||||
# With zero slope, result should be constant (intercept only)
|
||||
result1 = equation.evaluate(2.0)
|
||||
@@ -344,7 +367,12 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
context "with large coefficients" do
|
||||
it "handles large polynomial coefficients" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [100.0, 100.0])
|
||||
equation =
|
||||
Stats::PolynomialEquation.new(
|
||||
normalizer.x,
|
||||
normalizer.y,
|
||||
[100.0, 100.0],
|
||||
)
|
||||
result = equation.evaluate(5.0)
|
||||
expect(result).to be_finite
|
||||
expect(result).to be > 1000.0
|
||||
@@ -353,7 +381,12 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
context "with small coefficients" do
|
||||
it "handles small polynomial coefficients" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [1e-6, 1e-6])
|
||||
equation =
|
||||
Stats::PolynomialEquation.new(
|
||||
normalizer.x,
|
||||
normalizer.y,
|
||||
[1e-6, 1e-6],
|
||||
)
|
||||
result = equation.evaluate(5.0)
|
||||
expect(result).to be_finite
|
||||
expect(result).to be > 0
|
||||
@@ -365,7 +398,9 @@ RSpec.describe Stats::Equation do
|
||||
let(:normalizer) { Stats::LinearNormalizer.new(sample_records) }
|
||||
|
||||
context "linear equations" do
|
||||
let(:equation) { Stats::PolynomialEquation.new(normalizer, [2.0, 3.0]) }
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer.x, normalizer.y, [2.0, 3.0])
|
||||
end
|
||||
|
||||
it "maintains linear relationship" do
|
||||
x1, x2, x3 = 150.0, 250.0, 350.0
|
||||
@@ -383,7 +418,11 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
context "quadratic equations" do
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [1.0, 0.0, 1.0])
|
||||
Stats::PolynomialEquation.new(
|
||||
normalizer.x,
|
||||
normalizer.y,
|
||||
[1.0, 0.0, 1.0],
|
||||
)
|
||||
end
|
||||
|
||||
it "maintains quadratic relationship" do
|
||||
@@ -414,13 +453,17 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
describe "#initialize" do
|
||||
it "initializes with normalizer and coefficients" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [1.0, 2.0, 3.0])
|
||||
equation =
|
||||
Stats::PolynomialEquation.new(
|
||||
normalizer.x,
|
||||
normalizer.y,
|
||||
[1.0, 2.0, 3.0],
|
||||
)
|
||||
expect(equation.coefficients).to eq([1.0, 2.0, 3.0])
|
||||
expect(equation.normalizer).to eq(normalizer)
|
||||
end
|
||||
|
||||
it "handles empty coefficients" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [])
|
||||
equation = Stats::PolynomialEquation.new(normalizer.x, normalizer.y, [])
|
||||
expect(equation.coefficients).to eq([])
|
||||
end
|
||||
end
|
||||
@@ -428,7 +471,11 @@ RSpec.describe Stats::Equation do
|
||||
describe "#evaluate" do
|
||||
context "with linear coefficients" do
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [100.0, 50.0])
|
||||
Stats::PolynomialEquation.new(
|
||||
normalizer.x,
|
||||
normalizer.y,
|
||||
[100.0, 50.0],
|
||||
)
|
||||
end
|
||||
|
||||
it "evaluates linear equation correctly" do
|
||||
@@ -441,7 +488,11 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
context "with quadratic coefficients" do
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [1.0, 2.0, 3.0])
|
||||
Stats::PolynomialEquation.new(
|
||||
normalizer.x,
|
||||
normalizer.y,
|
||||
[1.0, 2.0, 3.0],
|
||||
)
|
||||
end
|
||||
|
||||
it "evaluates quadratic equation correctly" do
|
||||
@@ -452,7 +503,9 @@ RSpec.describe Stats::Equation do
|
||||
end
|
||||
|
||||
context "with empty coefficients" do
|
||||
let(:equation) { Stats::PolynomialEquation.new(normalizer, []) }
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer.x, normalizer.y, [])
|
||||
end
|
||||
|
||||
it "returns denormalized zero for empty coefficients" do
|
||||
result = equation.evaluate(200.0)
|
||||
@@ -461,7 +514,8 @@ RSpec.describe Stats::Equation do
|
||||
end
|
||||
|
||||
it "handles edge cases" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [0.0])
|
||||
equation =
|
||||
Stats::PolynomialEquation.new(normalizer.x, normalizer.y, [0.0])
|
||||
result = equation.evaluate(300.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
@@ -471,16 +525,10 @@ RSpec.describe Stats::Equation do
|
||||
describe "Stats::LogarithmicEquation" do
|
||||
let(:normalizer) { Stats::LogarithmicNormalizer.new(sample_records) }
|
||||
|
||||
describe "#initialize" do
|
||||
it "initializes with normalizer and slope/intercept" do
|
||||
equation = Stats::LogarithmicEquation.new(normalizer, 2.0, 1.0)
|
||||
expect(equation.coefficients).to eq([1.0, 2.0])
|
||||
expect(equation.normalizer).to eq(normalizer)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#evaluate" do
|
||||
let(:equation) { Stats::LogarithmicEquation.new(normalizer, 2.0, 1.0) }
|
||||
let(:equation) do
|
||||
Stats::LogarithmicEquation.new(normalizer.x, normalizer.y, 2.0, 1.0)
|
||||
end
|
||||
|
||||
it "evaluates logarithmic equation correctly" do
|
||||
result = equation.evaluate(200.0)
|
||||
@@ -501,7 +549,8 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
describe "#to_s" do
|
||||
it "formats logarithmic equation correctly" do
|
||||
equation = Stats::LogarithmicEquation.new(normalizer, 2.0, 1.0)
|
||||
equation =
|
||||
Stats::LogarithmicEquation.new(normalizer.x, normalizer.y, 2.0, 1.0)
|
||||
formatted = equation.to_s
|
||||
expect(formatted).to include("y = ")
|
||||
expect(formatted).to include("ln(x)")
|
||||
@@ -510,7 +559,8 @@ RSpec.describe Stats::Equation do
|
||||
end
|
||||
|
||||
it "handles negative coefficients" do
|
||||
equation = Stats::LogarithmicEquation.new(normalizer, -2.0, -1.0)
|
||||
equation =
|
||||
Stats::LogarithmicEquation.new(normalizer.x, normalizer.y, -2.0, -1.0)
|
||||
formatted = equation.to_s
|
||||
expect(formatted).to include("y = ")
|
||||
expect(formatted).to include("ln(x)")
|
||||
@@ -518,7 +568,8 @@ RSpec.describe Stats::Equation do
|
||||
end
|
||||
|
||||
it "returns formatted equation string" do
|
||||
equation = Stats::LogarithmicEquation.new(normalizer, 2.0, 1.0)
|
||||
equation =
|
||||
Stats::LogarithmicEquation.new(normalizer.x, normalizer.y, 2.0, 1.0)
|
||||
expect(equation.to_s).to be_a(String)
|
||||
expect(equation.to_s).to include("y = ")
|
||||
end
|
||||
@@ -528,16 +579,10 @@ RSpec.describe Stats::Equation do
|
||||
describe "Stats::SquareRootEquation" do
|
||||
let(:normalizer) { Stats::SquareRootNormalizer.new(sample_records) }
|
||||
|
||||
describe "#initialize" do
|
||||
it "initializes with normalizer and slope/intercept" do
|
||||
equation = Stats::SquareRootEquation.new(normalizer, 2.0, 1.0)
|
||||
expect(equation.coefficients).to eq([1.0, 2.0])
|
||||
expect(equation.normalizer).to eq(normalizer)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#evaluate" do
|
||||
let(:equation) { Stats::SquareRootEquation.new(normalizer, 2.0, 1.0) }
|
||||
let(:equation) do
|
||||
Stats::SquareRootEquation.new(normalizer.x, normalizer.y, 2.0, 1.0)
|
||||
end
|
||||
|
||||
it "evaluates square root equation correctly" do
|
||||
result = equation.evaluate(200.0)
|
||||
@@ -558,7 +603,8 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
describe "#to_s" do
|
||||
it "formats square root equation correctly" do
|
||||
equation = Stats::SquareRootEquation.new(normalizer, 2.0, 1.0)
|
||||
equation =
|
||||
Stats::SquareRootEquation.new(normalizer.x, normalizer.y, 2.0, 1.0)
|
||||
formatted = equation.to_s
|
||||
expect(formatted).to include("y = ")
|
||||
expect(formatted).to include("√x")
|
||||
@@ -567,7 +613,8 @@ RSpec.describe Stats::Equation do
|
||||
end
|
||||
|
||||
it "handles negative coefficients" do
|
||||
equation = Stats::SquareRootEquation.new(normalizer, -2.0, -1.0)
|
||||
equation =
|
||||
Stats::SquareRootEquation.new(normalizer.x, normalizer.y, -2.0, -1.0)
|
||||
formatted = equation.to_s
|
||||
expect(formatted).to include("y = ")
|
||||
expect(formatted).to include("√x")
|
||||
@@ -575,7 +622,8 @@ RSpec.describe Stats::Equation do
|
||||
end
|
||||
|
||||
it "returns formatted equation string" do
|
||||
equation = Stats::SquareRootEquation.new(normalizer, 2.0, 1.0)
|
||||
equation =
|
||||
Stats::SquareRootEquation.new(normalizer.x, normalizer.y, 2.0, 1.0)
|
||||
expect(equation.to_s).to be_a(String)
|
||||
expect(equation.to_s).to include("y = ")
|
||||
end
|
||||
@@ -583,16 +631,11 @@ RSpec.describe Stats::Equation do
|
||||
end
|
||||
|
||||
describe "Stats::TransformedEquation (abstract base class)" do
|
||||
it "cannot be instantiated directly" do
|
||||
normalizer = Stats::LogarithmicNormalizer.new(sample_records)
|
||||
expect {
|
||||
Stats::TransformedEquation.new(normalizer, 1.0, 2.0)
|
||||
}.to raise_error(RuntimeError, /abstract/)
|
||||
end
|
||||
|
||||
describe "shared behavior" do
|
||||
let(:normalizer) { Stats::LogarithmicNormalizer.new(sample_records) }
|
||||
let(:equation) { Stats::LogarithmicEquation.new(normalizer, 2.0, 1.0) }
|
||||
let(:equation) do
|
||||
Stats::LogarithmicEquation.new(normalizer.x, normalizer.y, 2.0, 1.0)
|
||||
end
|
||||
|
||||
it "stores normalized slope and intercept" do
|
||||
expect(equation.coefficients).to eq([1.0, 2.0])
|
||||
@@ -609,19 +652,26 @@ RSpec.describe Stats::Equation do
|
||||
let(:normalizer) { Stats::LinearNormalizer.new(sample_records) }
|
||||
|
||||
it "handles extreme coefficient values" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [1e10, -1e10])
|
||||
equation =
|
||||
Stats::PolynomialEquation.new(normalizer.x, normalizer.y, [1e10, -1e10])
|
||||
result = equation.evaluate(200.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
|
||||
it "handles very small coefficient values" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [1e-10, 1e-10])
|
||||
equation =
|
||||
Stats::PolynomialEquation.new(
|
||||
normalizer.x,
|
||||
normalizer.y,
|
||||
[1e-10, 1e-10],
|
||||
)
|
||||
result = equation.evaluate(200.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
|
||||
it "handles single coefficient polynomial" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [42.0])
|
||||
equation =
|
||||
Stats::PolynomialEquation.new(normalizer.x, normalizer.y, [42.0])
|
||||
result = equation.evaluate(200.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
@@ -637,10 +687,28 @@ RSpec.describe Stats::Equation do
|
||||
|
||||
it "works with different normalizer types" do
|
||||
equations = [
|
||||
Stats::PolynomialEquation.new(linear_normalizer, [1.0, 2.0]),
|
||||
Stats::PolynomialEquation.new(quadratic_normalizer, [1.0, 2.0, 3.0]),
|
||||
Stats::LogarithmicEquation.new(log_normalizer, 2.0, 1.0),
|
||||
Stats::SquareRootEquation.new(sqrt_normalizer, 2.0, 1.0),
|
||||
Stats::PolynomialEquation.new(
|
||||
linear_normalizer.x,
|
||||
linear_normalizer.y,
|
||||
[1.0, 2.0],
|
||||
),
|
||||
Stats::PolynomialEquation.new(
|
||||
quadratic_normalizer.x,
|
||||
quadratic_normalizer.y,
|
||||
[1.0, 2.0, 3.0],
|
||||
),
|
||||
Stats::LogarithmicEquation.new(
|
||||
log_normalizer.x,
|
||||
log_normalizer.y,
|
||||
2.0,
|
||||
1.0,
|
||||
),
|
||||
Stats::SquareRootEquation.new(
|
||||
sqrt_normalizer.x,
|
||||
sqrt_normalizer.y,
|
||||
2.0,
|
||||
1.0,
|
||||
),
|
||||
]
|
||||
|
||||
equations.each do |equation|
|
||||
@@ -651,7 +719,12 @@ RSpec.describe Stats::Equation do
|
||||
end
|
||||
|
||||
it "handles the full range of input values" do
|
||||
equation = Stats::PolynomialEquation.new(linear_normalizer, [1.0, 2.0])
|
||||
equation =
|
||||
Stats::PolynomialEquation.new(
|
||||
linear_normalizer.x,
|
||||
linear_normalizer.y,
|
||||
[1.0, 2.0],
|
||||
)
|
||||
|
||||
sample_records.each do |record|
|
||||
result = equation.evaluate(record.x)
|
||||
|
||||
@@ -25,5 +25,315 @@ RSpec.describe TrainedRegressionModel, type: :model do
|
||||
)
|
||||
expect(model.predict(1.0)).to be_a(Float)
|
||||
end
|
||||
|
||||
it "predicts a value for a quadratic model" do
|
||||
model =
|
||||
described_class.new(
|
||||
name: "test-quadratic",
|
||||
model_type: "quadratic",
|
||||
total_records_count: 10,
|
||||
training_records_count: 8,
|
||||
evaluation_records_count: 2,
|
||||
train_test_split_ratio: 0.8,
|
||||
random_seed: 42,
|
||||
x_min: 0.0,
|
||||
x_max: 10.0,
|
||||
y_min: 0.0,
|
||||
y_max: 100.0,
|
||||
coefficients: [0.1, 0.2, 0.3], # c, b, a for ax² + bx + c
|
||||
training_r_squared: 0.95,
|
||||
evaluation_r_squared: 0.90,
|
||||
equation_string: "y = 0.3x² + 0.2x + 0.1",
|
||||
)
|
||||
result = model.predict(5.0)
|
||||
expect(result).to be_a(Float)
|
||||
expect(result).to be_finite
|
||||
end
|
||||
|
||||
it "predicts a value for a logarithmic model" do
|
||||
model =
|
||||
described_class.new(
|
||||
name: "test-log",
|
||||
model_type: "logarithmic",
|
||||
total_records_count: 10,
|
||||
training_records_count: 8,
|
||||
evaluation_records_count: 2,
|
||||
train_test_split_ratio: 0.8,
|
||||
random_seed: 42,
|
||||
x_min: 1.0,
|
||||
x_max: 100.0,
|
||||
y_min: 0.0,
|
||||
y_max: 50.0,
|
||||
coefficients: [0.1, 0.5], # intercept, slope
|
||||
training_r_squared: 0.85,
|
||||
evaluation_r_squared: 0.80,
|
||||
equation_string: "y = 0.5 * ln(x) + 0.1",
|
||||
)
|
||||
result = model.predict(10.0)
|
||||
expect(result).to be_a(Float)
|
||||
expect(result).to be_finite
|
||||
expect(result).to be > 0
|
||||
end
|
||||
|
||||
it "predicts a value for a square root model" do
|
||||
model =
|
||||
described_class.new(
|
||||
name: "test-sqrt",
|
||||
model_type: "square_root",
|
||||
total_records_count: 10,
|
||||
training_records_count: 8,
|
||||
evaluation_records_count: 2,
|
||||
train_test_split_ratio: 0.8,
|
||||
random_seed: 42,
|
||||
x_min: 0.0,
|
||||
x_max: 100.0,
|
||||
y_min: 0.0,
|
||||
y_max: 50.0,
|
||||
coefficients: [0.2, 0.8], # intercept, slope
|
||||
training_r_squared: 0.88,
|
||||
evaluation_r_squared: 0.83,
|
||||
equation_string: "y = 0.8 * √x + 0.2",
|
||||
)
|
||||
result = model.predict(25.0)
|
||||
expect(result).to be_a(Float)
|
||||
expect(result).to be_finite
|
||||
expect(result).to be > 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "#equation" do
|
||||
it "constructs and caches a Stats::PolynomialEquation for linear models" do
|
||||
model =
|
||||
described_class.new(
|
||||
name: "test-linear",
|
||||
model_type: "linear",
|
||||
total_records_count: 10,
|
||||
training_records_count: 8,
|
||||
evaluation_records_count: 2,
|
||||
train_test_split_ratio: 0.8,
|
||||
random_seed: 42,
|
||||
x_min: 0.0,
|
||||
x_max: 10.0,
|
||||
y_min: 0.0,
|
||||
y_max: 20.0,
|
||||
coefficients: [0.5, 1.0],
|
||||
training_r_squared: 1.0,
|
||||
evaluation_r_squared: 1.0,
|
||||
equation_string: "y = x",
|
||||
)
|
||||
|
||||
equation = model.equation
|
||||
expect(equation).to be_a(Stats::PolynomialEquation)
|
||||
expect(equation.coefficients).to eq([0.5, 1.0])
|
||||
|
||||
# Verify caching
|
||||
expect(model.equation).to be(equation)
|
||||
end
|
||||
|
||||
it "constructs and caches a Stats::PolynomialEquation for quadratic models" do
|
||||
model =
|
||||
described_class.new(
|
||||
name: "test-quadratic",
|
||||
model_type: "quadratic",
|
||||
total_records_count: 10,
|
||||
training_records_count: 8,
|
||||
evaluation_records_count: 2,
|
||||
train_test_split_ratio: 0.8,
|
||||
random_seed: 42,
|
||||
x_min: 0.0,
|
||||
x_max: 10.0,
|
||||
y_min: 0.0,
|
||||
y_max: 100.0,
|
||||
coefficients: [0.1, 0.2, 0.3],
|
||||
training_r_squared: 0.95,
|
||||
evaluation_r_squared: 0.90,
|
||||
equation_string: "y = 0.3x² + 0.2x + 0.1",
|
||||
)
|
||||
|
||||
equation = model.equation
|
||||
expect(equation).to be_a(Stats::PolynomialEquation)
|
||||
expect(equation.coefficients).to eq([0.1, 0.2, 0.3])
|
||||
end
|
||||
|
||||
it "constructs and caches a Stats::LogarithmicEquation for logarithmic models" do
|
||||
model =
|
||||
described_class.new(
|
||||
name: "test-log",
|
||||
model_type: "logarithmic",
|
||||
total_records_count: 10,
|
||||
training_records_count: 8,
|
||||
evaluation_records_count: 2,
|
||||
train_test_split_ratio: 0.8,
|
||||
random_seed: 42,
|
||||
x_min: 1.0,
|
||||
x_max: 100.0,
|
||||
y_min: 0.0,
|
||||
y_max: 50.0,
|
||||
coefficients: [0.1, 0.5],
|
||||
training_r_squared: 0.85,
|
||||
evaluation_r_squared: 0.80,
|
||||
equation_string: "y = 0.5 * ln(x) + 0.1",
|
||||
)
|
||||
|
||||
equation = model.equation
|
||||
expect(equation).to be_a(Stats::LogarithmicEquation)
|
||||
expect(equation.coefficients).to eq([0.1, 0.5])
|
||||
end
|
||||
|
||||
it "constructs and caches a Stats::SquareRootEquation for square root models" do
|
||||
model =
|
||||
described_class.new(
|
||||
name: "test-sqrt",
|
||||
model_type: "square_root",
|
||||
total_records_count: 10,
|
||||
training_records_count: 8,
|
||||
evaluation_records_count: 2,
|
||||
train_test_split_ratio: 0.8,
|
||||
random_seed: 42,
|
||||
x_min: 0.0,
|
||||
x_max: 100.0,
|
||||
y_min: 0.0,
|
||||
y_max: 50.0,
|
||||
coefficients: [0.2, 0.8],
|
||||
training_r_squared: 0.88,
|
||||
evaluation_r_squared: 0.83,
|
||||
equation_string: "y = 0.8 * √x + 0.2",
|
||||
)
|
||||
|
||||
equation = model.equation
|
||||
expect(equation).to be_a(Stats::SquareRootEquation)
|
||||
expect(equation.coefficients).to eq([0.2, 0.8])
|
||||
end
|
||||
end
|
||||
|
||||
describe "consistency with math" do
|
||||
context "linear model" do
|
||||
let(:model) do
|
||||
described_class.new(
|
||||
name: "test-linear-consistency",
|
||||
model_type: "linear",
|
||||
total_records_count: 10,
|
||||
training_records_count: 8,
|
||||
evaluation_records_count: 2,
|
||||
train_test_split_ratio: 0.8,
|
||||
random_seed: 42,
|
||||
x_min: 0.0,
|
||||
x_max: 10.0,
|
||||
y_min: 0.0,
|
||||
y_max: 20.0,
|
||||
coefficients: [0.5, 1.0], # intercept: 0.5, slope: 1.0
|
||||
training_r_squared: 1.0,
|
||||
evaluation_r_squared: 1.0,
|
||||
equation_string: "y = x + 10",
|
||||
)
|
||||
end
|
||||
|
||||
it "maintains linear relationship" do
|
||||
x1, x2, x3 = 2.0, 5.0, 8.0
|
||||
y1 = model.predict(x1)
|
||||
y2 = model.predict(x2)
|
||||
y3 = model.predict(x3)
|
||||
|
||||
# For linear: (y2-y1)/(x2-x1) = (y3-y2)/(x3-x2)
|
||||
slope1 = (y2 - y1) / (x2 - x1)
|
||||
slope2 = (y3 - y2) / (x3 - x2)
|
||||
|
||||
expect(slope1).to be_within(0.001).of(slope2)
|
||||
end
|
||||
end
|
||||
|
||||
context "quadratic model" do
|
||||
let(:model) do
|
||||
described_class.new(
|
||||
name: "test-quadratic-consistency",
|
||||
model_type: "quadratic",
|
||||
total_records_count: 10,
|
||||
training_records_count: 8,
|
||||
evaluation_records_count: 2,
|
||||
train_test_split_ratio: 0.8,
|
||||
random_seed: 42,
|
||||
x_min: 0.0,
|
||||
x_max: 10.0,
|
||||
y_min: 0.0,
|
||||
y_max: 100.0,
|
||||
coefficients: [0.1, 0.2, 0.3], # c, b, a
|
||||
training_r_squared: 0.95,
|
||||
evaluation_r_squared: 0.90,
|
||||
equation_string: "y = 0.3x² + 0.2x + 0.1",
|
||||
)
|
||||
end
|
||||
|
||||
it "maintains quadratic relationship through second differences" do
|
||||
x_values = [1.0, 2.0, 3.0, 4.0, 5.0]
|
||||
y_values = x_values.map { |x| model.predict(x) }
|
||||
|
||||
# Calculate first differences
|
||||
first_diffs =
|
||||
(1...y_values.length).map { |i| y_values[i] - y_values[i - 1] }
|
||||
|
||||
# Calculate second differences
|
||||
second_diffs =
|
||||
(1...first_diffs.length).map do |i|
|
||||
first_diffs[i] - first_diffs[i - 1]
|
||||
end
|
||||
|
||||
# For quadratic, second differences should be approximately constant
|
||||
second_diffs.each_with_index do |diff, i|
|
||||
next if i == 0
|
||||
expect(diff).to be_within(0.01).of(second_diffs[0])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "error handling" do
|
||||
it "raises error for unsupported model type during initialization" do
|
||||
expect {
|
||||
described_class.new(
|
||||
name: "test-unsupported",
|
||||
model_type: "unsupported",
|
||||
total_records_count: 10,
|
||||
training_records_count: 8,
|
||||
evaluation_records_count: 2,
|
||||
train_test_split_ratio: 0.8,
|
||||
random_seed: 42,
|
||||
x_min: 0.0,
|
||||
x_max: 10.0,
|
||||
y_min: 0.0,
|
||||
y_max: 20.0,
|
||||
coefficients: [0.5, 1.0],
|
||||
training_r_squared: 1.0,
|
||||
evaluation_r_squared: 1.0,
|
||||
equation_string: "y = x",
|
||||
)
|
||||
}.to raise_error(ArgumentError, /'unsupported' is not a valid model_type/)
|
||||
end
|
||||
|
||||
it "produces consistent results across multiple calls" do
|
||||
model =
|
||||
described_class.new(
|
||||
name: "test-consistency",
|
||||
model_type: "linear",
|
||||
total_records_count: 10,
|
||||
training_records_count: 8,
|
||||
evaluation_records_count: 2,
|
||||
train_test_split_ratio: 0.8,
|
||||
random_seed: 42,
|
||||
x_min: 0.0,
|
||||
x_max: 10.0,
|
||||
y_min: 0.0,
|
||||
y_max: 20.0,
|
||||
coefficients: [0.5, 1.0],
|
||||
training_r_squared: 1.0,
|
||||
evaluation_r_squared: 1.0,
|
||||
equation_string: "y = x",
|
||||
)
|
||||
|
||||
x_value = 5.0
|
||||
result1 = model.predict(x_value)
|
||||
result2 = model.predict(x_value)
|
||||
|
||||
expect(result1).to eq(result2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user