Skip to content

Commit c2956f7

Browse files
authored
Merge pull request #155 from Yegorov/114
feat: add keyword argument support for `train_from_stream` (#114)
2 parents dfbd691 + 19bd731 commit c2956f7

9 files changed

Lines changed: 248 additions & 55 deletions

File tree

lib/classifier/bayes.rb

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -328,20 +328,14 @@ def remove_category(category)
328328
# puts "#{progress.completed} documents processed"
329329
# end
330330
#
331-
# @rbs (String | Symbol, IO, ?batch_size: Integer) { (Streaming::Progress) -> void } -> void
332-
def train_from_stream(category, io, batch_size: Streaming::DEFAULT_BATCH_SIZE)
333-
category = category.prepare_category_name
334-
raise StandardError, "No such category: #{category}" unless @categories.key?(category)
335-
336-
reader = Streaming::LineReader.new(io, batch_size: batch_size)
337-
total = reader.estimate_line_count
338-
progress = Streaming::Progress.new(total: total)
339-
340-
reader.each_batch do |batch|
341-
train_batch_internal(category, batch)
342-
progress.completed += batch.size
343-
progress.current_batch += 1
344-
yield progress if block_given?
331+
# @rbs (?(String | Symbol | nil), ?IO?, ?batch_size: Integer, **IO) { (Streaming::Progress) -> void } -> void
332+
def train_from_stream(category = nil, io = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &)
333+
raise ArgumentError, 'Provide either (category, io) or keyword category: io pairs' if category.nil? && io.nil? && categories.empty?
334+
raise ArgumentError, 'Provide both category and io, or use keyword arguments' if [category, io].one?(&:nil?)
335+
336+
pairs = category && io ? { category => io } : categories
337+
pairs.each do |cat, stream|
338+
stream_train_category(cat, stream, batch_size: batch_size, &)
345339
end
346340
end
347341

@@ -389,6 +383,25 @@ def self.load_checkpoint(storage:, checkpoint_id:)
389383

390384
private
391385

386+
# Trains from an IO stream with a single category.
387+
# @rbs (String | Symbol, IO, batch_size: Integer) { (Streaming::Progress) -> void } -> void
388+
def stream_train_category(category, io, batch_size:)
389+
category = category.prepare_category_name
390+
raise ArgumentError, "No such category: #{category}" unless @categories.key?(category)
391+
raise ArgumentError, 'Stream must respond to #each_line' unless io.respond_to?(:each_line)
392+
393+
reader = Streaming::LineReader.new(io, batch_size: batch_size)
394+
total = reader.estimate_line_count
395+
progress = Streaming::Progress.new(total: total)
396+
397+
reader.each_batch do |batch|
398+
train_batch_internal(category, batch)
399+
progress.completed += batch.size
400+
progress.current_batch += 1
401+
yield progress if block_given?
402+
end
403+
end
404+
392405
# Trains a batch of documents for a single category.
393406
# @rbs (String | Symbol, Array[String], ?batch_size: Integer) { (Streaming::Progress) -> void } -> void
394407
def train_batch_for_category(category, documents, batch_size: Streaming::DEFAULT_BATCH_SIZE)

lib/classifier/knn.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,10 @@ def self.load_checkpoint(storage:, checkpoint_id:)
268268
# puts "#{progress.completed} documents processed"
269269
# end
270270
#
271-
# @rbs (String | Symbol, IO, ?batch_size: Integer) { (Streaming::Progress) -> void } -> void
272-
def train_from_stream(category, io, batch_size: Streaming::DEFAULT_BATCH_SIZE, &block)
273-
@lsi.train_from_stream(category, io, batch_size: batch_size, &block)
271+
# @rbs (?(String | Symbol | nil), ?IO?, ?batch_size: Integer, **IO) { (Streaming::Progress) -> void } -> void
272+
def train_from_stream(category = nil, io = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &)
273+
# @type var categories: untype
274+
@lsi.train_from_stream(category, io, batch_size: batch_size, **categories, &)
274275
synchronize { @dirty = true }
275276
end
276277

lib/classifier/logistic_regression.rb

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -390,28 +390,14 @@ def self.load_checkpoint(storage:, checkpoint_id:)
390390
# end
391391
# classifier.fit
392392
#
393-
# @rbs (String | Symbol, IO, ?batch_size: Integer) { (Streaming::Progress) -> void } -> void
394-
def train_from_stream(category, io, batch_size: Streaming::DEFAULT_BATCH_SIZE)
395-
category = category.to_s.prepare_category_name
396-
raise StandardError, "No such category: #{category}" unless @categories.include?(category)
397-
398-
reader = Streaming::LineReader.new(io, batch_size: batch_size)
399-
total = reader.estimate_line_count
400-
progress = Streaming::Progress.new(total: total)
401-
402-
reader.each_batch do |batch|
403-
synchronize do
404-
batch.each do |text|
405-
features = text.word_hash(@min_word_length)
406-
features.each_key { |word| @vocabulary[word] = true }
407-
@training_data << { category: category, features: features }
408-
end
409-
@fitted = false
410-
@dirty = true
411-
end
412-
progress.completed += batch.size
413-
progress.current_batch += 1
414-
yield progress if block_given?
393+
# @rbs (?(String | Symbol | nil), ?IO?, ?batch_size: Integer, **IO) { (Streaming::Progress) -> void } -> void
394+
def train_from_stream(category = nil, io = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &)
395+
raise ArgumentError, 'Provide either (category, io) or keyword category: io pairs' if category.nil? && io.nil? && categories.empty?
396+
raise ArgumentError, 'Provide both category and io, or use keyword arguments' if [category, io].one?(&:nil?)
397+
398+
pairs = category && io ? { category => io } : categories
399+
pairs.each do |cat, stream|
400+
stream_train_category(cat, stream, batch_size:, &)
415401
end
416402
end
417403

@@ -440,6 +426,33 @@ def train_batch(category = nil, documents = nil, batch_size: Streaming::DEFAULT_
440426

441427
private
442428

429+
# Trains from an IO stream with a single category.
430+
# @rbs (String | Symbol, IO, batch_size: Integer) { (Streaming::Progress) -> void } -> void
431+
def stream_train_category(category, io, batch_size:)
432+
category = category.to_s.prepare_category_name
433+
raise ArgumentError, "No such category: #{category}" unless @categories.include?(category)
434+
raise ArgumentError, 'Stream must respond to #each_line' unless io.respond_to?(:each_line)
435+
436+
reader = Streaming::LineReader.new(io, batch_size: batch_size)
437+
total = reader.estimate_line_count
438+
progress = Streaming::Progress.new(total: total)
439+
440+
reader.each_batch do |batch|
441+
synchronize do
442+
batch.each do |text|
443+
features = text.word_hash(@min_word_length)
444+
features.each_key { |word| @vocabulary[word] = true }
445+
@training_data << { category: category, features: features }
446+
end
447+
@fitted = false
448+
@dirty = true
449+
end
450+
progress.completed += batch.size
451+
progress.current_batch += 1
452+
yield progress if block_given?
453+
end
454+
end
455+
443456
# Trains a batch of documents for a single category.
444457
# @rbs (String | Symbol, Array[String], ?batch_size: Integer) { (Streaming::Progress) -> void } -> void
445458
def train_batch_for_category(category, documents, batch_size: Streaming::DEFAULT_BATCH_SIZE)

lib/classifier/lsi.rb

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -662,21 +662,22 @@ def self.load_checkpoint(storage:, checkpoint_id:)
662662
# puts "#{progress.completed} documents processed"
663663
# end
664664
#
665-
# @rbs (String | Symbol, IO, ?batch_size: Integer) { (Streaming::Progress) -> void } -> void
666-
def train_from_stream(category, io, batch_size: Streaming::DEFAULT_BATCH_SIZE)
667-
original_auto_rebuild = @auto_rebuild
668-
@auto_rebuild = false
669-
665+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
666+
# @rbs (?(String | Symbol | nil), ?IO?, ?batch_size: Integer, **IO) { (Streaming::Progress) -> void } -> void
667+
def train_from_stream(category = nil, io = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &)
668+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
669+
raise ArgumentError, 'Provide either (category, io) or keyword category: io pairs' if category.nil? && io.nil? && categories.empty?
670+
raise ArgumentError, 'Provide both category and io, or use keyword arguments' if [category, io].one?(&:nil?)
671+
672+
pairs = category && io ? { category => io } : categories
673+
pairs.each_value do |io|
674+
raise ArgumentError, 'Stream must respond to #each_line' unless io.respond_to?(:each_line)
675+
end
670676
begin
671-
reader = Streaming::LineReader.new(io, batch_size: batch_size)
672-
total = reader.estimate_line_count
673-
progress = Streaming::Progress.new(total: total)
674-
675-
reader.each_batch do |batch|
676-
batch.each { |text| add_item(text, category) }
677-
progress.completed += batch.size
678-
progress.current_batch += 1
679-
yield progress if block_given?
677+
original_auto_rebuild = @auto_rebuild
678+
@auto_rebuild = false
679+
pairs.each do |cat, stream|
680+
stream_train_category(cat, stream, batch_size:, &)
680681
end
681682
ensure
682683
@auto_rebuild = original_auto_rebuild
@@ -729,6 +730,21 @@ def train_batch(category = nil, documents = nil, batch_size: Streaming::DEFAULT_
729730

730731
private
731732

733+
# Trains from an IO stream with a single category.
734+
# @rbs (String | Symbol, IO, batch_size: Integer) { (Streaming::Progress) -> void } -> void
735+
def stream_train_category(category, io, batch_size:)
736+
reader = Streaming::LineReader.new(io, batch_size: batch_size)
737+
total = reader.estimate_line_count
738+
progress = Streaming::Progress.new(total: total)
739+
740+
reader.each_batch do |batch|
741+
batch.each { |text| add_item(text, category) }
742+
progress.completed += batch.size
743+
progress.current_batch += 1
744+
yield progress if block_given?
745+
end
746+
end
747+
732748
# Restores LSI state from a JSON string (used by reload)
733749
# @rbs (String) -> void
734750
def restore_from_json(json)

lib/classifier/streaming.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ module Streaming
2626
# Trains the classifier from an IO stream.
2727
# Each line in the stream is treated as a separate document.
2828
#
29-
# @rbs (Symbol | String, IO, ?batch_size: Integer) { (Progress) -> void } -> void
30-
def train_from_stream(category, io, batch_size: DEFAULT_BATCH_SIZE, &block)
29+
# @rbs (?(Symbol | String | nil), ?IO?, ?batch_size: Integer, **IO) { (Progress) -> void } -> void
30+
def train_from_stream(category = nil, io = nil, batch_size: DEFAULT_BATCH_SIZE, **categories, &block)
3131
raise NotImplementedError, "#{self.class} must implement train_from_stream"
3232
end
3333

test/bayes/streaming_test.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@ def test_train_from_stream_basic
1717
assert_equal 'Spam', @classifier.classify('buy cheap free')
1818
end
1919

20+
def test_train_from_stream_many_categories
21+
classifier = Classifier::Bayes.new('Spam', 'Ham')
22+
classifier.train_from_stream(
23+
spam: StringIO.new("buy now cheap\nfree money\nlimited offer\n"),
24+
ham: StringIO.new("hello friend\nmeeting tomorrow\n")
25+
)
26+
27+
assert_equal 'Spam', classifier.classify('buy free')
28+
assert_equal 'Ham', classifier.classify('hello meeting')
29+
end
30+
31+
def test_train_from_stream_invalid_io_type
32+
assert_raises(ArgumentError) do
33+
@classifier.train_from_stream(spam: Object.new)
34+
end
35+
end
36+
2037
def test_train_from_stream_empty_io
2138
io = StringIO.new('')
2239
@classifier.train_from_stream(:spam, io)

test/knn/streaming_test.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
require_relative '../test_helper'
2+
require 'stringio'
3+
4+
class KNNStreamingTest < Minitest::Test
5+
def test_train_from_stream_basic
6+
knn = Classifier::KNN.new
7+
knn.train_from_stream(:spam, StringIO.new("buy now cheap\nfree money\nlimited offer\n"))
8+
9+
assert_equal 'spam', knn.classify('buy cheap free')
10+
end
11+
12+
def test_train_from_stream_many_categories
13+
knn = Classifier::KNN.new
14+
knn.train_from_stream(
15+
spam: StringIO.new("buy now cheap\nfree money\nlimited offer\n"),
16+
ham: StringIO.new("hello friend\nmeeting tomorrow\nhello fellow\n")
17+
)
18+
19+
assert_equal 'spam', knn.classify('free offer')
20+
assert_equal 'ham', knn.classify('hello')
21+
end
22+
23+
def test_train_from_stream_invalid_io_type
24+
knn = Classifier::KNN.new
25+
assert_raises(ArgumentError) { knn.train_from_stream(spam: Object.new) }
26+
end
27+
28+
def test_train_from_stream_raises_without_args
29+
knn = Classifier::KNN.new
30+
assert_raises(ArgumentError) { knn.train_from_stream }
31+
end
32+
33+
def test_train_from_stream_raises_with_partial_args
34+
knn = Classifier::KNN.new
35+
assert_raises(ArgumentError) { knn.train_from_stream(:spam) }
36+
end
37+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
require_relative '../test_helper'
2+
require 'stringio'
3+
4+
class LogisticRegressionStreamingTest < Minitest::Test
5+
def test_train_from_stream_basic
6+
classifier = Classifier::LogisticRegression.new('Spam', 'Ham')
7+
classifier.train_from_stream(:spam, StringIO.new("buy now cheap\nfree money\nlimited offer\n"))
8+
classifier.fit
9+
10+
assert_equal 'Spam', classifier.classify('buy cheap free')
11+
end
12+
13+
def test_train_from_stream_many_categories
14+
classifier = Classifier::LogisticRegression.new('Spam', 'Ham')
15+
classifier.train_from_stream(
16+
spam: StringIO.new("buy now cheap\nfree money\nlimited offer\n"),
17+
ham: StringIO.new("hello friend\nmeeting tomorrow\n")
18+
)
19+
classifier.fit
20+
21+
assert_equal 'Spam', classifier.classify('buy free')
22+
assert_equal 'Ham', classifier.classify('hello meeting')
23+
end
24+
25+
def test_train_from_stream_invalid_io_type
26+
classifier = Classifier::LogisticRegression.new('Spam', 'Ham')
27+
assert_raises(ArgumentError) { classifier.train_from_stream(spam: Object.new) }
28+
end
29+
30+
def test_train_from_stream_raises_without_args
31+
classifier = Classifier::LogisticRegression.new('Spam', 'Ham')
32+
assert_raises(ArgumentError) { classifier.train_from_stream }
33+
end
34+
35+
def test_train_from_stream_raises_with_partial_args
36+
classifier = Classifier::LogisticRegression.new('Spam', 'Ham')
37+
assert_raises(ArgumentError) { classifier.train_from_stream(:spam) }
38+
end
39+
end

test/lsi/streaming_test.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,39 @@ def test_train_from_stream_basic
2323
assert_equal 'dog', result.to_s
2424
end
2525

26+
def test_train_from_stream_many_categories
27+
lsi = Classifier::LSI.new
28+
lsi.train_from_stream(
29+
dog: StringIO.new("dogs are loyal pets\npuppies are playful\ndogs bark at strangers\n"),
30+
cat: StringIO.new("cats are independent\nkittens are curious\ncats meow softly\n")
31+
)
32+
33+
assert_equal :dog, lsi.classify('loyal pet that barks')
34+
assert_equal :cat, lsi.classify('independent curious pet')
35+
end
36+
37+
def test_train_from_stream_raises_without_args
38+
assert_raises(ArgumentError) { @lsi.train_from_stream }
39+
end
40+
41+
def test_train_from_stream_raises_with_partial_args
42+
assert_raises(ArgumentError) { @lsi.train_from_stream(:spam) }
43+
end
44+
45+
def test_train_from_stream_invalid_io_type
46+
assert_raises(ArgumentError) { @lsi.train_from_stream(category: Object.new) }
47+
end
48+
49+
def test_train_from_stream_with_invalid_io_type_does_not_modify_auto_rebuild_setting
50+
@lsi = Classifier::LSI.new(auto_rebuild: true)
51+
52+
assert_raises(ArgumentError) do
53+
@lsi.train_from_stream(cat1: StringIO.new("one\ntwo\n"), cat2: Object.new)
54+
end
55+
56+
assert @lsi.auto_rebuild
57+
end
58+
2659
def test_train_from_stream_empty_io
2760
@lsi.train_from_stream(:category, StringIO.new(''))
2861

@@ -82,6 +115,18 @@ def test_train_from_stream_rebuilds_index_when_auto_rebuild
82115
refute_predicate @lsi, :needs_rebuild?
83116
end
84117

118+
def test_train_from_stream_with_keyword_categories_rebuilds_index_when_auto_rebuild
119+
@lsi = Classifier::LSI.new(auto_rebuild: true)
120+
121+
@lsi.train_from_stream(
122+
dog: StringIO.new("dogs are loyal\ndogs bark\n"),
123+
cat: StringIO.new("cats are independent\ncats meow\n")
124+
)
125+
126+
# Index should be built
127+
refute_predicate @lsi, :needs_rebuild?
128+
end
129+
85130
def test_train_from_stream_skips_rebuild_when_auto_rebuild_false
86131
@lsi = Classifier::LSI.new(auto_rebuild: false)
87132

@@ -91,6 +136,18 @@ def test_train_from_stream_skips_rebuild_when_auto_rebuild_false
91136
assert_predicate @lsi, :needs_rebuild?
92137
end
93138

139+
def test_train_from_stream_with_keyword_categories_skips_rebuild_when_auto_rebuild_false
140+
@lsi = Classifier::LSI.new(auto_rebuild: false)
141+
142+
@lsi.train_from_stream(
143+
cat1: StringIO.new("document one\ndocument two\n"),
144+
cat2: StringIO.new("document three\ndocument four\n")
145+
)
146+
147+
# Index should need rebuild
148+
assert_predicate @lsi, :needs_rebuild?
149+
end
150+
94151
def test_train_from_stream_with_file
95152
Tempfile.create(['corpus', '.txt']) do |file|
96153
file.puts 'dogs are loyal pets'

0 commit comments

Comments
 (0)