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,299 @@
1
+ # rbs_inline: enabled
2
+
3
+ require 'optparse'
4
+ require 'fileutils'
5
+ require 'stringio'
6
+ require 'classifier'
7
+
8
+ module Classifier
9
+ module Keywords
10
+ class CLI
11
+ class UsageError < StandardError; end
12
+
13
+ # @rbs @args: Array[String]
14
+ # @rbs @stdin: String?
15
+ # @rbs @options: Hash[Symbol, untyped]
16
+ # @rbs @output: Array[String]
17
+ # @rbs @error: Array[String]
18
+ # @rbs @exit_code: Integer
19
+ # @rbs @parser: OptionParser
20
+
21
+ def initialize(args, stdin: nil)
22
+ @args = args.dup
23
+ @stdin = stdin
24
+ @options = {
25
+ model: File.expand_path('./keywords.json'),
26
+ top: nil,
27
+ quiet: false,
28
+ min_df: 1,
29
+ max_df: 1.0,
30
+ ngram_range: [1, 1]
31
+ }
32
+ @output = []
33
+ @error = []
34
+ @exit_code = 0
35
+ end
36
+
37
+ def run
38
+ parse_options
39
+ execute_command
40
+ { output: @output.join("\n"), error: @error.join("\n"), exit_code: @exit_code }
41
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument,
42
+ OptionParser::InvalidArgument, UsageError => e
43
+ @error << "Error: #{e.message}"
44
+ @exit_code = 2
45
+ { output: @output.join("\n"), error: @error.join("\n"), exit_code: @exit_code }
46
+ rescue StandardError => e
47
+ @error << "Error: #{e.message}"
48
+ @exit_code = 1
49
+ { output: @output.join("\n"), error: @error.join("\n"), exit_code: @exit_code }
50
+ end
51
+
52
+ private
53
+
54
+ def parse_options
55
+ @parser = OptionParser.new do |opts|
56
+ opts.banner = 'Usage: keywords [text] [options] [command] [arguments]'
57
+ opts.separator ''
58
+ opts.separator 'Commands:'
59
+ opts.separator ' fit <files...> Fit the model from files or stdin (each line is treated as a separate document)'
60
+ opts.separator ' extract <file> Extract keywords from a file'
61
+ opts.separator ' info Show model information'
62
+ opts.separator ' <text> Get weighted terms from text'
63
+ opts.separator ''
64
+ opts.separator 'Options:'
65
+
66
+ opts.on('-m', '--model FILE', 'Model file (default: ./keywords.json)') do |file|
67
+ @options[:model] = File.expand_path(file)
68
+ end
69
+
70
+ opts.on('-n', '--top N', Integer, 'Show top N terms only') do |n|
71
+ raise OptionParser::InvalidArgument, 'must be positive' unless n.positive?
72
+
73
+ @options[:top] = n
74
+ end
75
+
76
+ opts.on('--min-df N', Integer, 'Minimum document frequency (default: 1)') do |n|
77
+ raise OptionParser::InvalidArgument, 'must be non-negative' if n.negative?
78
+
79
+ @options[:min_df] = n
80
+ end
81
+
82
+ opts.on('--max-df N', Float, 'Maximum document frequency ratio (default: 1.0)') do |n|
83
+ raise OptionParser::InvalidArgument, 'must be between 0.0 and 1.0' unless n.between?(0.0, 1.0)
84
+
85
+ @options[:max_df] = n
86
+ end
87
+
88
+ opts.on('--ngram MIN,MAX', Array, 'N-gram range (default: 1,1)') do |range|
89
+ raise OptionParser::InvalidArgument, 'requires exactly two values' if range.count != 2
90
+
91
+ raise OptionParser::InvalidArgument, 'must be integers' unless range.all? { |n| n =~ /\A\d+\z/ }
92
+
93
+ min, max = range.map(&:to_i)
94
+
95
+ raise OptionParser::InvalidArgument, 'bounds must be >= 1 and min <= max' if min < 1 || max < 1 || min > max
96
+
97
+ @options[:ngram_range] = [min, max]
98
+ end
99
+
100
+ opts.on('-q', 'Quiet mode') do
101
+ @options[:quiet] = true
102
+ end
103
+
104
+ opts.on('-v', '--version', 'Show version') do
105
+ @output << Classifier::VERSION
106
+ @exit_code = 0
107
+ throw :done
108
+ end
109
+
110
+ opts.on('-h', '--help', 'Show help') do
111
+ @output << opts.to_s
112
+ @exit_code = 0
113
+ throw :done
114
+ end
115
+ end
116
+
117
+ catch(:done) do
118
+ @parser.parse!(@args)
119
+ end
120
+ end
121
+
122
+ def execute_command
123
+ return if @exit_code != 0 || @output.any?
124
+
125
+ command = @args.first
126
+
127
+ case command
128
+ when 'fit'
129
+ command_fit
130
+ when 'extract'
131
+ command_extract
132
+ when 'info'
133
+ command_info
134
+ else
135
+ command_keywords
136
+ end
137
+ end
138
+
139
+ def command_fit
140
+ @args.shift
141
+
142
+ # @type var sources: Array[IO | StringIO | String]
143
+ sources =
144
+ if @args.empty?
145
+ [@stdin ? StringIO.new(@stdin.to_s) : $stdin]
146
+ else
147
+ collect_files
148
+ end
149
+
150
+ tfidf = TFIDF.new(
151
+ min_df: @options[:min_df],
152
+ max_df: @options[:max_df],
153
+ ngram_range: @options[:ngram_range]
154
+ )
155
+ tfidf.fit_from_stream(Streaming::MultiIO.new(sources))
156
+
157
+ raise UsageError, 'No documents found to save the model' if tfidf.num_documents.zero?
158
+
159
+ tfidf.save_to_file(@options[:model])
160
+ @output << "Saved to #{@options[:model].inspect}" unless @options[:quiet]
161
+ end
162
+
163
+ def collect_files
164
+ files = @args.flat_map do |arg|
165
+ matches = Dir.glob(arg)
166
+ raise UsageError, "No files matched #{arg.inspect}" if matches.empty?
167
+
168
+ matches.select { |f| File.file?(f) }.map { |f| File.expand_path(f) }
169
+ end.uniq
170
+
171
+ raise UsageError, 'No files to fit' if files.empty?
172
+
173
+ files
174
+ end
175
+
176
+ def command_extract
177
+ @args.shift
178
+
179
+ document =
180
+ if @args.empty?
181
+ @stdin ? @stdin.to_s : $stdin.read
182
+ else
183
+ file = File.expand_path(@args.first)
184
+ raise UsageError, "File #{file.inspect} does not exist" unless File.exist?(file)
185
+ raise UsageError, "#{file.inspect} is a directory, not a file" if File.directory?(file)
186
+
187
+ File.read(file)
188
+ end
189
+
190
+ transform(document)
191
+ end
192
+
193
+ def command_info
194
+ ensure_model_exists!
195
+
196
+ @args.shift
197
+
198
+ tfidf = TFIDF.load_from_file(@options[:model])
199
+ documents = number_with_delimiter(tfidf.num_documents)
200
+ vocabulary = number_with_delimiter(tfidf.vocabulary.count)
201
+ min_df = tfidf.min_df
202
+ max_df = tfidf.max_df
203
+ @output << format(
204
+ "Documents: %<documents>s\nVocabulary: %<vocabulary>s\n" \
205
+ "Min DF: %<min_df>d\nMax DF: %<max_df>.1f",
206
+ documents: documents, vocabulary: vocabulary, min_df: min_df, max_df: max_df
207
+ )
208
+ end
209
+
210
+ def command_keywords
211
+ if @args.empty? && @stdin.nil? && $stdin.tty?
212
+ show_getting_started
213
+ return
214
+ end
215
+
216
+ document =
217
+ if @args.empty?
218
+ @stdin ? @stdin.to_s : $stdin.read
219
+ else
220
+ @args.join(' ')
221
+ end
222
+
223
+ transform(document)
224
+ end
225
+
226
+ def show_getting_started
227
+ @output << 'Keywords - Keyword extraction and term analysis using TF-IDF'
228
+ @output << ''
229
+ @output << 'Get started by building a vocabulary (fitting data):'
230
+ @output << ''
231
+ @output << ' # Fit from files'
232
+ @output << ' keywords fit corpus/*.txt'
233
+ @output << ''
234
+ @output << ' # Fit from stdin'
235
+ @output << ' cat documents.txt | keywords fit'
236
+ @output << ''
237
+ @output << ' # Keep in mind that each line is treated as a separate document.'
238
+ @output << ''
239
+ @output << 'Then extract weighted terms and analyze text:'
240
+ @output << ''
241
+ @output << ' # Extract from string'
242
+ @output << " keywords 'Ruby is a programming language'"
243
+ @output << ' # ruby:0.52 programming:0.41 language:0.38'
244
+ @output << ''
245
+ @output << ' # Extract from file (convenience alias)'
246
+ @output << ' keywords extract article.txt'
247
+ @output << ''
248
+ @output << ' # Pipeline with stdin and web data'
249
+ @output << ' curl -s https://example.com/article | keywords extract'
250
+ @output << ''
251
+ @output << 'Check model statistics:'
252
+ @output << ''
253
+ @output << ' keywords info'
254
+ @output << ' # Documents: 1,234'
255
+ @output << ' # Vocabulary: 5,678'
256
+ @output << ' # Min DF: 1'
257
+ @output << ' # Max DF: 1.0'
258
+ @output << ''
259
+ @output << 'General Options:'
260
+ @output << ' -m, --model FILE Model file (default: ./keywords.json)'
261
+ @output << ' -n, --top N Show top N terms only (e.g. keywords -n 5 "text...")'
262
+ @output << ''
263
+ @output << 'Fit-specific Options:'
264
+ @output << ' --min-df N Minimum document frequency (default: 1)'
265
+ @output << ' --max-df N Maximum document frequency ratio (default: 1.0)'
266
+ @output << ' --ngram MIN,MAX N-gram range (default: 1,1)'
267
+ @output << ''
268
+ @output << 'Run "keywords --help" for full usage.'
269
+ end
270
+
271
+ def transform(document)
272
+ ensure_model_exists!
273
+
274
+ stem_map = document.stem_to_word_hash
275
+ tfidf = TFIDF.load_from_file(@options[:model])
276
+ vector = tfidf.transform(document).sort_by { |_, v| v }.reverse
277
+ vector = vector.first(@options[:top]) if @options[:top]
278
+ @output << vector.map { |k, v| "#{label_for(k, stem_map)}:#{v.round(2)}" }.join(' ')
279
+ end
280
+
281
+ def ensure_model_exists!
282
+ return if File.exist?(@options[:model])
283
+
284
+ raise UsageError, "No model found; run 'keywords fit' first or " \
285
+ "pass correct model using the '-m' option."
286
+ end
287
+
288
+ def label_for(key, stem_map)
289
+ stem_map.fetch(key.to_sym) do
290
+ key.to_s.split('_').map { |part| stem_map[part.to_sym] || part }.join(' ')
291
+ end
292
+ end
293
+
294
+ def number_with_delimiter(number, delimiter: ',')
295
+ number.to_s.reverse.scan(/\d{1,3}/).join(delimiter).reverse
296
+ end
297
+ end
298
+ end
299
+ end
@@ -348,7 +348,7 @@ module Classifier
348
348
  avg_density = {}
