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,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
|
data/lib/classifier/lsi.rb
CHANGED
|
@@ -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)]
|
|
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
|
-
|
|
484
|
-
|
|
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 =
|
|
73
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
data/lib/classifier/streaming.rb
CHANGED
data/lib/classifier/tfidf.rb
CHANGED
|
@@ -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)
|
|
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
|
data/lib/classifier/version.rb
CHANGED
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.
|
|
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
|
-
-
|
|
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/
|
|
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.
|
|
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
|