Skip to content

Commit 661411d

Browse files
authored
perf: cache expensive computations in Bayes classifier (#84)
* perf: cache expensive computations in Bayes classifier Addresses issue #65 by memoizing O(n) operations that were recalculated on every classification call: - Cache training_count (sum of category counts) with dirty-flag pattern - Cache vocab_size (unique words across categories) with invalidation - Cache Vector#magnitude since Vector objects are immutable The dirty-flag pattern ensures caches are invalidated when training data changes via train, untrain, add_category, or remove_category calls. Also updates CLAUDE.md to use bundle exec for rake commands. Closes #65 * fix: align memoized variable names with method names RuboCop's Naming/MemoizedInstanceVariableName requires the memoized instance variable to match the method name. Update: - @training_count_cache -> @cached_training_count - @vocab_size_cache -> @cached_vocab_size - @magnitude_cache -> @magnitude
1 parent d3f4771 commit 661411d

3 files changed

Lines changed: 48 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,27 @@ Ruby gem providing text classification via two algorithms:
1212

1313
```bash
1414
# Compile native C extension
15-
rake compile
15+
bundle exec rake compile
1616

1717
# Run all tests (compiles first)
18-
rake test
18+
bundle exec rake test
1919

2020
# Run a single test file
2121
ruby -Ilib test/bayes/bayesian_test.rb
2222
ruby -Ilib test/lsi/lsi_test.rb
2323

2424
# Run tests with pure Ruby (no native extension)
25-
NATIVE_VECTOR=true rake test
25+
NATIVE_VECTOR=true bundle exec rake test
2626

2727
# Run benchmarks
28-
rake benchmark
29-
rake benchmark:compare
28+
bundle exec rake benchmark
29+
bundle exec rake benchmark:compare
3030

3131
# Interactive console
32-
rake console
32+
bundle exec rake console
3333

3434
# Generate documentation
35-
rake doc
35+
bundle exec rake doc
3636
```
3737

3838
## Architecture
@@ -67,7 +67,7 @@ LSI uses a native C extension for fast linear algebra operations:
6767
- Jacobi SVD implementation for singular value decomposition
6868

6969
Check current backend: `Classifier::LSI.backend` returns `:native` or `:ruby`
70-
Force pure Ruby: `NATIVE_VECTOR=true rake test`
70+
Force pure Ruby: `NATIVE_VECTOR=true bundle exec rake test`
7171

7272
### Content Nodes (`lib/classifier/lsi/content_node.rb`)
7373

lib/classifier/bayes.rb

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class Bayes
1515
# @rbs @total_words: Integer
1616
# @rbs @category_counts: Hash[Symbol, Integer]
1717
# @rbs @category_word_count: Hash[Symbol, Integer]
18+
# @rbs @cached_training_count: Float?
19+
# @rbs @cached_vocab_size: Integer?
1820

1921
# The class can be created with one or more categories, each of which will be
2022
# initialized and given a training method. E.g.,
@@ -27,6 +29,8 @@ def initialize(*categories)
2729
@total_words = 0
2830
@category_counts = Hash.new(0)
2931
@category_word_count = Hash.new(0)
32+
@cached_training_count = nil
33+
@cached_vocab_size = nil
3034
end
3135

3236
# Provides a general training method for all categories specified in Bayes#new
@@ -41,6 +45,7 @@ def train(category, text)
4145
category = category.prepare_category_name
4246
word_hash = text.word_hash
4347
synchronize do
48+
invalidate_caches
4449
@category_counts[category] += 1
4550
word_hash.each do |word, count|
4651
@categories[category][word] ||= 0
@@ -64,6 +69,7 @@ def untrain(category, text)
6469
category = category.prepare_category_name
6570
word_hash = text.word_hash
6671
synchronize do
72+
invalidate_caches
6773
@category_counts[category] -= 1
6874
word_hash.each do |word, count|
6975
next unless @total_words >= 0
@@ -90,8 +96,8 @@ def untrain(category, text)
9096
def classifications(text)
9197
words = text.word_hash.keys
9298
synchronize do
93-
training_count = @category_counts.values.sum.to_f
94-
vocab_size = [@categories.values.flat_map(&:keys).uniq.size, 1].max
99+
training_count = cached_training_count
100+
vocab_size = cached_vocab_size
95101

96102
@categories.to_h do |category, category_words|
97103
smoothed_total = ((@category_word_count[category] || 0) + vocab_size).to_f
@@ -211,7 +217,10 @@ def categories
211217
#
212218
# @rbs (String | Symbol) -> Hash[Symbol, Integer]
213219
def add_category(category)
214-
synchronize { @categories[category.prepare_category_name] = {} }
220+
synchronize do
221+
invalidate_caches
222+
@categories[category.prepare_category_name] = {}
223+
end
215224
end
216225

217226
alias append_category add_category
@@ -227,6 +236,8 @@ def marshal_dump
227236
def marshal_load(data)
228237
mu_initialize
229238
@categories, @total_words, @category_counts, @category_word_count = data
239+
@cached_training_count = nil
240+
@cached_vocab_size = nil
230241
end
231242

232243
# Allows you to remove categories from the classifier.
@@ -243,6 +254,7 @@ def remove_category(category)
243254
synchronize do
244255
raise StandardError, "No such category: #{category}" unless @categories.key?(category)
245256

257+
invalidate_caches
246258
@total_words -= @category_word_count[category].to_i
247259

248260
@categories.delete(category)
@@ -261,6 +273,8 @@ def restore_state(data)
261273
@total_words = data['total_words']
262274
@category_counts = Hash.new(0) #: Hash[Symbol, Integer]
263275
@category_word_count = Hash.new(0) #: Hash[Symbol, Integer]
276+
@cached_training_count = nil
277+
@cached_vocab_size = nil
264278

265279
data['categories'].each do |cat_name, words|
266280
@categories[cat_name.to_sym] = words.transform_keys(&:to_sym)
@@ -274,5 +288,21 @@ def restore_state(data)
274288
@category_word_count[cat_name.to_sym] = count
275289
end
276290
end
291+
292+
# @rbs () -> void
293+
def invalidate_caches
294+
@cached_training_count = nil
295+
@cached_vocab_size = nil
296+
end
297+
298+
# @rbs () -> Float
299+
def cached_training_count
300+
@cached_training_count ||= @category_counts.values.sum.to_f
301+
end
302+
303+
# @rbs () -> Integer
304+
def cached_vocab_size
305+
@cached_vocab_size ||= [@categories.values.flat_map(&:keys).uniq.size, 1].max
306+
end
277307
end
278308
end

lib/classifier/extensions/vector.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@ class Vector
2727
undef_method :normalize if method_defined?(:normalize)
2828

2929
def magnitude
30-
sum_of_squares = 0.to_r
31-
size.times do |i|
32-
sum_of_squares += self[i]**2.to_r
30+
# Cache magnitude since Vector is immutable after creation
31+
@magnitude ||= begin
32+
sum_of_squares = 0.to_r
33+
size.times do |i|
34+
sum_of_squares += self[i]**2.to_r
35+
end
36+
Math.sqrt(sum_of_squares.to_f)
3337
end
34-
Math.sqrt(sum_of_squares.to_f)
3538
end
3639

3740
def normalize

0 commit comments

Comments
 (0)