diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f4980..a33215f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project are documented in this file. +## [Unreleased] + +### Changed +- Clarified that response data must be a `Matrix` or array of arrays containing only integer `0`, integer `1`, or `nil`; floats, strings, booleans, and other values are rejected. + +--- + ## [0.3.0] - 2025-01-14 ### Changed diff --git a/README.md b/README.md index 9ca7521..c3ca0a5 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ result = model.fit puts "Abilities: #{result[:abilities]}" puts "Difficulties: #{result[:difficulties]}" ``` + +Response data passed to model constructors must be either a `Matrix` or an +array of arrays. Each response value must be the integer `0`, the integer `1`, +or `nil` for missing data; floats such as `0.0`/`1.0`, strings, booleans, and +other values are rejected. + ### Using 2PL and 3PL Models ```ruby two_pl_model = IrtRuby::TwoParameterModel.new(data) diff --git a/lib/irt_ruby.rb b/lib/irt_ruby.rb index c0bf58d..8fb5314 100644 --- a/lib/irt_ruby.rb +++ b/lib/irt_ruby.rb @@ -2,6 +2,7 @@ require "irt_ruby/version" require "matrix" +require "irt_ruby/response_data_validator" require "irt_ruby/rasch_model" require "irt_ruby/two_parameter_model" require "irt_ruby/three_parameter_model" diff --git a/lib/irt_ruby/rasch_model.rb b/lib/irt_ruby/rasch_model.rb index 0df0e82..086e70e 100644 --- a/lib/irt_ruby/rasch_model.rb +++ b/lib/irt_ruby/rasch_model.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "irt_ruby/response_data_validator" + module IrtRuby # A class representing the Rasch model for Item Response Theory (ability - difficulty). # Incorporates: @@ -20,7 +22,7 @@ def initialize(data, # missing_strategy: :ignore (skip), :treat_as_incorrect, :treat_as_correct @data = data - @data_array = data.to_a + @data_array = ResponseDataValidator.validate!(data) num_rows = @data_array.size num_cols = @data_array.first.size diff --git a/lib/irt_ruby/response_data_validator.rb b/lib/irt_ruby/response_data_validator.rb new file mode 100644 index 0000000..daebdcf --- /dev/null +++ b/lib/irt_ruby/response_data_validator.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module IrtRuby + # Validates response data accepted by IRT model constructors. + # @api private + module ResponseDataValidator + module_function + + def validate!(data) + raise ArgumentError, "response data must be a Matrix or array of arrays" unless valid_data_container?(data) + + data_array = data.to_a + + raise ArgumentError, "response data must have at least one row" unless data_array.any? + + validate_rows!(data_array) + validate_values!(data_array) + + data_array + end + + def validate_rows!(data_array) + first_row = data_array.first + + raise ArgumentError, "response data must be a Matrix or array of arrays" unless first_row.is_a?(Array) + + expected_columns = first_row.size + raise ArgumentError, "response data must have at least one column" if expected_columns.zero? + + data_array.each_with_index do |row, index| + row_number = index + 1 + raise ArgumentError, "response data row #{row_number} must be an Array" unless row.is_a?(Array) + + next if row.size == expected_columns + + raise ArgumentError, "response data must be rectangular; row #{row_number} has #{row.size} columns, expected #{expected_columns}" + end + end + + def validate_values!(data_array) + data_array.each_with_index do |row, row_index| + row.each_with_index do |value, column_index| + next if valid_response?(value) + + raise ArgumentError, + "response data contains invalid value #{value.inspect} at row #{row_index + 1}, column #{column_index + 1}; allowed values are 0, 1, and nil" + end + end + end + + def valid_response?(value) + value.nil? || value.eql?(0) || value.eql?(1) + end + + def valid_data_container?(data) + data.is_a?(Array) || (defined?(::Matrix) && data.is_a?(::Matrix)) + end + end +end diff --git a/lib/irt_ruby/three_parameter_model.rb b/lib/irt_ruby/three_parameter_model.rb index c3afcba..f172de2 100644 --- a/lib/irt_ruby/three_parameter_model.rb +++ b/lib/irt_ruby/three_parameter_model.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "irt_ruby/response_data_validator" + module IrtRuby # A class representing the Three-Parameter model (3PL) for Item Response Theory. # Incorporates: @@ -19,7 +21,7 @@ def initialize(data, decay_factor: 0.5, missing_strategy: :ignore) @data = data - @data_array = data.to_a + @data_array = ResponseDataValidator.validate!(data) num_rows = @data_array.size num_cols = @data_array.first.size diff --git a/lib/irt_ruby/two_parameter_model.rb b/lib/irt_ruby/two_parameter_model.rb index fd48a6a..e9a5a98 100644 --- a/lib/irt_ruby/two_parameter_model.rb +++ b/lib/irt_ruby/two_parameter_model.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "irt_ruby/response_data_validator" + module IrtRuby # A class representing the Two-Parameter model (2PL) for IRT. # Incorporates: @@ -15,7 +17,7 @@ def initialize(data, max_iter: 1000, tolerance: 1e-6, param_tolerance: 1e-6, learning_rate: 0.01, decay_factor: 0.5, missing_strategy: :ignore) @data = data - @data_array = data.to_a + @data_array = ResponseDataValidator.validate!(data) num_rows = @data_array.size num_cols = @data_array.first.size diff --git a/spec/irt_ruby/rasch_model_spec.rb b/spec/irt_ruby/rasch_model_spec.rb index 2e7a417..b4fa443 100644 --- a/spec/irt_ruby/rasch_model_spec.rb +++ b/spec/irt_ruby/rasch_model_spec.rb @@ -3,6 +3,8 @@ require "spec_helper" RSpec.describe IrtRuby::RaschModel do + it_behaves_like "response data validation" + let(:data_array) do [ [1, 1, 0], diff --git a/spec/irt_ruby/three_parameter_model_spec.rb b/spec/irt_ruby/three_parameter_model_spec.rb index 8693393..e120ea6 100644 --- a/spec/irt_ruby/three_parameter_model_spec.rb +++ b/spec/irt_ruby/three_parameter_model_spec.rb @@ -3,6 +3,8 @@ require "spec_helper" RSpec.describe IrtRuby::ThreeParameterModel do + it_behaves_like "response data validation" + let(:data_array) do [ [1, 1, 0], diff --git a/spec/irt_ruby/two_parameter_model_spec.rb b/spec/irt_ruby/two_parameter_model_spec.rb index 6687b87..4c9e906 100644 --- a/spec/irt_ruby/two_parameter_model_spec.rb +++ b/spec/irt_ruby/two_parameter_model_spec.rb @@ -3,6 +3,8 @@ require "spec_helper" RSpec.describe IrtRuby::TwoParameterModel do + it_behaves_like "response data validation" + let(:data_array) do [ [1, 1, 0], diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 188b66a..0ddcf0c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,6 +2,45 @@ require "irt_ruby" +RSpec.shared_examples "response data validation" do + it "rejects empty data" do + expect { described_class.new([]) }.to raise_error(ArgumentError, /at least one row/) + end + + it "rejects empty rows" do + expect { described_class.new([[]]) }.to raise_error(ArgumentError, /at least one column/) + end + + it "rejects ragged rows" do + expect { described_class.new([[1, 0], [1]]) }.to raise_error(ArgumentError, /rectangular; row 2/) + end + + it "rejects invalid response values" do + expect { described_class.new([[1, 2], [0, nil]]) }.to raise_error(ArgumentError, /invalid value 2/) + end + + it "rejects float response values that compare equal to allowed integers" do + expect { described_class.new([[0.0]]) }.to raise_error(ArgumentError, /invalid value 0\.0/) + expect { described_class.new([[1.0]]) }.to raise_error(ArgumentError, /invalid value 1\.0/) + end + + it "rejects string response values" do + expect { described_class.new([["1"]]) }.to raise_error(ArgumentError, /invalid value "1"/) + end + + it "rejects false response values" do + expect { described_class.new([[false]]) }.to raise_error(ArgumentError, /invalid value false/) + end + + it "rejects true response values" do + expect { described_class.new([[true]]) }.to raise_error(ArgumentError, /invalid value true/) + end + + it "rejects hash input even when it can be converted to an array" do + expect { described_class.new({ [0] => 1 }) }.to raise_error(ArgumentError, /Matrix or array of arrays/) + end +end + RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status"