summaryrefslogtreecommitdiff
path: root/lib/classifier/logistic_regression.rb
diff options
Diffstat (limited to 'lib/classifier/logistic_regression.rb')
-rw-r--r--lib/classifier/logistic_regression.rb614
1 files changed, 614 insertions, 0 deletions
diff --git a/lib/classifier/logistic_regression.rb b/lib/classifier/logistic_regression.rb
new file mode 100644
index 0000000..0d1819e
--- /dev/null
+++ b/lib/classifier/logistic_regression.rb
@@ -0,0 +1,614 @@
+# rbs_inline: enabled
+
+# Author:: Lucas Carlson (mailto:lucas@rufy.com)
+# Copyright:: Copyright (c) 2024 Lucas Carlson
+# License:: LGPL
+
+require 'json'
+require 'mutex_m'
+
+module Classifier
+ # Logistic Regression (MaxEnt) classifier using Stochastic Gradient Descent.
+ # Often provides better accuracy than Naive Bayes while remaining fast and interpretable.
+ #
+ # Example:
+ # classifier = Classifier::LogisticRegression.new(:spam, :ham)
+ # classifier.train(spam: ["Buy now!", "Free money!!!"])
+ # classifier.train(ham: ["Meeting tomorrow", "Project update"])
+ # classifier.classify("Claim your prize!") # => "Spam"
+ # classifier.probabilities("Claim your prize!") # => {"Spam" => 0.92, "Ham" => 0.08}
+ #
+ class LogisticRegression # rubocop:disable Metrics/ClassLength
+ include Mutex_m
+ include Streaming
+
+ # @rbs @categories: Array[Symbol]
+ # @rbs @weights: Hash[Symbol, Hash[Symbol, Float]]
+ # @rbs @bias: Hash[Symbol, Float]
+ # @rbs @vocabulary: Hash[Symbol, bool]
+ # @rbs @training_data: Array[{category: Symbol, features: Hash[Symbol, Integer]}]
+ # @rbs @learning_rate: Float
+ # @rbs @regularization: Float
+ # @rbs @max_iterations: Integer
+ # @rbs @tolerance: Float
+ # @rbs @fitted: bool
+ # @rbs @dirty: bool
+ # @rbs @storage: Storage::Base?
+ # @rbs @min_word_length: Integer
+
+ attr_accessor :storage
+
+ DEFAULT_LEARNING_RATE = 0.1
+ DEFAULT_REGULARIZATION = 0.01
+ DEFAULT_MAX_ITERATIONS = 100
+ DEFAULT_TOLERANCE = 1e-4
+
+ # Creates a new Logistic Regression classifier with the specified categories.
+ #
+ # classifier = Classifier::LogisticRegression.new(:spam, :ham)
+ # classifier = Classifier::LogisticRegression.new('Positive', 'Negative', 'Neutral')
+ # classifier = Classifier::LogisticRegression.new(['Positive', 'Negative', 'Neutral'])
+ #
+ # Options:
+ # - learning_rate: Step size for gradient descent (default: 0.1)
+ # - regularization: L2 regularization strength (default: 0.01)
+ # - max_iterations: Maximum training iterations (default: 100)
+ # - tolerance: Convergence threshold (default: 1e-4)
+ # - min_word_length: Minimum word length filter in tokenization
+ #
+ # @rbs (*String | Symbol | Array[String | Symbol], ?learning_rate: Float, ?regularization: Float,
+ # ?max_iterations: Integer, ?tolerance: Float, ?min_word_length: Integer) -> void
+ # rubocop:disable Metrics/ParameterLists
+ def initialize(*categories, learning_rate: DEFAULT_LEARNING_RATE,
+ regularization: DEFAULT_REGULARIZATION,
+ max_iterations: DEFAULT_MAX_ITERATIONS,
+ tolerance: DEFAULT_TOLERANCE,
+ min_word_length: Classifier.config.min_word_length)
+ super()
+ categories = categories.flatten
+ @categories = categories.map { |c| c.to_s.prepare_category_name }
+ @weights = @categories.to_h { |c| [c, {}] }
+ @bias = @categories.to_h { |c| [c, 0.0] }
+ @vocabulary = {}
+ @training_data = []
+ @learning_rate = learning_rate
+ @regularization = regularization
+ @max_iterations = max_iterations
+ @tolerance = tolerance
+ @fitted = false
+ @dirty = false
+ @storage = nil
+ @min_word_length = min_word_length
+ end
+ # rubocop:enable Metrics/ParameterLists
+
+ # Trains the classifier with text for a category.
+ #
+ # classifier.train(spam: "Buy now!", ham: ["Hello", "Meeting tomorrow"])
+ # classifier.train(:spam, "legacy positional API")
+ #
+ # @rbs (?(String | Symbol)?, ?String?, **(String | Array[String])) -> void
+ def train(category = nil, text = nil, **categories)
+ return train_single(category, text) if category && text
+
+ categories.each do |cat, texts|
+ (texts.is_a?(Array) ? texts : [texts]).each { |t| train_single(cat, t) }
+ end
+ end
+
+ # Fits the model to all accumulated training data.
+ # Called automatically during classify/probabilities if not already fitted.
+ #
+ # @rbs () -> self
+ def fit
+ synchronize do
+ return self if @training_data.empty?
+ raise ArgumentError, 'At least two categories required for fitting' if @categories.size < 2
+
+ optimize_weights
+ @fitted = true
+ @dirty = false
+ end
+ self
+ end
+
+ # Returns the best matching category for the provided text.
+ #
+ # classifier.classify("Buy now!") # => "Spam"
+ #
+ # @rbs (String) -> String
+ def classify(text)
+ probs = probabilities(text)
+ best = probs.max_by { |_, v| v }
+ raise StandardError, 'No classifications available' unless best
+
+ best.first
+ end
+
+ # Returns probability distribution across all categories.
+ # Probabilities are well-calibrated (unlike Naive Bayes).
+ # Raises NotFittedError if model has not been fitted.
+ #
+ # classifier.probabilities("Buy now!")
+ # # => {"Spam" => 0.92, "Ham" => 0.08}
+ #
+ # @rbs (String) -> Hash[String, Float]
+ def probabilities(text)
+ raise NotFittedError, 'Model not fitted. Call fit() after training.' unless @fitted
+
+ features = text.word_hash(@min_word_length)
+ synchronize do
+ softmax(compute_scores(features))
+ end
+ end
+
+ # Returns log-odds scores for each category (before softmax).
+ # Raises NotFittedError if model has not been fitted.
+ #
+ # @rbs (String) -> Hash[String, Float]
+ def classifications(text)
+ raise NotFittedError, 'Model not fitted. Call fit() after training.' unless @fitted
+
+ features = text.word_hash(@min_word_length)
+ synchronize do
+ compute_scores(features).transform_keys(&:to_s)
+ end
+ end
+
+ # Returns feature weights for a category, sorted by importance.
+ # Positive weights indicate the feature supports the category.
+ #
+ # classifier.weights(:spam)
+ # # => {:free => 2.3, :buy => 1.8, :money => 1.5, ...}
+ #
+ # @rbs (String | Symbol, ?limit: Integer?) -> Hash[Symbol, Float]
+ def weights(category, limit: nil)
+ fit unless @fitted
+
+ cat = category.to_s.prepare_category_name
+ raise StandardError, "No such category: #{cat}" unless @weights.key?(cat)
+
+ sorted = @weights[cat].sort_by { |_, v| -v.abs }
+ sorted = sorted.first(limit) if limit
+ sorted.to_h
+ end
+
+ # Returns the list of categories.
+ #
+ # @rbs () -> Array[String]
+ def categories
+ synchronize { @categories.map(&:to_s) }
+ end
+
+ # Adds a new category to the classifier.
+ # Allows dynamic category creation for CLI and incremental training.
+ #
+ # @rbs (String | Symbol) -> void
+ def add_category(category)
+ cat = category.to_s.prepare_category_name
+ synchronize do
+ return if @categories.include?(cat)
+
+ @categories << cat
+ @weights[cat] = {}
+ @bias[cat] = 0.0
+ @fitted = false
+ @dirty = true
+ end
+ end
+
+ # Returns true if the model has been fitted.
+ #
+ # @rbs () -> bool
+ def fitted?
+ @fitted
+ end
+
+ # Returns true if there are unsaved changes.
+ #
+ # @rbs () -> bool
+ def dirty?
+ @dirty
+ end
+
+ # Provides training methods for the categories.
+ # classifier.train_spam "Buy now!"
+ def method_missing(name, *args)
+ category_match = name.to_s.match(/train_(\w+)/)
+ return super unless category_match
+
+ category = category_match[1].to_s.prepare_category_name
+ raise StandardError, "No such category: #{category}" unless @categories.include?(category)
+
+ args.each { |text| train(category, text) }
+ end
+
+ # @rbs (Symbol, ?bool) -> bool
+ def respond_to_missing?(name, include_private = false)
+ !!(name.to_s =~ /train_(\w+)/) || super
+ end
+
+ # Returns a hash representation of the classifier state.
+ # Does NOT auto-fit; saves current state including unfitted models.
+ #
+ # @rbs (?untyped) -> Hash[Symbol, untyped]
+ def as_json(_options = nil)
+ {
+ version: 1,
+ type: 'logistic_regression',
+ categories: @categories.map(&:to_s),
+ weights: @weights.transform_keys(&:to_s).transform_values { |v| v.transform_keys(&:to_s) },
+ bias: @bias.transform_keys(&:to_s),
+ vocabulary: @vocabulary.keys.map(&:to_s),
+ training_data: @training_data.map { |d| { category: d[:category].to_s, features: d[:features].transform_keys(&:to_s) } },
+ learning_rate: @learning_rate,
+ regularization: @regularization,
+ max_iterations: @max_iterations,
+ tolerance: @tolerance,
+ fitted: @fitted,
+ min_word_length: @min_word_length
+ }
+ end
+
+ # Serializes the classifier state to a JSON string.
+ #
+ # @rbs (?untyped) -> String
+ def to_json(_options = nil)
+ JSON.generate(as_json)
+ end
+
+ # Loads a classifier from a JSON string or Hash.
+ #
+ # @rbs (String | Hash[String, untyped]) -> LogisticRegression
+ def self.from_json(json)
+ data = json.is_a?(String) ? JSON.parse(json) : json
+ raise ArgumentError, "Invalid classifier type: #{data['type']}" unless data['type'] == 'logistic_regression'
+
+ categories = data['categories'].map(&:to_sym)
+ instance = allocate
+ instance.send(:restore_state, data, categories)
+ instance
+ end
+
+ # Saves the classifier to the configured storage.
+ #
+ # @rbs () -> void
+ def save
+ raise ArgumentError, 'No storage configured' unless storage
+
+ storage.write(to_json)
+ @dirty = false
+ end
+
+ # Saves the classifier state to a file.
+ #
+ # @rbs (String) -> Integer
+ def save_to_file(path)
+ result = File.write(path, to_json)
+ @dirty = false
+ result
+ end
+
+ # Loads a classifier from the configured storage.
+ #
+ # @rbs (storage: Storage::Base) -> LogisticRegression
+ def self.load(storage:)
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ instance = from_json(data)
+ instance.storage = storage
+ instance
+ end
+
+ # Loads a classifier from a file.
+ #
+ # @rbs (String) -> LogisticRegression
+ def self.load_from_file(path)
+ from_json(File.read(path))
+ end
+
+ # Reloads the classifier from storage, raising if there are unsaved changes.
+ #
+ # @rbs () -> self
+ def reload
+ raise ArgumentError, 'No storage configured' unless storage
+ raise UnsavedChangesError, 'Unsaved changes would be lost. Call save first or use reload!' if @dirty
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # Force reloads the classifier from storage, discarding any unsaved changes.
+ #
+ # @rbs () -> self
+ def reload!
+ raise ArgumentError, 'No storage configured' unless storage
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # Custom marshal serialization to exclude mutex state.
+ #
+ # @rbs () -> Array[untyped]
+ def marshal_dump
+ fit unless @fitted
+ [@categories, @weights, @bias, @vocabulary, @learning_rate, @regularization,
+ @max_iterations, @tolerance, @fitted, @min_word_length]
+ end
+
+ # Custom marshal deserialization to recreate mutex.
+ #
+ # @rbs (Array[untyped]) -> void
+ def marshal_load(data)
+ mu_initialize
+ @categories, @weights, @bias, @vocabulary, @learning_rate, @regularization,
+ @max_iterations, @tolerance, @fitted, @min_word_length = data
+ @training_data = []
+ @dirty = false
+ @storage = nil
+ end
+
+ # Loads a classifier from a checkpoint.
+ #
+ # @rbs (storage: Storage::Base, checkpoint_id: String) -> LogisticRegression
+ def self.load_checkpoint(storage:, checkpoint_id:)
+ raise ArgumentError, 'Storage must be File storage for checkpoints' unless storage.is_a?(Storage::File)
+
+ dir = File.dirname(storage.path)
+ base = File.basename(storage.path, '.*')
+ ext = File.extname(storage.path)
+ checkpoint_path = File.join(dir, "#{base}_checkpoint_#{checkpoint_id}#{ext}")
+
+ checkpoint_storage = Storage::File.new(path: checkpoint_path)
+ instance = load(storage: checkpoint_storage)
+ instance.storage = storage
+ instance
+ end
+
+ # Trains the classifier from an IO stream.
+ # Each line in the stream is treated as a separate document.
+ # Note: The model is NOT automatically fitted after streaming.
+ # Call #fit to train the model after adding all data.
+ #
+ # @example Train from a file
+ # classifier.train_from_stream(:spam, File.open('spam_corpus.txt'))
+ # classifier.fit # Required to train the model
+ #
+ # @example With progress tracking
+ # classifier.train_from_stream(:spam, io, batch_size: 500) do |progress|
+ # puts "#{progress.completed} documents processed"
+ # end
+ # classifier.fit
+ #
+ # @rbs (?(String | Symbol | nil), ?IO?, ?batch_size: Integer, **IO) { (Streaming::Progress) -> void } -> void
+ def train_from_stream(category = nil, io = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &)
+ raise ArgumentError, 'Provide either (category, io) or keyword category: io pairs' if category.nil? && io.nil? && categories.empty?
+ raise ArgumentError, 'Provide both category and io, or use keyword arguments' if [category, io].one?(&:nil?)
+
+ pairs = category && io ? { category => io } : categories
+ pairs.each do |cat, stream|
+ stream_train_category(cat, stream, batch_size:, &)
+ end
+ end
+
+ # Trains the classifier with an array of documents in batches.
+ # Note: The model is NOT automatically fitted after batch training.
+ # Call #fit to train the model after adding all data.
+ #
+ # @example Positional style
+ # classifier.train_batch(:spam, documents, batch_size: 100)
+ # classifier.fit
+ #
+ # @example Keyword style
+ # classifier.train_batch(spam: documents, ham: other_docs)
+ # classifier.fit
+ #
+ # @rbs (?(String | Symbol)?, ?Array[String]?, ?batch_size: Integer, **Array[String]) { (Streaming::Progress) -> void } -> void
+ def train_batch(category = nil, documents = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &block)
+ if category && documents
+ train_batch_for_category(category, documents, batch_size: batch_size, &block)
+ else
+ categories.each do |cat, docs|
+ train_batch_for_category(cat, Array(docs), batch_size: batch_size, &block)
+ end
+ end
+ end
+
+ private
+
+ # Trains from an IO stream with a single category.
+ # @rbs (String | Symbol, IO, batch_size: Integer) { (Streaming::Progress) -> void } -> void
+ def stream_train_category(category, io, batch_size:)
+ category = category.to_s.prepare_category_name
+ raise ArgumentError, "No such category: #{category}" unless @categories.include?(category)
+ raise ArgumentError, 'Stream must respond to #each_line' unless io.respond_to?(:each_line)
+
+ reader = Streaming::LineReader.new(io, batch_size: batch_size)
+ total = reader.estimate_line_count
+ progress = Streaming::Progress.new(total: total)
+
+ reader.each_batch do |batch|
+ synchronize do
+ batch.each do |text|
+ features = text.word_hash(@min_word_length)
+ features.each_key { |word| @vocabulary[word] = true }
+ @training_data << { category: category, features: features }
+ end
+ @fitted = false
+ @dirty = true
+ end
+ progress.completed += batch.size
+ progress.current_batch += 1
+ yield progress if block_given?
+ end
+ end
+
+ # Trains a batch of documents for a single category.
+ # @rbs (String | Symbol, Array[String], ?batch_size: Integer) { (Streaming::Progress) -> void } -> void
+ def train_batch_for_category(category, documents, batch_size: Streaming::DEFAULT_BATCH_SIZE)
+ category = category.to_s.prepare_category_name
+ raise StandardError, "No such category: #{category}" unless @categories.include?(category)
+
+ progress = Streaming::Progress.new(total: documents.size)
+
+ documents.each_slice(batch_size) do |batch|
+ synchronize do
+ batch.each do |text|
+ features = text.word_hash(@min_word_length)
+ features.each_key { |word| @vocabulary[word] = true }
+ @training_data << { category: category, features: features }
+ end
+ @fitted = false
+ @dirty = true
+ end
+ progress.completed += batch.size
+ progress.current_batch += 1
+ yield progress if block_given?
+ end
+ end
+
+ # Core training logic for a single category and text.
+ # @rbs (String | Symbol, String) -> void
+ def train_single(category, text)
+ category = category.to_s.prepare_category_name
+ raise StandardError, "No such category: #{category}" unless @categories.include?(category)
+
+ features = text.word_hash(@min_word_length)
+ synchronize do
+ features.each_key { |word| @vocabulary[word] = true }
+ @training_data << { category: category, features: features }
+ @fitted = false
+ @dirty = true
+ end
+ end
+
+ # Optimizes weights using mini-batch SGD with L2 regularization.
+ # @rbs () -> void
+ def optimize_weights
+ return if @training_data.empty?
+
+ initialize_weights
+ prev_loss = Float::INFINITY
+
+ @max_iterations.times do
+ total_loss = run_training_epoch
+ break if (prev_loss - total_loss).abs < @tolerance
+
+ prev_loss = total_loss
+ end
+
+ @training_data = []
+ end
+
+ # @rbs () -> void
+ def initialize_weights
+ @vocabulary.each_key do |word|
+ @categories.each { |cat| @weights[cat][word] ||= 0.0 }
+ end
+ end
+
+ # @rbs () -> Float
+ def run_training_epoch
+ total_loss = 0.0
+
+ @training_data.shuffle.each do |sample|
+ probs = softmax(compute_scores(sample[:features]))
+ update_weights(sample[:features], sample[:category], probs)
+ total_loss -= Math.log([probs[sample[:category].to_s], 1e-15].max)
+ end
+
+ total_loss + l2_penalty
+ end
+
+ # @rbs (Hash[Symbol, Integer], Symbol, Hash[String, Float]) -> void
+ def update_weights(features, true_category, probs)
+ @categories.each do |cat|
+ error = probs[cat.to_s] - (cat == true_category ? 1.0 : 0.0)
+ @bias[cat] -= @learning_rate * error
+
+ features.each do |word, count|
+ gradient = (error * count) + (@regularization * (@weights[cat][word] || 0.0))
+ @weights[cat][word] ||= 0.0
+ @weights[cat][word] -= @learning_rate * gradient
+ end
+ end
+ end
+
+ # @rbs () -> Float
+ def l2_penalty
+ penalty = 0.0
+ @weights.each_value do |cat_weights|
+ cat_weights.each_value { |w| penalty += 0.5 * @regularization * w * w }
+ end
+ penalty
+ end
+
+ # Computes raw scores (logits) for each category.
+ # @rbs (Hash[Symbol, Integer]) -> Hash[Symbol, Float]
+ def compute_scores(features)
+ @categories.to_h do |cat|
+ score = @bias[cat]
+ features.each { |word, count| score += (@weights[cat][word] || 0.0) * count }
+ [cat, score]
+ end
+ end
+
+ # Applies softmax to convert scores to probabilities.
+ # @rbs (Hash[Symbol, Float]) -> Hash[String, Float]
+ def softmax(scores)
+ max_score = scores.values.max || 0.0
+ exp_scores = scores.transform_values { |s| Math.exp(s - max_score) }
+ sum = exp_scores.values.sum.to_f
+ exp_scores.transform_keys(&:to_s).transform_values { |e| (e / sum).to_f }
+ end
+
+ # Restores classifier state from JSON string.
+ # @rbs (String) -> void
+ def restore_from_json(json)
+ data = JSON.parse(json)
+ categories = data['categories'].map(&:to_sym)
+ restore_state(data, categories)
+ end
+
+ # Restores classifier state from parsed JSON data.
+ # @rbs (Hash[String, untyped], Array[Symbol]) -> void
+ def restore_state(data, categories)
+ mu_initialize
+ @categories = categories
+ restore_weights_and_bias(data)
+ restore_hyperparameters(data)
+ @fitted = data.fetch('fitted', true)
+ @dirty = false
+ @storage = nil
+ @min_word_length = data['min_word_length'] || Classifier.config.min_word_length
+ end
+
+ def restore_weights_and_bias(data)
+ @weights = {}
+ @bias = {}
+ data['weights'].each { |cat, words| @weights[cat.to_sym] = words.transform_keys(&:to_sym).transform_values(&:to_f) }
+ data['bias'].each { |cat, value| @bias[cat.to_sym] = value.to_f }
+ @vocabulary = data['vocabulary'].to_h { |v| [v.to_sym, true] }
+ @training_data = (data['training_data'] || []).map do |d|
+ { category: d['category'].to_sym, features: d['features'].transform_keys(&:to_sym).transform_values(&:to_i) }
+ end
+ end
+
+ def restore_hyperparameters(data)
+ @learning_rate = data['learning_rate']
+ @regularization = data['regularization']
+ @max_iterations = data['max_iterations']
+ @tolerance = data['tolerance']
+ end
+ end
+end