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.
@@ -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
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Force UTF-8 encoding for proper handling of model data and user input
5
4
  Encoding.default_external = Encoding::UTF_8
6
5
  Encoding.default_internal = Encoding::UTF_8
7
6
 
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]
@@ -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 = data
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
@@ -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
- registry_arg = @args.shift
374
- registry = parse_registry(registry_arg) || DEFAULT_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
- if index['models'].empty?
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
- index['models'].each do |name, info|
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
- info = load_model_info(model[:path])
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