Skip to content

Commit 775b51f

Browse files
committed
introduce optimization to classifier model
1. weighted token probability 2. adaptive token selection 3. false positive bias inspired by this post: https://www.paulgraham.com/better.html
1 parent 2b6c52c commit 775b51f

File tree

2 files changed

+78
-29
lines changed

2 files changed

+78
-29
lines changed

app/services/spam_classifier_service.rb

Lines changed: 69 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,13 @@ def train_only(trained_message)
4747
@classifier_state.spam_counts[token] = @classifier_state.spam_counts.fetch(token, 0) + 1
4848
@vocabulary.add(token)
4949
end
50-
else # :ham
50+
else # :ham - FALSE POSITIVE BIAS: count ham tokens double
51+
# https://www.paulgraham.com/better.html
5152
@classifier_state.total_ham_messages += 1
52-
@classifier_state.total_ham_words += tokens.size
53+
@classifier_state.total_ham_words += tokens.size * 2 # Double
54+
# count for bias
5355
tokens.each do |token|
54-
@classifier_state.ham_counts[token] = @classifier_state.ham_counts.fetch(token, 0) + 1
56+
@classifier_state.ham_counts[token] = @classifier_state.ham_counts.fetch(token, 0) + 2 # Double weight
5557
@vocabulary.add(token)
5658
end
5759
end
@@ -70,49 +72,44 @@ def train_batch(trained_messages)
7072
end
7173
@classifier_state.save!
7274
end
73-
7475
def classify(message_text)
75-
# P(Spam|Words) = P(Words|Spam) * P(Spam) / P(Words)
76-
# Return false if the model isn't trained enough
7776
@classifier_state.reload
78-
return [ false, 0.0, 0.0 ] if @classifier_state.total_ham_messages == 0 || @classifier_state.total_spam_messages == 0
77+
return [ false, 0.0 ] if @classifier_state.total_ham_messages.zero? || @classifier_state.total_spam_messages.zero?
7978

80-
tokens = tokenize(message_text)
8179
total_messages = @classifier_state.total_spam_messages + @classifier_state.total_ham_messages
8280

83-
# Calculate prior probabilities in log space
84-
# Use Math.log to resolve numerical underflow problem
85-
prob_spam_prior = Math.log(@classifier_state.total_spam_messages.to_f / total_messages)
86-
prob_ham_prior = Math.log(@classifier_state.total_ham_messages.to_f / total_messages)
81+
# These are the actual priors
82+
prob_spam_prior = @classifier_state.total_spam_messages.to_f / total_messages
83+
prob_ham_prior = @classifier_state.total_ham_messages.to_f / total_messages
84+
85+
tokens = tokenize(message_text)
8786

88-
spam_score = prob_spam_prior
89-
ham_score = prob_ham_prior
87+
# Pass the priors to the selection method for consistent logic
88+
significant_tokens = get_significant_tokens(tokens, prob_spam_prior, prob_ham_prior)
9089

91-
vocab_size = @classifier_state.vocabulary_size
90+
# Start scores with the log of the priors
91+
spam_score = Math.log(prob_spam_prior)
92+
ham_score = Math.log(prob_ham_prior)
9293

93-
tokens.each do |token|
94-
# Add 1 for Laplace smoothing, Laplace smoothing is tailored to solve zero probability problem
95-
spam_count = @classifier_state.spam_counts.fetch(token, 0) + 1
96-
spam_score += Math.log(spam_count.to_f / (@classifier_state.total_spam_words + vocab_size))
94+
significant_tokens.each do |token|
95+
spam_likelihood, ham_likelihood = get_likelihoods(token)
9796

98-
ham_count = @classifier_state.ham_counts.fetch(token, 0) + 1
99-
ham_score += Math.log(ham_count.to_f / (@classifier_state.total_ham_words + vocab_size))
97+
spam_score += Math.log(spam_likelihood)
98+
ham_score += Math.log(ham_likelihood)
10099
end
101100

