Class: ActiveSanction::Scorer::Weights

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/scorer/weights.rb

Overview

Every number the scorer uses, with a default and a reason for it.

ActiveSanction::Scorer::Weights.default.token_set        # => 0.45
ActiveSanction::Scorer::Weights.default.dob_conflict     # => -35.0

ActiveSanction.configure do |c|
c.scorer_weights = { dob_conflict: -20.0 }
end

There are two kinds of number here and they are in different units, which is the thing to know before changing one.

The name shares, which are fractions of the name score

jaro_winkler, levenshtein, token_sort, token_set and phonetic are shares of one blended similarity. They are each between 0 and 1 and they must sum to exactly 1, which is what makes the name score a percentage rather than an arbitrary total: two identical names score 100 because every share agreed, and a share that agreed on nothing contributes nothing.

The defaults are set from what the four algorithms disagree about -- Similarity's own table is the argument, and it is worth reading beside this:

                                   JW     Lev    Sort    Set   Phon   blend
abbas abu / abu abbas            0.805  0.333  1.000  1.000  1.000    90.4
putin vladimir vladimirovich /
  vladimir putin                 0.679  0.357  0.500  1.000  1.000    76.2
gazprom / gazprom neft           0.917  0.583  0.583  1.000  1.000    84.2
kim jong un / kim yong chol      0.869  0.615  0.462  0.462  0.667    54.8

The token ratios carry most of the weight, because the query shapes this corpus actually produces are rearrangements. An individual is published surname-first and typed given-name-first; a patronymic is on the record and not in the query. Row one and row two are both true matches that the character algorithms score in the sixties and the seventies, and a blend that let them decide would miss the two most common true positives there are.

token_set is the largest single share because a 1.0 from it means something specific and strong: every word of the shorter name appears in the longer one. That is the shape of nearly every honest partial query.

The character algorithms are the brake. Row four is the case they exist for -- two names already written in the same order, where sorting loses the information that they are, and the token ratios happily score a different person at 0.462. Levenshtein's share is the smallest because it is the harshest measure in the set: on a name that is one token longer it is already down in the fifties, and giving it more would pull every true partial match down with it.

The phonetic share is small and it is a share, not a bonus. A shared Double Metaphone key is real evidence -- it is what puts QADHAFI and GADDAFI together -- and it is weak evidence, because HSN is the key for HUSSEIN and equally for HASSAN. Five points is what it is worth on its own; the reason it is inside the sum rather than added on top is that a bonus would put an identical pair over 100 and need clamping to get back, and a score that reaches its ceiling by two different routes is one nobody can reason about.

The adjustments, which are points on the 0..100 score

These are added to the name score, not multiplied into it, because they are separate evidence rather than a re-reading of the name. A passport number is not "more name"; it is the thing that makes a mediocre name match decisive, and a boost that scaled with the name score could not do that.

identifier_match is 40 and is meant to be decisive: a name in the fifties plus the right passport number clears any sane threshold, which is the entire reason a screening tool asks for document numbers.

dob_conflict at -35 is the one number the acceptance criteria pin down. A name-identical pair scores 100, and a genuine date-of-birth conflict has to put it under the threshold rather than merely rank it lower -- 65 is under every default this library ships. The exact match is worth less than the conflict costs on purpose: sharing a birthday with a listed person is a coincidence a few thousand people have, and not having theirs is not.

dob_overlap at 6 is what a year-only date is worth. Most of these records carry one -- see PartialDate on why the type exists -- and 1948 against 1948-12-10 is agreement worth noting and not worth much.

Nationality moves the score least in both directions, because it is the softest of the three. People hold two passports, lists record the country a person was born in as often as the one they are a citizen of, and a conflict there is weaker evidence than a date conflict by some way.

Penalties are stored negative

dob_conflict is -35.0 rather than 35.0 subtracted somewhere else, so that a Reason's contribution is the number in this object and a host reading a configuration can see which way each one pushes. A boost written negative, or a penalty written positive, is refused.

Instances are frozen on construction and compare by value.

Constant Summary collapse

DEFAULTS =

Every weight this library ships, and the numbers the committed accuracy report was measured with. A host tuning one starts from here, changes what it means to and leaves the rest alone -- the shares are held to summing to 1.0, so they move in pairs.

T.let({
  jaro_winkler: 0.15,
  levenshtein: 0.10,
  token_sort: 0.25,
  token_set: 0.45,
  phonetic: 0.05,
  low_quality_alias: -10.0,
  identifier_match: 40.0,
  dob_exact: 15.0,
  dob_overlap: 6.0,
  dob_conflict: -35.0,
  nationality_match: 6.0,
  nationality_conflict: -12.0
}.freeze, T::Hash[Symbol, Float])

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(**overrides) ⇒ void

Every member defaults, so new and new(dob_conflict: -20.0) are both a complete set. Unlike the normalizer's dictionaries there is no danger in a partial replacement here: a number left out is the shipped one, and the shares are checked to sum to 1 whatever a caller passed.

Parameters:

  • overrides (T.untyped)

Raises:



225
226
227
228
229
230
231
232
233
# File 'lib/active_sanction/scorer/weights.rb', line 225

