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,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
@@ -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