349
349
  @items.each_key { |x| avg_density[x] = proximity_array_for_content_unlocked(x).sum { |pair| pair[1] } }
350
350
 
351
- avg_density.keys.sort_by { |x| avg_density[x] }.reverse[0..(max_chunks - 1)].map
351
+ avg_density.keys.sort_by { |x| avg_density[x] }.reverse[0..(max_chunks - 1)]
352
352
  end
353
353
  end
354
354
 
@@ -480,8 +480,8 @@ module Classifier
480
480
  raise 'Requested stem ranking on non-indexed content!' unless @items[doc]
481
481
 
482
482
  arr = node_for_content_unlocked(doc).lsi_vector.to_a
483
- top_n = arr.sort.reverse[0..(count - 1)]
484
- top_n.collect { |x| @word_list.word_for_index(arr.index(x)) }
483
+ top_indices = arr.each_index.sort_by { |index| -arr[index] }.first(count)
484
+ top_indices.collect { |index| @word_list.word_for_index(index) }
485
485
  end
486
486
  end
487
487
 
@@ -15,14 +15,14 @@ module Classifier
15
15
  class LineReader
16
16
  include Enumerable #[String]
17
17
 
18
- # @rbs @io: IO
18
+ # @rbs @io: IO | Classifier::Streaming::MultiIO
19
19
  # @rbs @batch_size: Integer
