Skip to content

Commit 2926338

Browse files
committed
feat: make min_word_length parameter configurable (#120)
1 parent eb5dec9 commit 2926338

12 files changed

Lines changed: 152 additions & 36 deletions

File tree

lib/classifier.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@
3636
require 'classifier/knn'
3737
require 'classifier/tfidf'
3838
require 'classifier/logistic_regression'
39+
require 'classifier/config'

lib/classifier/bayes.rb

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ class Bayes # rubocop:disable Metrics/ClassLength
2727
# initialized and given a training method. E.g.,
2828
# b = Classifier::Bayes.new 'Interesting', 'Uninteresting', 'Spam'
2929
# b = Classifier::Bayes.new ['Interesting', 'Uninteresting', 'Spam']
30-
# @rbs (*String | Symbol | Array[String | Symbol]) -> void
31-
def initialize(*categories)
30+
# b = Classifier::Bayes.new 'Spam', min_word_length: 1
31+
# @rbs (*String | Symbol | Array[String | Symbol], ?min_word_length: Integer) -> void
32+
def initialize(*categories, min_word_length: Classifier.config.min_word_length)
3233
super()
3334
@categories = {}
3435
categories.flatten.each { |category| @categories[category.prepare_category_name] = {} }
@@ -39,6 +40,7 @@ def initialize(*categories)
3940
@cached_vocab_size = nil
4041
@dirty = false
4142
@storage = nil
43+
@min_word_length = min_word_length
4244
end
4345

4446
# Trains the classifier with text for a category.
@@ -76,7 +78,7 @@ def untrain(category = nil, text = nil, **categories)
7678
#
7779
# @rbs (String) -> Hash[String, Float]
7880
def classifications(text)
79-
words = text.word_hash.keys
81+
words = text.word_hash(@min_word_length).keys
8082
synchronize do
8183
training_count = cached_training_count
8284
vocab_size = cached_vocab_size
@@ -117,7 +119,8 @@ def as_json(_options = nil)
117119
categories: @categories.transform_keys(&:to_s).transform_values { |v| v.transform_keys(&:to_s) },
118120
total_words: @total_words,
119121
category_counts: @category_counts.transform_keys(&:to_s),
120-
category_word_count: @category_word_count.transform_keys(&:to_s)
122+
category_word_count: @category_word_count.transform_keys(&:to_s),
123+
min_word_length: @min_word_length
121124
}
122125
end
123126

@@ -409,7 +412,7 @@ def train_batch_internal(category, batch)
409412
invalidate_caches
410413
@dirty = true
411414
batch.each do |text|
412-
word_hash = text.word_hash
415+
word_hash = text.word_hash(@min_word_length)
413416
@category_counts[category] += 1
414417
word_hash.each do |word, count|
415418
@categories[category][word] ||= 0
@@ -425,7 +428,7 @@ def train_batch_internal(category, batch)
425428
# @rbs (String | Symbol, String) -> void
426429
def train_single(category, text)
427430
category = category.prepare_category_name
428-
word_hash = text.word_hash
431+
word_hash = text.word_hash(@min_word_length)
429432
synchronize do
430433
invalidate_caches
431434
@dirty = true
@@ -443,7 +446,7 @@ def train_single(category, text)
443446
# @rbs (String | Symbol, String) -> void
444447
def untrain_single(category, text)
445448
category = category.prepare_category_name
446-
word_hash = text.word_hash
449+
word_hash = text.word_hash(@min_word_length)
447450
synchronize do
448451
invalidate_caches
449452
@dirty = true
@@ -487,6 +490,7 @@ def restore_state(data)
487490
@cached_vocab_size = nil
488491
@dirty = false
489492
@storage = nil
493+
@min_word_length = data['min_word_length'] || Classifier.config.min_word_length
490494

491495
data['categories'].each do |cat_name, words|
492496
@categories[cat_name.to_sym] = words.transform_keys(&:to_sym)

lib/classifier/config.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# rbs_inline: enabled
2+
3+
module Classifier
4+
# This lazy initialization is not thread-safe.
5+
# In multi-threaded environments, ensure this method is called
6+
# or configuration is set explicitly during startup before using classifiers.
7+
def config
8+
@config ||= Config.new
9+
end
10+
11+
def configure(&block)
12+
block.call(config)
13+
end
14+
15+
module_function :config, :configure
16+
17+
class Config
18+
attr_accessor :min_word_length #: Integer
19+
20+
def initialize
21+
@min_word_length = 3
22+
end
23+
end
24+
end

