@mailwoman/match 7.2.0 → 7.2.1
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.
- package/blocking.ts +180 -0
- package/clustering.ts +238 -0
- package/comparators.ts +142 -0
- package/distance.ts +140 -0
- package/em.ts +150 -0
- package/fellegi-sunter.ts +201 -0
- package/gbt.ts +209 -0
- package/index.ts +26 -0
- package/package.json +63 -28
- package/tf.ts +96 -0
package/em.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Unsupervised parameter estimation for the Fellegi-Sunter model — the part that makes the matcher
|
|
7
|
+
* work with no labeled data.
|
|
8
|
+
*
|
|
9
|
+
* The paradox: to estimate `m`/`u` you need to know which pairs match, but finding matches is the
|
|
10
|
+
* whole problem. EM (Winkler 1988) breaks it by treating the match/non-match status as a hidden
|
|
11
|
+
* variable and iterating:
|
|
12
|
+
*
|
|
13
|
+
* - **E-step** — under the current parameters, compute each pair's posterior responsibility `g =
|
|
14
|
+
* P(match | its agreement pattern)`.
|
|
15
|
+
* - **M-step** — re-estimate `λ`, and each level's `m`/`u`, as `g`-weighted (resp. `(1-g)`-weighted)
|
|
16
|
+
* fractions of the pairs landing in that level.
|
|
17
|
+
*
|
|
18
|
+
* It converges because true matches agree on most fields and non-matches don't, so the two classes
|
|
19
|
+
* pull apart. Assumes conditional independence of the comparisons given match status (the
|
|
20
|
+
* standard F-S assumption). Caveat from the literature: EM can land in a local optimum when the
|
|
21
|
+
* true match rate is very low — seed from sensible `m`/`u` (the model's existing levels do this)
|
|
22
|
+
* and sanity- check that the recovered `m` exceeds `u` on the top agreement level.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Comparison, FellegiSunterModel } from "./fellegi-sunter.ts"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tiny floor mixed into the M-step so an unobserved level never produces a zero (→ infinite weight).
|
|
29
|
+
*/
|
|
30
|
+
const EPSILON = 1e-9
|
|
31
|
+
|
|
32
|
+
/** Reduce a record pair to its agreement pattern — the per-comparison level index (`-1` = missing). */
|
|
33
|
+
export function agreementPattern<R>(comparisons: Comparison<R>[], a: R, b: R): number[] {
|
|
34
|
+
return comparisons.map((comparison) => comparison.assess(a, b))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Options for {@link estimateParameters}. */
|
|
38
|
+
export interface EmOptions {
|
|
39
|
+
/** Hard iteration cap. Default 100. */
|
|
40
|
+
maxIterations?: number
|
|
41
|
+
/** Convergence tolerance on the largest parameter change between iterations. Default 1e-6. */
|
|
42
|
+
tolerance?: number
|
|
43
|
+
/** Starting prior match rate. Defaults to the model's `lambda`. */
|
|
44
|
+
initialLambda?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The fitted model plus convergence diagnostics. */
|
|
48
|
+
export interface EmResult<R> {
|
|
49
|
+
/** The input model with every level's `m`/`u` and the prior `lambda` re-estimated. */
|
|
50
|
+
model: FellegiSunterModel<R>
|
|
51
|
+
/** The estimated prior match rate. */
|
|
52
|
+
lambda: number
|
|
53
|
+
iterations: number
|
|
54
|
+
converged: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Estimate `m`/`u` and the prior `λ` from unlabeled agreement patterns via EM. The patterns are per-comparison level
|
|
59
|
+
* indices (as produced by {@link agreementPattern}); a `-1` (missing) field contributes no evidence to either class. The
|
|
60
|
+
* model's existing level `m`/`u` seed the iteration.
|
|
61
|
+
*/
|
|
62
|
+
export function estimateParameters<R>(
|
|
63
|
+
model: FellegiSunterModel<R>,
|
|
64
|
+
patterns: number[][],
|
|
65
|
+
opts: EmOptions = {}
|
|
66
|
+
): EmResult<R> {
|
|
67
|
+
const maxIterations = opts.maxIterations ?? 100
|
|
68
|
+
const tolerance = opts.tolerance ?? 1e-6
|
|
69
|
+
const comparisons = model.comparisons
|
|
70
|
+
const levelCounts = comparisons.map((c) => c.levels.length)
|
|
71
|
+
|
|
72
|
+
// Per-comparison, per-level m/u, seeded from the model's current levels.
|
|
73
|
+
const m = comparisons.map((c) => c.levels.map((l) => l.m))
|
|
74
|
+
const u = comparisons.map((c) => c.levels.map((l) => l.u))
|
|
75
|
+
let lambda = opts.initialLambda ?? model.lambda
|
|
76
|
+
|
|
77
|
+
let iterations = 0
|
|
78
|
+
let converged = false
|
|
79
|
+
|
|
80
|
+
if (patterns.length === 0) {
|
|
81
|
+
return { model, lambda, iterations, converged }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (; iterations < maxIterations; iterations++) {
|
|
85
|
+
const mNumerator = comparisons.map((_, i) => new Array<number>(levelCounts[i]!).fill(0))
|
|
86
|
+
const uNumerator = comparisons.map((_, i) => new Array<number>(levelCounts[i]!).fill(0))
|
|
87
|
+
const mDenominator = comparisons.map(() => 0)
|
|
88
|
+
const uDenominator = comparisons.map(() => 0)
|
|
89
|
+
let responsibilitySum = 0
|
|
90
|
+
|
|
91
|
+
// E-step: posterior P(match | pattern) for each pair.
|
|
92
|
+
for (const pattern of patterns) {
|
|
93
|
+
let matchLikelihood = lambda
|
|
94
|
+
let nonMatchLikelihood = 1 - lambda
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < comparisons.length; i++) {
|
|
97
|
+
const level = pattern[i]!
|
|
98
|
+
|
|
99
|
+
if (level < 0) continue
|
|
100
|
+
matchLikelihood *= m[i]![level]!
|
|
101
|
+
nonMatchLikelihood *= u[i]![level]!
|
|
102
|
+
}
|
|
103
|
+
const total = matchLikelihood + nonMatchLikelihood
|
|
104
|
+
const g = total > 0 ? matchLikelihood / total : 0
|
|
105
|
+
responsibilitySum += g
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < comparisons.length; i++) {
|
|
108
|
+
const level = pattern[i]!
|
|
109
|
+
|
|
110
|
+
if (level < 0) continue
|
|
111
|
+
mNumerator[i]![level]! += g
|
|
112
|
+
uNumerator[i]![level]! += 1 - g
|
|
113
|
+
mDenominator[i]! += g
|
|
114
|
+
uDenominator[i]! += 1 - g
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// M-step: re-estimate λ and each level's m/u as (1-)g-weighted fractions.
|
|
119
|
+
const newLambda = responsibilitySum / patterns.length
|
|
120
|
+
let maxDelta = Math.abs(newLambda - lambda)
|
|
121
|
+
lambda = newLambda
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < comparisons.length; i++) {
|
|
124
|
+
const levels = levelCounts[i]!
|
|
125
|
+
|
|
126
|
+
for (let l = 0; l < levels; l++) {
|
|
127
|
+
const newM =
|
|
128
|
+
mDenominator[i]! > 0 ? (mNumerator[i]![l]! + EPSILON) / (mDenominator[i]! + EPSILON * levels) : m[i]![l]!
|
|
129
|
+
const newU =
|
|
130
|
+
uDenominator[i]! > 0 ? (uNumerator[i]![l]! + EPSILON) / (uDenominator[i]! + EPSILON * levels) : u[i]![l]!
|
|
131
|
+
maxDelta = Math.max(maxDelta, Math.abs(newM - m[i]![l]!), Math.abs(newU - u[i]![l]!))
|
|
132
|
+
m[i]![l] = newM
|
|
133
|
+
u[i]![l] = newU
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (maxDelta < tolerance) {
|
|
138
|
+
converged = true
|
|
139
|
+
iterations++
|
|
140
|
+
break
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fittedComparisons = comparisons.map((c, i) => ({
|
|
145
|
+
...c,
|
|
146
|
+
levels: c.levels.map((level, j) => ({ ...level, m: m[i]![j]!, u: u[i]![j]! })),
|
|
147
|
+
}))
|
|
148
|
+
|
|
149
|
+
return { model: { comparisons: fittedComparisons, lambda }, lambda, iterations, converged }
|
|
150
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The Fellegi-Sunter scorer — the matcher's decision layer.
|
|
7
|
+
*
|
|
8
|
+
* Each field comparison lands a record pair in a discrete _agreement level_ (exact / high / low /
|
|
9
|
+
* different / missing). Each level carries two probabilities: `m` = P(this level | the pair
|
|
10
|
+
* really matches) and `u` = P(this level | it doesn't). Their ratio is a Bayes factor, and its
|
|
11
|
+
* log is the level's contribution to the total match weight in bits:
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* M = log2(λ / (1 - λ)) + Σ_fields log2(m_level / u_level)
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* — a prior (how likely any two random records match) plus an additive, per-field-attributable
|
|
18
|
+
* stack of evidence. Convert `M` to a probability and threshold it: above the upper bound is a
|
|
19
|
+
* link, below the lower bound a non-link, and the band between is _clerical review_ — the
|
|
20
|
+
* calibrated abstain zone the whole design leans on.
|
|
21
|
+
*
|
|
22
|
+
* The `m`/`u` numbers here are NOT universal constants. They are estimated from the data — by EM,
|
|
23
|
+
* unsupervised (the next increment) — and the term-frequency adjustment that makes a rare-name
|
|
24
|
+
* agreement count more than a common one layers on top. This module is the deterministic core
|
|
25
|
+
* those build on: given the levels, it produces the weights, the probability, and the decision.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { nameSimilarity } from "./comparators.ts"
|
|
29
|
+
|
|
30
|
+
/** One agreement level of a comparison, with its match / non-match probabilities. */
|
|
31
|
+
export interface ComparisonLevel {
|
|
32
|
+
/** Human-readable label for debugging (`exact`, `high`, `different`). */
|
|
33
|
+
label: string
|
|
34
|
+
/** P(a pair lands in this level | it is a true match). A measure of data quality. */
|
|
35
|
+
m: number
|
|
36
|
+
/** P(a pair lands in this level | it is NOT a match). A measure of coincidence / cardinality. */
|
|
37
|
+
u: number
|
|
38
|
+
/** For similarity-driven comparisons: the minimum similarity (inclusive) to qualify. */
|
|
39
|
+
minSimilarity?: number
|
|
40
|
+
/** For distance-driven comparisons: the maximum distance in km (inclusive) to qualify. */
|
|
41
|
+
maxKm?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A per-field comparison: pull a value from each record and assign an agreement level. */
|
|
45
|
+
export interface Comparison<R> {
|
|
46
|
+
/** Field name, for attribution. */
|
|
47
|
+
name: string
|
|
48
|
+
/** Levels ordered highest agreement → lowest (`exact` first, `different` last). */
|
|
49
|
+
levels: ComparisonLevel[]
|
|
50
|
+
/** Index into {@link levels}, or `-1` when either value is missing (no evidence → weight 0). */
|
|
51
|
+
assess(a: R, b: R): number
|
|
52
|
+
/**
|
|
53
|
+
* Optional term-frequency adjustment: on the levels it names, replace the level's average `u` with the agreeing
|
|
54
|
+
* value's actual frequency, so agreement on a rare value (`Vijayan`) outweighs agreement on a common one (`Smith`).
|
|
55
|
+
* See `withTermFrequency`.
|
|
56
|
+
*/
|
|
57
|
+
termFrequency?: TermFrequencyAdjustment<R>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Per-value term-frequency adjustment for a comparison (the Splink/Winkler mechanism). `m` is unchanged; on an
|
|
62
|
+
* agreement level the effective `u` becomes the value's own frequency, adding `log2(u_level / frequency)` to the weight
|
|
63
|
+
* — large and positive for rare values, negative for common ones. Floored at
|
|
64
|
+
* {@link TermFrequencyAdjustment.minimumFrequency} so an ultra-rare value can't produce an unbounded boost.
|
|
65
|
+
*/
|
|
66
|
+
export interface TermFrequencyAdjustment<R> {
|
|
67
|
+
/** Relative frequency of a value in the data, in (0, 1]. Typically computed on-the-fly. */
|
|
68
|
+
frequency(value: string): number
|
|
69
|
+
/** The level indices the adjustment applies to (typically just the exact level). */
|
|
70
|
+
levels: ReadonlySet<number>
|
|
71
|
+
/** The agreeing value to look up for a pair (a normalized field value), or null to skip. */
|
|
72
|
+
value(a: R, b: R): string | null | undefined
|
|
73
|
+
/** Scale the adjustment in [0, 1]. Default 1. */
|
|
74
|
+
weight?: number
|
|
75
|
+
/** Floor for the looked-up frequency, bounding the boost on ultra-rare values. Default 1e-4. */
|
|
76
|
+
minimumFrequency?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A Fellegi-Sunter model: the field comparisons plus the prior match rate `λ`. */
|
|
80
|
+
export interface FellegiSunterModel<R> {
|
|
81
|
+
comparisons: Comparison<R>[]
|
|
82
|
+
/** Prior probability that two records drawn at random are a match. */
|
|
83
|
+
lambda: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The scored outcome for one record pair. */
|
|
87
|
+
export interface PairScore {
|
|
88
|
+
/** Total match weight in bits (`log2` odds). */
|
|
89
|
+
weight: number
|
|
90
|
+
/** Match probability in [0, 1]. */
|
|
91
|
+
probability: number
|
|
92
|
+
/** Per-field breakdown — what drove the score. */
|
|
93
|
+
contributions: Array<{ name: string; level: string | null; weight: number }>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The terminal decision for a pair under upper / lower match-weight thresholds. */
|
|
97
|
+
export type MatchDecision = "match" | "review" | "non-match"
|
|
98
|
+
|
|
99
|
+
/** The Bayes-factor weight of a single level, in bits: `log2(m / u)`. */
|
|
100
|
+
export function levelWeight(level: ComparisonLevel): number {
|
|
101
|
+
if (level.u <= 0) return level.m > 0 ? Infinity : 0
|
|
102
|
+
|
|
103
|
+
return Math.log2(level.m / level.u)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The prior match weight in bits: `log2(λ / (1 - λ))`. */
|
|
107
|
+
export function priorWeight(lambda: number): number {
|
|
108
|
+
if (lambda <= 0) return -Infinity
|
|
109
|
+
|
|
110
|
+
if (lambda >= 1) return Infinity
|
|
111
|
+
|
|
112
|
+
return Math.log2(lambda / (1 - lambda))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Convert a total match weight (bits) to a probability, numerically stable for extreme weights. */
|
|
116
|
+
export function probabilityFromWeight(weight: number): number {
|
|
117
|
+
return 1 / (1 + 2 ** -weight)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A comparison driven by a similarity function and a tier of `minSimilarity` thresholds (the StatCan/Splink recipe).
|
|
122
|
+
* Levels must be ordered highest → lowest similarity, the last acting as the `different` catch-all (`minSimilarity` 0).
|
|
123
|
+
* A missing value on either side yields no evidence.
|
|
124
|
+
*/
|
|
125
|
+
export function similarityComparison<R>(config: {
|
|
126
|
+
name: string
|
|
127
|
+
extract: (record: R) => string | null | undefined
|
|
128
|
+
/** Defaults to {@link nameSimilarity}. */
|
|
129
|
+
similarity?: (a: string, b: string) => number
|
|
130
|
+
levels: ComparisonLevel[]
|
|
131
|
+
}): Comparison<R> {
|
|
132
|
+
const similarity = config.similarity ?? nameSimilarity
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
name: config.name,
|
|
136
|
+
levels: config.levels,
|
|
137
|
+
assess(a, b) {
|
|
138
|
+
const va = config.extract(a)
|
|
139
|
+
const vb = config.extract(b)
|
|
140
|
+
|
|
141
|
+
if (!va || !vb || !va.trim() || !vb.trim()) return -1
|
|
142
|
+
|
|
143
|
+
const sim = similarity(va, vb)
|
|
144
|
+
|
|
145
|
+
for (let i = 0; i < config.levels.length; i++) {
|
|
146
|
+
if (sim >= (config.levels[i]!.minSimilarity ?? 0)) return i
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return config.levels.length - 1
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Score a record pair: total match weight, probability, and the per-field contributions. */
|
|
155
|
+
export function scorePair<R>(model: FellegiSunterModel<R>, a: R, b: R): PairScore {
|
|
156
|
+
let weight = priorWeight(model.lambda)
|
|
157
|
+
const contributions: PairScore["contributions"] = []
|
|
158
|
+
|
|
159
|
+
for (const comparison of model.comparisons) {
|
|
160
|
+
const index = comparison.assess(a, b)
|
|
161
|
+
|
|
162
|
+
if (index < 0) {
|
|
163
|
+
contributions.push({ name: comparison.name, level: null, weight: 0 })
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
const level = comparison.levels[index]!
|
|
167
|
+
let w = levelWeight(level)
|
|
168
|
+
|
|
169
|
+
// Term-frequency adjustment: swap the level's average u for the agreeing value's own frequency.
|
|
170
|
+
const tf = comparison.termFrequency
|
|
171
|
+
|
|
172
|
+
if (tf && tf.levels.has(index) && level.u > 0) {
|
|
173
|
+
const value = tf.value(a, b)
|
|
174
|
+
|
|
175
|
+
if (value) {
|
|
176
|
+
const frequency = Math.max(tf.frequency(value), tf.minimumFrequency ?? 1e-4)
|
|
177
|
+
|
|
178
|
+
if (frequency > 0) {
|
|
179
|
+
w += Math.log2(level.u / frequency) * (tf.weight ?? 1)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
weight += w
|
|
185
|
+
contributions.push({ name: comparison.name, level: level.label, weight: w })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { weight, probability: probabilityFromWeight(weight), contributions }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Classify a score against upper / lower match-weight thresholds (in bits): at or above `upper` is a link, at or below
|
|
193
|
+
* `lower` a non-link, and the band between is clerical review (abstain).
|
|
194
|
+
*/
|
|
195
|
+
export function decide(score: PairScore, thresholds: { upper: number; lower: number }): MatchDecision {
|
|
196
|
+
if (score.weight >= thresholds.upper) return "match"
|
|
197
|
+
|
|
198
|
+
if (score.weight <= thresholds.lower) return "non-match"
|
|
199
|
+
|
|
200
|
+
return "review"
|
|
201
|
+
}
|
package/gbt.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Gradient-boosted shallow regression trees (logistic loss), pure-Node — the learned scorer #603
|
|
7
|
+
* names: an offline-trained model (this trainer, or XGBoost/LightGBM exported to the same
|
|
8
|
+
* {@link GBT} shape) plus a trivial evaluator, no new runtime dependency. It sits behind the
|
|
9
|
+
* matcher's `scorer` hook to replace the Fellegi-Sunter link weight where labels (or a held-out
|
|
10
|
+
* truth like an NPI) let a tree learn the over-merge signature the hand-weights miss.
|
|
11
|
+
*
|
|
12
|
+
* This module is feature-agnostic: feature vectors are caller-defined `number[]` (the record
|
|
13
|
+
* matcher builds them in `@mailwoman/registry`'s learned-scorer module — one-hot agreement
|
|
14
|
+
* levels
|
|
15
|
+
*
|
|
16
|
+
* - Interaction terms + corpus statistics). It only fits ({@link trainGBT}) and scores
|
|
17
|
+
* ({@link gbtScore}). The trained {@link GBT} is plain JSON (`{trees, lr, base}`), so a model
|
|
18
|
+
* trains offline once and ships as a data file.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A trained tree: an internal split (feature `f` ≤ `thr` → `lo`, else `hi`) or a `leaf` value. */
|
|
22
|
+
export type TreeNode = { leaf: number } | { f: number; thr: number; lo: TreeNode; hi: TreeNode }
|
|
23
|
+
|
|
24
|
+
const sigmoid = (z: number): number => 1 / (1 + Math.exp(-Math.max(-30, Math.min(30, z))))
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Per-feature candidate split thresholds: midpoints for few-valued/binary features, quantiles for continuous.
|
|
28
|
+
*/
|
|
29
|
+
export function buildThresholds(X: number[][]): number[][] {
|
|
30
|
+
const dim = X[0]?.length ?? 0
|
|
31
|
+
const out: number[][] = []
|
|
32
|
+
|
|
33
|
+
for (let f = 0; f < dim; f++) {
|
|
34
|
+
const vals = X.map((r) => r[f]!)
|
|
35
|
+
const uniq = [...new Set(vals)].sort((p, q) => p - q)
|
|
36
|
+
|
|
37
|
+
if (uniq.length <= 1) {
|
|
38
|
+
out.push([])
|
|
39
|
+
} else if (uniq.length <= 5) {
|
|
40
|
+
const t: number[] = []
|
|
41
|
+
|
|
42
|
+
for (let k = 0; k < uniq.length - 1; k++) {
|
|
43
|
+
t.push((uniq[k]! + uniq[k + 1]!) / 2)
|
|
44
|
+
}
|
|
45
|
+
out.push(t)
|
|
46
|
+
} else {
|
|
47
|
+
const sorted = [...vals].sort((p, q) => p - q)
|
|
48
|
+
const t: number[] = []
|
|
49
|
+
|
|
50
|
+
for (let q = 1; q <= 6; q++) {
|
|
51
|
+
t.push(sorted[Math.floor((q / 7) * (sorted.length - 1))]!)
|
|
52
|
+
}
|
|
53
|
+
out.push([...new Set(t)])
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Weighted SSE of target `g` over `rows` around their weighted mean. */
|
|
61
|
+
function nodeSSE(rows: number[], g: number[], w: number[]): number {
|
|
62
|
+
let wsum = 0
|
|
63
|
+
let wg = 0
|
|
64
|
+
|
|
65
|
+
for (const i of rows) {
|
|
66
|
+
wsum += w[i]!
|
|
67
|
+
wg += w[i]! * g[i]!
|
|
68
|
+
}
|
|
69
|
+
const mean = wsum > 0 ? wg / wsum : 0
|
|
70
|
+
let sse = 0
|
|
71
|
+
|
|
72
|
+
for (const i of rows) {
|
|
73
|
+
const d = g[i]! - mean
|
|
74
|
+
sse += w[i]! * d * d
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return sse
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Greedy depth-limited weighted regression tree on target `g` (the boosting residual). */
|
|
81
|
+
function fitRegTree(
|
|
82
|
+
rows: number[],
|
|
83
|
+
X: number[][],
|
|
84
|
+
g: number[],
|
|
85
|
+
w: number[],
|
|
86
|
+
thresholds: number[][],
|
|
87
|
+
depth: number,
|
|
88
|
+
minLeaf: number
|
|
89
|
+
): TreeNode {
|
|
90
|
+
let wsum = 0
|
|
91
|
+
let wg = 0
|
|
92
|
+
|
|
93
|
+
for (const i of rows) {
|
|
94
|
+
wsum += w[i]!
|
|
95
|
+
wg += w[i]! * g[i]!
|
|
96
|
+
}
|
|
97
|
+
const leaf = wsum > 0 ? wg / wsum : 0
|
|
98
|
+
|
|
99
|
+
if (depth === 0 || rows.length < 2 * minLeaf) return { leaf }
|
|
100
|
+
const parentSSE = nodeSSE(rows, g, w)
|
|
101
|
+
let bestGain = 1e-12
|
|
102
|
+
let bestF = -1
|
|
103
|
+
let bestThr = 0
|
|
104
|
+
let bestLo: number[] = []
|
|
105
|
+
let bestHi: number[] = []
|
|
106
|
+
|
|
107
|
+
for (let f = 0; f < thresholds.length; f++) {
|
|
108
|
+
for (const thr of thresholds[f]!) {
|
|
109
|
+
const lo: number[] = []
|
|
110
|
+
const hi: number[] = []
|
|
111
|
+
|
|
112
|
+
for (const i of rows) {
|
|
113
|
+
;(X[i]![f]! <= thr ? lo : hi).push(i)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (lo.length < minLeaf || hi.length < minLeaf) continue
|
|
117
|
+
const gain = parentSSE - (nodeSSE(lo, g, w) + nodeSSE(hi, g, w))
|
|
118
|
+
|
|
119
|
+
if (gain > bestGain) {
|
|
120
|
+
bestGain = gain
|
|
121
|
+
bestF = f
|
|
122
|
+
bestThr = thr
|
|
123
|
+
bestLo = lo
|
|
124
|
+
bestHi = hi
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (bestF < 0) return { leaf }
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
f: bestF,
|
|
133
|
+
thr: bestThr,
|
|
134
|
+
lo: fitRegTree(bestLo, X, g, w, thresholds, depth - 1, minLeaf),
|
|
135
|
+
hi: fitRegTree(bestHi, X, g, w, thresholds, depth - 1, minLeaf),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function predictTree(t: TreeNode, x: number[]): number {
|
|
140
|
+
let n = t
|
|
141
|
+
|
|
142
|
+
while ("f" in n) {
|
|
143
|
+
n = x[n.f]! <= n.thr ? n.lo : n.hi
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return n.leaf
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A trained gradient-boosted-tree model: an additive ensemble over a base log-odds. Plain JSON. */
|
|
150
|
+
export interface GBT {
|
|
151
|
+
trees: TreeNode[]
|
|
152
|
+
lr: number
|
|
153
|
+
base: number
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Hyperparameters for {@link trainGBT}. */
|
|
157
|
+
export interface GBTOpts {
|
|
158
|
+
rounds: number
|
|
159
|
+
depth: number
|
|
160
|
+
lr: number
|
|
161
|
+
minLeaf: number
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Gradient-boosted regression trees on logistic loss, with per-sample class weights `w`. */
|
|
165
|
+
export function trainGBT(X: number[][], y: number[], w: number[], opts: GBTOpts): GBT {
|
|
166
|
+
const N = X.length
|
|
167
|
+
const thresholds = buildThresholds(X)
|
|
168
|
+
const rowsAll = Array.from({ length: N }, (_, i) => i)
|
|
169
|
+
let wpos = 0
|
|
170
|
+
let wtot = 0
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < N; i++) {
|
|
173
|
+
wtot += w[i]!
|
|
174
|
+
|
|
175
|
+
if (y[i] === 1) {
|
|
176
|
+
wpos += w[i]!
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const base = Math.log((wpos + 1) / (wtot - wpos + 1)) // weighted base log-odds
|
|
180
|
+
const F = new Array<number>(N).fill(base)
|
|
181
|
+
const trees: TreeNode[] = []
|
|
182
|
+
|
|
183
|
+
for (let m = 0; m < opts.rounds; m++) {
|
|
184
|
+
const g = new Array<number>(N)
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < N; i++) {
|
|
187
|
+
g[i] = y[i]! - sigmoid(F[i]!)
|
|
188
|
+
} // negative gradient of logistic loss
|
|
189
|
+
const tree = fitRegTree(rowsAll, X, g, w, thresholds, opts.depth, opts.minLeaf)
|
|
190
|
+
|
|
191
|
+
for (let i = 0; i < N; i++) {
|
|
192
|
+
F[i]! += opts.lr * predictTree(tree, X[i]!)
|
|
193
|
+
}
|
|
194
|
+
trees.push(tree)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { trees, lr: opts.lr, base }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** GBT score (logit) for one feature vector. Threshold-comparable like the FS weight. */
|
|
201
|
+
export function gbtScore(m: GBT, x: number[]): number {
|
|
202
|
+
let f = m.base
|
|
203
|
+
|
|
204
|
+
for (const t of m.trees) {
|
|
205
|
+
f += m.lr * predictTree(t, x)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return f
|
|
209
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `@mailwoman/match` — the geocode-first record matcher: block → score → cluster.
|
|
7
|
+
*
|
|
8
|
+
* The full three-stage pipeline:
|
|
9
|
+
*
|
|
10
|
+
* 1. {@link block Block} — geo-first candidate generation (a spatial-cell union of cheap, high-recall
|
|
11
|
+
* keys), so two records at the same place meet regardless of address spelling.
|
|
12
|
+
* 2. **Score** — string {@link jaroWinkler comparators} → the {@link scorePair Fellegi-Sunter} weight
|
|
13
|
+
* model (agreement levels → `log2(m/u)` weights → probability → link / review / non-link),
|
|
14
|
+
* with `m`/`u` learned label-free by {@link estimateParameters EM} and rare-value agreement
|
|
15
|
+
* up-weighted by {@link withTermFrequency term frequency}.
|
|
16
|
+
* 3. {@link cluster Cluster} — resolve the non-transitive pairwise link graph into canonical entities.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export * from "./blocking.ts"
|
|
20
|
+
export * from "./clustering.ts"
|
|
21
|
+
export * from "./comparators.ts"
|
|
22
|
+
export * from "./distance.ts"
|
|
23
|
+
export * from "./em.ts"
|
|
24
|
+
export * from "./fellegi-sunter.ts"
|
|
25
|
+
export * from "./gbt.ts"
|
|
26
|
+
export * from "./tf.ts"
|