20
20
 
21
21
  attr_reader :batch_size
22
22
 
23
23
  # Creates a new LineReader.
24
24
  #
25
- # @rbs (IO, ?batch_size: Integer) -> void
25
+ # @rbs (IO | Classifier::Streaming::MultiIO, ?batch_size: Integer) -> void
26
26
  def initialize(io, batch_size: 100)
27
27
  @io = io
28
28
  @batch_size = batch_size
@@ -68,27 +68,29 @@ module Classifier
68
68
  def estimate_line_count(sample_size: 100)
69
69
  return nil unless @io.respond_to?(:size) && @io.respond_to?(:rewind)
70
70
 
71
+ io = @io #: ::IO
72
+
71
73
  begin
72
- original_pos = @io.pos
73
- @io.rewind
74
+ original_pos = io.pos
75
+ io.rewind
74
76
 
75
77
  sample_bytes = 0
76
78
  sample_lines = 0
77
79
 
78
80
  sample_size.times do
79
- line = @io.gets
81
+ line = io.gets
80
82
  break unless line
81
83
 
82
84
  sample_bytes += line.bytesize
83
85
  sample_lines += 1
84
86
  end
85
87
 
86
- @io.seek(original_pos)
88
+ io.seek(original_pos)
87
89
 
88
90
  return nil if sample_lines.zero?