lib/classifier/extensions/word_hash.rb

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,27 @@ def without_punctuation
2020

2121
# Return a Hash of strings => ints. Each word in the string is stemmed,
2222
# interned, and indexes to its frequency in the document.
23-
# @rbs () -> Hash[Symbol, Integer]
24-
def word_hash
25-
word_hash = clean_word_hash
23+
# @rbs (?Integer) -> Hash[Symbol, Integer]
24+
def word_hash(min_word_length = 3)
25+
word_hash = clean_word_hash(min_word_length)
2626
symbol_hash = word_hash_for_symbols(gsub(/\w/, ' ').split)
2727
word_hash.merge(symbol_hash)
2828
end
2929

3030
# Return a word hash without extra punctuation or short symbols, just stemmed words
31-
# @rbs () -> Hash[Symbol, Integer]
32-
def clean_word_hash
33-
word_hash_for_words gsub(/[^\w\s]/, '').split
31+
# @rbs (?Integer) -> Hash[Symbol, Integer]
32+
def clean_word_hash(min_word_length = 3)
33+
word_hash_for_words(gsub(/[^\w\s]/, '').split, min_word_length)
3434
end
3535

3636
private
3737

38-
# @rbs (Array[String]) -> Hash[Symbol, Integer]
39-
def word_hash_for_words(words)
38+
# @rbs (Array[String], Integer) -> Hash[Symbol, Integer]
39+
def word_hash_for_words(words, min_word_length)
4040
d = Hash.new(0)
4141
words.each do |word|
4242
word.downcase!
43-
d[word.stem.intern] += 1 if !CORPUS_SKIP_WORDS.include?(word) && word.length > 2
43+
d[word.stem.intern] += 1 if !CORPUS_SKIP_WORDS.include?(word) && word.length >= min_word_length
4444
end
4545
d
4646
end

lib/classifier/logistic_regression.rb

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,16 @@ class LogisticRegression # rubocop:disable Metrics/ClassLength
5353
# - regularization: L2 regularization strength (default: 0.01)
5454
# - max_iterations: Maximum training iterations (default: 100)
5555
# - tolerance: Convergence threshold (default: 1e-4)
56+
# - min_word_length: Minimum word length filter in tokenization
5657
#
5758
# @rbs (*String | Symbol | Array[String | Symbol], ?learning_rate: Float, ?regularization: Float,
58-
# ?max_iterations: Integer, ?tolerance: Float) -> void
59+
# ?max_iterations: Integer, ?tolerance: Float, ?min_word_length: Integer) -> void
60+
# rubocop:disable Metrics/ParameterLists
5961
def initialize(*categories, learning_rate: DEFAULT_LEARNING_RATE,
6062
regularization: DEFAULT_REGULARIZATION,
6163
max_iterations: DEFAULT_MAX_ITERATIONS,
62-
tolerance: DEFAULT_TOLERANCE)
64+
tolerance: DEFAULT_TOLERANCE,
65+
min_word_length: Classifier.config.min_word_length)
6366
super()
6467
categories = categories.flatten
6568
@categories = categories.map { |c| c.to_s.prepare_category_name }
@@ -74,7 +77,9 @@ def initialize(*categories, learning_rate: DEFAULT_LEARNING_RATE,
7477
@fitted = false
7578
@dirty = false
7679
@storage = nil
80+
@min_word_length = min_word_length
7781
end
82+
# rubocop:enable Metrics/ParameterLists
7883

7984
# Trains the classifier with text for a category.
8085
#
@@ -130,7 +135,7 @@ def classify(text)
130135
def probabilities(text)
131136
raise NotFittedError, 'Model not fitted. Call fit() after training.' unless @fitted
132137

133-
features = text.word_hash
138+
features = text.word_hash(@min_word_length)
134139
synchronize do
135140
softmax(compute_scores(features))
136141
end
@@ -143,7 +148,7 @@ def probabilities(text)
143148
def classifications(text)
144149
raise NotFittedError, 'Model not fitted. Call fit() after training.' unless @fitted
145150

146-
features = text.word_hash
151+
features = text.word_hash(@min_word_length)
147152
synchronize do
148153
compute_scores(features).transform_keys(&:to_s)
149154
end
@@ -239,7 +244,8 @@ def as_json(_options = nil)
239244
regularization: @regularization,
240245
max_iterations: @max_iterations,
241246
tolerance: @tolerance,
242-
fitted: @fitted
247+
fitted: @fitted,
248+
min_word_length: @min_word_length
243249
}
244250
end
245251

