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
|
@@ -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).
|