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.
@@ -0,0 +1,100 @@
1
+ # Configuration
2
+
3
+ ## Global settings
4
+
5
+ `Classifier.configure` sets the defaults for every classifier:
6
+
7
+ ```ruby
8
+ require "classifier"
9
+
10
+ Classifier.configure do |config|
11
+ config.min_word_length = 2
12
+ end
13
+
14
+ Classifier.config.min_word_length
15
+ # => 2
16
+ ```
17
+
18
+ | Setting | Default | Effect |
19
+ |:--|:--|:--|
20
+ | `min_word_length` | 3 | The tokenizer drops any word shorter than this |
21
+
22
+ Set the configuration once at startup. The lazy setup is not thread-safe, so do
23
+ not first touch it from several threads at once.
24
+
25
+ Every classifier also takes `min_word_length` on its own, which overrides the
26
+ global value:
27
+
28
+ ```ruby
29
+ Classifier::Bayes.new(:spam, :ham, min_word_length: 2)
30
+ ```
31
+
32
+ ## Tokenization
33
+
34
+ The tokenizer downcases the text, strips punctuation, drops the stop words in
35
+ `CORPUS_SKIP_WORDS`, drops words shorter than `min_word_length`, and reduces
36
+ each remaining word to its Porter stem.
37
+
38
+ ```ruby
39
+ "Ruby programming is elegant".word_hash
40
+ # => {rubi: 1, program: 1, eleg: 1}
41
+ ```
42
+
43
+ `clean_word_hash` skips the punctuation strip when the text is already clean.
44
+ `stem_to_word_hash` maps each stem back to the most frequent original word:
45
+
46
+ ```ruby
47
+ "Ruby programming is elegant and programming rocks".stem_to_word_hash
48
+ # => {rubi: "ruby", program: "programming", eleg: "elegant", rock: "rocks"}
49
+ ```
50
+
51
+ ## Native extension
52
+
53
+ LSI uses a C extension for its linear algebra. It has no external dependency
54
+ and builds during `gem install`. Pure Ruby runs when the extension is absent,
55
+ with the same results and less speed.
56
+
57
+ ```ruby
58
+ Classifier::LSI.backend
59
+ # => :native
60
+ ```
61
+
62
+ The value is `:native` or `:ruby`.
63
+
64
+ Force pure Ruby with an environment variable, which is useful to compare the
65
+ two:
66
+
67
+ ```bash
68
+ NATIVE_VECTOR=true bundle exec rake test
69
+ ```
70
+
71
+ Build the extension from a checkout:
72
+
73
+ ```bash
74
+ bundle exec rake compile
75
+ ```
76
+
77
+ Silence the startup notice about the missing extension:
78
+
79
+ ```bash
80
+ SUPPRESS_LSI_WARNING=true
81
+ ```
82
+
83
+ ## Errors
84
+
85
+ Every error inherits from `Classifier::Error`.
86
+
87
+ | Error | Raised when |
88
+ |:--|:--|
89
+ | `Classifier::NotFittedError` | A model is used before its fit. Logistic regression and TF-IDF |
90
+ | `Classifier::UnsavedChangesError` | `reload!` would discard unsaved changes |
91
+ | `Classifier::StorageError` | A storage backend operation fails |
92
+
93
+ ```ruby
94
+ begin
95
+ classifier.classify("text")
96
+ rescue Classifier::NotFittedError
97
+ classifier.fit
98
+ retry
99
+ end
100
+ ```
data/docs/keywords.md ADDED
@@ -0,0 +1,171 @@
1
+ # keywords
2
+
3
+ `keywords` scores the terms of a text with TF-IDF. It prints `term:score` pairs
4
+ in descending order of score.
5
+
6
+ The gem installs this command next to `classifier`.
7
+
8
+ ## A model comes first
9
+
10
+ `keywords` ships no pre-trained models. Build a vocabulary before you score any
11
+ text:
12
+
13
+ ```console
14
+ $ keywords fit corpus/*.txt
15
+ Saved to "/path/to/keywords.json"
16
+ ```
17
+
18
+ A command that needs a model and finds none exits 2:
19
+
20
+ ```console
21
+ $ keywords "Ruby is elegant"
22
+ Error: No model found; run 'keywords fit' first or pass correct model using the '-m' option.
23
+ ```
24
+
25
+ The default model path is `./keywords.json`. Use `-m` for any other path.
26
+
27
+ ## Commands
28
+
29
+ | Command | Action |
30
+ |:--|:--|
31
+ | `keywords fit <files...>` | Build a vocabulary from files or standard input |
32
+ | `keywords extract <file>` | Score the contents of one file |
33
+ | `keywords info` | Print the model statistics |
34
+ | `keywords <text>` | Score the text given as arguments |
35
+
36
+ With no arguments and no piped input, `keywords` prints a short guide.
37
+
38
+ ## fit
39
+
40
+ Each **line** becomes a separate document. The document count drives the inverse
41
+ document frequency, so a file of 200 lines contributes 200 documents.
42
+
43
+ ```console
44
+ $ keywords fit corpus/*.txt
45
+ Saved to "/path/to/keywords.json"
46
+
47
+ $ cat documents.txt | keywords fit
48
+ Saved to "/path/to/keywords.json"
49
+
50
+ $ keywords fit --min-df 2 --max-df 0.85 --ngram 1,2 corpus/*.txt
51
+ Saved to "/path/to/keywords.json"
52
+ ```
53
+
54
+ `fit` skips a directory and keeps the files beside it, so a shell glob works
55
+ even when the directory holds a subdirectory:
56
+
57
+ ```console
58
+ $ ls corpus
59
+ a.txt b.txt archive/
60
+ $ keywords fit corpus/*
61
+ Saved to "/path/to/keywords.json"
62
+ ```
63
+
64
+ A path that matches nothing stops the run, so a typo never produces a smaller
65
+ model in silence:
66
+
67
+ ```console
68
+ $ keywords fit corpus/a.txt corpus/NOPE.txt
69
+ Error: No files matched "corpus/NOPE.txt"
70
+ ```
71
+
72
+ An argument set with no readable file at all reports `No files to fit`. Empty
73
+ input reports `No documents found to save the model`. Neither writes a model.
74
+
75
+ `fit` reads one file at a time, so a corpus larger than the file descriptor
76
+ limit still works.
77
+
78
+ ## extract
79
+
80
+ ```console
81
+ $ keywords extract article.txt
82
+ machine:0.58 network:0.47 neural:0.47 learning:0.47
83
+
84
+ $ curl -s https://example.com/article | keywords extract
85
+ ```
86
+
87
+ `extract` requires a real file. A path that does not exist, or a directory,
88
+ exits 2. To score literal text, use the bare form instead.
89
+
90
+ ## info
91
+
92
+ ```console
93
+ $ keywords info
94
+ Documents: 1,234
95
+ Vocabulary: 5,678
96
+ Min DF: 1
97
+ Max DF: 1.0
98
+ ```
99
+
100
+ ## Options
101
+
102
+ | Option | Meaning |
103
+ |:--|:--|
104
+ | `-m`, `--model FILE` | Model file. Default `./keywords.json` |
105
+ | `-n`, `--top N` | Print the top N terms only. N must be positive |
106
+ | `-q` | Quiet. Suppress the `Saved to` line from `fit` |
107
+ | `--min-df N` | Minimum document frequency, as a count. Default 1 |
108
+ | `--max-df N` | Maximum document frequency, as a ratio from 0.0 to 1.0. Default 1.0 |
109
+ | `--ngram MIN,MAX` | N-gram range. Default `1,1` |
110
+ | `-v`, `--version` | Print the gem version |
111
+ | `-h`, `--help` | Print the full usage |
112
+
113
+ `--min-df` and `--max-df` apply during `fit`. The model stores them, and `info`
114
+ reports them back.
115
+
116
+ `-q` suppresses progress text but never the term scores. A scripted `fit` stays
117
+ silent, and a scripted score still produces its data.
118
+
119
+ ## Output format
120
+
121
+ Terms print as `term:score`, separated by spaces, sorted by descending score.
122
+
123
+ The command maps stems back to whole words. A model built from `programming`
124
+ prints `programming`, not the `program` stem:
125
+
126
+ ```console
127
+ $ keywords "Ruby is a programming language"
128
+ language:0.58 programming:0.58 ruby:0.58
129
+ ```
130
+
131
+ An n-gram label joins its parts with a space:
132
+
133
+ ```console
134
+ $ keywords fit --ngram 1,2 -m ng.json corpus/*.txt
135
+ $ keywords -m ng.json "machine learning neural networks"
136
+ machine learning:0.46 machine:0.46 neural networks:0.38 networks:0.38 neural:0.38 learning:0.38
137
+ ```
138
+
139
+ Each score depends on the corpus you fitted, so your numbers will differ.
140
+
141
+ That space sits inside a label in an otherwise space-separated stream. Parse
142
+ n-gram output on the `:` separator, not on whitespace.
143
+
144
+ ## Exit codes
145
+
146
+ | Code | Meaning |
147
+ |:--|:--|
148
+ | 0 | Success |
149
+ | 1 | An unexpected error |
150
+ | 2 | A usage error, such as a bad option, a missing model, or a path that matches nothing |
151
+
152
+ Scripts can rely on 2 for every input mistake:
153
+
154
+ ```bash
155
+ keywords fit corpus/*.txt || echo "fit failed with $?"
156
+ ```
157
+
158
+ ## Equivalent Ruby
159
+
160
+ The command wraps [`Classifier::TFIDF`](tfidf.md). This code does what
161
+ `keywords fit` does:
162
+
163
+ ```ruby
164
+ require "classifier"
165
+
166
+ tfidf = Classifier::TFIDF.new(min_df: 2, max_df: 0.85, ngram_range: [1, 2])
167
+ tfidf.fit_from_stream(
168
+ Classifier::Streaming::MultiIO.new(Dir["corpus/*.txt"])
169
+ )
170
+ tfidf.save_to_file("keywords.json")
171
+ ```
data/docs/knn.md ADDED
@@ -0,0 +1,92 @@
1
+ # k-Nearest Neighbors
2
+
3
+ `Classifier::KNN` classifies text by the categories of its nearest examples. It
4
+ shows which examples drove the answer, which makes it useful when you must
5
+ explain a result.
6
+
7
+ It builds on [LSI](lsi.md) for the similarity measure.
8
+
9
+ ## Add examples and classify
10
+
11
+ ```ruby
12
+ require "classifier"
13
+
14
+ knn = Classifier::KNN.new(k: 3)
15
+ %w[laptop coding software developer programming].each { |w| knn.add(tech: w) }
16
+ %w[football basketball soccer goal team].each { |w| knn.add(sports: w) }
17
+
18
+ knn.classify("programming code")
19
+ # => "tech"
20
+ ```
21
+
22
+ `train` is an alias of `add`, so the call reads the same as Bayes:
23
+
24
+ ```ruby
25
+ knn.train(tech: "compiler", sports: "referee")
26
+ ```
27
+
28
+ ## See the neighbors
29
+
30
+ ```ruby
31
+ knn.classify_with_neighbors("programming code")
32
+ ```
33
+
34
+ The result holds the winning category, the neighbors that voted, the vote
35
+ tally, and a confidence value:
36
+
37
+ ```ruby
38
+ {
39
+ category: "tech",
40
+ neighbors: [
41
+ { item: "programming", category: "tech", similarity: 0.9999999999999993 },
42
+ { item: "coding", category: "tech", similarity: 0.9999999999999992 },
43
+ { item: "team", category: "sports", similarity: 2.6e-16 }
44
+ ],
45
+ votes: { "tech" => 2.0, "sports" => 1.0 },
46
+ confidence: 0.6666666666666666
47
+ }
48
+ ```
49
+
50
+ `confidence` is the winning share of the votes.
51
+
52
+ ## Choose k
53
+
54
+ `k` sets how many neighbors vote. It defaults to 5.
55
+
56
+ ```ruby
57
+ knn = Classifier::KNN.new(k: 3)
58
+ knn.k # => 3
59
+ knn.k = 5
60
+ ```
61
+
62
+ A small `k` follows the data closely and reacts to noise. A large `k` smooths
63
+ the boundary. Keep `k` at or below the number of examples you added.
64
+
65
+ ## Weighted voting
66
+
67
+ By default every neighbor casts an equal vote. Weighted voting scales each vote
68
+ by similarity, so a close neighbor counts for more:
69
+
70
+ ```ruby
71
+ knn = Classifier::KNN.new(k: 5, weighted: true)
72
+ ```
73
+
74
+ Set it later with `knn.weighted = true`.
75
+
76
+ ## Inspect the model
77
+
78
+ ```ruby
79
+ knn.categories # => ["tech", "sports"]
80
+ knn.items # every added example
81
+ knn.categories_for("programming")
82
+ knn.remove_item("programming")
83
+ ```
84
+
85
+ ## Save and load
86
+
87
+ ```ruby
88
+ knn.save_to_file("model.json")
89
+ loaded = Classifier::KNN.load_from_file("model.json")
90
+ ```
91
+
92
+ See [Persistence](persistence.md).
@@ -0,0 +1,107 @@
1
+ # Logistic Regression
2
+
3
+ `Classifier::LogisticRegression` is a linear classifier. It gives calibrated
4
+ probabilities that sum to 1.0, which Bayes does not.
5
+
6
+ ## Train, fit, then classify
7
+
8
+ Unlike Bayes, this classifier needs a `fit` call after training. `classify`
9
+ raises `Classifier::NotFittedError` before that call.
10
+
11
+ ```ruby
12
+ require "classifier"
13
+
14
+ classifier = Classifier::LogisticRegression.new(:positive, :negative)
15
+ classifier.train(positive: "love amazing great wonderful")
16
+ classifier.train(negative: "hate terrible awful bad")
17
+ classifier.fit
18
+
19
+ classifier.classify("I love it!")
20
+ # => "Positive"
21
+ ```
22
+
23
+ Train more documents at any time. Call `fit` again before the next classify.
24
+
25
+ ```ruby
26
+ classifier.fitted?
27
+ # => true
28
+ ```
29
+
30
+ ## Probabilities
31
+
32
+ ```ruby
33
+ classifier.probabilities("I love it!")
34
+ # => {"Positive" => 0.7398506195705559, "Negative" => 0.26014938042944413}
35
+ ```
36
+
37
+ The values sum to 1.0. Use `classifications` for the raw scores before the
38
+ sigmoid:
39
+
40
+ ```ruby
41
+ classifier.classifications("I love it!")
42
+ # => {"Positive" => 0.5225961471158276, "Negative" => -0.5225961471158275}
43
+ ```
44
+
45
+ ## Inspect the weights
46
+
47
+ `weights` shows which terms drive a category:
48
+
49
+ ```ruby
50
+ classifier.weights("positive")
51
+ # => {hate: -0.5225, terribl: -0.5225, aw: -0.5225, bad: -0.5225,
52
+ # love: 0.5225, amaz: 0.5225, great: 0.5225, wonder: 0.5225}
53
+ ```
54
+
55
+ The keys are Porter stems. The order runs by **absolute** value, so the terms
56
+ that matter most come first whichever way they point. A positive weight argues
57
+ for the category and a negative weight argues against it.
58
+
59
+ `limit` caps the count:
60
+
61
+ ```ruby
62
+ classifier.weights("positive", limit: 3)
63
+ ```
64
+
65
+ Terms of equal absolute weight tie, and a tie has no defined order. A toy
66
+ corpus like the one above gives every term the same magnitude, so `limit` there
67
+ returns an arbitrary three. Real training data separates the weights.
68
+
69
+ ## Tuning
70
+
71
+ ```ruby
72
+ Classifier::LogisticRegression.new(
73
+ :positive, :negative,
74
+ learning_rate: 0.1,
75
+ regularization: 0.01,
76
+ max_iterations: 100
77
+ )
78
+ ```
79
+
80
+ | Parameter | Default | Effect |
81
+ |:--|:--|:--|
82
+ | `learning_rate` | 0.1 | Step size per iteration. Raise it to train faster, lower it for stability |
83
+ | `regularization` | 0.01 | L2 penalty. Raise it to reduce overfit |
84
+ | `max_iterations` | 100 | Gradient descent iterations during `fit` |
85
+
86
+ ## Categories
87
+
88
+ ```ruby
89
+ classifier.categories
90
+ # => ["Positive", "Negative"]
91
+
92
+ classifier.add_category(:neutral)
93
+ ```
94
+
95
+ Call `fit` again after you add a category and train it.
96
+
97
+ ## Save and load
98
+
99
+ ```ruby
100
+ classifier.save_to_file("model.json")
101
+ loaded = Classifier::LogisticRegression.load_from_file("model.json")
102
+ ```
103
+
104
+ A saved model keeps its fitted weights, so a loaded model classifies with no
105
+ further `fit`.
106
+
107
+ See [Persistence](persistence.md) and [Streaming](streaming.md).
data/docs/lsi.md ADDED
@@ -0,0 +1,219 @@
1
+ # LSI
2
+
3
+ `Classifier::LSI` implements Latent Semantic Indexing. It finds documents that
4
+ share meaning, not only shared words, so it answers similarity, search, and
5
+ related-document questions that a word-count classifier cannot.
6
+
7
+ It uses Singular Value Decomposition. A [native C extension](configuration.md)
8
+ makes that 5 to 50 times faster, and pure Ruby runs when the extension is
9
+ absent.
10
+
11
+ ## Classify
12
+
13
+ ```ruby
14
+ require "classifier"
15
+
16
+ lsi = Classifier::LSI.new
17
+ lsi.add(dog: "dog puppy canine bark fetch", cat: "cat kitten feline meow purr")
18
+
19
+ lsi.classify("My puppy barks")
20
+ # => "dog"
21
+ ```
22
+
23
+ ## Confidence
24
+
25
+ ```ruby
26
+ lsi.classify_with_confidence("My puppy barks")
27
+ # => ["dog", 1.0]
28
+ ```
29
+
30
+ The second value runs from 0.0 to 1.0.
31
+
32
+ ## Search
33
+
34
+ ```ruby
35
+ lsi.search("puppy", 2)
36
+ # => ["dog puppy canine bark fetch", "cat kitten feline meow purr"]
37
+ ```
38
+
39
+ The second argument caps the result count. Results come back in descending
40
+ order of similarity.
41
+
42
+ ## Related documents
43
+
44
+ ```ruby
45
+ lsi.find_related("dog puppy canine bark fetch", 1)
46
+ ```
47
+
48
+ ## Add documents
49
+
50
+ `add` takes categories as keywords:
51
+
52
+ ```ruby
53
+ lsi.add(dog: "dog puppy canine bark fetch")
54
+ lsi.add(tech: ["Ruby is elegant", "Python is popular"])
55
+ ```
56
+
57
+ `add_item` takes the item first, then its categories, and accepts a block that
58
+ converts the item to text:
59
+
60
+ ```ruby
61
+ lsi.add_item("dog puppy canine", :dog)
62
+ lsi.add_item(article, :tech) { |a| a.body }
63
+ ```
64
+
65
+ ## The index
66
+
67
+ LSI builds an index before it answers a query. By default it rebuilds whenever
68
+ it needs to. Turn that off to add many documents and rebuild once:
69
+
70
+ ```ruby
71
+ lsi = Classifier::LSI.new(auto_rebuild: false)
72
+ lsi.add(dog: "dog puppy canine bark fetch")
73
+ lsi.add(cat: "cat kitten feline meow purr")
74
+ lsi.build_index
75
+ ```
76
+
77
+ ```ruby
78
+ lsi.needs_rebuild?
79
+ # => false
80
+ ```
81
+
82
+ ## Incremental mode
83
+
84
+ Incremental mode adds documents through Brand's algorithm, with no full
85
+ rebuild.
86
+
87
+ Turn `auto_rebuild` off. Incremental mode needs the whole starting corpus in
88
+ place before the first index build:
89
+
90
+ ```ruby
91
+ lsi = Classifier::LSI.new(incremental: true, auto_rebuild: false, max_rank: 100)
92
+ lsi.add(tech: [
93
+ "Ruby is an elegant programming language for web development",
94
+ "Python is a popular programming language for data science",
95
+ "JavaScript runs in browsers and powers modern web applications",
96
+ "Java is a compiled language used for enterprise backend systems",
97
+ "Rust provides memory safety without a garbage collector runtime"
98
+ ])
99
+ lsi.build_index
100
+
101
+ lsi.incremental_enabled?
102
+ # => true
103
+
104
+ lsi.add(tech: "Go is a fast compiled language for backend systems")
105
+ lsi.incremental_enabled?
106
+ # => true
107
+ ```
108
+
109
+ `build_index` stores the U matrix that later updates need. It stores that
110
+ matrix only while incremental mode is on.
111
+
112
+ **Leave `auto_rebuild` at its default and incremental mode never starts.** Each
113
+ `add` rebuilds at once, so the index builds from the first two documents, and
114
+ the next `add` measures its vocabulary growth against that tiny start. The
115
+ growth trips the threshold below, incremental mode switches off, and a later
116
+ `build_index` cannot turn it back on.
117
+
118
+ ### The fallback
119
+
120
+ An added document that grows the vocabulary by more than 20 percent of its
121
+ size at the first build is too large a shift for an incremental update. LSI
122
+ then turns incremental mode off and rebuilds in full. The results stay correct.
123
+ The speed advantage stops.
124
+
125
+ The fallback is permanent. Call `enable_incremental_mode!` to resume:
126
+
127
+ ```ruby
128
+ lsi.enable_incremental_mode!(max_rank: 100)
129
+ lsi.build_index(force: true)
130
+ ```
131
+
132
+ `current_rank` reports the count of positive singular values.
133
+ `disable_incremental_mode!` turns the mode off by hand.
134
+
135
+ A corpus of a few documents grows its vocabulary quickly, so incremental mode
136
+ suits a large starting corpus and small later additions.
137
+
138
+ ## Inspect the model
139
+
140
+ ```ruby
141
+ lsi.items # every indexed document
142
+ lsi.categories_for("dog puppy canine bark fetch")
143
+ lsi.remove_item("dog puppy canine bark fetch")
144
+ ```
145
+
146
+ `singular_values` returns the raw values after `build_index`, and
147
+ `singular_value_spectrum` returns the variance each dimension explains.
148
+
149
+ `highest_ranked_stems` names the stems that carry a document:
150
+
151
+ ```ruby
152
+ lsi.highest_ranked_stems("dog puppy canine bark fetch loyal", 3)
153
+ # => [:dog, :puppi, :canin]
154
+ ```
155
+
156
+ The document must already be indexed, or the call raises.
157
+
158
+ `highest_relative_content` returns the documents nearest the center of the
159
+ whole set, which describes what a corpus is mostly about:
160
+
161
+ ```ruby
162
+ lsi.highest_relative_content(2)
163
+ ```
164
+
165
+ It returns an empty array while the index still needs a rebuild.
166
+
167
+ ## Add without categories
168
+
169
+ `<<` indexes a document with no category, for search and similarity only:
170
+
171
+ ```ruby
172
+ lsi << "bird sparrow robin fly nest feather"
173
+ ```
174
+
175
+ ## Add in batches
176
+
177
+ `add_batch` turns `auto_rebuild` off for the run, adds everything, then builds
178
+ once. It reports progress like the streaming API:
179
+
180
+ ```ruby
181
+ lsi.add_batch(
182
+ tech: ["Ruby is elegant", "Python is popular"],
183
+ sports: ["soccer goal", "basketball hoop"]
184
+ ) { |progress| puts progress.completed }
185
+ ```
186
+
187
+ See [Streaming](streaming.md).
188
+
189
+ ## Summaries
190
+
191
+ The gem adds `summary` to `String`:
192
+
193
+ ```ruby
194
+ text = "The dog barks loudly. The cat sleeps quietly. " \
195
+ "Birds sing sweetly in the morning light."
196
+
197
+ text.summary(1)
198
+ # => "The cat sleeps quietly."
199
+ ```
200
+
201
+ The argument sets how many sentences come back.
202
+
203
+ ## Constructor options
204
+
205
+ | Option | Default | Meaning |
206
+ |:--|:--|:--|
207
+ | `auto_rebuild` | `true` | Rebuild the index automatically after a change |
208
+ | `incremental` | `false` | Use Brand's algorithm to add documents |
209
+ | `max_rank` | 100 | Rank cap in incremental mode |
210
+ | `min_word_length` | 3 | Drop words shorter than this |
211
+
212
+ ## Save and load
213
+
214
+ ```ruby
215
+ lsi.save_to_file("model.json")
216
+ loaded = Classifier::LSI.load_from_file("model.json")
217
+ ```
218
+
219
+ See [Persistence](persistence.md).