@@ -336,7 +342,7 @@ def reload!
336342
def marshal_dump
337343
fit unless @fitted
338344
[@categories, @weights, @bias, @vocabulary, @learning_rate, @regularization,
339-
@max_iterations, @tolerance, @fitted]
345+
@max_iterations, @tolerance, @fitted, @min_word_length]
340346
end
341347

342348
# Custom marshal deserialization to recreate mutex.
@@ -345,7 +351,7 @@ def marshal_dump
345351
def marshal_load(data)
346352
mu_initialize
347353
@categories, @weights, @bias, @vocabulary, @learning_rate, @regularization,
348-
@max_iterations, @tolerance, @fitted = data
354+
@max_iterations, @tolerance, @fitted, @min_word_length = data
349355
@training_data = []
350356
@dirty = false
351357
@storage = nil
@@ -395,7 +401,7 @@ def train_from_stream(category, io, batch_size: Streaming::DEFAULT_BATCH_SIZE)
395401
reader.each_batch do |batch|
396402
synchronize do
397403
batch.each do |text|
398-
features = text.word_hash
404+
features = text.word_hash(@min_word_length)
399405
features.each_key { |word| @vocabulary[word] = true }
400406
@training_data << { category: category, features: features }
401407
end
@@ -444,7 +450,7 @@ def train_batch_for_category(category, documents, batch_size: Streaming::DEFAULT
444450
documents.each_slice(batch_size) do |batch|
445451
synchronize do
446452
batch.each do |text|
447-
features = text.word_hash
453+
features = text.word_hash(@min_word_length)
448454
features.each_key { |word| @vocabulary[word] = true }
449455
@training_data << { category: category, features: features }
450456
end
@@ -463,7 +469,7 @@ def train_single(category, text)
463469
category = category.to_s.prepare_category_name
464470
raise StandardError, "No such category: #{category}" unless @categories.include?(category)
465471

466-
features = text.word_hash
472+
features = text.word_hash(@min_word_length)
467473
synchronize do
468474
features.each_key { |word| @vocabulary[word] = true }
469475
@training_data << { category: category, features: features }
@@ -570,6 +576,7 @@ def restore_state(data, categories)
570576
@fitted = data.fetch('fitted', true)
571577
@dirty = false
572578
@storage = nil
579+
@min_word_length = data['min_word_length'] || Classifier.config.min_word_length
573580
end
574581

575582
def restore_weights_and_bias(data)

lib/classifier/lsi.rb

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def initialize(options = {})
110110
@max_rank = options[:max_rank] || DEFAULT_MAX_RANK
111111
@u_matrix = nil
112112
@initial_vocab_size = nil
113+
@min_word_length = options[:min_word_length] || Classifier.config.min_word_length
113114
end
114115

115116
# Returns true if the index needs to be rebuilt. The index needs
@@ -216,7 +217,13 @@ def add(**items)
216217
#
217218
# @rbs (String, *String | Symbol) ?{ (String) -> String } -> void
218219
def add_item(item, *categories, &block)
219-
clean_word_hash = block ? block.call(item).clean_word_hash : item.to_s.clean_word_hash
220+
clean_word_hash =
221+
if block
222+
block.call(item).clean_word_hash(@min_word_length)
223+
else
224+
item.to_s.clean_word_hash(@min_word_length)
225+
end
226+
220227
node = nil
221228

222229
synchronize do
@@ -480,14 +487,15 @@ def highest_ranked_stems(doc, count = 3)
480487
# Custom marshal serialization to exclude mutex state
481488
# @rbs () -> Array[untyped]
482489
def marshal_dump
483-
[@auto_rebuild, @word_list, @items, @version, @built_at_version, @dirty]
490+
[@auto_rebuild, @word_list, @items, @version, @built_at_version, @dirty, @min_word_length]
484491
end
485492

486493
# Custom marshal deserialization to recreate mutex
487494
# @rbs (Array[untyped]) -> void
488495
def marshal_load(data)
489496
mu_initialize
490-
@auto_rebuild, @word_list, @items, @version, @built_at_version, @dirty = data
497+
@auto_rebuild, @word_list, @items, @version, @built_at_version, @dirty,
498+
@min_word_length = data
491499
@storage = nil
492500
end
493501

lib/classifier/tfidf.rb

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ class TFIDF
3636
# - min_df/max_df: filter terms by document frequency (Integer for count, Float for proportion)
3737
# - ngram_range: [1,1] for unigrams, [1,2] for unigrams+bigrams
3838
# - sublinear_tf: use 1 + log(tf) instead of raw term frequency
39+
# - min_word_length: minimum word length filter in tokenization
3940
#
4041
# @rbs (?min_df: Integer | Float, ?max_df: Integer | Float,
41-
# ?ngram_range: Array[Integer], ?sublinear_tf: bool) -> void
42-
def initialize(min_df: 1, max_df: 1.0, ngram_range: [1, 1], sublinear_tf: false)
42+
# ?ngram_range: Array[Integer], ?sublinear_tf: bool, ?min_word_length: Integer) -> void
43+
def initialize(min_df: 1, max_df: 1.0, ngram_range: [1, 1], sublinear_tf: false,
44+
min_word_length: Classifier.config.min_word_length)
4345
validate_df!(min_df, 'min_df')
4446
validate_df!(max_df, 'max_df')
4547
validate_ngram_range!(ngram_range)
@@ -54,6 +56,7 @@ def initialize(min_df: 1, max_df: 1.0, ngram_range: [1, 1], sublinear_tf: false)
5456
@fitted = false
5557
@dirty = false
5658
@storage = nil
59+
@min_word_length = min_word_length
5760
end
5861

5962
# Learns vocabulary and IDF weights from the corpus.
@@ -204,7 +207,8 @@ def as_json(_options = nil)
204207
vocabulary: @vocabulary,
205208
idf: @idf,
206209
num_documents: @num_documents,
207-
fitted: @fitted
210+
fitted: @fitted,
211+
min_word_length: @min_word_length
208212
}
209213
end
210214