89
91
 
90
92
  avg_line_size = sample_bytes.to_f / sample_lines
91
- io_size = @io.__send__(:size) #: Integer
93
+ io_size = io.__send__(:size) #: Integer
92
94
  (io_size / avg_line_size).round
93
95
  rescue IOError, Errno::ESPIPE
94
96
  nil
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Classifier
5
+ module Streaming
6
+ # A utility class that wraps multiple IO-like streams and treats them as a single,
7
+ # sequential stream.
8
+ #
9
+ # `MultiIO` allows you to iterate over lines across multiple input sources
10
+ # (such as files, standard input, or string buffers) seamlessly in the order
11
+ # they are provided.
12
+ #
13
+ # If a string is passed as an argument, it is automatically treated as a
14
+ # file path and opened. The file will be safely closed immediately after
15
+ # the iteration is complete.
16
+ #
17
+ # @example Reading from multiple files sequentially
18
+ # log1 = File.open("syslog.log")
19
+ # log2 = File.open("auth.log")
20
+ # path = '/var/log/nginx/access.log'
21
+ #
22
+ # multi = MultiIO.new([log1, log2, path])
23
+ # multi.each_line do |line|
24
+ # puts line if line.include?("ERROR")
25
+ # end
26
+ #
27
+ class MultiIO
28
+ # @rbs @sources: Array[IO | String]
29
+
30
+ def initialize(sources)
31
+ @sources = sources.dup
32
+ end
33
+
34
+ # @rbs () { (String) -> void } -> void
35
+ # @rbs () -> Enumerator[String, untyped]
36
+ def each_line
37
+ # rubocop:disable Style/ExplicitBlockArgument
38
+ return enum_for(:each_line) unless block_given?
39
+
40
+ @sources.each do |source|
41
+ if source.is_a?(String)
42
+ File.open(source) { |io| io.each_line { |line| yield line } }
43
+ else
44
+ source.each_line { |line| yield line }
45
+ end
46
+ end
47
+ # rubocop:enable Style/ExplicitBlockArgument
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'streaming/progress'
4
4
  require_relative 'streaming/line_reader'