102101
diff = spam_score - ham_score
103-
# stable logistic conversion
104-
p_spam = if diff.abs > 700
105-
diff > 0 ? 1.0 : 0.0
106-
else
107-
1.0 / (1.0 + Math.exp(-diff))
108-
end
102+
p_spam = 1.0 / (1.0 + Math.exp(-diff))
109103

110104
confidence_threshold = Rails.application.config.probability_threshold
111105
is_spam = p_spam >= confidence_threshold
112-
Rails.logger.info "classified_result: #{is_spam ? "maybe_spam": "maybe_ham"}, p_spam: #{p_spam}, message_text: #{message_text}"
106+
107+
Rails.logger.info "classified_result: #{is_spam ? "maybe_spam": "maybe_ham"}, p_spam: #{p_spam.round(4)}, tokens: #{significant_tokens.join(', ')}"
108+
113109
[ is_spam, spam_score, ham_score ]
114110
end
115111

112+
116113
def tokenize(text)
117114
cleaned_text = clean_text(text)
118115
# This regex pre-tokenizes the string into 4 groups:
@@ -188,6 +185,50 @@ def pure_numbers?(token)
188185
token.match?(/^[0-9一二三四五六七八九十百千万亿零]+$/)
189186
end
190187

188+
# It correctly calculates P(token|class) for all cases using Laplace smoothing.
189+
def get_likelihoods(token)
190+
vocab_size = @classifier_state.vocabulary_size
191+
192+
# For a spam-only word, ham_count is 0, so ham_likelihood will be very small.
193+
# This is the correct, mathematically consistent way to handle it.
194+
spam_count = @classifier_state.spam_counts.fetch(token, 0)
195+
spam_likelihood = (spam_count + 1.0) / (@classifier_state.total_spam_words + vocab_size)
196+
197+
ham_count = @classifier_state.ham_counts.fetch(token, 0)
198+
ham_likelihood = (ham_count + 1.0) / (@classifier_state.total_ham_words + vocab_size)
199+
200+
[ spam_likelihood, ham_likelihood ]
201+
end
202+
203+
# Corrected to use the actual priors when determining "interestingness"
204+
def get_significant_tokens(tokens, prob_spam_prior, prob_ham_prior)
205+
# Use a Set to consider each unique token only once
206+
unique_tokens = tokens.to_set
207+
208+
token_scores = unique_tokens.map do |token|
209+
spam_likelihood, ham_likelihood = get_likelihoods(token)
210+
211+
# Calculate the actual P(Spam|token) using the real priors
212+
# P(S|W) = P(W|S)P(S) / (P(W|S)P(S) + P(W|H)P(H))
213+
prob_word_given_spam = spam_likelihood * prob_spam_prior
214+
prob_word_given_ham = ham_likelihood * prob_ham_prior
215+
216+
# Avoid division by zero if both are 0
217+
denominator = prob_word_given_spam + prob_word_given_ham
218+
next [ token, 0.5 ] if denominator == 0
219+
220+
prob = prob_word_given_spam / denominator
221+
interestingness = (prob - 0.5).abs
222+
223+
[ token, interestingness ]
224+
end
225+
226+
# Select the top 15 most interesting tokens
227+
token_scores.sort_by { |_, interest| -interest }
228+
.first(15)
229+
.map { |token, _| token }
230+
end
231+
191232
class << self
192233
def rebuild_all_public
193234
Rails.logger.info "Starting rebuild for all public classifiers..."

vendor/dictionaries/user.dict.utf8

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,4 +308,12 @@ K 线
308308
学生党
309309
分享群
310310
冷静期
311-
大魔王
311+
大魔王
312+
副卡
313+
文爱
314+
解绑
315+
反差婊
316+
黄毛
317+
模拟仓
318+
吃干抹净
319+
防刷屏

0 commit comments

Comments
 (0)