@@ -223,7 +227,8 @@ def self.from_json(json)
223227
min_df: data['min_df'],
224228
max_df: data['max_df'],
225229
ngram_range: data['ngram_range'],
226-
sublinear_tf: data['sublinear_tf']
230+
sublinear_tf: data['sublinear_tf'],
231+
min_word_length: data['min_word_length'] || Classifier.config.min_word_length
227232
)
228233

229234
instance.instance_variable_set(:@vocabulary, symbolize_keys(data['vocabulary']))
@@ -238,12 +243,14 @@ def self.from_json(json)
238243

239244
# @rbs () -> Array[untyped]
240245
def marshal_dump
241-
[@min_df, @max_df, @ngram_range, @sublinear_tf, @vocabulary, @idf, @num_documents, @fitted]
246+
[@min_df, @max_df, @ngram_range, @sublinear_tf, @vocabulary, @idf, @num_documents, @fitted,
247+
@min_word_length]
242248
end
243249

244250
# @rbs (Array[untyped]) -> void
245251
def marshal_load(data)
246-
@min_df, @max_df, @ngram_range, @sublinear_tf, @vocabulary, @idf, @num_documents, @fitted = data
252+
@min_df, @max_df, @ngram_range, @sublinear_tf, @vocabulary, @idf, @num_documents, @fitted,
253+
@min_word_length = data
247254
@dirty = false
248255
@storage = nil
249256
end
@@ -334,7 +341,7 @@ def extract_terms(document)
334341
result = Hash.new(0)
335342

336343
if @ngram_range[0] <= 1
337-
word_hash = document.clean_word_hash
344+
word_hash = document.clean_word_hash(@min_word_length)
338345
word_hash.each { |term, count| result[term] += count }
339346
end
340347

test/bayes/bayesian_test.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ def test_array_initialization
2828
assert_equal 'Spam', classifier.classify('this is spam')
2929
end
3030

31+
def test_initialization_with_min_word_length
32+
classifier = Classifier::Bayes.new(%w[Spam Ham], min_word_length: 5)
33+
34+
classifier.train_spam 'bad nasty spam email'
35+
classifier.train_ham 'good legitimate email'
36+
37+
assert_equal 'Spam', classifier.classify('nasty text')
38+
assert_equal 'Ham', classifier.classify('legitimate text')
39+
assert_equal 'Spam', classifier.classify('good text')
40+
end
41+
3142
def test_add_category
3243
@classifier.add_category 'Test'
3344

0 commit comments

Comments
 (0)