summaryrefslogtreecommitdiff
path: root/lib/classifier/tfidf.rb
diff options
Diffstat (limited to 'lib/classifier/tfidf.rb')
-rw-r--r--lib/classifier/tfidf.rb416
1 files changed, 416 insertions, 0 deletions
diff --git a/lib/classifier/tfidf.rb b/lib/classifier/tfidf.rb
new file mode 100644
index 0000000..20a65e1
--- /dev/null
+++ b/lib/classifier/tfidf.rb
@@ -0,0 +1,416 @@
+# rbs_inline: enabled
+
+# Author:: Lucas Carlson (mailto:lucas@rufy.com)
+# Copyright:: Copyright (c) 2024 Lucas Carlson
+# License:: LGPL
+
+require 'json'
+
+module Classifier
+ # TF-IDF vectorizer: transforms text to weighted feature vectors.
+ # Downweights common words, upweights discriminative terms.
+ #
+ # Example:
+ # tfidf = Classifier::TFIDF.new
+ # tfidf.fit(["Dogs are great pets", "Cats are independent"])
+ # tfidf.transform("Dogs are loyal") # => {:dog=>0.7071..., :loyal=>0.7071...}
+ #
+ class TFIDF
+ include Streaming
+
+ # @rbs @min_df: Integer | Float
+ # @rbs @max_df: Integer | Float
+ # @rbs @ngram_range: Array[Integer]
+ # @rbs @sublinear_tf: bool
+ # @rbs @vocabulary: Hash[Symbol, Integer]
+ # @rbs @idf: Hash[Symbol, Float]
+ # @rbs @num_documents: Integer
+ # @rbs @fitted: bool
+ # @rbs @dirty: bool
+ # @rbs @storage: Storage::Base?
+ # @rbs @min_word_length: Integer
+
+ attr_reader :vocabulary, :idf, :num_documents
+ attr_accessor :storage
+
+ # Creates a new TF-IDF vectorizer.
+ # - min_df/max_df: filter terms by document frequency (Integer for count, Float for proportion)
+ # - ngram_range: [1,1] for unigrams, [1,2] for unigrams+bigrams
+ # - sublinear_tf: use 1 + log(tf) instead of raw term frequency
+ # - min_word_length: minimum word length filter in tokenization
+ #
+ # @rbs (?min_df: Integer | Float, ?max_df: Integer | Float,
+ # ?ngram_range: Array[Integer], ?sublinear_tf: bool, ?min_word_length: Integer) -> void
+ def initialize(min_df: 1, max_df: 1.0, ngram_range: [1, 1], sublinear_tf: false,
+ min_word_length: Classifier.config.min_word_length)
+ validate_df!(min_df, 'min_df')
+ validate_df!(max_df, 'max_df')
+ validate_ngram_range!(ngram_range)
+
+ @min_df = min_df
+ @max_df = max_df
+ @ngram_range = ngram_range
+ @sublinear_tf = sublinear_tf
+ @vocabulary = {}
+ @idf = {}
+ @num_documents = 0
+ @fitted = false
+ @dirty = false
+ @storage = nil
+ @min_word_length = min_word_length
+ end
+
+ # Learns vocabulary and IDF weights from the corpus.
+ # @rbs (Array[String]) -> self
+ def fit(documents)
+ raise ArgumentError, 'documents must be an array' unless documents.is_a?(Array)
+ raise ArgumentError, 'documents cannot be empty' if documents.empty?
+
+ @num_documents = documents.size
+ document_frequencies = Hash.new(0)
+
+ documents.each do |doc|
+ terms = extract_terms(doc)
+ terms.each_key { |term| document_frequencies[term] += 1 }
+ end
+
+ @vocabulary = {}
+ @idf = {}
+ vocab_index = 0
+
+ document_frequencies.each do |term, df|
+ next unless within_df_bounds?(df, @num_documents)
+
+ @vocabulary[term] = vocab_index
+ vocab_index += 1
+
+ # IDF: log((N + 1) / (df + 1)) + 1
+ @idf[term] = Math.log((@num_documents + 1).to_f / (df + 1)) + 1
+ end
+
+ @fitted = true
+ @dirty = true
+ self
+ end
+
+ # Transforms a document into a normalized TF-IDF vector.
+ # @rbs (String) -> Hash[Symbol, Float]
+ def transform(document)
+ raise NotFittedError, 'TFIDF has not been fitted. Call fit first.' unless @fitted
+
+ terms = extract_terms(document)
+ result = {} #: Hash[Symbol, Float]
+
+ terms.each do |term, tf|
+ next unless @vocabulary.key?(term)
+
+ tf_value = @sublinear_tf && tf.positive? ? 1 + Math.log(tf) : tf.to_f
+ result[term] = (tf_value * @idf[term]).to_f
+ end
+
+ normalize_vector(result)
+ end
+
+ # Fits and transforms in one step.
+ # @rbs (Array[String]) -> Array[Hash[Symbol, Float]]
+ def fit_transform(documents)
+ fit(documents)
+ documents.map { |doc| transform(doc) }
+ end
+
+ # Returns vocabulary terms in index order.
+ # @rbs () -> Array[Symbol]
+ def feature_names
+ @vocabulary.keys.sort_by { |term| @vocabulary[term] }
+ end
+
+ # @rbs () -> bool
+ def fitted?
+ @fitted
+ end
+
+ # Returns true if there are unsaved changes.
+ # @rbs () -> bool
+ def dirty?
+ @dirty
+ end
+
+ # Saves the vectorizer to the configured storage.
+ # @rbs () -> void
+ def save
+ raise ArgumentError, 'No storage configured' unless storage
+
+ storage.write(to_json)
+ @dirty = false
+ end
+
+ # Saves the vectorizer state to a file.
+ # @rbs (String) -> Integer
+ def save_to_file(path)
+ result = File.write(path, to_json)
+ @dirty = false
+ result
+ end
+
+ # Loads a vectorizer from the configured storage.
+ # @rbs (storage: Storage::Base) -> TFIDF
+ 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 vectorizer from a file.
+ # @rbs (String) -> TFIDF
+ def self.load_from_file(path)
+ from_json(File.read(path))
+ end
+
+ # Reloads the vectorizer 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 vectorizer 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
+
+ # @rbs (?untyped) -> Hash[Symbol, untyped]
+ def as_json(_options = nil)
+ {
+ version: 1,
+ type: 'tfidf',
+ min_df: @min_df,
+ max_df: @max_df,
+ ngram_range: @ngram_range,
+ sublinear_tf: @sublinear_tf,
+ vocabulary: @vocabulary,
+ idf: @idf,
+ num_documents: @num_documents,
+ fitted: @fitted,
+ min_word_length: @min_word_length
+ }
+ end
+
+ # @rbs (?untyped) -> String
+ def to_json(_options = nil)
+ JSON.generate(as_json)
+ end
+
+ # Loads a vectorizer from JSON.
+ # @rbs (String | Hash[String, untyped]) -> TFIDF
+ def self.from_json(json)
+ data = json.is_a?(String) ? JSON.parse(json) : json
+ raise ArgumentError, "Invalid vectorizer type: #{data['type']}" unless data['type'] == 'tfidf'
+
+ instance = new(
+ min_df: data['min_df'],
+ max_df: data['max_df'],
+ ngram_range: data['ngram_range'],
+ sublinear_tf: data['sublinear_tf'],
+ min_word_length: data['min_word_length'] || Classifier.config.min_word_length
+ )
+
+ instance.instance_variable_set(:@vocabulary, symbolize_keys(data['vocabulary']))
+ instance.instance_variable_set(:@idf, symbolize_keys(data['idf']))
+ instance.instance_variable_set(:@num_documents, data['num_documents'])
+ instance.instance_variable_set(:@fitted, data['fitted'])
+ instance.instance_variable_set(:@dirty, false)
+ instance.instance_variable_set(:@storage, nil)
+
+ instance
+ end
+
+ # @rbs () -> Array[untyped]
+ def marshal_dump
+ [@min_df, @max_df, @ngram_range, @sublinear_tf, @vocabulary, @idf, @num_documents, @fitted,
+ @min_word_length]
+ end
+
+ # @rbs (Array[untyped]) -> void
+ def marshal_load(data)
+ @min_df, @max_df, @ngram_range, @sublinear_tf, @vocabulary, @idf, @num_documents, @fitted,
+ @min_word_length = data
+ @dirty = false
+ @storage = nil
+ end
+
+ # Loads a vectorizer from a checkpoint.
+ #
+ # @rbs (storage: Storage::Base, checkpoint_id: String) -> TFIDF
+ 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
+
+ # Fits the vectorizer from an IO stream.
+ # Collects all documents from the stream, then fits the model.
+ # Note: All documents must be collected in memory for IDF calculation.
+ #
+ # @example Fit from a file
+ # tfidf.fit_from_stream(File.open('corpus.txt'))
+ #
+ # @example With progress tracking
+ # tfidf.fit_from_stream(io, batch_size: 500) do |progress|
+ # puts "#{progress.completed} documents loaded"
+ # end
+ #
+ # @rbs (IO, ?batch_size: Integer) { (Streaming::Progress) -> void } -> self
+ def fit_from_stream(io, batch_size: Streaming::DEFAULT_BATCH_SIZE)
+ reader = Streaming::LineReader.new(io, batch_size: batch_size)
+ total = reader.estimate_line_count
+ progress = Streaming::Progress.new(total: total)
+
+ documents = [] #: Array[String]
+
+ reader.each_batch do |batch|
+ documents.concat(batch)
+ progress.completed += batch.size
+ progress.current_batch += 1
+ yield progress if block_given?
+ end
+
+ fit(documents) unless documents.empty?
+ self
+ end
+
+ # TFIDF doesn't support train_from_stream (use fit_from_stream instead).
+ # This method raises NotImplementedError with guidance.
+ #
+ # @rbs (*untyped, **untyped) -> void
+ def train_from_stream(*) # steep:ignore
+ raise NotImplementedError, 'TFIDF uses fit_from_stream instead of train_from_stream'
+ end
+
+ # TFIDF doesn't support train_batch (use fit instead).
+ # This method raises NotImplementedError with guidance.
+ #
+ # @rbs (*untyped, **untyped) -> void
+ def train_batch(*) # steep:ignore
+ raise NotImplementedError, 'TFIDF uses fit instead of train_batch'
+ end
+
+ private
+
+ # Restores vectorizer state from JSON string.
+ # @rbs (String) -> void
+ def restore_from_json(json)
+ data = JSON.parse(json)
+
+ @min_df = data['min_df']
+ @max_df = data['max_df']
+ @ngram_range = data['ngram_range']
+ @sublinear_tf = data['sublinear_tf']
+ @vocabulary = self.class.send(:symbolize_keys, data['vocabulary'])
+ @idf = self.class.send(:symbolize_keys, data['idf'])
+ @num_documents = data['num_documents']
+ @fitted = data['fitted']
+ end
+
+ # @rbs (String) -> Hash[Symbol, Integer]
+ def extract_terms(document)
+ result = Hash.new(0)
+
+ if @ngram_range[0] <= 1
+ word_hash = document.clean_word_hash(@min_word_length)
+ word_hash.each { |term, count| result[term] += count }
+ end
+
+ return result if @ngram_range[1] <= 1
+
+ tokens = tokenize_for_ngrams(document)
+ (2..@ngram_range[1]).each do |n|
+ next if n < @ngram_range[0]
+
+ generate_ngrams(tokens, n).each { |ngram| result[ngram] += 1 }
+ end
+
+ result
+ end
+
+ # @rbs (String) -> Array[String]
+ def tokenize_for_ngrams(document)
+ document
+ .gsub(/[^\w\s]/, '')
+ .split
+ .map(&:downcase)
+ .reject { |w| w.length <= 2 || String::CORPUS_SKIP_WORDS.include?(w) }
+ .map(&:stem)
+ end
+
+ # @rbs (Array[String], Integer) -> Array[Symbol]
+ def generate_ngrams(tokens, n) # rubocop:disable Naming/MethodParameterName
+ return [] if tokens.size < n
+
+ tokens.each_cons(n).map { |gram| gram.join('_').intern }
+ end
+
+ # @rbs (Integer, Integer) -> bool
+ def within_df_bounds?(doc_freq, num_docs)
+ doc_freq.between?(
+ @min_df.is_a?(Float) ? (@min_df * num_docs).ceil : @min_df,
+ @max_df.is_a?(Float) ? (@max_df * num_docs).floor : @max_df
+ )
+ end
+
+ # @rbs (Hash[Symbol, Float]) -> Hash[Symbol, Float]
+ def normalize_vector(vector)
+ return vector if vector.empty?
+
+ magnitude = Math.sqrt(vector.values.sum { |v| v * v })
+ return vector if magnitude.zero?
+
+ vector.transform_values { |v| v / magnitude }
+ end
+
+ # @rbs (Integer | Float, String) -> void
+ def validate_df!(value, name)
+ raise ArgumentError, "#{name} must be an Integer or Float" unless value.is_a?(Float) || value.is_a?(Integer)
+ raise ArgumentError, "#{name} must be between 0.0 and 1.0" if value.is_a?(Float) && !value.between?(0.0, 1.0)
+ raise ArgumentError, "#{name} must be non-negative" if value.is_a?(Integer) && value.negative?
+ end
+
+ # @rbs (Array[Integer]) -> void
+ def validate_ngram_range!(range)
+ raise ArgumentError, 'ngram_range must be an array of two integers' unless range.is_a?(Array) && range.size == 2
+ raise ArgumentError, 'ngram_range values must be positive integers' unless range.all?(Integer) && range.all?(&:positive?)
+ raise ArgumentError, 'ngram_range[0] must be <= ngram_range[1]' if range[0] > range[1]
+ end
+
+ # @rbs (Hash[String, untyped]) -> Hash[Symbol, untyped]
+ def self.symbolize_keys(hash)
+ hash.transform_keys(&:to_sym)
+ end
+ private_class_method :symbolize_keys
+ end
+end