def initialize(**overrides)
  unknown = overrides.keys - MEMBERS
  raise InvalidArgument, "unknown weight(s): #{unknown.join(", ")}" if unknown.any?

  @weights = T.let(DEFAULTS.merge(overrides).to_h { |member, weight| [member, number!(member, weight)] }.freeze,
                   T::Hash[Symbol, Float])
  validate_shares!
  freeze
end

Class Method Details

.build(value) ⇒ Weights

The shipped numbers with some replaced, which is what a host almost always wants:

Weights.build(dob_conflict: -20.0)

A Weights passes through, so a caller holding either can hand this whatever it has.

Parameters:

  • value (T.untyped)

Returns:

Raises:



211
212
213
214
215
216
217
# File 'lib/active_sanction/scorer/weights.rb', line 211

def build(value)
  return default if value.nil?
  return value if value.is_a?(Weights)
  raise InvalidArgument, "expected a #{self} or a Hash of weights, got #{value.class}" unless value.is_a?(Hash)

  T.unsafe(default).merge(**value.to_h { |member, weight| [member.to_s.to_sym, weight] })
end

.default ⇒ Weights

The shipped numbers. Built at load, so nothing has to synchronize its construction.

Returns:



201
# File 'lib/active_sanction/scorer/weights.rb', line 201

def default = DEFAULT

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


246
247
248
249
250
# File 'lib/active_sanction/scorer/weights.rb', line 246

def ==(other)
  return false unless other.instance_of?(self.class)

  to_h == other.to_h
end

#dob_conflict ⇒ Float

Returns:

  • (Float)


187
# File 'lib/active_sanction/scorer/weights.rb', line 187

def dob_conflict = fetch(:dob_conflict)

#dob_exact ⇒ Float

Returns:

  • (Float)


181
# File 'lib/active_sanction/scorer/weights.rb', line 181

def dob_exact = fetch(:dob_exact)

#dob_overlap ⇒ Float

Returns:

  • (Float)


184
# File 'lib/active_sanction/scorer/weights.rb', line 184

def dob_overlap = fetch(:dob_overlap)

#fetch(member) ⇒ Float

Parameters:

  • member (Symbol)

Returns:

  • (Float)


240
# File 'lib/active_sanction/scorer/weights.rb', line 240

def fetch(member) = @weights.fetch(member)

#hash ⇒ Integer

Returns:

  • (Integer)


254
# File 'lib/active_sanction/scorer/weights.rb', line 254

def hash = [self.class, to_h].hash

#identifier_match ⇒ Float

Returns:

  • (Float)


178
# File 'lib/active_sanction/scorer/weights.rb', line 178

def identifier_match = fetch(:identifier_match)

#inspect ⇒ String

Returns:

  • (String)


257
# File 'lib/active_sanction/scorer/weights.rb', line 257

def inspect = "#<#{self.class} #{to_h.map { |member, weight| "#{member}=#{weight}" }.join(" ")}>"

#jaro_winkler ⇒ Float

Spelled out rather than defined from MEMBERS in a loop, because a reader Sorbet cannot see is a reader every call site has to be T.unsafe to reach. Each one reads the hash; nothing here is stored twice.

Returns:

  • (Float)


160
# File 'lib/active_sanction/scorer/weights.rb', line 160

def jaro_winkler = fetch(:jaro_winkler)

#levenshtein ⇒ Float

Returns:

  • (Float)


163
# File 'lib/active_sanction/scorer/weights.rb', line 163

def levenshtein = fetch(:levenshtein)

#low_quality_alias ⇒ Float

Returns:

  • (Float)


175
# File 'lib/active_sanction/scorer/weights.rb', line 175

def low_quality_alias = fetch(:low_quality_alias)

#merge(**overrides) ⇒ Weights

These weights with some replaced.

Parameters:

  • overrides (T.untyped)

Returns:



237
# File 'lib/active_sanction/scorer/weights.rb', line 237

def merge(**overrides) = T.unsafe(self.class).new(**@weights, **overrides)

#nationality_conflict ⇒ Float

Returns:

  • (Float)


193
# File 'lib/active_sanction/scorer/weights.rb', line 193

def nationality_conflict = fetch(:nationality_conflict)

#nationality_match ⇒ Float

Returns:

  • (Float)


190
# File 'lib/active_sanction/scorer/weights.rb', line 190

def nationality_match = fetch(:nationality_match)

#phonetic ⇒ Float

Returns:

  • (Float)


172
# File 'lib/active_sanction/scorer/weights.rb', line 172

def phonetic = fetch(:phonetic)

#to_h ⇒ Hash{Symbol => Float}

Returns:

  • (Hash{Symbol => Float})


243
# File 'lib/active_sanction/scorer/weights.rb', line 243

def to_h = @weights.dup

#token_set ⇒ Float

Returns:

  • (Float)


169
# File 'lib/active_sanction/scorer/weights.rb', line 169

def token_set = fetch(:token_set)

#token_sort ⇒ Float

Returns:

  • (Float)


166
# File 'lib/active_sanction/scorer/weights.rb', line 166

def token_sort = fetch(:token_sort)