5
+ require_relative 'streaming/multi_io'
5
6
 
6
7
  module Classifier
7
8
  # Streaming module provides memory-efficient training capabilities for classifiers.
@@ -30,7 +30,7 @@ module Classifier
30
30
  # @rbs @storage: Storage::Base?
31
31
  # @rbs @min_word_length: Integer
32
32
 
33
- attr_reader :vocabulary, :idf, :num_documents
33
+ attr_reader :vocabulary, :idf, :num_documents, :min_df, :max_df
34
34
  attr_accessor :storage
35
35
 
36
36
  # Creates a new TF-IDF vectorizer.
@@ -285,7 +285,8 @@ module Classifier
285
285
  # puts "#{progress.completed} documents loaded"
286
286
  # end
287
287
  #
288
- # @rbs (IO, ?batch_size: Integer) { (Streaming::Progress) -> void } -> self
288
+ # @rbs (IO | Classifier::Streaming::MultiIO, ?batch_size: Integer) -> self
289
+ # @rbs (IO | Classifier::Streaming::MultiIO, ?batch_size: Integer) { (Streaming::Progress) -> void } -> self
289
290
  def fit_from_stream(io, batch_size: Streaming::DEFAULT_BATCH_SIZE)
290
291
  reader = Streaming::LineReader.new(io, batch_size: batch_size)
291
292
  total = reader.estimate_line_count
@@ -1,3 +1,3 @@
1
1
  module Classifier
