add tests for expression
This commit is contained in:
@@ -8,19 +8,11 @@ module Stats
|
||||
extend T::Helpers
|
||||
abstract!
|
||||
|
||||
sig(:final) { params(records: T::Array[Domain::FaFavIdAndDate]).void }
|
||||
def initialize(records)
|
||||
data_points =
|
||||
records.map do |record|
|
||||
{
|
||||
x: record.fav_fa_id.to_f,
|
||||
y: T.cast(record.date&.to_time&.to_i&.to_f, Float),
|
||||
}
|
||||
end
|
||||
|
||||
data_points.sort_by! { |point| point[:x] }
|
||||
@x_values = T.let(data_points.map { |p| p[:x] }, T::Array[Float])
|
||||
@y_values = T.let(data_points.map { |p| p[:y] }, T::Array[Float])
|
||||
sig(:final) { params(data_points: T::Array[Stats::DataPoint]).void }
|
||||
def initialize(data_points)
|
||||
data_points.sort_by! { |point| point.x }
|
||||
@x_values = T.let(data_points.map { |p| p.x.to_f }, T::Array[Float])
|
||||
@y_values = T.let(data_points.map { |p| p.y.to_f }, T::Array[Float])
|
||||
|
||||
# Calculate min/max for normalization
|
||||
x_minmax = T.cast(@x_values.minmax, [Float, Float])
|
||||
|
||||
17
app/lib/stats/data_point.rb
Normal file
17
app/lib/stats/data_point.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Stats
|
||||
class DataPoint
|
||||
extend T::Sig
|
||||
|
||||
sig { returns(Float) }
|
||||
attr_reader :x, :y
|
||||
|
||||
sig { params(x: Numeric, y: Numeric).void }
|
||||
def initialize(x:, y:)
|
||||
@x = T.let(x.to_f, Float)
|
||||
@y = T.let(y.to_f, Float)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,30 +13,6 @@ module Stats
|
||||
@normalizer = normalizer
|
||||
end
|
||||
|
||||
# Format a number with significant figures and scientific notation when needed
|
||||
sig { params(num: Float, sig_figs: Integer).returns(String) }
|
||||
def format_number(num, sig_figs = 3)
|
||||
# Handle zero case
|
||||
return "0.0" if num.zero?
|
||||
|
||||
# Get order of scale
|
||||
order = Math.log10(num.abs).floor
|
||||
|
||||
# Use scientific notation for very large or small numbers
|
||||
if order >= 6 || order <= -3
|
||||
# Scale number between 1 and 10
|
||||
scaled = num / (10.0**order)
|
||||
# Round to sig figs
|
||||
rounded = scaled.round(sig_figs - 1)
|
||||
"#{rounded}e#{order}"
|
||||
else
|
||||
# For normal range numbers, just round to appropriate decimal places
|
||||
decimal_places = sig_figs - (order + 1)
|
||||
decimal_places = 0 if decimal_places < 0
|
||||
num.round(decimal_places).to_s
|
||||
end
|
||||
end
|
||||
|
||||
sig { returns(String) }
|
||||
def to_s
|
||||
format_equation
|
||||
|
||||
@@ -5,9 +5,7 @@ module Stats::Helpers
|
||||
extend T::Sig
|
||||
|
||||
sig do
|
||||
params(max_points: T.nilable(Integer)).returns(
|
||||
T::Array[Domain::FaFavIdAndDate],
|
||||
)
|
||||
params(max_points: T.nilable(Integer)).returns(T::Array[Stats::DataPoint])
|
||||
end
|
||||
def self.sample_records(max_points)
|
||||
records = Domain::FaFavIdAndDate.complete
|
||||
@@ -43,14 +41,18 @@ module Stats::Helpers
|
||||
puts "📊 Using all #{records_array.length} records (#{message})"
|
||||
end
|
||||
|
||||
records_array
|
||||
records_array.map do |record|
|
||||
Stats::DataPoint.new(
|
||||
x: record.fav_fa_id.to_f,
|
||||
y: T.cast(record.date&.to_time&.to_i&.to_f, Float),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
sig do
|
||||
params(
|
||||
records: T::Array[Domain::FaFavIdAndDate],
|
||||
eval_ratio: Float,
|
||||
).returns(Stats::TrainTestSplit)
|
||||
params(records: T::Array[Stats::DataPoint], eval_ratio: Float).returns(
|
||||
Stats::TrainTestSplit,
|
||||
)
|
||||
end
|
||||
def self.split_train_test(records, eval_ratio = 0.2)
|
||||
# Set random seed for reproducibility
|
||||
@@ -63,15 +65,9 @@ module Stats::Helpers
|
||||
split_index = (records.length * (1.0 - eval_ratio)).round
|
||||
|
||||
training_records =
|
||||
T.cast(
|
||||
shuffled_records[0...split_index],
|
||||
T::Array[Domain::FaFavIdAndDate],
|
||||
)
|
||||
T.cast(shuffled_records[0...split_index], T::Array[Stats::DataPoint])
|
||||
evaluation_records =
|
||||
T.cast(
|
||||
shuffled_records[split_index..-1],
|
||||
T::Array[Domain::FaFavIdAndDate],
|
||||
)
|
||||
T.cast(shuffled_records[split_index..-1], T::Array[Stats::DataPoint])
|
||||
|
||||
split =
|
||||
Stats::TrainTestSplit.new(
|
||||
@@ -86,4 +82,28 @@ module Stats::Helpers
|
||||
def self.format_r_squared(value)
|
||||
value.round(3).to_f
|
||||
end
|
||||
|
||||
# Format a number with significant figures and scientific notation when needed
|
||||
sig { params(num: Float, sig_figs: Integer).returns(String) }
|
||||
def self.format_number(num, sig_figs = 3)
|
||||
# Handle zero case
|
||||
return "0.0" if num.zero?
|
||||
|
||||
# Get order of scale
|
||||
order = Math.log10(num.abs).floor
|
||||
|
||||
# Use scientific notation for very large or small numbers
|
||||
if order >= 6 || order <= -3
|
||||
# Scale number between 1 and 10
|
||||
scaled = num / (10.0**order)
|
||||
# Round to sig figs
|
||||
rounded = scaled.round(sig_figs - 1)
|
||||
"#{rounded}e#{order}"
|
||||
else
|
||||
# For normal range numbers, just round to appropriate decimal places
|
||||
decimal_places = sig_figs - (order + 1)
|
||||
decimal_places = 0 if decimal_places < 0
|
||||
num.round(decimal_places).to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -52,7 +52,7 @@ module Stats
|
||||
coefficients.each_with_index.map do |coeff, power|
|
||||
next if coeff.zero?
|
||||
|
||||
term = format_number(coeff)
|
||||
term = Stats::Helpers.format_number(coeff)
|
||||
case power
|
||||
when 0
|
||||
term
|
||||
|
||||
@@ -37,7 +37,7 @@ module Stats
|
||||
class RegressionAnalyzer
|
||||
extend T::Sig
|
||||
|
||||
sig { params(records: T::Array[Domain::FaFavIdAndDate]).void }
|
||||
sig { params(records: T::Array[Stats::DataPoint]).void }
|
||||
def initialize(records)
|
||||
@records = records
|
||||
end
|
||||
|
||||
@@ -6,8 +6,8 @@ module Stats
|
||||
class TrainTestSplit < T::ImmutableStruct
|
||||
extend T::Sig
|
||||
|
||||
const :training_records, T::Array[Domain::FaFavIdAndDate]
|
||||
const :evaluation_records, T::Array[Domain::FaFavIdAndDate]
|
||||
const :training_records, T::Array[Stats::DataPoint]
|
||||
const :evaluation_records, T::Array[Stats::DataPoint]
|
||||
|
||||
sig { returns(Integer) }
|
||||
def training_count
|
||||
|
||||
@@ -40,7 +40,7 @@ module Stats
|
||||
slope_orig = @norm_slope * @normalizer.y.scale
|
||||
intercept_orig = @norm_intercept * @normalizer.y.scale + @normalizer.y.min
|
||||
|
||||
"y = #{format_number(slope_orig)} * #{transform_symbol("x")} + #{format_number(intercept_orig)}"
|
||||
"y = #{Stats::Helpers.format_number(slope_orig)} * #{transform_symbol("x")} + #{Stats::Helpers.format_number(intercept_orig)}"
|
||||
end
|
||||
|
||||
# Abstract method for the transformation symbol
|
||||
|
||||
8
spec/factories/domain/fa_fav_id_and_date.rb
Normal file
8
spec/factories/domain/fa_fav_id_and_date.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
FactoryBot.define do
|
||||
factory :domain_fa_fav_id_and_date, class: "Domain::FaFavIdAndDate" do
|
||||
association :user, factory: :domain_user_fa_user
|
||||
post_fa_id { 12_345 }
|
||||
fav_fa_id { 67_890 }
|
||||
date { Time.current }
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,663 @@
|
||||
# typed: false
|
||||
require "rails_helper"
|
||||
|
||||
describe Stats::Equation do
|
||||
RSpec.describe Stats::Equation do
|
||||
let(:sample_records) do
|
||||
[
|
||||
Stats::DataPoint.new(x: 100, y: 1_000_000),
|
||||
Stats::DataPoint.new(x: 200, y: 2_000_000),
|
||||
Stats::DataPoint.new(x: 300, y: 3_000_000),
|
||||
Stats::DataPoint.new(x: 400, y: 4_000_000),
|
||||
Stats::DataPoint.new(x: 500, y: 5_000_000),
|
||||
]
|
||||
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")
|
||||
expect(Stats::Helpers.format_number(1.23456)).to eq("1.23")
|
||||
expect(Stats::Helpers.format_number(0.00123)).to eq("1.23e-3")
|
||||
end
|
||||
|
||||
it "formats zero correctly" do
|
||||
expect(Stats::Helpers.format_number(0.0)).to eq("0.0")
|
||||
end
|
||||
|
||||
it "formats very large numbers using scientific notation" do
|
||||
expect(Stats::Helpers.format_number(1234567890.0)).to eq("1.23e9")
|
||||
expect(Stats::Helpers.format_number(999999999.0)).to eq("10.0e8")
|
||||
end
|
||||
|
||||
it "formats very small numbers using scientific notation" do
|
||||
expect(Stats::Helpers.format_number(0.000001)).to eq("1.0e-6")
|
||||
expect(Stats::Helpers.format_number(0.0000123)).to eq("1.23e-5")
|
||||
end
|
||||
|
||||
it "respects significant figures parameter" do
|
||||
expect(Stats::Helpers.format_number(123.456, 2)).to eq("123")
|
||||
expect(Stats::Helpers.format_number(123.456, 4)).to eq("123.5")
|
||||
end
|
||||
|
||||
it "handles negative numbers" do
|
||||
expect(Stats::Helpers.format_number(-123.456)).to eq("-123")
|
||||
expect(Stats::Helpers.format_number(-0.000001)).to eq("-1.0e-6")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Equation evaluation correctness" do
|
||||
# Simple data with known mathematical relationships for testing
|
||||
let(:simple_records) do
|
||||
[Stats::DataPoint.new(x: 0, y: 0), Stats::DataPoint.new(x: 10, y: 100)]
|
||||
end
|
||||
|
||||
describe "PolynomialEquation evaluation correctness" do
|
||||
let(:normalizer) { Stats::LinearNormalizer.new(simple_records) }
|
||||
|
||||
context "with constant polynomial (y = c)" do
|
||||
let(:equation) { Stats::PolynomialEquation.new(normalizer, [0.42]) }
|
||||
|
||||
it "returns constant value for any x" do
|
||||
# With y_scale = 100, coefficient 0.42 becomes 42.0 in original space
|
||||
expected_value = 0.42 * 100 + 0 # coefficient * y_scale + y_min
|
||||
expect(equation.evaluate(0.0)).to be_within(0.001).of(expected_value)
|
||||
expect(equation.evaluate(5.0)).to be_within(0.001).of(expected_value)
|
||||
expect(equation.evaluate(10.0)).to be_within(0.001).of(expected_value)
|
||||
end
|
||||
end
|
||||
|
||||
context "with linear polynomial (y = mx + b)" 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])
|
||||
end
|
||||
|
||||
it "evaluates linear equation correctly" do
|
||||
# These tests verify the mathematical relationship, not exact values
|
||||
# since the coefficients are in normalized space
|
||||
y_0 = equation.evaluate(0.0)
|
||||
y_1 = equation.evaluate(1.0)
|
||||
y_5 = equation.evaluate(5.0)
|
||||
y_10 = equation.evaluate(10.0)
|
||||
|
||||
# Verify it's actually linear (constant slope)
|
||||
slope_0_to_1 = y_1 - y_0
|
||||
slope_1_to_5 = (y_5 - y_1) / 4
|
||||
slope_5_to_10 = (y_10 - y_5) / 5
|
||||
|
||||
expect(slope_0_to_1).to be_within(0.001).of(slope_1_to_5)
|
||||
expect(slope_1_to_5).to be_within(0.001).of(slope_5_to_10)
|
||||
end
|
||||
|
||||
it "handles boundary values correctly" do
|
||||
# Just verify the values are finite and in ascending order for positive slope
|
||||
result_min = equation.evaluate(0.0) # x_min
|
||||
result_max = equation.evaluate(10.0) # x_max
|
||||
|
||||
expect(result_min).to be_finite
|
||||
expect(result_max).to be_finite
|
||||
expect(result_max).to be > result_min # positive slope
|
||||
end
|
||||
end
|
||||
|
||||
context "with quadratic polynomial (y = ax² + bx + c)" do
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [0.01, 0.02, 0.01])
|
||||
end
|
||||
|
||||
it "evaluates quadratic equation correctly" do
|
||||
# Test that it maintains quadratic relationship
|
||||
x_values = [0.0, 2.0, 4.0, 6.0, 8.0, 10.0]
|
||||
y_values = x_values.map { |x| equation.evaluate(x) }
|
||||
|
||||
# For quadratic, second differences should be approximately constant
|
||||
first_diffs =
|
||||
(1...y_values.length).map { |i| y_values[i] - y_values[i - 1] }
|
||||
second_diffs =
|
||||
(1...first_diffs.length).map do |i|
|
||||
first_diffs[i] - first_diffs[i - 1]
|
||||
end
|
||||
|
||||
# All second differences should be approximately equal
|
||||
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
|
||||
|
||||
context "with negative coefficients" do
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [0.1, -0.01])
|
||||
end
|
||||
|
||||
it "handles negative slopes correctly" do
|
||||
y_0 = equation.evaluate(0.0)
|
||||
y_5 = equation.evaluate(5.0)
|
||||
y_10 = equation.evaluate(10.0)
|
||||
|
||||
# Negative slope means decreasing values
|
||||
expect(y_5).to be < y_0
|
||||
expect(y_10).to be < y_5
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "TransformedEquation evaluation correctness" do
|
||||
context "LogarithmicEquation" do
|
||||
let(:log_records) do
|
||||
[
|
||||
Stats::DataPoint.new(x: 1, y: 0),
|
||||
Stats::DataPoint.new(x: 10, y: 100),
|
||||
]
|
||||
end
|
||||
let(:normalizer) { Stats::LogarithmicNormalizer.new(log_records) }
|
||||
|
||||
context "with simple coefficients" 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)
|
||||
end
|
||||
|
||||
it "evaluates at known points" do
|
||||
# At x = 1: ln(1) = 0, so y should be close to y_min
|
||||
result_at_1 = equation.evaluate(1.0)
|
||||
expect(result_at_1).to be_within(5.0).of(0.0)
|
||||
|
||||
# At x = e ≈ 2.718: ln(e) = 1
|
||||
result_at_e = equation.evaluate(Math::E)
|
||||
expect(result_at_e).to be > result_at_1
|
||||
|
||||
# At x = 10: ln(10) ≈ 2.3
|
||||
result_at_10 = equation.evaluate(10.0)
|
||||
expect(result_at_10).to be > result_at_e
|
||||
end
|
||||
|
||||
it "maintains logarithmic relationship" do
|
||||
# ln(ab) = ln(a) + ln(b), so for y = k*ln(x):
|
||||
# y(2*4) should be related to y(2) + y(4)
|
||||
y_2 = equation.evaluate(2.0)
|
||||
y_4 = equation.evaluate(4.0)
|
||||
y_8 = equation.evaluate(8.0)
|
||||
|
||||
# For pure logarithmic: y(8) = y(2) + y(4) + y(1), but we have scaling
|
||||
# Just verify the ordering is correct
|
||||
expect(y_8).to be > y_4
|
||||
expect(y_4).to be > y_2
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "SquareRootEquation" do
|
||||
let(:sqrt_records) do
|
||||
[
|
||||
Stats::DataPoint.new(x: 0, y: 0),
|
||||
Stats::DataPoint.new(x: 100, y: 1000),
|
||||
]
|
||||
end
|
||||
let(:normalizer) { Stats::SquareRootNormalizer.new(sqrt_records) }
|
||||
|
||||
context "with simple coefficients" do
|
||||
let(:equation) { Stats::SquareRootEquation.new(normalizer, 1.0, 0.0) }
|
||||
|
||||
it "evaluates at known points" do
|
||||
# At x = 0: sqrt(0) = 0
|
||||
result_at_0 = equation.evaluate(0.0)
|
||||
expect(result_at_0).to be_within(5.0).of(0.0)
|
||||
|
||||
# At x = 1: sqrt(1) = 1
|
||||
result_at_1 = equation.evaluate(1.0)
|
||||
expect(result_at_1).to be > result_at_0
|
||||
|
||||
# At x = 4: sqrt(4) = 2
|
||||
result_at_4 = equation.evaluate(4.0)
|
||||
expect(result_at_4).to be > result_at_1
|
||||
|
||||
# At x = 100: sqrt(100) = 10
|
||||
result_at_100 = equation.evaluate(100.0)
|
||||
expect(result_at_100).to be > result_at_4
|
||||
end
|
||||
|
||||
it "maintains square root relationship" do
|
||||
# sqrt(x) grows slower than x
|
||||
y_4 = equation.evaluate(4.0)
|
||||
y_16 = equation.evaluate(16.0)
|
||||
y_64 = equation.evaluate(64.0)
|
||||
|
||||
# Verify square root growth pattern
|
||||
expect(y_16).to be > y_4
|
||||
expect(y_64).to be > y_16
|
||||
|
||||
# For pure square root: sqrt(16)/sqrt(4) = 2, sqrt(64)/sqrt(16) = 2
|
||||
# The ratio should be similar (accounting for normalization)
|
||||
if y_4 > 0 && y_16 > 0
|
||||
ratio_1 = y_16 / y_4
|
||||
ratio_2 = y_64 / y_16
|
||||
|
||||
# Both ratios should be similar for square root function
|
||||
expect(ratio_1).to be_within(0.5).of(ratio_2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "axis bounds impact on evaluation" do
|
||||
context "with different axis ranges" do
|
||||
let(:narrow_range_records) do
|
||||
[
|
||||
Stats::DataPoint.new(x: 10, y: 100),
|
||||
Stats::DataPoint.new(x: 20, y: 200),
|
||||
]
|
||||
end
|
||||
|
||||
let(:wide_range_records) do
|
||||
[
|
||||
Stats::DataPoint.new(x: 0, y: 0),
|
||||
Stats::DataPoint.new(x: 1000, y: 1_000_000),
|
||||
]
|
||||
end
|
||||
|
||||
it "produces different results for same coefficients with different axis bounds" do
|
||||
narrow_normalizer = Stats::LinearNormalizer.new(narrow_range_records)
|
||||
wide_normalizer = Stats::LinearNormalizer.new(wide_range_records)
|
||||
|
||||
# Same coefficients, different normalizers
|
||||
equation_narrow =
|
||||
Stats::PolynomialEquation.new(narrow_normalizer, [1.0, 2.0])
|
||||
equation_wide =
|
||||
Stats::PolynomialEquation.new(wide_normalizer, [1.0, 2.0])
|
||||
|
||||
# Same x value should produce different results due to different normalization
|
||||
x_test = 15.0
|
||||
result_narrow = equation_narrow.evaluate(x_test)
|
||||
result_wide = equation_wide.evaluate(x_test)
|
||||
|
||||
expect(result_narrow).not_to be_within(1.0).of(result_wide)
|
||||
end
|
||||
end
|
||||
|
||||
context "boundary value testing" do
|
||||
let(:normalizer) { Stats::LinearNormalizer.new(sample_records) }
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [1.0, 0.01])
|
||||
end
|
||||
|
||||
it "handles values at axis boundaries" do
|
||||
# Test at exact boundaries
|
||||
result_at_min = equation.evaluate(100.0) # x_min
|
||||
result_at_max = equation.evaluate(500.0) # x_max
|
||||
|
||||
expect(result_at_min).to be_finite
|
||||
expect(result_at_max).to be_finite
|
||||
expect(result_at_max).to be > result_at_min
|
||||
end
|
||||
|
||||
it "handles values outside axis boundaries" do
|
||||
# Test beyond boundaries
|
||||
result_below_min = equation.evaluate(50.0) # below x_min
|
||||
result_above_max = equation.evaluate(600.0) # above x_max
|
||||
|
||||
expect(result_below_min).to be_finite
|
||||
expect(result_above_max).to be_finite
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "coefficient impact on evaluation" do
|
||||
let(:normalizer) { Stats::LinearNormalizer.new(simple_records) }
|
||||
|
||||
context "with zero coefficients" do
|
||||
it "handles all-zero coefficients for polynomial" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [0.0, 0.0, 0.0])
|
||||
result = equation.evaluate(5.0)
|
||||
expect(result).to be_within(0.001).of(0.0)
|
||||
end
|
||||
|
||||
it "handles zero slope for transformed equations" do
|
||||
log_normalizer =
|
||||
Stats::LogarithmicNormalizer.new(
|
||||
[
|
||||
Stats::DataPoint.new(x: 1, y: 0),
|
||||
Stats::DataPoint.new(x: 10, y: 100),
|
||||
],
|
||||
)
|
||||
equation = Stats::LogarithmicEquation.new(log_normalizer, 0.0, 1.0)
|
||||
|
||||
# With zero slope, result should be constant (intercept only)
|
||||
result1 = equation.evaluate(2.0)
|
||||
result2 = equation.evaluate(8.0)
|
||||
expect(result1).to be_within(0.001).of(result2)
|
||||
end
|
||||
end
|
||||
|
||||
context "with large coefficients" do
|
||||
it "handles large polynomial coefficients" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [100.0, 100.0])
|
||||
result = equation.evaluate(5.0)
|
||||
expect(result).to be_finite
|
||||
expect(result).to be > 1000.0
|
||||
end
|
||||
end
|
||||
|
||||
context "with small coefficients" do
|
||||
it "handles small polynomial coefficients" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [1e-6, 1e-6])
|
||||
result = equation.evaluate(5.0)
|
||||
expect(result).to be_finite
|
||||
expect(result).to be > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "mathematical consistency" do
|
||||
let(:normalizer) { Stats::LinearNormalizer.new(sample_records) }
|
||||
|
||||
context "linear equations" do
|
||||
let(:equation) { Stats::PolynomialEquation.new(normalizer, [2.0, 3.0]) }
|
||||
|
||||
it "maintains linear relationship" do
|
||||
x1, x2, x3 = 150.0, 250.0, 350.0
|
||||
y1 = equation.evaluate(x1)
|
||||
y2 = equation.evaluate(x2)
|
||||
y3 = equation.evaluate(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 equations" do
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [1.0, 0.0, 1.0])
|
||||
end
|
||||
|
||||
it "maintains quadratic relationship" do
|
||||
# For y = x² + 1, second derivative should be constant
|
||||
x1, x2, x3, x4 = 200.0, 250.0, 300.0, 350.0
|
||||
y1 = equation.evaluate(x1)
|
||||
y2 = equation.evaluate(x2)
|
||||
y3 = equation.evaluate(x3)
|
||||
y4 = equation.evaluate(x4)
|
||||
|
||||
# Calculate second differences
|
||||
diff1 = y2 - y1
|
||||
diff2 = y3 - y2
|
||||
diff3 = y4 - y3
|
||||
|
||||
second_diff1 = diff2 - diff1
|
||||
second_diff2 = diff3 - diff2
|
||||
|
||||
# Second differences should be approximately equal for quadratic
|
||||
expect(second_diff1).to be_within(0.1).of(second_diff2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Stats::PolynomialEquation" do
|
||||
let(:normalizer) { Stats::LinearNormalizer.new(sample_records) }
|
||||
|
||||
describe "#initialize" do
|
||||
it "initializes with normalizer and coefficients" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [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, [])
|
||||
expect(equation.coefficients).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe "#evaluate" do
|
||||
context "with linear coefficients" do
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [100.0, 50.0])
|
||||
end
|
||||
|
||||
it "evaluates linear equation correctly" do
|
||||
# For a linear equation: y = 100 + 50x
|
||||
result = equation.evaluate(200.0)
|
||||
expect(result).to be_a(Float)
|
||||
expect(result).to be > 0
|
||||
end
|
||||
end
|
||||
|
||||
context "with quadratic coefficients" do
|
||||
let(:equation) do
|
||||
Stats::PolynomialEquation.new(normalizer, [1.0, 2.0, 3.0])
|
||||
end
|
||||
|
||||
it "evaluates quadratic equation correctly" do
|
||||
# For a quadratic equation: y = 1 + 2x + 3x²
|
||||
result = equation.evaluate(200.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
end
|
||||
|
||||
context "with empty coefficients" do
|
||||
let(:equation) { Stats::PolynomialEquation.new(normalizer, []) }
|
||||
|
||||
it "returns denormalized zero for empty coefficients" do
|
||||
result = equation.evaluate(200.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
end
|
||||
|
||||
it "handles edge cases" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [0.0])
|
||||
result = equation.evaluate(300.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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) }
|
||||
|
||||
it "evaluates logarithmic equation correctly" do
|
||||
result = equation.evaluate(200.0)
|
||||
expect(result).to be_a(Float)
|
||||
expect(result).to be > 0
|
||||
end
|
||||
|
||||
it "handles minimum x values" do
|
||||
result = equation.evaluate(100.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
|
||||
it "handles maximum x values" do
|
||||
result = equation.evaluate(500.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#to_s" do
|
||||
it "formats logarithmic equation correctly" do
|
||||
equation = Stats::LogarithmicEquation.new(normalizer, 2.0, 1.0)
|
||||
formatted = equation.to_s
|
||||
expect(formatted).to include("y = ")
|
||||
expect(formatted).to include("ln(x)")
|
||||
expect(formatted).to include("*")
|
||||
expect(formatted).to include("+")
|
||||
end
|
||||
|
||||
it "handles negative coefficients" do
|
||||
equation = Stats::LogarithmicEquation.new(normalizer, -2.0, -1.0)
|
||||
formatted = equation.to_s
|
||||
expect(formatted).to include("y = ")
|
||||
expect(formatted).to include("ln(x)")
|
||||
expect(formatted).to include("-")
|
||||
end
|
||||
|
||||
it "returns formatted equation string" do
|
||||
equation = Stats::LogarithmicEquation.new(normalizer, 2.0, 1.0)
|
||||
expect(equation.to_s).to be_a(String)
|
||||
expect(equation.to_s).to include("y = ")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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) }
|
||||
|
||||
it "evaluates square root equation correctly" do
|
||||
result = equation.evaluate(200.0)
|
||||
expect(result).to be_a(Float)
|
||||
expect(result).to be > 0
|
||||
end
|
||||
|
||||
it "handles minimum x values" do
|
||||
result = equation.evaluate(100.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
|
||||
it "handles maximum x values" do
|
||||
result = equation.evaluate(500.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#to_s" do
|
||||
it "formats square root equation correctly" do
|
||||
equation = Stats::SquareRootEquation.new(normalizer, 2.0, 1.0)
|
||||
formatted = equation.to_s
|
||||
expect(formatted).to include("y = ")
|
||||
expect(formatted).to include("√x")
|
||||
expect(formatted).to include("*")
|
||||
expect(formatted).to include("+")
|
||||
end
|
||||
|
||||
it "handles negative coefficients" do
|
||||
equation = Stats::SquareRootEquation.new(normalizer, -2.0, -1.0)
|
||||
formatted = equation.to_s
|
||||
expect(formatted).to include("y = ")
|
||||
expect(formatted).to include("√x")
|
||||
expect(formatted).to include("-")
|
||||
end
|
||||
|
||||
it "returns formatted equation string" do
|
||||
equation = Stats::SquareRootEquation.new(normalizer, 2.0, 1.0)
|
||||
expect(equation.to_s).to be_a(String)
|
||||
expect(equation.to_s).to include("y = ")
|
||||
end
|
||||
end
|
||||
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) }
|
||||
|
||||
it "stores normalized slope and intercept" do
|
||||
expect(equation.coefficients).to eq([1.0, 2.0])
|
||||
end
|
||||
|
||||
it "evaluates using the normalized transformation" do
|
||||
result = equation.evaluate(200.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Edge cases and error handling" do
|
||||
let(:normalizer) { Stats::LinearNormalizer.new(sample_records) }
|
||||
|
||||
it "handles extreme coefficient values" do
|
||||
equation = Stats::PolynomialEquation.new(normalizer, [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])
|
||||
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])
|
||||
result = equation.evaluate(200.0)
|
||||
expect(result).to be_a(Float)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Integration with normalizers" do
|
||||
let(:linear_normalizer) { Stats::LinearNormalizer.new(sample_records) }
|
||||
let(:quadratic_normalizer) do
|
||||
Stats::QuadraticNormalizer.new(sample_records)
|
||||
end
|
||||
let(:log_normalizer) { Stats::LogarithmicNormalizer.new(sample_records) }
|
||||
let(:sqrt_normalizer) { Stats::SquareRootNormalizer.new(sample_records) }
|
||||
|
||||
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),
|
||||
]
|
||||
|
||||
equations.each do |equation|
|
||||
result = equation.evaluate(300.0)
|
||||
expect(result).to be_a(Float)
|
||||
expect(equation.to_s).to include("y = ")
|
||||
end
|
||||
end
|
||||
|
||||
it "handles the full range of input values" do
|
||||
equation = Stats::PolynomialEquation.new(linear_normalizer, [1.0, 2.0])
|
||||
|
||||
sample_records.each do |record|
|
||||
result = equation.evaluate(record.x)
|
||||
expect(result).to be_a(Float)
|
||||
expect(result).to be_finite
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user