classifier 2.5.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/cli.rb +84 -6
- 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
|
data/lib/classifier/cli.rb
CHANGED
|
@@ -95,6 +95,13 @@ module Classifier
|
|
|
95
95
|
@options[:type] = type
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
+
opts.on(
|
|
99
|
+
'--search TEXT',
|
|
100
|
+
'Search remote models by name/description, and local models by name only. Use quotes for multiword search'
|
|
101
|
+
) do |text|
|
|
102
|
+
@options[:search] = text
|
|
103
|
+
end
|
|
104
|
+
|
|
98
105
|
opts.on('-r', '--remote MODEL', 'Use remote model: name or @user/repo:name') do |model|
|
|
99
106
|
@options[:remote] = model
|
|
100
107
|
end
|
|
@@ -370,18 +377,49 @@ module Classifier
|
|
|
370
377
|
end
|
|
371
378
|
|
|
372
379
|
def list_remote_models
|
|
373
|
-
|
|
374
|
-
registry =
|
|
380
|
+
registry_or_model_arg = @args.shift
|
|
381
|
+
registry, model = detect_registry_and_model(registry_or_model_arg)
|
|
382
|
+
registry = parse_registry(registry) || DEFAULT_REGISTRY
|
|
375
383
|
index = fetch_registry_index(registry)
|
|
376
384
|
|
|
377
385
|
return if @exit_code != 0
|
|
378
386
|
|
|
379
|
-
|
|
387
|
+
models = index['models']
|
|
388
|
+
|
|
389
|
+
if model
|
|
390
|
+
info = models[model]
|
|
391
|
+
if info.nil?
|
|
392
|
+
@output << "No model #{model.inspect} found in registry"
|
|
393
|
+
return
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
type = info['type'] || 'unknown'
|
|
397
|
+
size = info['size'] || 'unknown'
|
|
398
|
+
desc = info['description'] || ''
|
|
399
|
+
categories = (info['categories'] || []).map(&:downcase).join(', ')
|
|
400
|
+
version = info['version'] || ''
|
|
401
|
+
author = info['author'] || ''
|
|
402
|
+
@output << format(
|
|
403
|
+
"Name: %<name>s\nDescription: %<desc>s\nType: %<type>s\n" \
|
|
404
|
+
"Categories: %<categories>s\nVersion: %<version>s\nAuthor: %<author>s\nSize: %<size>s",
|
|
405
|
+
name: model, desc: desc, type: type, categories: categories,
|
|
406
|
+
version: version, author: author, size: size
|
|
407
|
+
)
|
|
408
|
+
return
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
if @options[:search]
|
|
412
|
+
models = models.filter do |name, info|
|
|
413
|
+
[name, info['description']].any?(/#{Regexp.escape(@options[:search])}/i)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
if models.empty?
|
|
380
418
|
@output << 'No models found in registry'
|
|
381
419
|
return
|
|
382
420
|
end
|
|
383
421
|
|
|
384
|
-
|
|
422
|
+
models.each do |name, info|
|
|
385
423
|
type = info['type'] || 'unknown'
|
|
386
424
|
size = info['size'] || 'unknown'
|
|
387
425
|
desc = info['description'] || ''
|
|
@@ -390,6 +428,8 @@ module Classifier
|
|
|
390
428
|
end
|
|
391
429
|
|
|
392
430
|
def list_local_models
|
|
431
|
+
model_arg = @args.shift
|
|
432
|
+
|
|
393
433
|
models_dir = File.join(CACHE_DIR, 'models')
|
|
394
434
|
|
|
395
435
|
unless Dir.exist?(models_dir)
|
|
@@ -413,14 +453,43 @@ module Classifier
|
|
|
413
453
|
|
|
414
454
|
models = default_models + custom_models #: Array[{name: String, registry: String?, path: String}]
|
|
415
455
|
|
|
456
|
+
models.each do |model|
|
|
457
|
+
model[:info] = load_model_info(model[:path])
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
if model_arg
|
|
461
|
+
model = models.find { |model| model[:name] == model_arg }
|
|
462
|
+
if model.nil?
|
|
463
|
+
@output << "No local model #{model_arg.inspect} found"
|
|
464
|
+
return
|
|
465
|
+
end
|
|
466
|
+
display_name = model[:registry] ? "@#{model[:registry]}:#{model[:name]}" : model[:name]
|
|
467
|
+
type = model.dig(:info, 'type') || 'unknown'
|
|
468
|
+
version = model.dig(:info, 'version')
|
|
469
|
+
categories = (model.dig(:info, 'categories') || {}).keys.map(&:downcase).join(', ')
|
|
470
|
+
size = File.size(model[:path])
|
|
471
|
+
@output << format(
|
|
472
|
+
"Name: %<name>s\nType: %<type>s\n" \
|
|
473
|
+
"Categories: %<categories>s\nVersion: %<version>s\nSize: %<size>s",
|
|
474
|
+
name: display_name, type: type, categories: categories,
|
|
475
|
+
version: version, size: human_size(size)
|
|
476
|
+
)
|
|
477
|
+
return
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
if @options[:search]
|
|
481
|
+
models = models.filter do |model|
|
|
482
|
+
[model[:name], model.dig(:info, 'description')].any?(/#{Regexp.escape(@options[:search])}/i)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
416
486
|
if models.empty?
|
|
417
487
|
@output << 'No local models found'
|
|
418
488
|
return
|
|
419
489
|
end
|
|
420
490
|
|
|
421
491
|
models.each do |model|
|
|
422
|
-
|
|
423
|
-
type = info['type'] || 'unknown'
|
|
492
|
+
type = model.dig(:info, 'type') || 'unknown'
|
|
424
493
|
display_name = model[:registry] ? "@#{model[:registry]}:#{model[:name]}" : model[:name]
|
|
425
494
|
size = File.size(model[:path])
|
|
426
495
|
@output << format('%-30<name>s (%<type>s, %<size>s)', name: display_name, type: type, size: human_size(size))
|
|
@@ -800,6 +869,15 @@ module Classifier
|
|
|
800
869
|
@output << 'Run "classifier --help" for full usage.'
|
|
801
870
|
end
|
|
802
871
|
|
|
872
|
+
# @rbs (String?) -> [String?, String?]
|
|
873
|
+
def detect_registry_and_model(arg)
|
|
874
|
+
return nil, nil if arg.nil?
|
|
875
|
+
return *arg.split(':', 2) if arg.include?(':')
|
|
876
|
+
return nil, arg unless arg.start_with?('@')
|
|
877
|
+
|
|
878
|
+
[arg, nil]
|
|
879
|
+
end
|
|
880
|
+
|
|
803
881
|
# Parse @user/repo format to extract registry
|
|
804
882
|
# @rbs (String?) -> String?
|
|
805
883
|
def parse_registry(arg)
|
|
@@ -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
|