2
- VERSION = '2.6.0'.freeze
2
+ VERSION = '2.7.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: classifier
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.6.0
4
+ version: 2.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson
@@ -142,16 +142,29 @@ description: A Ruby library for text classification featuring Naive Bayes, LSI (
142
142
  email: lucas@rufy.com
143
143
  executables:
144
144
  - classifier
145
+ - keywords
145
146
  extensions:
146
147
  - ext/classifier/extconf.rb
147
148
  extra_rdoc_files: []
148
149
  files:
149
- - CLAUDE.md
150
+ - CHANGELOG.md
150
151
  - LICENSE
151
152
  - README.md
152
153
  - bin/bayes.rb
153
154
  - bin/summarize.rb
155
+ - docs/README.md
156
+ - docs/bayes.md
157
+ - docs/cli.md
158
+ - docs/configuration.md
159
+ - docs/keywords.md
160
+ - docs/knn.md
161
+ - docs/logistic-regression.md
162
+ - docs/lsi.md
163
+ - docs/persistence.md
164
+ - docs/streaming.md
165
+ - docs/tfidf.md
154
166
  - exe/classifier
167
+ - exe/keywords
155
168
  - ext/classifier/classifier_ext.c
156
169
  - ext/classifier/extconf.rb
157
170
  - ext/classifier/incremental_svd.c
@@ -167,6 +180,7 @@ files:
167
180
  - lib/classifier/extensions/string.rb
168
181
  - lib/classifier/extensions/vector.rb
169
182
  - lib/classifier/extensions/word_hash.rb
183
+ - lib/classifier/keywords/cli.rb
170
184
  - lib/classifier/knn.rb
171
185
  - lib/classifier/logistic_regression.rb
172
186
  - lib/classifier/lsi.rb
@@ -180,6 +194,7 @@ files:
180
194
  - lib/classifier/storage/memory.rb
181
195
  - lib/classifier/streaming.rb
182
196
  - lib/classifier/streaming/line_reader.rb
197
+ - lib/classifier/streaming/multi_io.rb
183
198
  - lib/classifier/streaming/progress.rb
184
199
  - lib/classifier/tfidf.rb
185
200
  - lib/classifier/version.rb
@@ -199,7 +214,7 @@ metadata:
199
214
  documentation_uri: https://rubyclassifier.com/docs
200
215
  source_code_uri: https://github.com/cardmagic/classifier
201
216
  bug_tracker_uri: https://github.com/cardmagic/classifier/issues
202
- changelog_uri: https://github.com/cardmagic/classifier/releases
217
+ changelog_uri: https://github.com/cardmagic/classifier/blob/master/CHANGELOG.md
203
218
  rdoc_options: []
204
219
  require_paths:
205
220
  - lib
@@ -214,7 +229,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
214
229
  - !ruby/object:Gem::Version
215
230
  version: '0'
216
231
  requirements: []
217
- rubygems_version: 4.0.10
232
+ rubygems_version: 4.0.16
218
233
  specification_version: 4
219
234
  summary: Text classification with Bayesian, LSI, Logistic Regression, kNN, and TF-IDF
220
235
  vectorization.
data/CLAUDE.md DELETED
@@ -1,77 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Project Overview
6
-
7
- Ruby gem providing text classification via two algorithms:
8
- - **Bayes** (`Classifier::Bayes`) - Naive Bayesian classification
9
- - **LSI** (`Classifier::LSI`) - Latent Semantic Indexing for semantic classification, clustering, and search
10
-
11
- ## Common Commands
12
-
13
- ```bash
14
- # Compile native C extension
15
- bundle exec rake compile
16
-
17
- # Run all tests (compiles first)
18
- bundle exec rake test
19
-
20
- # Run a single test file
21
- ruby -Ilib test/bayes/bayesian_test.rb
22
- ruby -Ilib test/lsi/lsi_test.rb
23
-
24
- # Run tests with pure Ruby (no native extension)
25
- NATIVE_VECTOR=true bundle exec rake test
26
-
27
- # Run benchmarks
28
- bundle exec rake benchmark
29
- bundle exec rake benchmark:compare
30
-
31
- # Interactive console
32
- bundle exec rake console
33
-
34
- # Generate documentation
35
- bundle exec rake doc
36
- ```
37
-
38
- ## Architecture
39
-
40
- ### Core Components
41
-
42
- **Bayesian Classifier** (`lib/classifier/bayes.rb`)
43
- - Train with `train(category, text)` or dynamic methods like `train_spam(text)`
44
- - Classify with `classify(text)` returning the best category
45
- - Uses log probabilities for numerical stability
46
-
47
- **LSI Classifier** (`lib/classifier/lsi.rb`)
48
- - Uses Singular Value Decomposition (SVD) for semantic analysis
49
- - Native C extension for 5-50x faster matrix operations; falls back to pure Ruby
50
- - Key operations: `add_item`, `classify`, `find_related`, `search`
51
- - `auto_rebuild` option controls automatic index rebuilding after changes
52
-
53
- **String Extensions** (`lib/classifier/extensions/word_hash.rb`)
54
- - `word_hash` / `clean_word_hash` - tokenize text to stemmed word frequencies
55
- - `CORPUS_SKIP_WORDS` - stopwords filtered during tokenization
56
- - Uses `fast-stemmer` gem for Porter stemming
57
-
58
- **Vector Extensions** (`lib/classifier/extensions/vector.rb`)
59
- - Pure Ruby SVD implementation (`Matrix#SV_decomp`) - used as fallback
60
- - Vector normalization and magnitude calculations
61
-
62
- ### Native C Extension (`ext/classifier/`)
63
-
64
- LSI uses a native C extension for fast linear algebra operations:
65
- - `Classifier::Linalg::Vector` - Vector operations (alloc, normalize, dot product)
66
- - `Classifier::Linalg::Matrix` - Matrix operations (alloc, transpose, multiply)
67
- - Jacobi SVD implementation for singular value decomposition
68
-
69
- Check current backend: `Classifier::LSI.backend` returns `:native` or `:ruby`
70
- Force pure Ruby: `NATIVE_VECTOR=true bundle exec rake test`
71
-
72
- ### Content Nodes (`lib/classifier/lsi/content_node.rb`)
73
-
74
- Internal data structure storing:
75
- - `word_hash` - term frequencies
76
- - `raw_vector` / `raw_norm` - initial vector representation
77
- - `lsi_vector` / `lsi_norm` - reduced dimensionality representation after SVD