@mark1russell7/rigor-statistics 0.1.28 → 0.2.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.
package/build/index.js CHANGED
@@ -3842,9 +3842,29 @@ function ratingToStrength(rating, options = {}) {
3842
3842
  * Ties use the Davidson (1970) model, or count as half a win and half a loss. The fit uses Newton steps with a line search on the negative log posterior. This function is
3843
3843
  * convex, so the steps go to its one minimum.
3844
3844
  */
3845
- const ARMIJO = 1e-4;
3846
- const HALVINGS = 60;
3847
- const ROUNDING = 64 * Number.EPSILON;
3845
+ /**
3846
+ * This function gives the start of a fit: the log-strengths, and the log of the tie parameter. It throws a RangeError
3847
+ * for a start with the wrong number of items. It also throws for a log-strength that is not finite, or a tie parameter
3848
+ * of 0 or less.
3849
+ */
3850
+ function startOf(name, items, priorMean, start) {
3851
+ if (start === void 0) return {
3852
+ strengths: new Float64Array(items).fill(priorMean),
3853
+ logTie: 0
3854
+ };
3855
+ if (start.strengths.length !== items) throw new RangeError(`${name}: the start has ${start.strengths.length} log-strengths, and the fit has ${items} items.`);
3856
+ const strengths = Float64Array.from(start.strengths);
3857
+ for (const [i, value] of strengths.entries()) if (!Number.isFinite(value)) throw new RangeError(`${name}: the start log-strength of item ${i} is a finite number, not ${start.strengths[i]}.`);
3858
+ const tie = start.tie ?? 1;
3859
+ if (!(tie > 0 && Number.isFinite(tie))) throw new RangeError(`${name}: the start tie parameter is a finite number above 0, not ${tie}.`);
3860
+ return {
3861
+ strengths,
3862
+ logTie: Math.log(tie)
3863
+ };
3864
+ }
3865
+ const ARMIJO$1 = 1e-4;
3866
+ const HALVINGS$1 = 60;
3867
+ const ROUNDING$1 = 64 * Number.EPSILON;
3848
3868
  function checkedTerms(n, comparisons, ties) {
3849
3869
  const terms = [];
3850
3870
  for (const [i, comparison] of comparisons.entries()) {
@@ -3866,7 +3886,7 @@ function checkedTerms(n, comparisons, ties) {
3866
3886
  }
3867
3887
  return terms;
3868
3888
  }
3869
- function positiveOption(label, value) {
3889
+ function positiveOption$1(label, value) {
3870
3890
  if (!(value > 0 && Number.isFinite(value))) throw new RangeError(`bradleyTerry: ${label} is a finite number above 0, not ${value}.`);
3871
3891
  }
3872
3892
  /**
@@ -3883,11 +3903,12 @@ function bradleyTerry(items, comparisons, options = {}) {
3883
3903
  const maxIterations = options.maxIterations ?? 100;
3884
3904
  const tolerance = options.tolerance ?? 1e-10;
3885
3905
  if (!Number.isFinite(priorMean)) throw new RangeError(`bradleyTerry: the prior mean is a finite number, not ${priorMean}.`);
3886
- positiveOption("the prior SD", priorSd);
3887
- positiveOption("the tie prior SD", tiePriorSd);
3888
- positiveOption("the tolerance", tolerance);
3906
+ positiveOption$1("the prior SD", priorSd);
3907
+ positiveOption$1("the tie prior SD", tiePriorSd);
3908
+ positiveOption$1("the tolerance", tolerance);
3889
3909
  if (ties !== "davidson" && ties !== "half") throw new RangeError(`bradleyTerry: the tie model is "davidson" or "half", not ${String(ties)}.`);
3890
3910
  if (!Number.isSafeInteger(maxIterations) || maxIterations < 1) throw new RangeError(`bradleyTerry: maxIterations is a whole number of 1 or more, not ${maxIterations}.`);
3911
+ const begin = startOf("bradleyTerry", items, priorMean, options.start);
3891
3912
  const terms = checkedTerms(items, comparisons, ties);
3892
3913
  const fitTie = terms.some((t) => t.tie === 1);
3893
3914
  const k = fitTie ? items + 1 : items;
@@ -3899,8 +3920,9 @@ function bradleyTerry(items, comparisons, options = {}) {
3899
3920
  priorPrecision: 1 / (priorSd * priorSd),
3900
3921
  tiePrecision: 1 / (tiePriorSd * tiePriorSd)
3901
3922
  };
3902
- const x = new Float64Array(k).fill(priorMean);
3903
- if (fitTie) x[items] = 0;
3923
+ const x = new Float64Array(k);
3924
+ x.set(begin.strengths);
3925
+ if (fitTie) x[items] = begin.logTie;
3904
3926
  let iterations = 0;
3905
3927
  let converged = false;
3906
3928
  let value = objective(model, x);
@@ -3913,8 +3935,8 @@ function bradleyTerry(items, comparisons, options = {}) {
3913
3935
  let t = 1;
3914
3936
  let next = moved(x, step, t);
3915
3937
  let nextValue = objective(model, next);
3916
- const slack = ROUNDING * (Math.abs(value) + 1);
3917
- for (let h = 0; h < HALVINGS && !(nextValue <= value + ARMIJO * t * slope + slack); h++) {
3938
+ const slack = ROUNDING$1 * (Math.abs(value) + 1);
3939
+ for (let h = 0; h < HALVINGS$1 && !(nextValue <= value + ARMIJO$1 * t * slope + slack); h++) {
3918
3940
  t /= 2;
3919
3941
  next = moved(x, step, t);
3920
3942
  nextValue = objective(model, next);
@@ -4056,6 +4078,652 @@ function inverseDiagonal(hessian, k) {
4056
4078
  }
4057
4079
  return out;
4058
4080
  }
4059
- export { EMPTY_RUNNING, KENDALL_EXACT, RESPONSE_LAMBDAS, RESPONSE_MAX_POWER, RESPONSE_MAX_TERMS, RESPONSE_RECORDS_SCHEMA, SIGN_FLIP_EXACT_LIMIT, SIGN_FLIP_EXACT_MAX, U_STATISTIC_TIGHT_MIN_SAMPLE, Z95, addToRunning, bestSmoothLaw, binomialInterval, binomialTest, bootstrap, bootstrapClusters, bootstrapLeastSquares, bootstrapMedian, bootstrapMedianRatio, bootstrapStrata, bootstrapTwo, bradleyTerry, brier, calibration, cohenKappa, compareRatios, coverageMinSample, eloExpected, eloUpdate, fitResponse, glicko2Idle, glicko2Update, heldOutError, heldOutErrorAt, holm, kendallTau, leastSquares, logLoss, mean, median, medianInterval, mergeRunning, modeCount, negativeBinomialInterval, noiseFloor, noiseVerdict, normalCdf, normalQuantile, optionFloor, pairProbabilities, pairedPermutation, parseResponseRecord, partialConjunction, predictResponse, quantile, quantileInterval, quantileSamples, quantileSorted, ranks, ratingToStrength, responseCells, ridgeSolve, runningMean, runningOf, runningSd, sampleTotalInterval, sd, signFlipTest, signTest, signTestPower, sliceResponse, smoothLawScores, sobolIndices, sorted, spearman, strengthToRating, summarize, surfaceResponse, tQuantile, twoSamplePermutation, uStatisticInterval, whenHolds, wilson, wilsonSampleSize };
4081
+ // src/bradley-terry-sparse.ts
4082
+ /**
4083
+ * The sparse Bradley-Terry fit. It has the model, the prior and the tie model of `bradleyTerry`, so the two fits have
4084
+ * one optimum. This fit keeps one record for each distinct pair of items, not a matrix of all the items. Thus its
4085
+ * memory is in proportion to the items and the pairs. The dense fit of 10,000 items keeps a Hessian of 800 MB.
4086
+ *
4087
+ * The fit uses Newton steps on the negative log posterior, as `bradleyTerry` does. It does not factor the Hessian.
4088
+ * Preconditioned conjugate gradients solve each Newton system, and the diagonal of the Hessian is the preconditioner.
4089
+ * One product of the Hessian and a vector reads each pair one time.
4090
+ *
4091
+ * The prior makes the objective strongly convex, so the Hessian is positive definite. Thus each step of the solve
4092
+ * gives a direction that goes down. The solve is loose far from the minimum and tight near it (an inexact Newton
4093
+ * method). A line search with the Armijo rule makes the objective smaller at each step. The steps go to the one
4094
+ * minimum, and the fit stops when the gradient puts each parameter inside the tolerance of that minimum.
4095
+ */
4096
+ /** The largest number of items. The key of a pair is `low * items + high`, and it must be a safe integer. */
4097
+ const MAX_ITEMS = Math.floor(Math.sqrt(Number.MAX_SAFE_INTEGER));
4098
+ const ROUNDING = 64 * Number.EPSILON;
4099
+ const NAME = "bradleyTerrySparse";
4100
+ /** The Armijo constant of the line search: a step gives at least this part of the decrease that the slope forecasts. */
4101
+ const ARMIJO = 1e-4;
4102
+ /** The largest number of halvings of a step in the line search. */
4103
+ const HALVINGS = 60;
4104
+ /** The largest number of conjugate-gradient steps for one Newton system. */
4105
+ const SOLVE_LIMIT = 500;
4106
+ function positiveOption(label, value) {
4107
+ if (!(value > 0 && Number.isFinite(value))) throw new RangeError(`${NAME}: ${label} is a finite number above 0, not ${value}.`);
4108
+ }
4109
+ /** This function checks the comparisons, and sums them for each distinct pair. A comparison with the weight 0 adds nothing. */
4110
+ function pairsOf(items, comparisons, ties) {
4111
+ const index = /* @__PURE__ */ new Map();
4112
+ const low = [];
4113
+ const high = [];
4114
+ const total = [];
4115
+ const net = [];
4116
+ const tie = [];
4117
+ for (const [i, comparison] of comparisons.entries()) {
4118
+ const { a, b, outcome } = comparison;
4119
+ const w = comparison.weight ?? 1;
4120
+ for (const item of [a, b]) if (!Number.isSafeInteger(item) || item < 0 || item >= items) throw new RangeError(`${NAME}: comparison ${i} has the index ${item}, and the indexes are whole numbers from 0 to ${items - 1}.`);
4121
+ if (a === b) throw new RangeError(`${NAME}: comparison ${i} compares the item ${a} with itself.`);
4122
+ if (outcome !== "a" && outcome !== "b" && outcome !== "tie") throw new RangeError(`${NAME}: comparison ${i} has the outcome ${String(outcome)}.`);
4123
+ if (!(w >= 0 && Number.isFinite(w))) throw new RangeError(`${NAME}: comparison ${i} has the weight ${w}, and a weight is a finite number of 0 or more.`);
4124
+ if (w === 0) continue;
4125
+ const lo = Math.min(a, b);
4126
+ const hi = Math.max(a, b);
4127
+ const key = lo * items + hi;
4128
+ let p = index.get(key);
4129
+ if (p === void 0) {
4130
+ p = low.length;
4131
+ index.set(key, p);
4132
+ low.push(lo);
4133
+ high.push(hi);
4134
+ total.push(0);
4135
+ net.push(0);
4136
+ tie.push(0);
4137
+ }
4138
+ total[p] = total[p] + w;
4139
+ if (outcome === "tie") {
4140
+ if (ties === "davidson") tie[p] = tie[p] + w;
4141
+ } else {
4142
+ const winner = outcome === "a" ? a : b;
4143
+ net[p] = net[p] + (winner === lo ? w : -w);
4144
+ }
4145
+ }
4146
+ return {
4147
+ count: low.length,
4148
+ low: Int32Array.from(low),
4149
+ high: Int32Array.from(high),
4150
+ total: Float64Array.from(total),
4151
+ net: Float64Array.from(net),
4152
+ ties: Float64Array.from(tie)
4153
+ };
4154
+ }
4155
+ /** The connected groups of the items: the group of each item, and the size of each group. */
4156
+ function groupsOf(items, pairs) {
4157
+ const parent = new Int32Array(items);
4158
+ for (let i = 0; i < items; i++) parent[i] = i;
4159
+ const root = (i) => {
4160
+ let r = i;
4161
+ while (parent[r] !== r) r = parent[r];
4162
+ for (let j = i; parent[j] !== r;) {
4163
+ const next = parent[j];
4164
+ parent[j] = r;
4165
+ j = next;
4166
+ }
4167
+ return r;
4168
+ };
4169
+ for (let p = 0; p < pairs.count; p++) {
4170
+ const x = root(pairs.low[p]);
4171
+ const y = root(pairs.high[p]);
4172
+ if (x !== y) parent[Math.max(x, y)] = Math.min(x, y);
4173
+ }
4174
+ const group = new Int32Array(items).fill(-1);
4175
+ const ids = new Int32Array(items).fill(-1);
4176
+ let count = 0;
4177
+ for (let i = 0; i < items; i++) {
4178
+ const r = root(i);
4179
+ if (ids[r] === -1) ids[r] = count++;
4180
+ group[i] = ids[r];
4181
+ }
4182
+ const size = new Float64Array(count);
4183
+ for (let i = 0; i < items; i++) size[group[i]] = size[group[i]] + 1;
4184
+ return {
4185
+ group,
4186
+ size,
4187
+ count
4188
+ };
4189
+ }
4190
+ /**
4191
+ * The probabilities of the three outcomes of a pair with the difference `d` of the log-strengths, and the log of the
4192
+ * denominator `exp(d / 2) + exp(-d / 2) + nu`. The function writes them into `out`, so the loops make no objects.
4193
+ */
4194
+ function outcomes(d, logNu, out) {
4195
+ const half = d / 2;
4196
+ const top = Math.max(Math.abs(half), logNu);
4197
+ const ea = Math.exp(half - top);
4198
+ const eb = Math.exp(-half - top);
4199
+ const et = Math.exp(logNu - top);
4200
+ const sum = ea + eb + et;
4201
+ out[0] = ea / sum;
4202
+ out[1] = eb / sum;
4203
+ out[2] = et / sum;
4204
+ out[3] = top + Math.log(sum);
4205
+ }
4206
+ /** This function makes an empty point for `items` items and `count` pairs. */
4207
+ function pointOf(items, count) {
4208
+ return {
4209
+ s: new Float64Array(items),
4210
+ lambda: 0,
4211
+ value: 0,
4212
+ logLikelihood: 0,
4213
+ gradient: new Float64Array(items),
4214
+ tieGradient: 0,
4215
+ norm: 0,
4216
+ curvature: new Float64Array(count),
4217
+ diagonal: new Float64Array(items),
4218
+ cross: new Float64Array(items),
4219
+ tieCurvature: 0
4220
+ };
4221
+ }
4222
+ /** This function writes the objective, the gradient and the Hessian at the parameters of `point` into `point`. */
4223
+ function evaluate(problem, point, out) {
4224
+ const { items, pairs, fitTie, priorMean, p, tau, tieWeight } = problem;
4225
+ const { s, gradient, curvature, diagonal, cross, lambda } = point;
4226
+ const logNu = fitTie ? lambda : Number.NEGATIVE_INFINITY;
4227
+ let prior = 0;
4228
+ for (let i = 0; i < items; i++) {
4229
+ const offset = s[i] - priorMean;
4230
+ prior += offset * offset;
4231
+ gradient[i] = p * offset;
4232
+ diagonal[i] = p;
4233
+ cross[i] = 0;
4234
+ }
4235
+ let logLikelihood = fitTie ? tieWeight * lambda : 0;
4236
+ let tieGradient = fitTie ? tau * lambda - tieWeight : 0;
4237
+ let tieCurvature = fitTie ? tau : 0;
4238
+ for (let q = 0; q < pairs.count; q++) {
4239
+ const lo = pairs.low[q];
4240
+ const hi = pairs.high[q];
4241
+ const total = pairs.total[q];
4242
+ const net = pairs.net[q];
4243
+ const d = s[lo] - s[hi];
4244
+ outcomes(d, logNu, out);
4245
+ const pa = out[0];
4246
+ const pb = out[1];
4247
+ const pt = out[2];
4248
+ const half = (pa - pb) / 2;
4249
+ logLikelihood += net / 2 * d - total * out[3];
4250
+ const g = total * half - net / 2;
4251
+ gradient[lo] = gradient[lo] + g;
4252
+ gradient[hi] = gradient[hi] - g;
4253
+ const c = total * (pa * pb + pt * (1 - pt) / 4);
4254
+ curvature[q] = c;
4255
+ diagonal[lo] = diagonal[lo] + c;
4256
+ diagonal[hi] = diagonal[hi] + c;
4257
+ if (fitTie) {
4258
+ tieGradient += total * pt;
4259
+ tieCurvature += total * pt * (1 - pt);
4260
+ const b = -total * pt * half;
4261
+ cross[lo] = cross[lo] + b;
4262
+ cross[hi] = cross[hi] - b;
4263
+ }
4264
+ }
4265
+ let norm = tieGradient * tieGradient;
4266
+ for (let i = 0; i < items; i++) norm += gradient[i] ** 2;
4267
+ point.value = p * prior / 2 + (fitTie ? tau * lambda * lambda / 2 : 0) - logLikelihood;
4268
+ point.logLikelihood = logLikelihood;
4269
+ point.tieGradient = tieGradient;
4270
+ point.tieCurvature = tieCurvature;
4271
+ point.norm = Math.sqrt(norm);
4272
+ }
4273
+ /**
4274
+ * This function writes the product of the Hessian at `point` and the vector (`v`, `vTie`) into `out`. It gives the
4275
+ * tie part of the product. The product reads each pair one time, and it makes no arrays.
4276
+ */
4277
+ function multiply(problem, point, v, vTie, out) {
4278
+ const { items, pairs, fitTie, p } = problem;
4279
+ const { curvature, cross } = point;
4280
+ for (let i = 0; i < items; i++) out[i] = p * v[i] + cross[i] * vTie;
4281
+ for (let q = 0; q < pairs.count; q++) {
4282
+ const lo = pairs.low[q];
4283
+ const hi = pairs.high[q];
4284
+ const t = curvature[q] * (v[lo] - v[hi]);
4285
+ out[lo] = out[lo] + t;
4286
+ out[hi] = out[hi] - t;
4287
+ }
4288
+ if (!fitTie) return 0;
4289
+ let tie = point.tieCurvature * vTie;
4290
+ for (let i = 0; i < items; i++) tie += cross[i] * v[i];
4291
+ return tie;
4292
+ }
4293
+ /**
4294
+ * This function solves the Newton system `H x = -g` at `point` with preconditioned conjugate gradients. It writes the
4295
+ * log-strength part of `x` into `step`, and it gives the tie part. The solve stops when the norm of the residual is
4296
+ * `forcing` times the norm of the gradient or less. The solve starts at 0, so each step of it gives a descent direction.
4297
+ */
4298
+ function newtonStep(problem, point, forcing, step, work) {
4299
+ const { items, fitTie } = problem;
4300
+ const { residual: r, scaled: z, direction: d, product: ad } = work;
4301
+ const { gradient, diagonal } = point;
4302
+ let rz = 0;
4303
+ for (let i = 0; i < items; i++) {
4304
+ const ri = -gradient[i];
4305
+ const zi = ri / diagonal[i];
4306
+ r[i] = ri;
4307
+ z[i] = zi;
4308
+ d[i] = zi;
4309
+ rz += ri * zi;
4310
+ }
4311
+ let rTie = fitTie ? -point.tieGradient : 0;
4312
+ let dTie = fitTie ? rTie / point.tieCurvature : 0;
4313
+ rz += rTie * dTie;
4314
+ step.fill(0);
4315
+ let stepTie = 0;
4316
+ const target = forcing * point.norm;
4317
+ const limit = Math.min(SOLVE_LIMIT, items + 1);
4318
+ for (let k = 0; k < limit; k++) {
4319
+ const adTie = multiply(problem, point, d, dTie, ad);
4320
+ let dad = dTie * adTie;
4321
+ for (let i = 0; i < items; i++) dad += d[i] * ad[i];
4322
+ if (!(dad > 0)) break;
4323
+ const alpha = rz / dad;
4324
+ let residual = 0;
4325
+ for (let i = 0; i < items; i++) {
4326
+ step[i] = step[i] + alpha * d[i];
4327
+ const ri = r[i] - alpha * ad[i];
4328
+ r[i] = ri;
4329
+ residual += ri * ri;
4330
+ }
4331
+ stepTie += alpha * dTie;
4332
+ rTie -= alpha * adTie;
4333
+ residual += rTie * rTie;
4334
+ if (Math.sqrt(residual) <= target) break;
4335
+ let next = 0;
4336
+ for (let i = 0; i < items; i++) {
4337
+ const zi = r[i] / diagonal[i];
4338
+ z[i] = zi;
4339
+ next += r[i] * zi;
4340
+ }
4341
+ const zTie = fitTie ? rTie / point.tieCurvature : 0;
4342
+ next += rTie * zTie;
4343
+ const beta = next / rz;
4344
+ rz = next;
4345
+ for (let i = 0; i < items; i++) d[i] = z[i] + beta * d[i];
4346
+ dTie = zTie + beta * dTie;
4347
+ }
4348
+ return stepTie;
4349
+ }
4350
+ /**
4351
+ * The sparse Bradley-Terry fit of `comparisons` among `items` items. It has the model, the options and the checks of
4352
+ * `bradleyTerry`, and its memory is in proportion to `items` plus the distinct pairs. The function does not sort or
4353
+ * change its inputs. It throws a RangeError for a bad index, a bad outcome, a bad weight and a bad option.
4354
+ */
4355
+ function bradleyTerrySparse(items, comparisons, options = {}) {
4356
+ if (!Number.isSafeInteger(items) || items < 1 || items > MAX_ITEMS) throw new RangeError(`${NAME}: the number of items is a whole number from 1 to ${MAX_ITEMS}, not ${items}.`);
4357
+ const priorMean = options.priorMean ?? 0;
4358
+ const priorSd = options.priorSd ?? 1;
4359
+ const ties = options.ties ?? "davidson";
4360
+ const tiePriorSd = options.tiePriorSd ?? 10;
4361
+ const maxIterations = options.maxIterations ?? 100;
4362
+ const tolerance = options.tolerance ?? 1e-8;
4363
+ if (!Number.isFinite(priorMean)) throw new RangeError(`${NAME}: the prior mean is a finite number, not ${priorMean}.`);
4364
+ positiveOption("the prior SD", priorSd);
4365
+ positiveOption("the tie prior SD", tiePriorSd);
4366
+ positiveOption("the tolerance", tolerance);
4367
+ if (ties !== "davidson" && ties !== "half") throw new RangeError(`${NAME}: the tie model is "davidson" or "half", not ${String(ties)}.`);
4368
+ if (!Number.isSafeInteger(maxIterations) || maxIterations < 1) throw new RangeError(`${NAME}: maxIterations is a whole number of 1 or more, not ${maxIterations}.`);
4369
+ const begin = startOf(NAME, items, priorMean, options.start);
4370
+ const pairs = pairsOf(items, comparisons, ties);
4371
+ let tieWeight = 0;
4372
+ let totalWeight = 0;
4373
+ for (let q = 0; q < pairs.count; q++) {
4374
+ tieWeight += pairs.ties[q];
4375
+ totalWeight += pairs.total[q];
4376
+ }
4377
+ const p = 1 / (priorSd * priorSd);
4378
+ const tau = 1 / (tiePriorSd * tiePriorSd);
4379
+ const problem = {
4380
+ items,
4381
+ pairs,
4382
+ fitTie: tieWeight > 0,
4383
+ priorMean,
4384
+ p,
4385
+ tau,
4386
+ tieWeight
4387
+ };
4388
+ const smallest = problem.fitTie ? Math.min(p, tau) : p;
4389
+ const floor = 4 * Number.EPSILON * (totalWeight + 1);
4390
+ let current = pointOf(items, pairs.count);
4391
+ let trial = pointOf(items, pairs.count);
4392
+ current.s.set(begin.strengths);
4393
+ current.lambda = problem.fitTie ? begin.logTie : 0;
4394
+ const out = /* @__PURE__ */ new Float64Array(4);
4395
+ const step = new Float64Array(items);
4396
+ const work = {
4397
+ residual: new Float64Array(items),
4398
+ scaled: new Float64Array(items),
4399
+ direction: new Float64Array(items),
4400
+ product: new Float64Array(items)
4401
+ };
4402
+ evaluate(problem, current, out);
4403
+ let iterations = 0;
4404
+ let bound = current.norm / smallest;
4405
+ let rounded = current.norm <= floor;
4406
+ while (!(bound <= tolerance || rounded) && iterations < maxIterations) {
4407
+ iterations++;
4408
+ const forcing = Math.min(.5, Math.sqrt(current.norm));
4409
+ const stepTie = newtonStep(problem, current, forcing, step, work);
4410
+ let slope = stepTie * current.tieGradient;
4411
+ for (let i = 0; i < items; i++) slope += step[i] * current.gradient[i];
4412
+ const slack = ROUNDING * (Math.abs(current.value) + 1);
4413
+ let t = 1;
4414
+ let accepted = false;
4415
+ for (let h = 0; h <= HALVINGS && !accepted; h++) {
4416
+ for (let i = 0; i < items; i++) trial.s[i] = current.s[i] + t * step[i];
4417
+ trial.lambda = current.lambda + t * stepTie;
4418
+ evaluate(problem, trial, out);
4419
+ accepted = trial.value <= current.value + ARMIJO * t * slope + slack;
4420
+ if (!accepted) t /= 2;
4421
+ }
4422
+ if (!accepted) break;
4423
+ [current, trial] = [trial, current];
4424
+ bound = current.norm / smallest;
4425
+ rounded = current.norm <= floor;
4426
+ }
4427
+ const { group, size } = groupsOf(items, pairs);
4428
+ const se = Array.from(current.s, (_, i) => {
4429
+ const n = size[group[i]];
4430
+ return Math.sqrt(1 / (n * p) + (1 - 1 / n) / current.diagonal[i]);
4431
+ });
4432
+ return {
4433
+ strengths: Array.from(current.s),
4434
+ se,
4435
+ tie: problem.fitTie ? Math.exp(current.lambda) : 0,
4436
+ logLikelihood: current.logLikelihood,
4437
+ iterations,
4438
+ converged: bound <= tolerance || rounded,
4439
+ bound,
4440
+ pairs: pairs.count
4441
+ };
4442
+ }
4443
+ // src/rating-models.ts
4444
+ /**
4445
+ * The rating models of a log of picks. The Elo and the Glicko-2 replays read the picks in their order. The
4446
+ * Bradley-Terry fit reads the whole log at one time. The choice of a model compares the log-loss of each model on the
4447
+ * last picks of the log. Each function is pure: the same log gives the same result.
4448
+ */
4449
+ /** The score of the first item of a pick: 1 for a win, 0.5 for a tie and 0 for a loss. */
4450
+ const SCORE = {
4451
+ a: 1,
4452
+ b: 0,
4453
+ tie: .5
4454
+ };
4455
+ /** This function checks the indexes and the outcomes of `comparisons`. With `ordered`, it also refuses a weight other than 1. */
4456
+ function checkedPicks(name, items, comparisons, ordered) {
4457
+ if (!Number.isSafeInteger(items) || items < 0) throw new RangeError(`${name}: the number of items is a whole number of 0 or more, not ${items}.`);
4458
+ for (const [i, { a, b, outcome, weight }] of comparisons.entries()) {
4459
+ for (const index of [a, b]) if (!Number.isSafeInteger(index) || index < 0 || index >= items) throw new RangeError(`${name}: pick ${i} has the index ${index}, and the indexes are whole numbers from 0 to ${items - 1}.`);
4460
+ if (a === b) throw new RangeError(`${name}: pick ${i} compares the item ${a} with itself.`);
4461
+ if (outcome !== "a" && outcome !== "b" && outcome !== "tie") throw new RangeError(`${name}: pick ${i} has the outcome ${String(outcome)}.`);
4462
+ if (ordered && weight !== void 0 && weight !== 1) throw new RangeError(`${name}: pick ${i} has the weight ${weight}. An ordered replay makes one update for each pick, so each weight is 1.`);
4463
+ }
4464
+ }
4465
+ /**
4466
+ * The Elo ratings of `items` items after each pick of `comparisons`, in the order of the list. Each item starts at
4467
+ * `start`. The update of each pick is `eloUpdate` with the options. The order of the picks changes the result.
4468
+ */
4469
+ function eloReplay(items, comparisons, options = {}) {
4470
+ checkedPicks("eloReplay", items, comparisons, true);
4471
+ const start = options.start ?? 1500;
4472
+ if (!Number.isFinite(start)) throw new RangeError(`eloReplay: the start rating is a finite number, not ${start}.`);
4473
+ eloUpdate(start, start, .5, options);
4474
+ const ratings = new Array(items).fill(start);
4475
+ for (const { a, b, outcome } of comparisons) {
4476
+ const next = eloUpdate(ratings[a], ratings[b], SCORE[outcome], options);
4477
+ ratings[a] = next.a;
4478
+ ratings[b] = next.b;
4479
+ }
4480
+ return ratings;
4481
+ }
4482
+ /**
4483
+ * The Glicko-2 ratings of `items` items after each pick of `comparisons`, in the order of the list. Each pick is one
4484
+ * rating period of its two items. Each item of a pick gets `glicko2Update` against the rating of the other item before
4485
+ * the pick. An item that is not in a pick does not change in that period. The order of the picks changes the result.
4486
+ */
4487
+ function glicko2Replay(items, comparisons, options = {}) {
4488
+ checkedPicks("glicko2Replay", items, comparisons, true);
4489
+ const start = options.start ?? {
4490
+ rating: 1500,
4491
+ rd: 350,
4492
+ volatility: .06
4493
+ };
4494
+ glicko2Update(start, [], options);
4495
+ const players = Array.from({ length: items }, () => ({
4496
+ rating: start.rating,
4497
+ rd: start.rd,
4498
+ volatility: start.volatility
4499
+ }));
4500
+ for (const { a, b, outcome } of comparisons) {
4501
+ const pa = players[a];
4502
+ const pb = players[b];
4503
+ players[a] = glicko2Update(pa, [{
4504
+ rating: pb.rating,
4505
+ rd: pb.rd,
4506
+ score: SCORE[outcome]
4507
+ }], options);
4508
+ players[b] = glicko2Update(pb, [{
4509
+ rating: pa.rating,
4510
+ rd: pa.rd,
4511
+ score: 1 - SCORE[outcome]
4512
+ }], options);
4513
+ }
4514
+ return players;
4515
+ }
4516
+ /** The default largest number of items of the dense Bradley-Terry fit in `fitRatings`. */
4517
+ const BRADLEY_TERRY_DENSE_LIMIT = 200;
4518
+ /**
4519
+ * The log-strengths of `items` items with one rating model. The model `"bradley-terry"` uses `bradleyTerry`, or
4520
+ * `bradleyTerrySparse` above `denseLimit` items. The models `"elo"` and `"glicko2"` use `eloReplay` and
4521
+ * `glicko2Replay`, and they refuse a weight other than 1. A log of 0 items gives empty lists.
4522
+ */
4523
+ function fitRatings(model, items, comparisons, options = {}) {
4524
+ checkedPicks("fitRatings", items, comparisons, model !== "bradley-terry");
4525
+ const denseLimit = options.denseLimit ?? 200;
4526
+ if (!(denseLimit >= 0)) throw new RangeError(`fitRatings: denseLimit is a number of 0 or more, not ${denseLimit}.`);
4527
+ if (items === 0) return {
4528
+ strengths: [],
4529
+ se: [],
4530
+ tie: 0
4531
+ };
4532
+ switch (model) {
4533
+ case "bradley-terry": {
4534
+ const fit = items <= denseLimit ? bradleyTerry(items, comparisons, options.bradleyTerry) : bradleyTerrySparse(items, comparisons, options.bradleyTerry);
4535
+ return {
4536
+ strengths: fit.strengths,
4537
+ se: fit.se,
4538
+ tie: fit.tie
4539
+ };
4540
+ }
4541
+ case "elo": {
4542
+ const ratings = eloReplay(items, comparisons, options.elo);
4543
+ return {
4544
+ strengths: ratings.map((rating) => ratingToStrength(rating, options.elo)),
4545
+ se: ratings.map(() => void 0),
4546
+ tie: 0
4547
+ };
4548
+ }
4549
+ case "glicko2": {
4550
+ const players = glicko2Replay(items, comparisons, options.glicko2);
4551
+ return {
4552
+ strengths: players.map((player) => ratingToStrength(player.rating)),
4553
+ se: players.map((player) => ratingToStrength(1500 + player.rd)),
4554
+ tie: 0
4555
+ };
4556
+ }
4557
+ default: throw new RangeError(`fitRatings: the model is "bradley-terry", "elo" or "glicko2", not ${String(model)}.`);
4558
+ }
4559
+ }
4560
+ /** The lowest probability of a forecast. An outcome with a probability of 0 gives a log-loss of Infinity. */
4561
+ const FORECAST_FLOOR = 1e-9;
4562
+ /**
4563
+ * The choice of a rating model by the log-loss on held-out picks. Each model learns from the first picks of the log
4564
+ * and forecasts the last `holdout` part. All models use the tie parameter of the Bradley-Terry fit of the first picks.
4565
+ * Thus the strengths are the only difference. Two equal scores keep the order of `models`. Each pick has the weight 1.
4566
+ */
4567
+ function selectRatingModel(items, comparisons, options = {}) {
4568
+ checkedPicks("selectRatingModel", items, comparisons, true);
4569
+ const holdout = options.holdout ?? .2;
4570
+ const minimum = options.minimum ?? 30;
4571
+ const models = options.models ?? [
4572
+ "bradley-terry",
4573
+ "elo",
4574
+ "glicko2"
4575
+ ];
4576
+ if (!(holdout > 0 && holdout < 1)) throw new RangeError(`selectRatingModel: the holdout is above 0 and below 1, not ${holdout}.`);
4577
+ if (!Number.isSafeInteger(minimum) || minimum < 0) throw new RangeError(`selectRatingModel: the minimum is a whole number of 0 or more, not ${minimum}.`);
4578
+ if (models.length === 0) throw new RangeError("selectRatingModel: the list of models is empty.");
4579
+ for (const model of models) if (model !== "bradley-terry" && model !== "elo" && model !== "glicko2") throw new RangeError(`selectRatingModel: a model is "bradley-terry", "elo" or "glicko2", not ${String(model)}.`);
4580
+ const tested = Math.floor(comparisons.length * holdout);
4581
+ if (comparisons.length < minimum || tested < 1) return {
4582
+ model: "bradley-terry",
4583
+ scores: [],
4584
+ trained: comparisons.length,
4585
+ tested: 0
4586
+ };
4587
+ const train = comparisons.slice(0, comparisons.length - tested);
4588
+ const test = comparisons.slice(comparisons.length - tested);
4589
+ const bradley = fitRatings("bradley-terry", items, train, options);
4590
+ const scores = models.map((model) => {
4591
+ const { strengths } = model === "bradley-terry" ? bradley : fitRatings(model, items, train, options);
4592
+ return {
4593
+ model,
4594
+ logLoss: logLoss(test.map(({ a, b, outcome }) => {
4595
+ const p = pairProbabilities(strengths[a], strengths[b], bradley.tie)[outcome];
4596
+ return {
4597
+ p: Math.max(p, FORECAST_FLOOR),
4598
+ hit: true
4599
+ };
4600
+ }))
4601
+ };
4602
+ });
4603
+ scores.sort((x, y) => x.logLoss - y.logLoss);
4604
+ return {
4605
+ model: scores[0].model,
4606
+ scores,
4607
+ trained: train.length,
4608
+ tested
4609
+ };
4610
+ }
4611
+ // src/pair-choice.ts
4612
+ /**
4613
+ * The choice of the next pair of a duel. A good pair has a result that is not sure: two items with near ratings, or
4614
+ * items with few picks. The choice uses a seed of `@mark1russell7/rigor-generators`, so a seed gives the same pair again.
4615
+ */
4616
+ /**
4617
+ * The uncertainty of an item with no standard error and no picks, in rating points. It is the first RD of Glicko-2.
4618
+ * An item with no standard error has the uncertainty `350 / sqrt(1 + picks)`.
4619
+ */
4620
+ const RATING_START_UNCERTAINTY = 350;
4621
+ /** The largest pool of items for a full search of a pair that is not recent. */
4622
+ const PAIR_FULL_SEARCH = 64;
4623
+ /** The uncertainty of a rating, in rating points: its standard error, or `RATING_START_UNCERTAINTY / sqrt(1 + picks)`. */
4624
+ function ratingUncertainty(item) {
4625
+ return item.se ?? 350 / Math.sqrt(1 + item.picks);
4626
+ }
4627
+ /**
4628
+ * The expected information of a pick between two items: `p * (1 - p) * (ua^2 + ub^2)`. Here `p` is `eloExpected` of
4629
+ * the two ratings, and `ua` and `ub` are the uncertainties. The value is high when the result is not sure (a `p` near
4630
+ * 0.5) and the two ratings are not sure.
4631
+ */
4632
+ function pairInformation(a, b, options = {}) {
4633
+ const p = eloExpected(a.rating, b.rating, options);
4634
+ return p * (1 - p) * (ratingUncertainty(a) ** 2 + ratingUncertainty(b) ** 2);
4635
+ }
4636
+ const pairKey = (a, b) => a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
4637
+ /**
4638
+ * The indexes of the second items to examine: the neighbors in the order, and random items. Only the indexes in
4639
+ * `allowed` are candidates.
4640
+ */
4641
+ function candidatesOf(first, allowed, size, count, draw) {
4642
+ if (allowed.size <= count) return [...allowed].sort((x, y) => x - y);
4643
+ const chosen = /* @__PURE__ */ new Set();
4644
+ const near = Math.ceil(count / 2);
4645
+ for (let step = 1; chosen.size < near && step < size; step++) {
4646
+ if (allowed.has(first - step)) chosen.add(first - step);
4647
+ if (chosen.size < near && allowed.has(first + step)) chosen.add(first + step);
4648
+ }
4649
+ while (chosen.size < count) {
4650
+ const index = draw(size);
4651
+ if (allowed.has(index)) chosen.add(index);
4652
+ }
4653
+ return [...chosen];
4654
+ }
4655
+ /**
4656
+ * The choice of the next pair of a duel from a pool of rated items. The pool goes in the order of the ratings, from
4657
+ * the highest. These are the steps:
4658
+ *
4659
+ * 1. A draw selects the first item. The weight of an item is `1 / (1 + picks)`, so the items with few picks come first.
4660
+ * An item with no permitted pair leaves the draw.
4661
+ * 2. The second item is the candidate with the most information. The candidates are the neighbors of the first item in
4662
+ * the order, and random items, each in a permitted pair. A recent pair loses to each pair that is not recent.
4663
+ * 3. A draw selects the left and the right side, so the side does not change the picks.
4664
+ *
4665
+ * In a pool of `PAIR_FULL_SEARCH` items or fewer, a recent pair comes only when each permitted pair is recent. The
4666
+ * function gives `undefined` when there is no permitted pair. Each step has its own stream of the seed.
4667
+ */
4668
+ function choosePair(items, options) {
4669
+ const candidates = options.candidates ?? 24;
4670
+ if (!Number.isSafeInteger(candidates) || candidates < 1) throw new RangeError(`choosePair: candidates is a whole number of 1 or more, not ${candidates}.`);
4671
+ for (const item of items) {
4672
+ if (!Number.isFinite(item.rating)) throw new RangeError(`choosePair: the item ${item.id} has the rating ${item.rating}, and a rating is a finite number.`);
4673
+ if (!Number.isSafeInteger(item.picks) || item.picks < 0) throw new RangeError(`choosePair: the item ${item.id} has ${item.picks} picks, and a count of picks is a whole number of 0 or more.`);
4674
+ }
4675
+ const seen = /* @__PURE__ */ new Set();
4676
+ const pool = items.filter((item) => !seen.has(item.id) && seen.add(item.id)).sort((x, y) => y.rating - x.rating);
4677
+ if (pool.length < 2) return void 0;
4678
+ const draw = random(options.seed);
4679
+ const recent = new Set((options.recent ?? []).map(([a, b]) => pairKey(a, b)));
4680
+ const at = (i) => pool[i];
4681
+ const isRecent = (i, j) => recent.has(pairKey(at(i).id, at(j).id));
4682
+ const information = options.information ?? ((a, b) => pairInformation(a, b));
4683
+ const informationOf = (i, j) => {
4684
+ const value = information(at(i), at(j));
4685
+ if (value !== void 0 && !Number.isFinite(value)) throw new RangeError(`choosePair: the information of ${at(i).id} and ${at(j).id} is ${value}, and it is a finite number or undefined.`);
4686
+ return value;
4687
+ };
4688
+ const jitter = draw.fork("jitter");
4689
+ let best;
4690
+ const consider = (i, j, score) => {
4691
+ if (score === void 0) return;
4692
+ const option = {
4693
+ i,
4694
+ j,
4695
+ fresh: !isRecent(i, j),
4696
+ score,
4697
+ tie: jitter.next()
4698
+ };
4699
+ if (best === void 0 || option.fresh && !best.fresh || option.fresh === best.fresh && (option.score > best.score || option.score === best.score && option.tie > best.tie)) best = option;
4700
+ };
4701
+ const weights = pool.map((item) => 1 / (1 + item.picks));
4702
+ const firstDraw = draw.fork("first");
4703
+ let first = -1;
4704
+ const scores = /* @__PURE__ */ new Map();
4705
+ while (first < 0 && weights.some((weight) => weight > 0)) {
4706
+ const index = firstDraw.weighted(weights);
4707
+ for (let j = 0; j < pool.length; j++) {
4708
+ const score = j === index ? void 0 : informationOf(index, j);
4709
+ if (score !== void 0) scores.set(j, score);
4710
+ }
4711
+ if (scores.size > 0) first = index;
4712
+ else weights[index] = 0;
4713
+ }
4714
+ if (first < 0) return void 0;
4715
+ const second = draw.fork("second");
4716
+ const allowed = new Set(scores.keys());
4717
+ for (const j of candidatesOf(first, allowed, pool.length, candidates, (n) => second.below(n))) consider(first, j, scores.get(j));
4718
+ if (!best.fresh && pool.length <= 64) for (let i = 0; i < pool.length; i++) for (let j = i + 1; j < pool.length; j++) consider(i, j, informationOf(i, j));
4719
+ const { i, j, score } = best;
4720
+ const [left, right] = draw.fork("side").chance(.5) ? [at(j), at(i)] : [at(i), at(j)];
4721
+ return {
4722
+ a: left.id,
4723
+ b: right.id,
4724
+ information: score
4725
+ };
4726
+ }
4727
+ export { BRADLEY_TERRY_DENSE_LIMIT, EMPTY_RUNNING, KENDALL_EXACT, PAIR_FULL_SEARCH, RATING_START_UNCERTAINTY, RESPONSE_LAMBDAS, RESPONSE_MAX_POWER, RESPONSE_MAX_TERMS, RESPONSE_RECORDS_SCHEMA, SIGN_FLIP_EXACT_LIMIT, SIGN_FLIP_EXACT_MAX, U_STATISTIC_TIGHT_MIN_SAMPLE, Z95, addToRunning, bestSmoothLaw, binomialInterval, binomialTest, bootstrap, bootstrapClusters, bootstrapLeastSquares, bootstrapMedian, bootstrapMedianRatio, bootstrapStrata, bootstrapTwo, bradleyTerry, bradleyTerrySparse, brier, calibration, choosePair, cohenKappa, compareRatios, coverageMinSample, eloExpected, eloReplay, eloUpdate, fitRatings, fitResponse, glicko2Idle, glicko2Replay, glicko2Update, heldOutError, heldOutErrorAt, holm, kendallTau, leastSquares, logLoss, mean, median, medianInterval, mergeRunning, modeCount, negativeBinomialInterval, noiseFloor, noiseVerdict, normalCdf, normalQuantile, optionFloor, pairInformation, pairProbabilities, pairedPermutation, parseResponseRecord, partialConjunction, predictResponse, quantile, quantileInterval, quantileSamples, quantileSorted, ranks, ratingToStrength, ratingUncertainty, responseCells, ridgeSolve, runningMean, runningOf, runningSd, sampleTotalInterval, sd, selectRatingModel, signFlipTest, signTest, signTestPower, sliceResponse, smoothLawScores, sobolIndices, sorted, spearman, strengthToRating, summarize, surfaceResponse, tQuantile, twoSamplePermutation, uStatisticInterval, whenHolds, wilson, wilsonSampleSize };
4060
4728
 
4061
4729
  //# sourceMappingURL=index.js.map