classifier 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +165 -0
- data/README.md +76 -11
- data/docs/README.md +49 -0
- data/docs/bayes.md +108 -0
- data/docs/cli.md +144 -0
- data/docs/configuration.md +100 -0
- data/docs/keywords.md +171 -0
- data/docs/knn.md +92 -0
- data/docs/logistic-regression.md +107 -0
- data/docs/lsi.md +219 -0
- data/docs/persistence.md +124 -0
- data/docs/streaming.md +104 -0
- data/docs/tfidf.md +137 -0
- data/exe/classifier +0 -1
- data/exe/keywords +15 -0
- data/lib/classifier/bayes.rb +5 -2
- data/lib/classifier/extensions/word_hash.rb +20 -0
- data/lib/classifier/keywords/cli.rb +299 -0
- data/lib/classifier/lsi.rb +3 -3
- data/lib/classifier/streaming/line_reader.rb +9 -7
- data/lib/classifier/streaming/multi_io.rb +51 -0
- data/lib/classifier/streaming.rb +1 -0
- data/lib/classifier/tfidf.rb +3 -2
- data/lib/classifier/version.rb +1 -1
- metadata +19 -4
- data/CLAUDE.md +0 -77
data/docs/persistence.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Persistence
|
|
2
|
+
|
|
3
|
+
Every classifier saves and loads the same way. `Classifier::Bayes`,
|
|
4
|
+
`Classifier::LogisticRegression`, `Classifier::LSI`, `Classifier::KNN`, and
|
|
5
|
+
`Classifier::TFIDF` all share this API.
|
|
6
|
+
|
|
7
|
+
## Files
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require "classifier"
|
|
11
|
+
|
|
12
|
+
classifier = Classifier::Bayes.new(:spam, :ham)
|
|
13
|
+
classifier.train(spam: "cheap pills", ham: "meeting tomorrow")
|
|
14
|
+
|
|
15
|
+
classifier.save_to_file("model.json")
|
|
16
|
+
|
|
17
|
+
loaded = Classifier::Bayes.load_from_file("model.json")
|
|
18
|
+
loaded.classify("pills")
|
|
19
|
+
# => "Spam"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The format is JSON, so a saved model is readable and portable between Ruby
|
|
23
|
+
versions.
|
|
24
|
+
|
|
25
|
+
## Storage backends
|
|
26
|
+
|
|
27
|
+
A backend separates the model from where it lives. Assign one, then call `save`
|
|
28
|
+
and `load` with no path:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
classifier.storage = Classifier::Storage::File.new(path: "model.json")
|
|
32
|
+
classifier.save
|
|
33
|
+
|
|
34
|
+
loaded = Classifier::Bayes.load(storage: classifier.storage)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The gem ships two backends:
|
|
38
|
+
|
|
39
|
+
| Backend | Use it for |
|
|
40
|
+
|:--|:--|
|
|
41
|
+
| `Classifier::Storage::File` | A model on disk |
|
|
42
|
+
| `Classifier::Storage::Memory` | Tests, and a model that lives for one process |
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
storage = Classifier::Storage::Memory.new
|
|
46
|
+
|
|
47
|
+
classifier = Classifier::Bayes.new(:a, :b)
|
|
48
|
+
classifier.train(a: "alpha", b: "beta")
|
|
49
|
+
classifier.storage = storage
|
|
50
|
+
classifier.save
|
|
51
|
+
|
|
52
|
+
Classifier::Bayes.load(storage: storage).categories
|
|
53
|
+
# => ["A", "B"]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Write your own backend
|
|
57
|
+
|
|
58
|
+
Subclass `Classifier::Storage::Base` and implement four methods:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
Classifier::Storage::Base.instance_methods(false).sort
|
|
62
|
+
# => [:delete, :exists?, :read, :write]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Method | Contract |
|
|
66
|
+
|:--|:--|
|
|
67
|
+
| `write(key, data)` | Store the serialized model |
|
|
68
|
+
| `read(key)` | Return what `write` stored, or nil |
|
|
69
|
+
| `exists?(key)` | Report whether a model is stored under the key |
|
|
70
|
+
| `delete(key)` | Remove the stored model |
|
|
71
|
+
|
|
72
|
+
A Redis backend looks like this:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class RedisStorage < Classifier::Storage::Base
|
|
76
|
+
def initialize(redis:, namespace: "classifier")
|
|
77
|
+
@redis = redis
|
|
78
|
+
@namespace = namespace
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def write(key, data) = @redis.set(namespaced(key), data)
|
|
82
|
+
def read(key) = @redis.get(namespaced(key))
|
|
83
|
+
def exists?(key) = @redis.exists?(namespaced(key))
|
|
84
|
+
def delete(key) = @redis.del(namespaced(key))
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def namespaced(key) = "#{@namespace}:#{key}"
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The same shape covers S3, a SQL table, or any other store.
|
|
93
|
+
|
|
94
|
+
## Track unsaved changes
|
|
95
|
+
|
|
96
|
+
`dirty?` reports whether the model changed since the last save:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
classifier.dirty?
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`reload` discards unsaved changes and reads the stored model again. `reload!`
|
|
103
|
+
does the same and raises when no stored model exists.
|
|
104
|
+
|
|
105
|
+
## Marshal
|
|
106
|
+
|
|
107
|
+
Every classifier also supports `Marshal`:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
data = Marshal.dump(classifier)
|
|
111
|
+
restored = Marshal.load(data)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Prefer JSON. Only load a marshalled model from a source you trust.
|
|
115
|
+
|
|
116
|
+
## Checkpoints
|
|
117
|
+
|
|
118
|
+
Streaming training writes checkpoints, so a long run resumes after a failure:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
Classifier::Bayes.load_checkpoint(storage: storage, checkpoint_id: "run-1")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
See [Streaming](streaming.md).
|
data/docs/streaming.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Streaming
|
|
2
|
+
|
|
3
|
+
Streaming trains a classifier on data larger than memory. The reader pulls one
|
|
4
|
+
batch of lines at a time, so peak memory stays flat whatever the corpus size.
|
|
5
|
+
|
|
6
|
+
Each **line** is one document.
|
|
7
|
+
|
|
8
|
+
## Train from a stream
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
require "classifier"
|
|
12
|
+
|
|
13
|
+
classifier = Classifier::Bayes.new(:spam, :ham)
|
|
14
|
+
classifier.train_from_stream(:spam, File.open("spam_corpus.txt"))
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`Classifier::LogisticRegression` and `Classifier::KNN` accept the same call.
|
|
18
|
+
`Classifier::TFIDF` uses `fit_from_stream`, because it fits a vocabulary rather
|
|
19
|
+
than a category.
|
|
20
|
+
|
|
21
|
+
## Progress
|
|
22
|
+
|
|
23
|
+
Pass a block to watch the run:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
classifier.train_from_stream(:spam, File.open("spam_corpus.txt")) do |progress|
|
|
27
|
+
puts "completed=#{progress.completed}"
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`Classifier::Streaming::Progress` reports `completed`, and `total` when the
|
|
32
|
+
reader can estimate the line count from the file size.
|
|
33
|
+
|
|
34
|
+
## Batch size
|
|
35
|
+
|
|
36
|
+
The reader groups lines into batches. The default is
|
|
37
|
+
`Classifier::Streaming::DEFAULT_BATCH_SIZE`.
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
classifier.train_from_stream(:spam, File.open("corpus.txt"), batch_size: 500)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A larger batch does less bookkeeping and uses more memory.
|
|
44
|
+
|
|
45
|
+
## Train from an array
|
|
46
|
+
|
|
47
|
+
`train_batch` takes documents already in memory and uses the same batching:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
classifier.train_batch(:spam, ["cheap pills", "you won a prize"])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`Classifier::LSI` and `Classifier::KNN` name the same method `add_batch`, which
|
|
54
|
+
matches their `add` API:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
lsi.add_batch(tech: ["Ruby is elegant", "Python is popular"])
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Read several files as one stream
|
|
61
|
+
|
|
62
|
+
`Classifier::Streaming::MultiIO` presents many sources as one sequential
|
|
63
|
+
stream. It accepts file paths, IO objects, or both:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
multi = Classifier::Streaming::MultiIO.new(["a.txt", "b.txt"])
|
|
67
|
+
multi.each_line { |line| puts line }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
Classifier::Streaming::MultiIO.new(Dir["corpus/*.txt"]).each_line.to_a
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Given a path, `MultiIO` opens the file, reads it, and closes it before it moves
|
|
75
|
+
to the next one. Only one file is ever open, so a corpus larger than the file
|
|
76
|
+
descriptor limit still works. Given an IO object, it reads that object and
|
|
77
|
+
leaves the closing to you.
|
|
78
|
+
|
|
79
|
+
`each_line` returns an Enumerator when you pass no block.
|
|
80
|
+
|
|
81
|
+
Combine it with a vectorizer to fit a whole corpus:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
tfidf = Classifier::TFIDF.new
|
|
85
|
+
tfidf.fit_from_stream(
|
|
86
|
+
Classifier::Streaming::MultiIO.new(Dir["corpus/*.txt"])
|
|
87
|
+
)
|
|
88
|
+
tfidf.num_documents
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Checkpoints
|
|
92
|
+
|
|
93
|
+
A long run writes checkpoints through the assigned storage backend, so a
|
|
94
|
+
failure does not cost the whole pass:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
classifier.storage = Classifier::Storage::File.new(path: "model.json")
|
|
98
|
+
resumed = Classifier::Bayes.load_checkpoint(
|
|
99
|
+
storage: classifier.storage,
|
|
100
|
+
checkpoint_id: "run-1"
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See [Persistence](persistence.md) for the backends.
|
data/docs/tfidf.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# TF-IDF
|
|
2
|
+
|
|
3
|
+
`Classifier::TFIDF` turns text into term weights. It answers "which terms matter
|
|
4
|
+
in this document", not "which category is this". For the same thing from a
|
|
5
|
+
shell, use [`keywords`](keywords.md).
|
|
6
|
+
|
|
7
|
+
TF-IDF raises the weight of a term that is frequent in one document, and lowers
|
|
8
|
+
it for a term that is common across every document.
|
|
9
|
+
|
|
10
|
+
## Fit and transform
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require "classifier"
|
|
14
|
+
|
|
15
|
+
tfidf = Classifier::TFIDF.new
|
|
16
|
+
tfidf.fit(["Ruby is great", "Python is great", "Ruby on Rails"])
|
|
17
|
+
|
|
18
|
+
tfidf.transform("Ruby programming")
|
|
19
|
+
# => {rubi: 1.0}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The keys are Porter stems. `fit_transform` does both steps at once:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
tfidf.fit_transform(["Ruby is great", "Python is great"])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`transform` raises `Classifier::NotFittedError` before a fit.
|
|
29
|
+
|
|
30
|
+
## The vocabulary
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
tfidf.feature_names # every term in the vocabulary
|
|
34
|
+
tfidf.vocabulary # term => column index
|
|
35
|
+
tfidf.idf # term => inverse document frequency
|
|
36
|
+
tfidf.num_documents # documents seen during the fit
|
|
37
|
+
tfidf.fitted? # => true
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Document frequency filters
|
|
41
|
+
|
|
42
|
+
`min_df` and `max_df` drop terms that are too rare or too common.
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
tfidf = Classifier::TFIDF.new(min_df: 2, max_df: 0.85)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| Parameter | Type | Meaning |
|
|
49
|
+
|:--|:--|:--|
|
|
50
|
+
| `min_df` | Integer | Keep a term only when at least this many documents hold it |
|
|
51
|
+
| `min_df` | Float | The same, as a ratio of the document count |
|
|
52
|
+
| `max_df` | Float | Drop a term that appears in more than this ratio of documents |
|
|
53
|
+
| `max_df` | Integer | The same, as an absolute document count |
|
|
54
|
+
|
|
55
|
+
An Integer means a count and a Float means a ratio. A Float must fall between
|
|
56
|
+
0.0 and 1.0, and an Integer must not be negative.
|
|
57
|
+
|
|
58
|
+
Both values are readable after construction, and a saved model keeps them:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
tfidf.min_df # => 2
|
|
62
|
+
tfidf.max_df # => 0.85
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## N-grams
|
|
66
|
+
|
|
67
|
+
`ngram_range` sets the shortest and longest phrase to index. It defaults to
|
|
68
|
+
`[1, 1]`, which indexes single words only.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
tfidf = Classifier::TFIDF.new(ngram_range: [1, 2])
|
|
72
|
+
tfidf.fit(["machine learning rocks", "machine learning is fun"])
|
|
73
|
+
|
|
74
|
+
tfidf.feature_names.sort.first(6)
|
|
75
|
+
# => [:fun, :learn, :learn_fun, :learn_rock, :machin, :machin_learn]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
An n-gram key joins its stems with an underscore. Both bounds must be 1 or
|
|
79
|
+
more, and the first must not exceed the second.
|
|
80
|
+
|
|
81
|
+
## Sublinear term frequency
|
|
82
|
+
|
|
83
|
+
`sublinear_tf: true` replaces the raw count with `1 + log(count)`, which damps
|
|
84
|
+
the effect of a term repeated many times in one document:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
tfidf = Classifier::TFIDF.new(sublinear_tf: true)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Constructor
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
Classifier::TFIDF.new(
|
|
94
|
+
min_df: 1,
|
|
95
|
+
max_df: 1.0,
|
|
96
|
+
ngram_range: [1, 1],
|
|
97
|
+
sublinear_tf: false,
|
|
98
|
+
min_word_length: 3
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Fit from a stream
|
|
103
|
+
|
|
104
|
+
`fit_from_stream` reads line by line, so a corpus larger than memory still
|
|
105
|
+
fits. Each line is one document.
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
tfidf = Classifier::TFIDF.new
|
|
109
|
+
tfidf.fit_from_stream(File.open("corpus.txt"))
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Pass a [`MultiIO`](streaming.md) to read several files as one stream:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
tfidf.fit_from_stream(
|
|
116
|
+
Classifier::Streaming::MultiIO.new(Dir["corpus/*.txt"])
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Map stems back to words
|
|
121
|
+
|
|
122
|
+
`transform` returns stems. `String#stem_to_word_hash` maps each stem back to the
|
|
123
|
+
most frequent original word, which is how `keywords` prints whole words:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
"Ruby programming is elegant and programming rocks".stem_to_word_hash
|
|
127
|
+
# => {rubi: "ruby", program: "programming", eleg: "elegant", rock: "rocks"}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Save and load
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
tfidf.save_to_file("vectorizer.json")
|
|
134
|
+
loaded = Classifier::TFIDF.load_from_file("vectorizer.json")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
See [Persistence](persistence.md).
|
data/exe/classifier
CHANGED
data/exe/keywords
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Force UTF-8 encoding for proper handling of model data and user input
|
|
5
|
+
Encoding.default_external = Encoding::UTF_8
|
|
6
|
+
Encoding.default_internal = Encoding::UTF_8
|
|
7
|
+
|
|
8
|
+
require 'classifier/keywords/cli'
|
|
9
|
+
|
|
10
|
+
result = Classifier::Keywords::CLI.new(ARGV).run
|
|
11
|
+
|
|
12
|
+
warn result[:error] unless result[:error].empty?
|
|
13
|
+
puts result[:output] unless result[:output].empty?
|
|
14
|
+
|
|
15
|
+
exit result[:exit_code]
|
data/lib/classifier/bayes.rb
CHANGED
|
@@ -279,14 +279,17 @@ module Classifier
|
|
|
279
279
|
# Custom marshal serialization to exclude mutex state
|
|
280
280
|
# @rbs () -> Array[untyped]
|
|
281
281
|
def marshal_dump
|
|
282
|
-
[@categories, @total_words, @category_counts, @category_word_count, @dirty
|
|
282
|
+
[@categories, @total_words, @category_counts, @category_word_count, @dirty,
|
|
283
|
+
@min_word_length]
|
|
283
284
|
end
|
|
284
285
|
|
|
285
286
|
# Custom marshal deserialization to recreate mutex
|
|
286
287
|
# @rbs (Array[untyped]) -> void
|
|
287
288
|
def marshal_load(data)
|
|
288
289
|
mu_initialize
|
|
289
|
-
@categories, @total_words, @category_counts, @category_word_count, @dirty
|
|
290
|
+
@categories, @total_words, @category_counts, @category_word_count, @dirty,
|
|
291
|
+
@min_word_length = data
|
|
292
|
+
@min_word_length ||= Classifier.config.min_word_length
|
|
290
293
|
@cached_training_count = nil
|
|
291
294
|
@cached_vocab_size = nil
|
|
292
295
|
@storage = nil
|
|
@@ -33,6 +33,12 @@ class String
|
|
|
33
33
|
word_hash_for_words(gsub(/[^\w\s]/, '').split, min_word_length)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Builds a mapping between stemmed roots and their most frequent original words.
|
|
37
|
+
# @rbs (?Integer) -> Hash[Symbol, String]
|
|
38
|
+
def stem_to_word_hash(min_word_length = 3)
|
|
39
|
+
mapping_stem_to_word_for_words(gsub(/[^\w\s]/, '').split, min_word_length)
|
|
40
|
+
end
|
|
41
|
+
|
|
36
42
|
private
|
|
37
43
|
|
|
38
44
|
# @rbs (Array[String], Integer) -> Hash[Symbol, Integer]
|
|
@@ -54,6 +60,20 @@ class String
|
|
|
54
60
|
d
|
|
55
61
|
end
|
|
56
62
|
|
|
63
|
+
# @rbs (Array[String], Integer) -> Hash[Symbol, Integer]
|
|
64
|
+
def mapping_stem_to_word_for_words(words, min_word_length)
|
|
65
|
+
h = {}
|
|
66
|
+
words.map { _1.tap(&:downcase!) }.tally.each do |word, count|
|
|
67
|
+
next unless !CORPUS_SKIP_WORDS.include?(word) && word.length >= min_word_length
|
|
68
|
+
|
|
69
|
+
stem = word.stem.intern
|
|
70
|
+
h[stem] ||= [word, count]
|
|
71
|
+
h[stem] = [word, count] if h.dig(stem, 1) < count
|
|
72
|
+
end
|
|
73
|
+
h.each_key { |k| h[k] = h[k].first }
|
|
74
|
+
h
|
|
75
|
+
end
|
|
76
|
+
|
|
57
77
|
CORPUS_SKIP_WORDS = ::Set.new(%w[
|
|
58
78
|
a
|
|
59
79
|
again
|