@mark1russell7/rigor-statistics 0.1.27 → 0.1.29

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
@@ -3844,7 +3844,7 @@ function ratingToStrength(rating, options = {}) {
3844
3844
  */
3845
3845
  const ARMIJO = 1e-4;
3846
3846
  const HALVINGS = 60;
3847
- const ROUNDING = 64 * Number.EPSILON;
3847
+ const ROUNDING$1 = 64 * Number.EPSILON;
3848
3848
  function checkedTerms(n, comparisons, ties) {
3849
3849
  const terms = [];
3850
3850
  for (const [i, comparison] of comparisons.entries()) {
@@ -3866,7 +3866,7 @@ function checkedTerms(n, comparisons, ties) {
3866
3866
  }
3867
3867
  return terms;
3868
3868
  }
3869
- function positiveOption(label, value) {
3869
+ function positiveOption$1(label, value) {
3870
3870
  if (!(value > 0 && Number.isFinite(value))) throw new RangeError(`bradleyTerry: ${label} is a finite number above 0, not ${value}.`);
3871
3871
  }
3872
3872
  /**
@@ -3883,9 +3883,9 @@ function bradleyTerry(items, comparisons, options = {}) {
3883
3883
  const maxIterations = options.maxIterations ?? 100;
3884
3884
  const tolerance = options.tolerance ?? 1e-10;
3885
3885
  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);
3886
+ positiveOption$1("the prior SD", priorSd);
3887
+ positiveOption$1("the tie prior SD", tiePriorSd);
3888
+ positiveOption$1("the tolerance", tolerance);
3889
3889
  if (ties !== "davidson" && ties !== "half") throw new RangeError(`bradleyTerry: the tie model is "davidson" or "half", not ${String(ties)}.`);
3890
3890
  if (!Number.isSafeInteger(maxIterations) || maxIterations < 1) throw new RangeError(`bradleyTerry: maxIterations is a whole number of 1 or more, not ${maxIterations}.`);
3891
3891
  const terms = checkedTerms(items, comparisons, ties);
@@ -3913,7 +3913,7 @@ function bradleyTerry(items, comparisons, options = {}) {
3913
3913
  let t = 1;
3914
3914
  let next = moved(x, step, t);
3915
3915
  let nextValue = objective(model, next);
3916
- const slack = ROUNDING * (Math.abs(value) + 1);
3916
+ const slack = ROUNDING$1 * (Math.abs(value) + 1);
3917
3917
  for (let h = 0; h < HALVINGS && !(nextValue <= value + ARMIJO * t * slope + slack); h++) {
3918
3918
  t /= 2;
3919
3919
  next = moved(x, step, t);
@@ -4056,6 +4056,519 @@ function inverseDiagonal(hessian, k) {
4056
4056
  }
4057
4057
  return out;
4058
4058
  }
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 };
4059
+ // src/bradley-terry-sparse.ts
4060
+ /** The largest number of items. The key of a pair is `low * items + high`, and it must be a safe integer. */
4061
+ const MAX_ITEMS = Math.floor(Math.sqrt(Number.MAX_SAFE_INTEGER));
4062
+ const ROUNDING = 64 * Number.EPSILON;
4063
+ const NAME = "bradleyTerrySparse";
4064
+ function positiveOption(label, value) {
4065
+ if (!(value > 0 && Number.isFinite(value))) throw new RangeError(`${NAME}: ${label} is a finite number above 0, not ${value}.`);
4066
+ }
4067
+ /** This function checks the comparisons, and sums them for each distinct pair. A comparison with the weight 0 adds nothing. */
4068
+ function pairsOf(items, comparisons, ties) {
4069
+ const index = /* @__PURE__ */ new Map();
4070
+ const low = [];
4071
+ const high = [];
4072
+ const total = [];
4073
+ const net = [];
4074
+ const tie = [];
4075
+ for (const [i, comparison] of comparisons.entries()) {
4076
+ const { a, b, outcome } = comparison;
4077
+ const w = comparison.weight ?? 1;
4078
+ 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}.`);
4079
+ if (a === b) throw new RangeError(`${NAME}: comparison ${i} compares the item ${a} with itself.`);
4080
+ if (outcome !== "a" && outcome !== "b" && outcome !== "tie") throw new RangeError(`${NAME}: comparison ${i} has the outcome ${String(outcome)}.`);
4081
+ 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.`);
4082
+ if (w === 0) continue;
4083
+ const lo = Math.min(a, b);
4084
+ const hi = Math.max(a, b);
4085
+ const key = lo * items + hi;
4086
+ let p = index.get(key);
4087
+ if (p === void 0) {
4088
+ p = low.length;
4089
+ index.set(key, p);
4090
+ low.push(lo);
4091
+ high.push(hi);
4092
+ total.push(0);
4093
+ net.push(0);
4094
+ tie.push(0);
4095
+ }
4096
+ total[p] = total[p] + w;
4097
+ if (outcome === "tie") {
4098
+ if (ties === "davidson") tie[p] = tie[p] + w;
4099
+ } else {
4100
+ const winner = outcome === "a" ? a : b;
4101
+ net[p] = net[p] + (winner === lo ? w : -w);
4102
+ }
4103
+ }
4104
+ return {
4105
+ count: low.length,
4106
+ low: Int32Array.from(low),
4107
+ high: Int32Array.from(high),
4108
+ total: Float64Array.from(total),
4109
+ net: Float64Array.from(net),
4110
+ ties: Float64Array.from(tie)
4111
+ };
4112
+ }
4113
+ /** The connected groups of the items: the group of each item, and the size of each group. */
4114
+ function groupsOf(items, pairs) {
4115
+ const parent = new Int32Array(items);
4116
+ for (let i = 0; i < items; i++) parent[i] = i;
4117
+ const root = (i) => {
4118
+ let r = i;
4119
+ while (parent[r] !== r) r = parent[r];
4120
+ for (let j = i; parent[j] !== r;) {
4121
+ const next = parent[j];
4122
+ parent[j] = r;
4123
+ j = next;
4124
+ }
4125
+ return r;
4126
+ };
4127
+ for (let p = 0; p < pairs.count; p++) {
4128
+ const x = root(pairs.low[p]);
4129
+ const y = root(pairs.high[p]);
4130
+ if (x !== y) parent[Math.max(x, y)] = Math.min(x, y);
4131
+ }
4132
+ const group = new Int32Array(items).fill(-1);
4133
+ const ids = new Int32Array(items).fill(-1);
4134
+ let count = 0;
4135
+ for (let i = 0; i < items; i++) {
4136
+ const r = root(i);
4137
+ if (ids[r] === -1) ids[r] = count++;
4138
+ group[i] = ids[r];
4139
+ }
4140
+ const size = new Float64Array(count);
4141
+ for (let i = 0; i < items; i++) size[group[i]] = size[group[i]] + 1;
4142
+ return {
4143
+ group,
4144
+ size,
4145
+ count
4146
+ };
4147
+ }
4148
+ /**
4149
+ * The probabilities of the three outcomes of a pair with the difference `d` of the log-strengths, and the log of the
4150
+ * denominator `exp(d / 2) + exp(-d / 2) + nu`. The function writes them into `out`, so the loops make no objects.
4151
+ */
4152
+ function outcomes(d, logNu, out) {
4153
+ const half = d / 2;
4154
+ const top = Math.max(Math.abs(half), logNu);
4155
+ const ea = Math.exp(half - top);
4156
+ const eb = Math.exp(-half - top);
4157
+ const et = Math.exp(logNu - top);
4158
+ const sum = ea + eb + et;
4159
+ out[0] = ea / sum;
4160
+ out[1] = eb / sum;
4161
+ out[2] = et / sum;
4162
+ out[3] = top + Math.log(sum);
4163
+ }
4164
+ /**
4165
+ * The minimum over `z` of `s * exp(z) + tau * (lambda + z) ^ 2 / 2 - t * z`: the root of
4166
+ * `s * exp(z) + tau * z = t - tau * lambda`. The left side increases and is convex. Newton steps from a point right of
4167
+ * the root thus go down to the root, and they do not go past it.
4168
+ */
4169
+ function tieStep(s, t, tau, lambda) {
4170
+ const c = t - tau * lambda;
4171
+ if (s === 0) return c / tau;
4172
+ let z = c > 0 ? Math.max(0, Math.log(c / s)) : 0;
4173
+ for (let step = 0; step < 200; step++) {
4174
+ const ez = s * Math.exp(z);
4175
+ const move = (ez + tau * z - c) / (ez + tau);
4176
+ z -= move;
4177
+ if (!(Math.abs(move) > ROUNDING * (1 + Math.abs(z)))) break;
4178
+ }
4179
+ return z;
4180
+ }
4181
+ /**
4182
+ * The sparse Bradley-Terry fit of `comparisons` among `items` items. It has the model, the options and the checks of
4183
+ * `bradleyTerry`, and its memory is in proportion to `items` plus the distinct pairs. The function does not sort or
4184
+ * change its inputs. It throws a RangeError for a bad index, a bad outcome, a bad weight and a bad option.
4185
+ */
4186
+ function bradleyTerrySparse(items, comparisons, options = {}) {
4187
+ 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}.`);
4188
+ const priorMean = options.priorMean ?? 0;
4189
+ const priorSd = options.priorSd ?? 1;
4190
+ const ties = options.ties ?? "davidson";
4191
+ const tiePriorSd = options.tiePriorSd ?? 10;
4192
+ const maxIterations = options.maxIterations ?? 1e4;
4193
+ const tolerance = options.tolerance ?? 1e-8;
4194
+ if (!Number.isFinite(priorMean)) throw new RangeError(`${NAME}: the prior mean is a finite number, not ${priorMean}.`);
4195
+ positiveOption("the prior SD", priorSd);
4196
+ positiveOption("the tie prior SD", tiePriorSd);
4197
+ positiveOption("the tolerance", tolerance);
4198
+ if (ties !== "davidson" && ties !== "half") throw new RangeError(`${NAME}: the tie model is "davidson" or "half", not ${String(ties)}.`);
4199
+ if (!Number.isSafeInteger(maxIterations) || maxIterations < 1) throw new RangeError(`${NAME}: maxIterations is a whole number of 1 or more, not ${maxIterations}.`);
4200
+ const pairs = pairsOf(items, comparisons, ties);
4201
+ const { group, size, count: groupCount } = groupsOf(items, pairs);
4202
+ const p = 1 / (priorSd * priorSd);
4203
+ const tau = 1 / (tiePriorSd * tiePriorSd);
4204
+ let tieWeight = 0;
4205
+ let totalWeight = 0;
4206
+ const weight = new Float64Array(items);
4207
+ for (let q = 0; q < pairs.count; q++) {
4208
+ tieWeight += pairs.ties[q];
4209
+ totalWeight += pairs.total[q];
4210
+ weight[pairs.low[q]] = weight[pairs.low[q]] + pairs.total[q];
4211
+ weight[pairs.high[q]] = weight[pairs.high[q]] + pairs.total[q];
4212
+ }
4213
+ const fitTie = tieWeight > 0;
4214
+ const smallest = fitTie ? Math.min(p, tau) : p;
4215
+ const floor = 4 * Number.EPSILON * (totalWeight + 1);
4216
+ const s = new Float64Array(items).fill(priorMean);
4217
+ const gradient = new Float64Array(items);
4218
+ const sums = new Float64Array(groupCount);
4219
+ const out = /* @__PURE__ */ new Float64Array(4);
4220
+ let lambda = 0;
4221
+ let iterations = 0;
4222
+ let bound = Number.POSITIVE_INFINITY;
4223
+ let rounded = false;
4224
+ for (;;) {
4225
+ const logNu = fitTie ? lambda : Number.NEGATIVE_INFINITY;
4226
+ for (let i = 0; i < items; i++) gradient[i] = p * (s[i] - priorMean);
4227
+ let tieGradient = fitTie ? tau * lambda - tieWeight : 0;
4228
+ for (let q = 0; q < pairs.count; q++) {
4229
+ const lo = pairs.low[q];
4230
+ const hi = pairs.high[q];
4231
+ const total = pairs.total[q];
4232
+ outcomes(s[lo] - s[hi], logNu, out);
4233
+ const g = total * ((out[0] - out[1]) / 2) - pairs.net[q] / 2;
4234
+ gradient[lo] = gradient[lo] + g;
4235
+ gradient[hi] = gradient[hi] - g;
4236
+ if (fitTie) tieGradient += total * out[2];
4237
+ }
4238
+ let norm = tieGradient * tieGradient;
4239
+ for (let i = 0; i < items; i++) norm += gradient[i] ** 2;
4240
+ norm = Math.sqrt(norm);
4241
+ bound = norm / smallest;
4242
+ rounded = norm <= floor;
4243
+ if (bound <= tolerance || rounded || iterations >= maxIterations) break;
4244
+ iterations++;
4245
+ for (let i = 0; i < items; i++) s[i] = s[i] - gradient[i] / (weight[i] / 2 + p);
4246
+ sums.fill(0);
4247
+ for (let i = 0; i < items; i++) sums[group[i]] = sums[group[i]] + s[i];
4248
+ for (let i = 0; i < items; i++) s[i] = s[i] + priorMean - sums[group[i]] / size[group[i]];
4249
+ if (fitTie) {
4250
+ let expected = 0;
4251
+ for (let q = 0; q < pairs.count; q++) {
4252
+ outcomes(s[pairs.low[q]] - s[pairs.high[q]], lambda, out);
4253
+ expected += pairs.total[q] * out[2];
4254
+ }
4255
+ lambda += tieStep(expected, tieWeight, tau, lambda);
4256
+ }
4257
+ }
4258
+ const logNu = fitTie ? lambda : Number.NEGATIVE_INFINITY;
4259
+ const diagonal = new Float64Array(items).fill(p);
4260
+ let logLikelihood = 0;
4261
+ for (let q = 0; q < pairs.count; q++) {
4262
+ const lo = pairs.low[q];
4263
+ const hi = pairs.high[q];
4264
+ const total = pairs.total[q];
4265
+ const d = s[lo] - s[hi];
4266
+ outcomes(d, logNu, out);
4267
+ const half = (out[0] - out[1]) / 2;
4268
+ const curvature = total * ((out[0] + out[1]) / 4 - half * half);
4269
+ diagonal[lo] = diagonal[lo] + curvature;
4270
+ diagonal[hi] = diagonal[hi] + curvature;
4271
+ logLikelihood += pairs.net[q] / 2 * d - total * out[3] + (fitTie ? pairs.ties[q] * lambda : 0);
4272
+ }
4273
+ const se = Array.from(s, (_, i) => {
4274
+ const n = size[group[i]];
4275
+ return Math.sqrt(1 / (n * p) + (1 - 1 / n) / diagonal[i]);
4276
+ });
4277
+ return {
4278
+ strengths: Array.from(s),
4279
+ se,
4280
+ tie: fitTie ? Math.exp(lambda) : 0,
4281
+ logLikelihood,
4282
+ iterations,
4283
+ converged: bound <= tolerance || rounded,
4284
+ bound,
4285
+ pairs: pairs.count
4286
+ };
4287
+ }
4288
+ // src/rating-models.ts
4289
+ /**
4290
+ * The rating models of a log of picks. The Elo and the Glicko-2 replays read the picks in their order. The
4291
+ * Bradley-Terry fit reads the whole log at one time. The choice of a model compares the log-loss of each model on the
4292
+ * last picks of the log. Each function is pure: the same log gives the same result.
4293
+ */
4294
+ /** The score of the first item of a pick: 1 for a win, 0.5 for a tie and 0 for a loss. */
4295
+ const SCORE = {
4296
+ a: 1,
4297
+ b: 0,
4298
+ tie: .5
4299
+ };
4300
+ /** This function checks the indexes and the outcomes of `comparisons`. With `ordered`, it also refuses a weight other than 1. */
4301
+ function checkedPicks(name, items, comparisons, ordered) {
4302
+ if (!Number.isSafeInteger(items) || items < 0) throw new RangeError(`${name}: the number of items is a whole number of 0 or more, not ${items}.`);
4303
+ for (const [i, { a, b, outcome, weight }] of comparisons.entries()) {
4304
+ 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}.`);
4305
+ if (a === b) throw new RangeError(`${name}: pick ${i} compares the item ${a} with itself.`);
4306
+ if (outcome !== "a" && outcome !== "b" && outcome !== "tie") throw new RangeError(`${name}: pick ${i} has the outcome ${String(outcome)}.`);
4307
+ 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.`);
4308
+ }
4309
+ }
4310
+ /**
4311
+ * The Elo ratings of `items` items after each pick of `comparisons`, in the order of the list. Each item starts at
4312
+ * `start`. The update of each pick is `eloUpdate` with the options. The order of the picks changes the result.
4313
+ */
4314
+ function eloReplay(items, comparisons, options = {}) {
4315
+ checkedPicks("eloReplay", items, comparisons, true);
4316
+ const start = options.start ?? 1500;
4317
+ if (!Number.isFinite(start)) throw new RangeError(`eloReplay: the start rating is a finite number, not ${start}.`);
4318
+ eloUpdate(start, start, .5, options);
4319
+ const ratings = new Array(items).fill(start);
4320
+ for (const { a, b, outcome } of comparisons) {
4321
+ const next = eloUpdate(ratings[a], ratings[b], SCORE[outcome], options);
4322
+ ratings[a] = next.a;
4323
+ ratings[b] = next.b;
4324
+ }
4325
+ return ratings;
4326
+ }
4327
+ /**
4328
+ * The Glicko-2 ratings of `items` items after each pick of `comparisons`, in the order of the list. Each pick is one
4329
+ * rating period of its two items. Each item of a pick gets `glicko2Update` against the rating of the other item before
4330
+ * the pick. An item that is not in a pick does not change in that period. The order of the picks changes the result.
4331
+ */
4332
+ function glicko2Replay(items, comparisons, options = {}) {
4333
+ checkedPicks("glicko2Replay", items, comparisons, true);
4334
+ const start = options.start ?? {
4335
+ rating: 1500,
4336
+ rd: 350,
4337
+ volatility: .06
4338
+ };
4339
+ glicko2Update(start, [], options);
4340
+ const players = Array.from({ length: items }, () => ({
4341
+ rating: start.rating,
4342
+ rd: start.rd,
4343
+ volatility: start.volatility
4344
+ }));
4345
+ for (const { a, b, outcome } of comparisons) {
4346
+ const pa = players[a];
4347
+ const pb = players[b];
4348
+ players[a] = glicko2Update(pa, [{
4349
+ rating: pb.rating,
4350
+ rd: pb.rd,
4351
+ score: SCORE[outcome]
4352
+ }], options);
4353
+ players[b] = glicko2Update(pb, [{
4354
+ rating: pa.rating,
4355
+ rd: pa.rd,
4356
+ score: 1 - SCORE[outcome]
4357
+ }], options);
4358
+ }
4359
+ return players;
4360
+ }
4361
+ /** The default largest number of items of the dense Bradley-Terry fit in `fitRatings`. */
4362
+ const BRADLEY_TERRY_DENSE_LIMIT = 200;
4363
+ /**
4364
+ * The log-strengths of `items` items with one rating model. The model `"bradley-terry"` uses `bradleyTerry`, or
4365
+ * `bradleyTerrySparse` above `denseLimit` items. The models `"elo"` and `"glicko2"` use `eloReplay` and
4366
+ * `glicko2Replay`, and they refuse a weight other than 1. A log of 0 items gives empty lists.
4367
+ */
4368
+ function fitRatings(model, items, comparisons, options = {}) {
4369
+ checkedPicks("fitRatings", items, comparisons, model !== "bradley-terry");
4370
+ const denseLimit = options.denseLimit ?? 200;
4371
+ if (!(denseLimit >= 0)) throw new RangeError(`fitRatings: denseLimit is a number of 0 or more, not ${denseLimit}.`);
4372
+ if (items === 0) return {
4373
+ strengths: [],
4374
+ se: [],
4375
+ tie: 0
4376
+ };
4377
+ switch (model) {
4378
+ case "bradley-terry": {
4379
+ const fit = items <= denseLimit ? bradleyTerry(items, comparisons, options.bradleyTerry) : bradleyTerrySparse(items, comparisons, options.bradleyTerry);
4380
+ return {
4381
+ strengths: fit.strengths,
4382
+ se: fit.se,
4383
+ tie: fit.tie
4384
+ };
4385
+ }
4386
+ case "elo": {
4387
+ const ratings = eloReplay(items, comparisons, options.elo);
4388
+ return {
4389
+ strengths: ratings.map((rating) => ratingToStrength(rating, options.elo)),
4390
+ se: ratings.map(() => void 0),
4391
+ tie: 0
4392
+ };
4393
+ }
4394
+ case "glicko2": {
4395
+ const players = glicko2Replay(items, comparisons, options.glicko2);
4396
+ return {
4397
+ strengths: players.map((player) => ratingToStrength(player.rating)),
4398
+ se: players.map((player) => ratingToStrength(1500 + player.rd)),
4399
+ tie: 0
4400
+ };
4401
+ }
4402
+ default: throw new RangeError(`fitRatings: the model is "bradley-terry", "elo" or "glicko2", not ${String(model)}.`);
4403
+ }
4404
+ }
4405
+ /** The lowest probability of a forecast. An outcome with a probability of 0 gives a log-loss of Infinity. */
4406
+ const FORECAST_FLOOR = 1e-9;
4407
+ /**
4408
+ * The choice of a rating model by the log-loss on held-out picks. Each model learns from the first picks of the log
4409
+ * and forecasts the last `holdout` part. All models use the tie parameter of the Bradley-Terry fit of the first picks.
4410
+ * Thus the strengths are the only difference. Two equal scores keep the order of `models`. Each pick has the weight 1.
4411
+ */
4412
+ function selectRatingModel(items, comparisons, options = {}) {
4413
+ checkedPicks("selectRatingModel", items, comparisons, true);
4414
+ const holdout = options.holdout ?? .2;
4415
+ const minimum = options.minimum ?? 30;
4416
+ const models = options.models ?? [
4417
+ "bradley-terry",
4418
+ "elo",
4419
+ "glicko2"
4420
+ ];
4421
+ if (!(holdout > 0 && holdout < 1)) throw new RangeError(`selectRatingModel: the holdout is above 0 and below 1, not ${holdout}.`);
4422
+ if (!Number.isSafeInteger(minimum) || minimum < 0) throw new RangeError(`selectRatingModel: the minimum is a whole number of 0 or more, not ${minimum}.`);
4423
+ if (models.length === 0) throw new RangeError("selectRatingModel: the list of models is empty.");
4424
+ 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)}.`);
4425
+ const tested = Math.floor(comparisons.length * holdout);
4426
+ if (comparisons.length < minimum || tested < 1) return {
4427
+ model: "bradley-terry",
4428
+ scores: [],
4429
+ trained: comparisons.length,
4430
+ tested: 0
4431
+ };
4432
+ const train = comparisons.slice(0, comparisons.length - tested);
4433
+ const test = comparisons.slice(comparisons.length - tested);
4434
+ const bradley = fitRatings("bradley-terry", items, train, options);
4435
+ const scores = models.map((model) => {
4436
+ const { strengths } = model === "bradley-terry" ? bradley : fitRatings(model, items, train, options);
4437
+ return {
4438
+ model,
4439
+ logLoss: logLoss(test.map(({ a, b, outcome }) => {
4440
+ const p = pairProbabilities(strengths[a], strengths[b], bradley.tie)[outcome];
4441
+ return {
4442
+ p: Math.max(p, FORECAST_FLOOR),
4443
+ hit: true
4444
+ };
4445
+ }))
4446
+ };
4447
+ });
4448
+ scores.sort((x, y) => x.logLoss - y.logLoss);
4449
+ return {
4450
+ model: scores[0].model,
4451
+ scores,
4452
+ trained: train.length,
4453
+ tested
4454
+ };
4455
+ }
4456
+ // src/pair-choice.ts
4457
+ /**
4458
+ * 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
4459
+ * items with few picks. The choice uses a seed of `@mark1russell7/rigor-generators`, so a seed gives the same pair again.
4460
+ */
4461
+ /**
4462
+ * The uncertainty of an item with no standard error and no picks, in rating points. It is the first RD of Glicko-2.
4463
+ * An item with no standard error has the uncertainty `350 / sqrt(1 + picks)`.
4464
+ */
4465
+ const RATING_START_UNCERTAINTY = 350;
4466
+ /** The largest pool of items for a full search of a pair that is not recent. */
4467
+ const PAIR_FULL_SEARCH = 64;
4468
+ /** The uncertainty of a rating, in rating points: its standard error, or `RATING_START_UNCERTAINTY / sqrt(1 + picks)`. */
4469
+ function ratingUncertainty(item) {
4470
+ return item.se ?? 350 / Math.sqrt(1 + item.picks);
4471
+ }
4472
+ /**
4473
+ * The expected information of a pick between two items: `p * (1 - p) * (ua^2 + ub^2)`. Here `p` is `eloExpected` of
4474
+ * the two ratings, and `ua` and `ub` are the uncertainties. The value is high when the result is not sure (a `p` near
4475
+ * 0.5) and the two ratings are not sure.
4476
+ */
4477
+ function pairInformation(a, b, options = {}) {
4478
+ const p = eloExpected(a.rating, b.rating, options);
4479
+ return p * (1 - p) * (ratingUncertainty(a) ** 2 + ratingUncertainty(b) ** 2);
4480
+ }
4481
+ const pairKey = (a, b) => a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
4482
+ /**
4483
+ * The indexes of the second items to examine: the neighbors in the order, and random items. Only the indexes in
4484
+ * `allowed` are candidates.
4485
+ */
4486
+ function candidatesOf(first, allowed, size, count, draw) {
4487
+ if (allowed.size <= count) return [...allowed].sort((x, y) => x - y);
4488
+ const chosen = /* @__PURE__ */ new Set();
4489
+ const near = Math.ceil(count / 2);
4490
+ for (let step = 1; chosen.size < near && step < size; step++) {
4491
+ if (allowed.has(first - step)) chosen.add(first - step);
4492
+ if (chosen.size < near && allowed.has(first + step)) chosen.add(first + step);
4493
+ }
4494
+ while (chosen.size < count) {
4495
+ const index = draw(size);
4496
+ if (allowed.has(index)) chosen.add(index);
4497
+ }
4498
+ return [...chosen];
4499
+ }
4500
+ /**
4501
+ * 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
4502
+ * the highest. These are the steps:
4503
+ *
4504
+ * 1. A draw selects the first item. The weight of an item is `1 / (1 + picks)`, so the items with few picks come first.
4505
+ * An item with no permitted pair leaves the draw.
4506
+ * 2. The second item is the candidate with the most information. The candidates are the neighbors of the first item in
4507
+ * the order, and random items, each in a permitted pair. A recent pair loses to each pair that is not recent.
4508
+ * 3. A draw selects the left and the right side, so the side does not change the picks.
4509
+ *
4510
+ * In a pool of `PAIR_FULL_SEARCH` items or fewer, a recent pair comes only when each permitted pair is recent. The
4511
+ * function gives `undefined` when there is no permitted pair. Each step has its own stream of the seed.
4512
+ */
4513
+ function choosePair(items, options) {
4514
+ const candidates = options.candidates ?? 24;
4515
+ if (!Number.isSafeInteger(candidates) || candidates < 1) throw new RangeError(`choosePair: candidates is a whole number of 1 or more, not ${candidates}.`);
4516
+ for (const item of items) {
4517
+ 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.`);
4518
+ 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.`);
4519
+ }
4520
+ const seen = /* @__PURE__ */ new Set();
4521
+ const pool = items.filter((item) => !seen.has(item.id) && seen.add(item.id)).sort((x, y) => y.rating - x.rating);
4522
+ if (pool.length < 2) return void 0;
4523
+ const draw = random(options.seed);
4524
+ const recent = new Set((options.recent ?? []).map(([a, b]) => pairKey(a, b)));
4525
+ const at = (i) => pool[i];
4526
+ const isRecent = (i, j) => recent.has(pairKey(at(i).id, at(j).id));
4527
+ const information = options.information ?? ((a, b) => pairInformation(a, b));
4528
+ const informationOf = (i, j) => {
4529
+ const value = information(at(i), at(j));
4530
+ 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.`);
4531
+ return value;
4532
+ };
4533
+ const jitter = draw.fork("jitter");
4534
+ let best;
4535
+ const consider = (i, j, score) => {
4536
+ if (score === void 0) return;
4537
+ const option = {
4538
+ i,
4539
+ j,
4540
+ fresh: !isRecent(i, j),
4541
+ score,
4542
+ tie: jitter.next()
4543
+ };
4544
+ 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;
4545
+ };
4546
+ const weights = pool.map((item) => 1 / (1 + item.picks));
4547
+ const firstDraw = draw.fork("first");
4548
+ let first = -1;
4549
+ const scores = /* @__PURE__ */ new Map();
4550
+ while (first < 0 && weights.some((weight) => weight > 0)) {
4551
+ const index = firstDraw.weighted(weights);
4552
+ for (let j = 0; j < pool.length; j++) {
4553
+ const score = j === index ? void 0 : informationOf(index, j);
4554
+ if (score !== void 0) scores.set(j, score);
4555
+ }
4556
+ if (scores.size > 0) first = index;
4557
+ else weights[index] = 0;
4558
+ }
4559
+ if (first < 0) return void 0;
4560
+ const second = draw.fork("second");
4561
+ const allowed = new Set(scores.keys());
4562
+ for (const j of candidatesOf(first, allowed, pool.length, candidates, (n) => second.below(n))) consider(first, j, scores.get(j));
4563
+ 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));
4564
+ const { i, j, score } = best;
4565
+ const [left, right] = draw.fork("side").chance(.5) ? [at(j), at(i)] : [at(i), at(j)];
4566
+ return {
4567
+ a: left.id,
4568
+ b: right.id,
4569
+ information: score
4570
+ };
4571
+ }
4572
+ 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
4573
 
4061
4574
  //# sourceMappingURL=index.js.map