Class: Vangrail::Rails::Bayes

Inherits:
Vangrail::Rail show all
Defined in:
lib/vangrail/rails/bayes.rb

Overview

A naive Bayes classifier over word n-grams, which is the oldest working text classifier there is and the only rail here that says how sure it is.

Every other rail answers yes or no, so it hands the evidence arithmetic exactly one bit of information however certain it was. This one computes a log-likelihood ratio directly, which is the quantity that arithmetic actually wants: a clause scoring twelve bits and a clause scoring three both "fire", and they are not the same observation. A rail that puts bits in its result's raw is read that way by Engine#assess, and this is the first rail to do it.

Scored clause by clause, taking the worst. The same dilution problem the containment rail hit applies here with force: an attack document is ordinary documentation with one injected sentence in it, and a bag of features over the whole page is mostly evidence about the handbook.

The shipped weights are a demonstration, and the honest number says so. Source groups have disjoint train, calibration, threshold, and final-test roles. On six final-test attacks and six benign pages, the selected threshold catches three attacks and flags two benign pages. Six calibration attacks defend no positive likelihood-ratio bits at 95% joint credibility. The junk-mail filters this borrows from were fitted on millions of examples.

So it is off by default, and what it is for is the retraining path: a deployment with its own traffic runs script/train_bayes.rb against its own corpus and gets a rail fitted to the attacks it actually receives, with separate calibration, threshold, and final-test evidence.

WHAT THE SHIPPED WEIGHTS DO ON REAL TEXT, WHICH IS WORSE THAN "A DEMONSTRATION" SUGGESTS. Measured on installed documentation, this rail fires on 74.9% of it -- three documents in four -- at the threshold that ships. It catches 101 of BIPIA's 125 injections spliced into the same pages, and those two numbers are one number: a rail that fires on everything catches everything. Its AUC on that splice is 0.557 against 0.5 for a coin, and script/calibrate_threshold.rb held to a 1% false-alarm rate finds a threshold that catches 0.8%.

The cause is in the weights and anyone can read it. The top features tie at 2.492 and they are "answer", "page", "as", "all", "of", "that", "thi page", "page as"; 21 of the 300 are nothing but stopwords; and "page" is the commonest word in a corpus of manual pages. This is the shortcut learning Li et al. name in InjecGuard (arXiv:2410.22770), where a guard model learns a trigger word straight through to a verdict, arrived at from 60 training clauses.

The external evaluation carried 8.86% for years because that rate was measured before the artifact was regenerated and nothing re-ran it. A trained artifact and a measurement of it are one unit.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(weights: BayesData::WEIGHTS, threshold: BayesData::THRESHOLD, calibration: BayesData::CALIBRATION, name: 'bayes', sides: %i[input context])) ⇒ Bayes

Returns a new instance of Bayes.



58
59
60
61
62
63
64
# File 'lib/vangrail/rails/bayes.rb', line 58

def initialize(weights: BayesData::WEIGHTS, threshold: BayesData::THRESHOLD,
               calibration: BayesData::CALIBRATION, name: 'bayes', sides: %i[input context])
  super(name: name, sides: sides)
  @weights = weights
  @threshold = threshold
  @calibration = calibration
end

Instance Attribute Details

#calibrationObject (readonly)

Returns the value of attribute calibration.



66
67
68
# File 'lib/vangrail/rails/bayes.rb', line 66

def calibration
  @calibration
end

#thresholdObject (readonly)

Returns the value of attribute threshold.



66
67
68
# File 'lib/vangrail/rails/bayes.rb', line 66

def threshold
  @threshold
end

#weightsObject (readonly)

Returns the value of attribute weights.



66
67
68
# File 'lib/vangrail/rails/bayes.rb', line 66

def weights
  @weights
end

Instance Method Details

#bits(text) ⇒ Object

What that score is worth, fitted only on the calibration role and read at the simultaneous 95% likelihood-ratio bound. This is the number that goes into a posterior; unsupported score bands contribute zero.



111
112
113
# File 'lib/vangrail/rails/bayes.rb', line 111

def bits(text)
  bits_for(score_for(text))
end

#bits_for(score) ⇒ Object



115
116
117
118
# File 'lib/vangrail/rails/bayes.rb', line 115

def bits_for(score)
  band = calibration.reverse.detect { |floor, _| score > floor }
  band ? band.last : calibration.first.last
end

#cache_key(text, _context) ⇒ Object



72
73
74
# File 'lib/vangrail/rails/bayes.rb', line 72

def cache_key(text, _context)
  "#{threshold}\n#{text}"
end

#decide(text, _context) ⇒ Object



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/vangrail/rails/bayes.rb', line 76

def decide(text, _context)
  score = score_for(text)
  evidence = bits_for(score)
  payload = { 'bits' => evidence, 'score' => score }
  # A positive score at or below the threshold is not a decision. The
  # threshold role did not select it, and the small calibration role cannot
  # defend positive evidence for it.
  if uncertain_score?(score)
    return unchecked('score sits in a band the calibration cannot separate', raw: payload)
  end
  return pass(raw: payload) if score <= threshold

  block(categories: ['bayes'], raw: payload,
        reason: format('scores %<score>+.1f, worth %<bits>+.1f bits of evidence', score: score,
                                                                                  bits: evidence))
end

#quantifies?Boolean

Returns:

  • (Boolean)


68
69
70
# File 'lib/vangrail/rails/bayes.rb', line 68

def quantifies?
  true
end

#score_for(text) ⇒ Object

The worst clause's raw naive Bayes score. Not a likelihood ratio and not to be added to one: the features are counted as independent and are not, so this number is confidently wrong about its own size. It decides the block, because a threshold only needs an ordering.



101
102
103
104
105
106
# File 'lib/vangrail/rails/bayes.rb', line 101

def score_for(text)
  clauses = NLP.clauses(text)
  return clause_score(text.to_s) if clauses.empty?

  clauses.map { |clause| clause_score(clause) }.max
end

#uncertain_score?(score) ⇒ Boolean

Returns:

  • (Boolean)


93
94
95
# File 'lib/vangrail/rails/bayes.rb', line 93

def uncertain_score?(score)
  score.positive? && score <= threshold
end