@mark1russell7/rigor-statistics 0.1.6 → 0.1.8
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/README.md +78 -1
- package/build/index.d.ts +487 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1437 -1
- package/build/index.js.map +1 -1
- package/package.json +4 -3
package/build/index.js
CHANGED
|
@@ -436,6 +436,1442 @@ function factor(a, k) {
|
|
|
436
436
|
}
|
|
437
437
|
return true;
|
|
438
438
|
}
|
|
439
|
+
// src/response.ts
|
|
440
|
+
/**
|
|
441
|
+
* The format `response-records/1`: the measured points of a function of many dimensions, with a fitted model and the
|
|
442
|
+
* held-out error of each measure. A record is plain JSON. A browser reads it with `parseResponseRecord`, and it
|
|
443
|
+
* evaluates the models with `predictResponse`. A view shows one dimension with `sliceResponse`, or two dimensions
|
|
444
|
+
* with `surfaceResponse`, and it holds the other dimensions.
|
|
445
|
+
*
|
|
446
|
+
* This file has the types and the pure functions that read a record. `response-parse.ts` has the validator, and
|
|
447
|
+
* `response-fit.ts` has the fit. The three files use no Node import, no clock and no `Math.random`.
|
|
448
|
+
*
|
|
449
|
+
* A model calculates integer powers with multiplication, so its terms are equal in each JavaScript engine. A log
|
|
450
|
+
* scale uses `Math.log10` and `10 ** x`. An engine can give a different last bit for these two operations.
|
|
451
|
+
*/
|
|
452
|
+
/** The value of the field `schema` of a record. */
|
|
453
|
+
const RESPONSE_RECORDS_SCHEMA = "response-records/1";
|
|
454
|
+
/** This function gives the key of the group of a point. A point without a group is a group of its own. */
|
|
455
|
+
function groupKey(point) {
|
|
456
|
+
return point.group === void 0 ? `point ${JSON.stringify(point.id)}` : `group ${JSON.stringify(point.group)}`;
|
|
457
|
+
}
|
|
458
|
+
/** This function gives true for a dimension whose value is a number. */
|
|
459
|
+
function isNumberDimension(dimension) {
|
|
460
|
+
return dimension.kind === "continuous" || dimension.kind === "ordinal" && typeof dimension.levels[0] === "number";
|
|
461
|
+
}
|
|
462
|
+
/** This function gives the low and the high end of a dimension on the numeric axis of a model. */
|
|
463
|
+
function axisEnds(dimension) {
|
|
464
|
+
if (!isNumberDimension(dimension)) return [0, dimension.levels.length - 1];
|
|
465
|
+
const values = dimension.kind === "continuous" ? dimension.range : dimension.levels;
|
|
466
|
+
const low = values[0];
|
|
467
|
+
const high = values[values.length - 1];
|
|
468
|
+
return dimension.log === true ? [Math.log10(low), Math.log10(high)] : [low, high];
|
|
469
|
+
}
|
|
470
|
+
/** This function gives true when a place has the condition of a dimension. A dimension with no condition is present. */
|
|
471
|
+
function whenHolds(dimension, values) {
|
|
472
|
+
return dimension.when === void 0 || dimension.when.levels.includes(values[dimension.when.dimension]);
|
|
473
|
+
}
|
|
474
|
+
/** This function gives the reason that `value` is not a value of `dimension`. It gives `undefined` for a good value. */
|
|
475
|
+
function valueFault(dimension, value) {
|
|
476
|
+
if (dimension.kind === "continuous") {
|
|
477
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return `the value ${JSON.stringify(value)} is not a finite number`;
|
|
478
|
+
const [low, high] = dimension.range;
|
|
479
|
+
return value < low || value > high ? `the value ${value} is outside the range ${low} to ${high}` : void 0;
|
|
480
|
+
}
|
|
481
|
+
return dimension.levels.includes(value) ? void 0 : `the value ${JSON.stringify(value)} is not a level of the dimension`;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* This function gives the input of a dimension, for each kind of model. It reads only a field of the object, not of
|
|
485
|
+
* its prototype. It refuses `null` at a dimension with no condition, so a mistake of a caller cannot give a quiet value.
|
|
486
|
+
*/
|
|
487
|
+
function inputValue(inputs, dimension, conditional) {
|
|
488
|
+
if (!Object.hasOwn(inputs, dimension)) throw new RangeError(`predictResponse: the inputs have no value of the dimension "${dimension}".`);
|
|
489
|
+
const value = inputs[dimension];
|
|
490
|
+
if (value === null && !conditional) throw new RangeError(`predictResponse: the dimension "${dimension}" has no condition, so it needs a value, not null.`);
|
|
491
|
+
return value;
|
|
492
|
+
}
|
|
493
|
+
/** This function gives the coordinate of one input of a ridge model. An absent input has `null`, and a level has 0. */
|
|
494
|
+
function coordinate(input, value) {
|
|
495
|
+
if (value === null) return null;
|
|
496
|
+
const bad = (reason) => /* @__PURE__ */ new RangeError(`predictResponse: the value ${JSON.stringify(value)} of the dimension "${input.dimension}" ${reason}.`);
|
|
497
|
+
if (input.kind === "number") {
|
|
498
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw bad("is not a finite number");
|
|
499
|
+
if (input.log === true && value <= 0) throw bad("is not above 0, and the dimension has a log scale");
|
|
500
|
+
return ((input.log === true ? Math.log10(value) : value) - input.center) / input.spread;
|
|
501
|
+
}
|
|
502
|
+
const position = input.levels.indexOf(value);
|
|
503
|
+
if (position < 0) throw bad("is not a level of the model");
|
|
504
|
+
return input.kind === "rank" ? (position - input.center) / input.spread : 0;
|
|
505
|
+
}
|
|
506
|
+
/** This function gives the coordinate of each input of a ridge model. */
|
|
507
|
+
function coordinates(inputs, values) {
|
|
508
|
+
return new Map(inputs.map((input) => [input.dimension, coordinate(input, inputValue(values, input.dimension, input.conditional === true))]));
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* This function gives the product of the factors of a term at a place. A power is a loop of multiplications. A term
|
|
512
|
+
* with an absent dimension is 0, so the columns of the levels of the parent carry the mean of that place.
|
|
513
|
+
*/
|
|
514
|
+
function product(factors, at, values) {
|
|
515
|
+
let value = 1;
|
|
516
|
+
for (const factor of factors) {
|
|
517
|
+
const c = at.get(factor.dimension);
|
|
518
|
+
if (c === null) return 0;
|
|
519
|
+
if ("level" in factor) {
|
|
520
|
+
if (values[factor.dimension] !== factor.level) return 0;
|
|
521
|
+
} else for (let i = 0; i < factor.power; i++) value *= c;
|
|
522
|
+
}
|
|
523
|
+
return value;
|
|
524
|
+
}
|
|
525
|
+
/** This function gives the prediction on the scale of the model. The sum with 0 removes a negative zero. */
|
|
526
|
+
function predictScaled(model, inputs) {
|
|
527
|
+
if (model.kind === "table") {
|
|
528
|
+
const at = model.dimensions.map((d) => inputValue(inputs, d, model.conditional?.includes(d) === true));
|
|
529
|
+
return model.cells.find((cell) => cell.at.every((v, i) => v === at[i]))?.value ?? model.fallback;
|
|
530
|
+
}
|
|
531
|
+
const at = coordinates(model.inputs, inputs);
|
|
532
|
+
let sum = model.intercept;
|
|
533
|
+
for (const term of model.terms) sum += term.weight * product(term.factors, at, inputs);
|
|
534
|
+
return sum + 0;
|
|
535
|
+
}
|
|
536
|
+
/** This function changes a measured value to the scale of a model. A log scale with a floor lifts each smaller value. */
|
|
537
|
+
function toModelScale(value, log, floor) {
|
|
538
|
+
return log ? Math.log10(Math.max(value, floor ?? value)) : value;
|
|
539
|
+
}
|
|
540
|
+
/** This function changes a value from the scale of the model to the scale of the measure. */
|
|
541
|
+
function unscale(model, value) {
|
|
542
|
+
return model.scale === "log" ? 10 ** value : value;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* This function gives the value of a model at `inputs`, on the scale of the measure. The inputs have a value for each
|
|
546
|
+
* dimension that the model reads. An absent conditional dimension has `null`. A table model gives its `fallback` for a
|
|
547
|
+
* combination with no cell.
|
|
548
|
+
*
|
|
549
|
+
* The function throws a RangeError for a missing input and for a value that is not a number or a level of the model.
|
|
550
|
+
* It throws for `null` at a dimension with no condition, for each kind of model. It also throws for a value at or below
|
|
551
|
+
* 0 on a log scale. A number outside the range of the record is good, so the
|
|
552
|
+
* function extrapolates.
|
|
553
|
+
*/
|
|
554
|
+
function predictResponse(model, inputs) {
|
|
555
|
+
return unscale(model, predictScaled(model, inputs));
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* This function gives the held-out error of a measure over all the folds. Each point has the same weight, so a small
|
|
559
|
+
* fold has a small effect. The function throws a RangeError when no fold has the measure.
|
|
560
|
+
*/
|
|
561
|
+
function heldOutError(record, measure) {
|
|
562
|
+
let n = 0;
|
|
563
|
+
let folds = 0;
|
|
564
|
+
let absolute = 0;
|
|
565
|
+
let square = 0;
|
|
566
|
+
let baseline = 0;
|
|
567
|
+
for (const fold of record.folds) {
|
|
568
|
+
if (!Object.hasOwn(fold.errors, measure)) continue;
|
|
569
|
+
const error = fold.errors[measure];
|
|
570
|
+
n += error.n;
|
|
571
|
+
folds += 1;
|
|
572
|
+
absolute += error.n * error.mae;
|
|
573
|
+
square += error.n * error.rmse * error.rmse;
|
|
574
|
+
baseline += error.n * error.baselineMae;
|
|
575
|
+
}
|
|
576
|
+
if (n === 0) throw new RangeError(`heldOutError: no fold has an error of the measure "${measure}".`);
|
|
577
|
+
const mae = absolute / n;
|
|
578
|
+
const baselineMae = baseline / n;
|
|
579
|
+
return {
|
|
580
|
+
n,
|
|
581
|
+
folds,
|
|
582
|
+
mae,
|
|
583
|
+
rmse: Math.sqrt(square / n),
|
|
584
|
+
baselineMae,
|
|
585
|
+
skill: baselineMae > 0 ? 1 - mae / baselineMae : null
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/** This function gives the stored predictions of a measure in a fold. A fold with no predictions gives `undefined`. */
|
|
589
|
+
function predictionsOf(fold, measure) {
|
|
590
|
+
return fold.predictions !== void 0 && Object.hasOwn(fold.predictions, measure) ? fold.predictions[measure] : void 0;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* This function gives the held-out error of a chosen set of points, on the scale of the model. It uses the stored
|
|
594
|
+
* predictions of the option `outOfFold`. Thus a reader can get the error of one cell. The result has the fields of
|
|
595
|
+
* `heldOutError`, and the set of all the points gives the same values to the rounding of the sums.
|
|
596
|
+
*
|
|
597
|
+
* Each point counts at most once. A point with no value of the measure, or with no prediction, adds nothing to `n`.
|
|
598
|
+
* The baseline of a point is the mean of the training points of its fold.
|
|
599
|
+
*
|
|
600
|
+
* The function throws a RangeError for an unknown measure and for a record with no predictions of the measure. It
|
|
601
|
+
* also throws for an unknown id, for a repeated id, and for a set with no point to score.
|
|
602
|
+
*/
|
|
603
|
+
function heldOutErrorAt(record, measure, ids) {
|
|
604
|
+
const model = record.models.find((m) => m.measure === measure);
|
|
605
|
+
if (model === void 0) throw new RangeError(`heldOutErrorAt: the record has no model of the measure "${measure}".`);
|
|
606
|
+
if (!record.folds.some((fold) => predictionsOf(fold, measure) !== void 0)) throw new RangeError(`heldOutErrorAt: the record has no predictions of the measure "${measure}". Fit it with the option outOfFold.`);
|
|
607
|
+
const chosen = new Set(ids);
|
|
608
|
+
if (chosen.size !== ids.length) throw new RangeError("heldOutErrorAt: the list of ids has a repeated id.");
|
|
609
|
+
const points = new Map(record.points.map((p) => [p.id, p]));
|
|
610
|
+
for (const id of ids) if (!points.has(id)) throw new RangeError(`heldOutErrorAt: the record has no point "${id}".`);
|
|
611
|
+
const scaled = (point) => {
|
|
612
|
+
const value = point.measures[measure];
|
|
613
|
+
return typeof value === "number" ? toModelScale(value, model.scale === "log", model.floor) : void 0;
|
|
614
|
+
};
|
|
615
|
+
let n = 0;
|
|
616
|
+
let folds = 0;
|
|
617
|
+
let absolute = 0;
|
|
618
|
+
let square = 0;
|
|
619
|
+
let baselineSum = 0;
|
|
620
|
+
for (const fold of record.folds) {
|
|
621
|
+
const predicted = predictionsOf(fold, measure);
|
|
622
|
+
if (predicted === void 0) continue;
|
|
623
|
+
const scored = fold.heldOut.filter((id) => chosen.has(id) && Object.hasOwn(predicted, id));
|
|
624
|
+
if (scored.length === 0) continue;
|
|
625
|
+
const held = new Set(fold.heldOut);
|
|
626
|
+
const baseline = mean(record.points.flatMap((p) => held.has(p.id) || scaled(p) === void 0 ? [] : [scaled(p)]));
|
|
627
|
+
folds += 1;
|
|
628
|
+
for (const id of scored) {
|
|
629
|
+
const y = scaled(points.get(id));
|
|
630
|
+
const error = y - predicted[id];
|
|
631
|
+
n += 1;
|
|
632
|
+
absolute += Math.abs(error);
|
|
633
|
+
square += error * error;
|
|
634
|
+
baselineSum += Math.abs(y - baseline);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (n === 0) throw new RangeError(`heldOutErrorAt: no chosen point has a prediction of the measure "${measure}".`);
|
|
638
|
+
const mae = absolute / n;
|
|
639
|
+
const baselineMae = baselineSum / n;
|
|
640
|
+
return {
|
|
641
|
+
n,
|
|
642
|
+
folds,
|
|
643
|
+
mae,
|
|
644
|
+
rmse: Math.sqrt(square / n),
|
|
645
|
+
baselineMae,
|
|
646
|
+
skill: baselineMae > 0 ? 1 - mae / baselineMae : null
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* This function gives the usual value of a dimension: a measured median, or the most common level among `among`. A
|
|
651
|
+
* tie gives the first level. A dimension with no measured value gives its low end or its first level.
|
|
652
|
+
*/
|
|
653
|
+
function usualValue(dimension, points, among) {
|
|
654
|
+
const values = points.flatMap((p) => p.values[dimension.id] === null ? [] : [p.values[dimension.id]]);
|
|
655
|
+
if (dimension.kind === "continuous") return values.length === 0 ? dimension.range[0] : quantile(values, .5, "nearest-rank");
|
|
656
|
+
const levels = dimension.levels.filter((level) => among === void 0 || among.includes(level));
|
|
657
|
+
let best = levels[0];
|
|
658
|
+
let most = 0;
|
|
659
|
+
for (const level of levels) {
|
|
660
|
+
const count = values.filter((v) => v === level).length;
|
|
661
|
+
if (count > most) [best, most] = [level, count];
|
|
662
|
+
}
|
|
663
|
+
return best;
|
|
664
|
+
}
|
|
665
|
+
/** This function gives the place of a value on its axis. A number or a rank is a share of the axis, from 0 to 1. */
|
|
666
|
+
function axisPlace(dimension, value) {
|
|
667
|
+
if (value === null || dimension.kind === "categorical") return value;
|
|
668
|
+
const [low, high] = axisEnds(dimension);
|
|
669
|
+
if (high === low) return 0;
|
|
670
|
+
if (!isNumberDimension(dimension)) return dimension.levels.indexOf(value) / high;
|
|
671
|
+
return ((dimension.log === true ? Math.log10(value) : value) - low) / (high - low);
|
|
672
|
+
}
|
|
673
|
+
/** This function gives the count of one dimension between two places: their distance, or 1 when they do not compare. */
|
|
674
|
+
const axisGap = (a, b) => a === b ? 0 : typeof a === "number" && typeof b === "number" ? Math.abs(a - b) : 1;
|
|
675
|
+
/**
|
|
676
|
+
* This function gives a function of the field `nearest` of a band. A place compares with each point that has a value
|
|
677
|
+
* of the measure. A point stops at the first count that is not less than the best distance so far.
|
|
678
|
+
*/
|
|
679
|
+
function nearestOf(dimensions, points, measure) {
|
|
680
|
+
const rows = points.filter((p) => typeof p.measures[measure] === "number").map((p) => dimensions.map((d) => axisPlace(d, p.values[d.id])));
|
|
681
|
+
return (place) => {
|
|
682
|
+
const here = dimensions.map((d) => axisPlace(d, place[d.id]));
|
|
683
|
+
let best = 1;
|
|
684
|
+
for (const row of rows) {
|
|
685
|
+
let far = 0;
|
|
686
|
+
for (let i = 0; i < here.length && far < best; i++) far = Math.max(far, axisGap(row[i], here[i]));
|
|
687
|
+
if (far < best) best = far;
|
|
688
|
+
}
|
|
689
|
+
return best;
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/** This function gives the levels that the parent of each varied conditional dimension needs. */
|
|
693
|
+
function neededLevels(name, varied, vary) {
|
|
694
|
+
const needed = /* @__PURE__ */ new Map();
|
|
695
|
+
for (const { id, when } of varied) {
|
|
696
|
+
if (when === void 0) continue;
|
|
697
|
+
if (vary.includes(when.dimension)) throw new RangeError(`${name}: the dimension "${id}" has a value only at some levels of "${when.dimension}", so hold "${when.dimension}".`);
|
|
698
|
+
const earlier = needed.get(when.dimension);
|
|
699
|
+
needed.set(when.dimension, earlier === void 0 ? when.levels : earlier.filter((level) => when.levels.includes(level)));
|
|
700
|
+
}
|
|
701
|
+
return needed;
|
|
702
|
+
}
|
|
703
|
+
/** This function gives the value of each dimension that does not vary. `sliceResponse` tells the rules. */
|
|
704
|
+
function heldValues(name, record, vary, hold, needed) {
|
|
705
|
+
const others = record.dimensions.filter((d) => !vary.includes(d.id));
|
|
706
|
+
const plain = new Map(others.filter((d) => d.when === void 0).map((d) => [d.id, Object.hasOwn(hold, d.id) ? hold[d.id] : usualValue(d, record.points, needed.get(d.id))]));
|
|
707
|
+
for (const [parent, levels] of needed) {
|
|
708
|
+
const value = plain.get(parent);
|
|
709
|
+
if (!levels.includes(value)) throw new RangeError(`${name}: a varied dimension needs "${parent}" at one of ${JSON.stringify(levels)}, not ${JSON.stringify(value)}.`);
|
|
710
|
+
}
|
|
711
|
+
const valueOf = ({ id, when }) => {
|
|
712
|
+
if (when === void 0) return plain.get(id);
|
|
713
|
+
if (!vary.includes(when.dimension) && !when.levels.includes(plain.get(when.dimension))) return null;
|
|
714
|
+
const value = Object.hasOwn(hold, id) ? hold[id] : usualValue(record.dimensions.find((d) => d.id === id), record.points);
|
|
715
|
+
if (value === null) throw new RangeError(`${name}: the held dimension "${id}" is null, and a level of "${when.dimension}" in this view needs a value.`);
|
|
716
|
+
return value;
|
|
717
|
+
};
|
|
718
|
+
return Object.fromEntries(others.map((d) => [d.id, valueOf(d)]));
|
|
719
|
+
}
|
|
720
|
+
/** This function gives the values of an axis: each level, or `steps` equal distances on the scale of the dimension. */
|
|
721
|
+
function axisValues(dimension, steps) {
|
|
722
|
+
if (dimension.kind !== "continuous") return dimension.levels;
|
|
723
|
+
const [low, high] = dimension.range;
|
|
724
|
+
const [a, b] = axisEnds(dimension);
|
|
725
|
+
return Array.from({ length: steps }, (_, i) => {
|
|
726
|
+
if (i === 0) return low;
|
|
727
|
+
if (i === steps - 1) return high;
|
|
728
|
+
const x = a + (b - a) * i / (steps - 1);
|
|
729
|
+
return dimension.log === true ? 10 ** x : x;
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
/** This function gives true when the value of a point matches a held value of a dimension. */
|
|
733
|
+
function matches(dimension, value, held, tolerance) {
|
|
734
|
+
if (typeof value !== "number" || typeof held !== "number") return value === held;
|
|
735
|
+
const [low, high] = axisEnds(dimension);
|
|
736
|
+
const log = dimension.kind !== "categorical" && dimension.log === true;
|
|
737
|
+
return Math.abs(log ? Math.log10(value) - Math.log10(held) : value - held) <= tolerance * (high - low);
|
|
738
|
+
}
|
|
739
|
+
/** This function examines the request of a view, and it gives the parts that the two views share. */
|
|
740
|
+
function frameOf(name, record, measure, vary, options) {
|
|
741
|
+
const { hold = {}, steps = 33, tolerance = 0, width = 1 } = options;
|
|
742
|
+
if (!Number.isSafeInteger(steps) || steps < 2) throw new RangeError(`${name}: steps is a whole number of 2 or more, not ${steps}.`);
|
|
743
|
+
if (!(Number.isFinite(tolerance) && tolerance >= 0)) throw new RangeError(`${name}: tolerance is a finite number of 0 or more, not ${tolerance}.`);
|
|
744
|
+
if (!(Number.isFinite(width) && width >= 0)) throw new RangeError(`${name}: width is a finite number of 0 or more, not ${width}.`);
|
|
745
|
+
const model = record.models.find((m) => m.measure === measure);
|
|
746
|
+
if (model === void 0) throw new RangeError(`${name}: the record has no model of the measure "${measure}".`);
|
|
747
|
+
const known = (id) => {
|
|
748
|
+
const dimension = record.dimensions.find((d) => d.id === id);
|
|
749
|
+
if (dimension === void 0) throw new RangeError(`${name}: the record has no dimension "${id}".`);
|
|
750
|
+
return dimension;
|
|
751
|
+
};
|
|
752
|
+
const varied = vary.map(known);
|
|
753
|
+
if (new Set(vary).size !== vary.length) throw new RangeError(`${name}: the two varied dimensions are one dimension, "${vary[0]}".`);
|
|
754
|
+
for (const id of Object.keys(hold)) {
|
|
755
|
+
if (vary.includes(id)) throw new RangeError(`${name}: the dimension "${id}" is both varied and held.`);
|
|
756
|
+
const dimension = known(id);
|
|
757
|
+
if (hold[id] === null && dimension.when !== void 0) continue;
|
|
758
|
+
const fault = valueFault(dimension, hold[id]);
|
|
759
|
+
if (fault !== void 0) throw new RangeError(`${name}: the held dimension "${id}": ${fault}.`);
|
|
760
|
+
}
|
|
761
|
+
const others = record.dimensions.filter((d) => !vary.includes(d.id));
|
|
762
|
+
const held = heldValues(name, record, vary, hold, neededLevels(name, varied, vary));
|
|
763
|
+
const placed = record.points.filter((p) => vary.every((id) => p.values[id] !== null) && others.every((d) => matches(d, p.values[d.id], whenHolds(d, p.values) ? held[d.id] : null, tolerance)));
|
|
764
|
+
const matched = placed.filter((p) => typeof p.measures[measure] === "number");
|
|
765
|
+
const missing = placed.filter((p) => typeof p.measures[measure] !== "number");
|
|
766
|
+
const error = heldOutError(record, measure);
|
|
767
|
+
const nearest = nearestOf(record.dimensions, record.points, measure);
|
|
768
|
+
const band = (inputs) => {
|
|
769
|
+
const place = Object.fromEntries(record.dimensions.map((d) => [d.id, whenHolds(d, inputs) ? inputs[d.id] : null]));
|
|
770
|
+
const value = predictScaled(model, place);
|
|
771
|
+
return {
|
|
772
|
+
value: unscale(model, value),
|
|
773
|
+
low: unscale(model, value - width * error.rmse),
|
|
774
|
+
high: unscale(model, value + width * error.rmse),
|
|
775
|
+
nearest: nearest(place)
|
|
776
|
+
};
|
|
777
|
+
};
|
|
778
|
+
return {
|
|
779
|
+
held,
|
|
780
|
+
axes: varied.map((d) => axisValues(d, steps)),
|
|
781
|
+
matched,
|
|
782
|
+
missing,
|
|
783
|
+
error,
|
|
784
|
+
band
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/** This function gives the group field of a measured point of a view. */
|
|
788
|
+
const groupOf = (point) => point.group === void 0 ? {} : { group: point.group };
|
|
789
|
+
/** This function gives the declared reason of a point with no value of a measure, or `null` for no stated reason. */
|
|
790
|
+
const reasonOf = (point, measure) => point.absent !== void 0 && Object.hasOwn(point.absent, measure) ? point.absent[measure] : null;
|
|
791
|
+
/**
|
|
792
|
+
* This function gives a view of a measure over one dimension of a parsed record:
|
|
793
|
+
* - `curve`: the fitted value at each level, or at `steps` values of a continuous dimension, with its band.
|
|
794
|
+
* - `points`: the measured points that match the held values. This list is empty when no point matches them.
|
|
795
|
+
* - `absent`: the points that match the held values and have no value of the measure, each with its reason. A chart
|
|
796
|
+
* can draw each reason apart.
|
|
797
|
+
* - `held`: the value of each other dimension.
|
|
798
|
+
* - `error`: the held-out error.
|
|
799
|
+
*
|
|
800
|
+
* Each dimension with no value in `hold` takes its usual value. For a continuous dimension, this value is the median
|
|
801
|
+
* of its measured values. For the other kinds, it is the most common level, and a tie gives the first level.
|
|
802
|
+
*
|
|
803
|
+
* A conditional dimension follows its parent:
|
|
804
|
+
* - To vary a conditional dimension, the parent needs a level inside `when`. With no held value, the parent takes the
|
|
805
|
+
* most common level inside `when`. A held level outside `when` is a RangeError.
|
|
806
|
+
* - A held conditional dimension reads `null` in `held` when its parent is outside `when`, also with a value in `hold`.
|
|
807
|
+
* The points that match have `null` at this dimension.
|
|
808
|
+
* - When the parent varies, a held conditional dimension keeps its value in `held`. Each place outside `when` reads
|
|
809
|
+
* `null` for the fit, and for the matching points.
|
|
810
|
+
*
|
|
811
|
+
* The band is the fitted value, plus and less `width` times the held-out root mean square error. On a log scale, this
|
|
812
|
+
* distance is on the log scale of the model, so the band is a ratio. The band has one width along all the curve. The
|
|
813
|
+
* band is the mean error of the model, and `nearest` tells how far the place is from a measurement.
|
|
814
|
+
*
|
|
815
|
+
* The function throws a RangeError for an unknown measure or dimension, and for a held value that is not good for its
|
|
816
|
+
* dimension. It also throws for a dimension that is both varied and held, and for a bad option.
|
|
817
|
+
*/
|
|
818
|
+
function sliceResponse(record, measure, options) {
|
|
819
|
+
const { vary } = options;
|
|
820
|
+
const { held, axes, matched, missing, error, band } = frameOf("sliceResponse", record, measure, [vary], options);
|
|
821
|
+
return {
|
|
822
|
+
measure,
|
|
823
|
+
vary,
|
|
824
|
+
held,
|
|
825
|
+
curve: axes[0].map((at) => ({
|
|
826
|
+
at,
|
|
827
|
+
...band({
|
|
828
|
+
...held,
|
|
829
|
+
[vary]: at
|
|
830
|
+
})
|
|
831
|
+
})),
|
|
832
|
+
points: matched.map((p) => ({
|
|
833
|
+
id: p.id,
|
|
834
|
+
at: p.values[vary],
|
|
835
|
+
value: p.measures[measure],
|
|
836
|
+
...groupOf(p)
|
|
837
|
+
})),
|
|
838
|
+
absent: missing.map((p) => ({
|
|
839
|
+
id: p.id,
|
|
840
|
+
at: p.values[vary],
|
|
841
|
+
reason: reasonOf(p, measure),
|
|
842
|
+
...groupOf(p)
|
|
843
|
+
})),
|
|
844
|
+
error
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* This function gives the view of `sliceResponse` over two dimensions. The fields `x` and `y` are the two axes, and
|
|
849
|
+
* `cells` has one row for each value of `y`. The rules of the held values, of the matching points and of the band are
|
|
850
|
+
* the rules of `sliceResponse`. The function also throws a RangeError when the two varied dimensions are one dimension.
|
|
851
|
+
* It throws when a conditional dimension varies with its parent, and when two varied dimensions need no common level.
|
|
852
|
+
*/
|
|
853
|
+
function surfaceResponse(record, measure, options) {
|
|
854
|
+
const { vary } = options;
|
|
855
|
+
const [xId, yId] = vary;
|
|
856
|
+
const { held, axes, matched, missing, error, band } = frameOf("surfaceResponse", record, measure, vary, options);
|
|
857
|
+
const x = axes[0];
|
|
858
|
+
const y = axes[1];
|
|
859
|
+
return {
|
|
860
|
+
measure,
|
|
861
|
+
vary,
|
|
862
|
+
held,
|
|
863
|
+
x,
|
|
864
|
+
y,
|
|
865
|
+
cells: y.map((b) => x.map((a) => band({
|
|
866
|
+
...held,
|
|
867
|
+
[xId]: a,
|
|
868
|
+
[yId]: b
|
|
869
|
+
}))),
|
|
870
|
+
points: matched.map((p) => ({
|
|
871
|
+
id: p.id,
|
|
872
|
+
at: [p.values[xId], p.values[yId]],
|
|
873
|
+
value: p.measures[measure],
|
|
874
|
+
...groupOf(p)
|
|
875
|
+
})),
|
|
876
|
+
absent: missing.map((p) => ({
|
|
877
|
+
id: p.id,
|
|
878
|
+
at: [p.values[xId], p.values[yId]],
|
|
879
|
+
reason: reasonOf(p, measure),
|
|
880
|
+
...groupOf(p)
|
|
881
|
+
})),
|
|
882
|
+
error
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
// src/response-parse.ts
|
|
886
|
+
/**
|
|
887
|
+
* The validator of the format `response-records/1`. The functions here read unknown data and write faults. Thus a page
|
|
888
|
+
* can show the reason that a file is bad.
|
|
889
|
+
*/
|
|
890
|
+
/** The largest power of a factor. A larger power of a coordinate from -1 to 1 adds almost nothing to a model. */
|
|
891
|
+
const RESPONSE_MAX_POWER = 8;
|
|
892
|
+
/** This function gives an unknown value as short text for a message. */
|
|
893
|
+
function shown(value) {
|
|
894
|
+
if (value === void 0) return "nothing";
|
|
895
|
+
const text = JSON.stringify(value) ?? String(value);
|
|
896
|
+
return text.length > 60 ? `${text.slice(0, 60)}...` : text;
|
|
897
|
+
}
|
|
898
|
+
/** This function gives a path with one more key. */
|
|
899
|
+
const at = (path, key) => path === "" ? key : `${path}.${key}`;
|
|
900
|
+
const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
901
|
+
/** This function gives the field `key` of an unknown value, or `undefined` for a value that is not an object. */
|
|
902
|
+
const peek = (value, key) => typeof value === "object" && value !== null ? value[key] : void 0;
|
|
903
|
+
/** This function writes a fault when a test fails. */
|
|
904
|
+
function need(ok, path, message, faults) {
|
|
905
|
+
if (!ok) faults.push({
|
|
906
|
+
path,
|
|
907
|
+
message
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
/** This function gives the fields of a plain object. It writes a fault for another value, and for each wrong key. */
|
|
911
|
+
function fields(value, path, keys, optional, faults) {
|
|
912
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
913
|
+
faults.push({
|
|
914
|
+
path,
|
|
915
|
+
message: `this is an object, not ${shown(value)}.`
|
|
916
|
+
});
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const object = value;
|
|
920
|
+
for (const key of keys) need(Object.hasOwn(object, key), at(path, key), "this field is missing.", faults);
|
|
921
|
+
for (const key of Object.keys(object)) need(keys.includes(key) || optional.includes(key), at(path, key), `${RESPONSE_RECORDS_SCHEMA} has no such field.`, faults);
|
|
922
|
+
return object;
|
|
923
|
+
}
|
|
924
|
+
/** This function gives the items of a list. It writes a fault for another value, and for a short list. */
|
|
925
|
+
function list(value, path, least, faults) {
|
|
926
|
+
if (!Array.isArray(value)) {
|
|
927
|
+
faults.push({
|
|
928
|
+
path,
|
|
929
|
+
message: `this is a list, not ${shown(value)}.`
|
|
930
|
+
});
|
|
931
|
+
return [];
|
|
932
|
+
}
|
|
933
|
+
need(value.length >= least, path, `this list has ${value.length} items, and it needs ${least} or more.`, faults);
|
|
934
|
+
return value;
|
|
935
|
+
}
|
|
936
|
+
/** This function examines a field when it is present. A missing field already has a fault, or it is optional. */
|
|
937
|
+
function check(object, key, path, test, wanted, faults) {
|
|
938
|
+
if (Object.hasOwn(object, key)) need(test(object[key]), at(path, key), `this is ${wanted}, not ${shown(object[key])}.`, faults);
|
|
939
|
+
}
|
|
940
|
+
const isId = (v) => typeof v === "string" && v !== "";
|
|
941
|
+
const isString = (v) => typeof v === "string";
|
|
942
|
+
const isPositive = (v) => isNumber(v) && v > 0;
|
|
943
|
+
const isNotNegative = (v) => isNumber(v) && v >= 0;
|
|
944
|
+
/** This function writes a fault for each id that is a duplicate, and it gives the set of the ids. */
|
|
945
|
+
function uniqueIds(items, path, faults) {
|
|
946
|
+
const ids = /* @__PURE__ */ new Set();
|
|
947
|
+
items.forEach((item, i) => {
|
|
948
|
+
const id = peek(item, "id");
|
|
949
|
+
if (typeof id !== "string") return;
|
|
950
|
+
need(!ids.has(id), `${path}[${i}].id`, `an earlier item has the id ${shown(id)}.`, faults);
|
|
951
|
+
ids.add(id);
|
|
952
|
+
});
|
|
953
|
+
return ids;
|
|
954
|
+
}
|
|
955
|
+
/** This function examines the levels of a categorical or ordinal dimension. */
|
|
956
|
+
function checkLevels(value, path, numbersToo, log, faults) {
|
|
957
|
+
const levels = list(value, path, 1, faults);
|
|
958
|
+
const numbers = numbersToo && levels.every(isNumber);
|
|
959
|
+
need(numbers || levels.every(isString), path, numbersToo ? "these levels are all text or all finite numbers." : "these levels are all text.", faults);
|
|
960
|
+
need(new Set(levels).size === levels.length, path, "these levels have a duplicate.", faults);
|
|
961
|
+
if (numbers) need(levels.every((v, i) => i === 0 || levels[i - 1] < v), path, "these number levels are not in ascending order.", faults);
|
|
962
|
+
if (log) need(levels.every(isPositive), path, "a log dimension has number levels above 0.", faults);
|
|
963
|
+
}
|
|
964
|
+
/** This function examines one dimension. */
|
|
965
|
+
function checkDimension(value, path, faults) {
|
|
966
|
+
const kind = peek(value, "kind");
|
|
967
|
+
const object = fields(value, path, [
|
|
968
|
+
"id",
|
|
969
|
+
"label",
|
|
970
|
+
"kind",
|
|
971
|
+
"unit"
|
|
972
|
+
], [
|
|
973
|
+
"range",
|
|
974
|
+
"levels",
|
|
975
|
+
"log",
|
|
976
|
+
"when"
|
|
977
|
+
], faults);
|
|
978
|
+
if (object === void 0) return;
|
|
979
|
+
check(object, "id", path, isId, "text with one character or more", faults);
|
|
980
|
+
check(object, "label", path, isString, "text", faults);
|
|
981
|
+
check(object, "unit", path, isString, "text", faults);
|
|
982
|
+
check(object, "log", path, (v) => typeof v === "boolean", "true or false", faults);
|
|
983
|
+
const log = object["log"] === true;
|
|
984
|
+
if (kind === "continuous") {
|
|
985
|
+
need(!Object.hasOwn(object, "levels"), at(path, "levels"), "a continuous dimension has a range, not levels.", faults);
|
|
986
|
+
need(!Object.hasOwn(object, "range") || !log || !isNumber(peek(object["range"], "0")) || peek(object["range"], "0") > 0, at(path, "range"), "a log dimension has a range above 0.", faults);
|
|
987
|
+
need(Object.hasOwn(object, "range"), at(path, "range"), "this field is missing.", faults);
|
|
988
|
+
const range = object["range"];
|
|
989
|
+
const good = Array.isArray(range) && range.length === 2 && isNumber(range[0]) && isNumber(range[1]) && range[0] < range[1];
|
|
990
|
+
if (Object.hasOwn(object, "range")) need(good, at(path, "range"), `this is a low and a high finite number, with low less than high, not ${shown(range)}.`, faults);
|
|
991
|
+
} else if (kind === "ordinal" || kind === "categorical") {
|
|
992
|
+
need(!Object.hasOwn(object, "range"), at(path, "range"), `a dimension of the kind ${kind} has levels, not a range.`, faults);
|
|
993
|
+
need(kind === "ordinal" || !Object.hasOwn(object, "log"), at(path, "log"), "a categorical dimension has no log scale.", faults);
|
|
994
|
+
need(Object.hasOwn(object, "levels"), at(path, "levels"), "this field is missing.", faults);
|
|
995
|
+
if (Object.hasOwn(object, "levels")) checkLevels(object["levels"], at(path, "levels"), kind === "ordinal", log, faults);
|
|
996
|
+
} else if (Object.hasOwn(object, "kind")) faults.push({
|
|
997
|
+
path: at(path, "kind"),
|
|
998
|
+
message: `this is "continuous", "ordinal" or "categorical", not ${shown(kind)}.`
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
/** This function examines the condition of one dimension against all the dimensions. */
|
|
1002
|
+
function checkWhen(value, path, dimensions, faults) {
|
|
1003
|
+
const object = fields(value, path, ["dimension", "levels"], [], faults);
|
|
1004
|
+
if (object === void 0) return;
|
|
1005
|
+
const levels = Object.hasOwn(object, "levels") ? list(object["levels"], at(path, "levels"), 1, faults) : [];
|
|
1006
|
+
need(new Set(levels).size === levels.length, at(path, "levels"), "these levels have a duplicate.", faults);
|
|
1007
|
+
if (!Object.hasOwn(object, "dimension")) return;
|
|
1008
|
+
const id = object["dimension"];
|
|
1009
|
+
const parent = dimensions.find((d) => peek(d, "id") === id);
|
|
1010
|
+
if (parent === void 0) {
|
|
1011
|
+
faults.push({
|
|
1012
|
+
path: at(path, "dimension"),
|
|
1013
|
+
message: `the record has no dimension ${shown(id)}.`
|
|
1014
|
+
});
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const kind = peek(parent, "kind");
|
|
1018
|
+
need(kind === "categorical" || kind === "ordinal", at(path, "dimension"), `the parent ${shown(id)} is ${shown(kind)}, and a parent is categorical or ordinal.`, faults);
|
|
1019
|
+
need(peek(parent, "when") === void 0, at(path, "dimension"), `the parent ${shown(id)} is itself conditional.`, faults);
|
|
1020
|
+
const known = peek(parent, "levels");
|
|
1021
|
+
if (!Array.isArray(known)) return;
|
|
1022
|
+
levels.forEach((level, i) => need(known.includes(level), `${at(path, "levels")}[${i}]`, `the dimension ${shown(id)} has no level ${shown(level)}.`, faults));
|
|
1023
|
+
}
|
|
1024
|
+
/** This function examines one measure. */
|
|
1025
|
+
function checkMeasure(value, path, faults) {
|
|
1026
|
+
const object = fields(value, path, [
|
|
1027
|
+
"id",
|
|
1028
|
+
"label",
|
|
1029
|
+
"unit",
|
|
1030
|
+
"direction"
|
|
1031
|
+
], [], faults);
|
|
1032
|
+
if (object === void 0) return;
|
|
1033
|
+
check(object, "id", path, isId, "text with one character or more", faults);
|
|
1034
|
+
check(object, "label", path, isString, "text", faults);
|
|
1035
|
+
check(object, "unit", path, isString, "text", faults);
|
|
1036
|
+
check(object, "direction", path, (v) => v === "higher" || v === "lower" || v === "none", `"higher", "lower" or "none"`, faults);
|
|
1037
|
+
}
|
|
1038
|
+
/** This function examines one declared reason. */
|
|
1039
|
+
function checkReason(value, path, faults) {
|
|
1040
|
+
const object = fields(value, path, ["id", "label"], [], faults);
|
|
1041
|
+
if (object === void 0) return;
|
|
1042
|
+
check(object, "id", path, isId, "text with one character or more", faults);
|
|
1043
|
+
check(object, "label", path, isString, "text", faults);
|
|
1044
|
+
}
|
|
1045
|
+
/** This function examines the reasons of a point: each has a declaration, and each is on a measure with no number. */
|
|
1046
|
+
function checkAbsent(value, path, measuresOfPoint, measures, reasons, faults) {
|
|
1047
|
+
const absent = fields(value, path, [], [...measures], faults);
|
|
1048
|
+
if (absent === void 0) return;
|
|
1049
|
+
for (const measure of measures) {
|
|
1050
|
+
if (!Object.hasOwn(absent, measure)) continue;
|
|
1051
|
+
need(reasons.has(absent[measure]), at(path, measure), `the record declares no reason ${shown(absent[measure])}.`, faults);
|
|
1052
|
+
need(typeof peek(measuresOfPoint, measure) !== "number", at(path, measure), "this measure has a number, so it has no reason.", faults);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
/** This function examines one point, with the dimensions, the measures and the reasons of the record. */
|
|
1056
|
+
function checkPoint(value, path, dimensions, measures, reasons, faults) {
|
|
1057
|
+
const object = fields(value, path, [
|
|
1058
|
+
"id",
|
|
1059
|
+
"values",
|
|
1060
|
+
"measures"
|
|
1061
|
+
], ["group", "absent"], faults);
|
|
1062
|
+
if (object === void 0) return;
|
|
1063
|
+
check(object, "id", path, isId, "text with one character or more", faults);
|
|
1064
|
+
check(object, "group", path, isId, "text with one character or more", faults);
|
|
1065
|
+
if (Object.hasOwn(object, "values")) {
|
|
1066
|
+
const inside = at(path, "values");
|
|
1067
|
+
const ids = dimensions.map((d) => d.id);
|
|
1068
|
+
const values = fields(object["values"], inside, ids, [], faults);
|
|
1069
|
+
for (const dimension of dimensions) {
|
|
1070
|
+
if (values === void 0 || !Object.hasOwn(values, dimension.id)) continue;
|
|
1071
|
+
const value = values[dimension.id];
|
|
1072
|
+
const present = whenHolds(dimension, values);
|
|
1073
|
+
const parent = dimension.when?.dimension;
|
|
1074
|
+
if (value === null && dimension.when !== void 0) {
|
|
1075
|
+
need(!present, at(inside, dimension.id), `this needs a value when the dimension "${parent}" is ${shown(values[parent])}.`, faults);
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
const fault = valueFault(dimension, value);
|
|
1079
|
+
if (fault !== void 0) faults.push({
|
|
1080
|
+
path: at(inside, dimension.id),
|
|
1081
|
+
message: `${fault}.`
|
|
1082
|
+
});
|
|
1083
|
+
else need(present, at(inside, dimension.id), `this is null when the dimension "${parent}" is ${shown(values[parent])}.`, faults);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (Object.hasOwn(object, "absent")) checkAbsent(object["absent"], at(path, "absent"), object["measures"], measures, reasons, faults);
|
|
1087
|
+
if (Object.hasOwn(object, "measures")) {
|
|
1088
|
+
const inside = at(path, "measures");
|
|
1089
|
+
const values = fields(object["measures"], inside, [...measures], [], faults);
|
|
1090
|
+
if (values === void 0) return;
|
|
1091
|
+
for (const measure of measures) check(values, measure, inside, (v) => v === null || isNumber(v), "a finite number or null", faults);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* This function examines the data of a record: all but its models and its folds. It gives each fault that it finds.
|
|
1096
|
+
* It examines the points only when the dimensions and the measures have no fault, because the points refer to them.
|
|
1097
|
+
* `required` and `optional` name the other top-level fields.
|
|
1098
|
+
*/
|
|
1099
|
+
function responseDataFaults(value, required, optional) {
|
|
1100
|
+
const faults = [];
|
|
1101
|
+
const object = fields(value, "", [
|
|
1102
|
+
"schema",
|
|
1103
|
+
"name",
|
|
1104
|
+
"dimensions",
|
|
1105
|
+
"measures",
|
|
1106
|
+
"points",
|
|
1107
|
+
...required
|
|
1108
|
+
], ["reasons", ...optional], faults);
|
|
1109
|
+
if (object === void 0) return faults;
|
|
1110
|
+
check(object, "schema", "", (v) => v === RESPONSE_RECORDS_SCHEMA, `"${RESPONSE_RECORDS_SCHEMA}"`, faults);
|
|
1111
|
+
check(object, "name", "", isId, "text with one character or more", faults);
|
|
1112
|
+
const dimensions = Object.hasOwn(object, "dimensions") ? list(object["dimensions"], "dimensions", 1, faults) : [];
|
|
1113
|
+
dimensions.forEach((d, i) => checkDimension(d, `dimensions[${i}]`, faults));
|
|
1114
|
+
uniqueIds(dimensions, "dimensions", faults);
|
|
1115
|
+
dimensions.forEach((d, i) => {
|
|
1116
|
+
if (peek(d, "when") !== void 0) checkWhen(peek(d, "when"), `dimensions[${i}].when`, dimensions, faults);
|
|
1117
|
+
});
|
|
1118
|
+
const measures = Object.hasOwn(object, "measures") ? list(object["measures"], "measures", 1, faults) : [];
|
|
1119
|
+
measures.forEach((m, i) => checkMeasure(m, `measures[${i}]`, faults));
|
|
1120
|
+
const measureIds = uniqueIds(measures, "measures", faults);
|
|
1121
|
+
const reasons = Object.hasOwn(object, "reasons") ? list(object["reasons"], "reasons", 0, faults) : [];
|
|
1122
|
+
reasons.forEach((r, i) => checkReason(r, `reasons[${i}]`, faults));
|
|
1123
|
+
const reasonIds = uniqueIds(reasons, "reasons", faults);
|
|
1124
|
+
if (faults.length > 0) return faults;
|
|
1125
|
+
const points = list(object["points"], "points", 1, faults);
|
|
1126
|
+
points.forEach((p, i) => checkPoint(p, `points[${i}]`, dimensions, measureIds, reasonIds, faults));
|
|
1127
|
+
uniqueIds(points, "points", faults);
|
|
1128
|
+
return faults;
|
|
1129
|
+
}
|
|
1130
|
+
/** This function examines one input of a ridge model against the dimensions of the record. */
|
|
1131
|
+
function checkInput(value, path, dimensions, faults) {
|
|
1132
|
+
const kind = peek(value, "kind");
|
|
1133
|
+
if (kind !== "number" && kind !== "rank" && kind !== "level") {
|
|
1134
|
+
faults.push({
|
|
1135
|
+
path: at(path, "kind"),
|
|
1136
|
+
message: `this is "number", "rank" or "level", not ${shown(kind)}.`
|
|
1137
|
+
});
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
const keys = {
|
|
1141
|
+
number: ["center", "spread"],
|
|
1142
|
+
rank: [
|
|
1143
|
+
"levels",
|
|
1144
|
+
"center",
|
|
1145
|
+
"spread"
|
|
1146
|
+
],
|
|
1147
|
+
level: ["levels"]
|
|
1148
|
+
}[kind];
|
|
1149
|
+
const object = fields(value, path, [
|
|
1150
|
+
"kind",
|
|
1151
|
+
"dimension",
|
|
1152
|
+
...keys
|
|
1153
|
+
], kind === "number" ? ["log", "conditional"] : ["conditional"], faults);
|
|
1154
|
+
if (object === void 0) return;
|
|
1155
|
+
check(object, "center", path, isNumber, "a finite number", faults);
|
|
1156
|
+
check(object, "spread", path, isPositive, "a finite number above 0", faults);
|
|
1157
|
+
check(object, "log", path, (v) => typeof v === "boolean", "true or false", faults);
|
|
1158
|
+
check(object, "conditional", path, (v) => typeof v === "boolean", "true or false", faults);
|
|
1159
|
+
const dimension = dimensions.get(object["dimension"]);
|
|
1160
|
+
if (dimension === void 0) {
|
|
1161
|
+
faults.push({
|
|
1162
|
+
path: at(path, "dimension"),
|
|
1163
|
+
message: `the record has no dimension ${shown(object["dimension"])}.`
|
|
1164
|
+
});
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
const want = isNumberDimension(dimension) ? "number" : dimension.kind === "categorical" ? "level" : "rank";
|
|
1168
|
+
need(kind === want, at(path, "kind"), `the dimension "${dimension.id}" needs an input of the kind "${want}".`, faults);
|
|
1169
|
+
need((object["conditional"] ?? false) === (dimension.when !== void 0), at(path, "conditional"), `this does not agree with the condition of the dimension "${dimension.id}".`, faults);
|
|
1170
|
+
if (kind === "number" && dimension.kind !== "categorical") need((object["log"] ?? false) === (dimension.log ?? false), at(path, "log"), `this does not agree with the log scale of the dimension "${dimension.id}".`, faults);
|
|
1171
|
+
if (kind !== "number" && dimension.kind !== "continuous") need(JSON.stringify(object["levels"]) === JSON.stringify(dimension.levels), at(path, "levels"), `these are not the levels of the dimension "${dimension.id}".`, faults);
|
|
1172
|
+
}
|
|
1173
|
+
/** This function examines the factors of one term against the inputs of a model. `fitResponse` also uses it. */
|
|
1174
|
+
function factorFaults(value, path, inputs, dimensions) {
|
|
1175
|
+
const faults = [];
|
|
1176
|
+
list(value, path, 1, faults).forEach((item, i) => {
|
|
1177
|
+
const inside = `${path}[${i}]`;
|
|
1178
|
+
const isLevel = peek(item, "level") !== void 0;
|
|
1179
|
+
const factor = fields(item, inside, ["dimension", isLevel ? "level" : "power"], [], faults);
|
|
1180
|
+
if (factor === void 0) return;
|
|
1181
|
+
const input = inputs.get(factor["dimension"]);
|
|
1182
|
+
if (input === void 0) faults.push({
|
|
1183
|
+
path: at(inside, "dimension"),
|
|
1184
|
+
message: `the model has no input ${shown(factor["dimension"])}.`
|
|
1185
|
+
});
|
|
1186
|
+
else if (isLevel) {
|
|
1187
|
+
const dimension = dimensions.get(input.dimension);
|
|
1188
|
+
need((dimension === void 0 || dimension.kind === "continuous" ? [] : dimension.levels).includes(factor["level"]), at(inside, "level"), `the input "${input.dimension}" has no level ${shown(factor["level"])}.`, faults);
|
|
1189
|
+
} else {
|
|
1190
|
+
need(input.kind !== "level", inside, `the categorical input "${input.dimension}" takes a level, not a power.`, faults);
|
|
1191
|
+
const good = (v) => Number.isSafeInteger(v) && v >= 1 && v <= 8;
|
|
1192
|
+
check(factor, "power", inside, good, `a whole number from 1 to 8`, faults);
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
return faults;
|
|
1196
|
+
}
|
|
1197
|
+
/** This function examines the fields of a ridge model after its common fields. */
|
|
1198
|
+
function checkRidge(object, path, dimensions, faults) {
|
|
1199
|
+
check(object, "lambda", path, isNotNegative, "a finite number of 0 or more", faults);
|
|
1200
|
+
check(object, "intercept", path, isNumber, "a finite number", faults);
|
|
1201
|
+
const inputs = Object.hasOwn(object, "inputs") ? list(object["inputs"], at(path, "inputs"), 0, faults) : [];
|
|
1202
|
+
const known = /* @__PURE__ */ new Map();
|
|
1203
|
+
inputs.forEach((input, i) => {
|
|
1204
|
+
const inside = `${at(path, "inputs")}[${i}]`;
|
|
1205
|
+
checkInput(input, inside, dimensions, faults);
|
|
1206
|
+
const dimension = peek(input, "dimension");
|
|
1207
|
+
if (typeof dimension !== "string") return;
|
|
1208
|
+
need(!known.has(dimension), at(inside, "dimension"), `an earlier input has the dimension "${dimension}".`, faults);
|
|
1209
|
+
known.set(dimension, input);
|
|
1210
|
+
});
|
|
1211
|
+
(Object.hasOwn(object, "terms") ? list(object["terms"], at(path, "terms"), 0, faults) : []).forEach((term, i) => {
|
|
1212
|
+
const inside = `${at(path, "terms")}[${i}]`;
|
|
1213
|
+
const fieldsOfTerm = fields(term, inside, ["factors", "weight"], [], faults);
|
|
1214
|
+
if (fieldsOfTerm === void 0) return;
|
|
1215
|
+
check(fieldsOfTerm, "weight", inside, isNumber, "a finite number", faults);
|
|
1216
|
+
if (Object.hasOwn(fieldsOfTerm, "factors")) faults.push(...factorFaults(fieldsOfTerm["factors"], at(inside, "factors"), known, dimensions));
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
/** This function examines the dimensions of a table model, and it gives the good ones. */
|
|
1220
|
+
function tableDimensions(object, path, dimensions, faults) {
|
|
1221
|
+
const table = [];
|
|
1222
|
+
(Object.hasOwn(object, "dimensions") ? list(object["dimensions"], at(path, "dimensions"), 1, faults) : []).forEach((id, i) => {
|
|
1223
|
+
const dimension = dimensions.get(id);
|
|
1224
|
+
const inside = `${at(path, "dimensions")}[${i}]`;
|
|
1225
|
+
if (dimension === void 0) faults.push({
|
|
1226
|
+
path: inside,
|
|
1227
|
+
message: `the record has no dimension ${shown(id)}.`
|
|
1228
|
+
});
|
|
1229
|
+
else if (dimension.kind === "continuous") faults.push({
|
|
1230
|
+
path: inside,
|
|
1231
|
+
message: `a table cannot read the continuous dimension "${dimension.id}".`
|
|
1232
|
+
});
|
|
1233
|
+
else {
|
|
1234
|
+
need(!table.includes(dimension), inside, `an earlier item is the dimension "${dimension.id}".`, faults);
|
|
1235
|
+
table.push(dimension);
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
return table;
|
|
1239
|
+
}
|
|
1240
|
+
/** This function examines the fields of a table model after its common fields. */
|
|
1241
|
+
function checkTable(object, path, dimensions, faults) {
|
|
1242
|
+
check(object, "fallback", path, isNumber, "a finite number", faults);
|
|
1243
|
+
const table = tableDimensions(object, path, dimensions, faults);
|
|
1244
|
+
const conditional = table.filter((d) => d.when !== void 0).map((d) => d.id);
|
|
1245
|
+
need(JSON.stringify(object["conditional"] ?? []) === JSON.stringify(conditional), at(path, "conditional"), `this does not agree with the conditions of the dimensions of the table: ${shown(conditional)}.`, faults);
|
|
1246
|
+
const cells = Object.hasOwn(object, "cells") ? list(object["cells"], at(path, "cells"), 1, faults) : [];
|
|
1247
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1248
|
+
cells.forEach((cell, i) => {
|
|
1249
|
+
const inside = `${at(path, "cells")}[${i}]`;
|
|
1250
|
+
const fieldsOfCell = fields(cell, inside, [
|
|
1251
|
+
"at",
|
|
1252
|
+
"value",
|
|
1253
|
+
"n"
|
|
1254
|
+
], [], faults);
|
|
1255
|
+
if (fieldsOfCell === void 0) return;
|
|
1256
|
+
check(fieldsOfCell, "value", inside, isNumber, "a finite number", faults);
|
|
1257
|
+
check(fieldsOfCell, "n", inside, (v) => Number.isSafeInteger(v) && v >= 1, "a whole number of 1 or more", faults);
|
|
1258
|
+
if (!Object.hasOwn(fieldsOfCell, "at")) return;
|
|
1259
|
+
const values = list(fieldsOfCell["at"], at(inside, "at"), 0, faults);
|
|
1260
|
+
need(values.length === table.length, at(inside, "at"), `this list has ${values.length} values, and the table has ${table.length} dimensions.`, faults);
|
|
1261
|
+
table.forEach((dimension, j) => {
|
|
1262
|
+
const absent = values[j] === null && dimension.when !== void 0;
|
|
1263
|
+
const fault = j < values.length && !absent ? valueFault(dimension, values[j]) : void 0;
|
|
1264
|
+
if (fault !== void 0) faults.push({
|
|
1265
|
+
path: `${at(inside, "at")}[${j}]`,
|
|
1266
|
+
message: `${fault}.`
|
|
1267
|
+
});
|
|
1268
|
+
});
|
|
1269
|
+
const key = JSON.stringify(values);
|
|
1270
|
+
need(!seen.has(key), at(inside, "at"), `an earlier cell has the values ${shown(values)}.`, faults);
|
|
1271
|
+
seen.add(key);
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
/** This function examines the list of models: one model for each measure. */
|
|
1275
|
+
function checkModels(value, data, faults) {
|
|
1276
|
+
const dimensions = new Map(data.dimensions.map((d) => [d.id, d]));
|
|
1277
|
+
const measures = new Set(data.measures.map((m) => m.id));
|
|
1278
|
+
const covered = /* @__PURE__ */ new Set();
|
|
1279
|
+
list(value, "models", 0, faults).forEach((item, i) => {
|
|
1280
|
+
const path = `models[${i}]`;
|
|
1281
|
+
const kind = peek(item, "kind");
|
|
1282
|
+
if (kind !== "ridge" && kind !== "table") {
|
|
1283
|
+
faults.push({
|
|
1284
|
+
path: at(path, "kind"),
|
|
1285
|
+
message: `this is "ridge" or "table", not ${shown(kind)}.`
|
|
1286
|
+
});
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
const object = fields(item, path, [
|
|
1290
|
+
"kind",
|
|
1291
|
+
"measure",
|
|
1292
|
+
"scale",
|
|
1293
|
+
...kind === "ridge" ? [
|
|
1294
|
+
"lambda",
|
|
1295
|
+
"inputs",
|
|
1296
|
+
"intercept",
|
|
1297
|
+
"terms"
|
|
1298
|
+
] : [
|
|
1299
|
+
"dimensions",
|
|
1300
|
+
"cells",
|
|
1301
|
+
"fallback"
|
|
1302
|
+
]
|
|
1303
|
+
], kind === "ridge" ? ["floor"] : ["floor", "conditional"], faults);
|
|
1304
|
+
if (object === void 0) return;
|
|
1305
|
+
const measure = object["measure"];
|
|
1306
|
+
if (typeof measure !== "string" || !measures.has(measure)) faults.push({
|
|
1307
|
+
path: at(path, "measure"),
|
|
1308
|
+
message: `the record has no measure ${shown(measure)}.`
|
|
1309
|
+
});
|
|
1310
|
+
else {
|
|
1311
|
+
need(!covered.has(measure), at(path, "measure"), `an earlier model has the measure "${measure}".`, faults);
|
|
1312
|
+
covered.add(measure);
|
|
1313
|
+
}
|
|
1314
|
+
check(object, "scale", path, (v) => v === "linear" || v === "log", `"linear" or "log"`, faults);
|
|
1315
|
+
check(object, "floor", path, isPositive, "a finite number above 0", faults);
|
|
1316
|
+
need(!Object.hasOwn(object, "floor") || object["scale"] === "log", at(path, "floor"), "only a model with the scale log has a floor.", faults);
|
|
1317
|
+
if (kind === "ridge") checkRidge(object, path, dimensions, faults);
|
|
1318
|
+
else checkTable(object, path, dimensions, faults);
|
|
1319
|
+
});
|
|
1320
|
+
for (const measure of measures) need(covered.has(measure), "models", `no model has the measure "${measure}".`, faults);
|
|
1321
|
+
}
|
|
1322
|
+
/** This function examines the error of one measure in one fold. */
|
|
1323
|
+
function checkFoldError(value, path, count, faults) {
|
|
1324
|
+
const object = fields(value, path, [
|
|
1325
|
+
"n",
|
|
1326
|
+
"mae",
|
|
1327
|
+
"rmse",
|
|
1328
|
+
"baselineMae"
|
|
1329
|
+
], [], faults);
|
|
1330
|
+
if (object === void 0) return;
|
|
1331
|
+
check(object, "n", path, (v) => v === count, `${count}, the held-out points of the fold with a value of this measure`, faults);
|
|
1332
|
+
for (const key of [
|
|
1333
|
+
"mae",
|
|
1334
|
+
"rmse",
|
|
1335
|
+
"baselineMae"
|
|
1336
|
+
]) check(object, key, path, isNotNegative, "a finite number of 0 or more", faults);
|
|
1337
|
+
}
|
|
1338
|
+
/** This function examines the stored predictions of a fold against its held-out points and its errors. */
|
|
1339
|
+
function checkPredictions(value, path, held, errors, measures, faults) {
|
|
1340
|
+
const predictions = fields(value, path, [], measures, faults);
|
|
1341
|
+
if (predictions === void 0) return;
|
|
1342
|
+
for (const measure of measures) {
|
|
1343
|
+
if (!Object.hasOwn(predictions, measure)) continue;
|
|
1344
|
+
const inside = at(path, measure);
|
|
1345
|
+
const stored = fields(predictions[measure], inside, [], Object.keys(peek(predictions, measure) ?? {}), faults);
|
|
1346
|
+
if (stored === void 0) continue;
|
|
1347
|
+
need(peek(errors, measure) !== void 0, inside, "this fold has no error of this measure.", faults);
|
|
1348
|
+
const scored = new Set(held.filter((p) => typeof p.measures[measure] === "number").map((p) => p.id));
|
|
1349
|
+
for (const id of Object.keys(stored)) {
|
|
1350
|
+
need(scored.has(id), at(inside, id), `this fold has no held-out point ${shown(id)} with a value of the measure.`, faults);
|
|
1351
|
+
check(stored, id, inside, isNumber, "a finite number", faults);
|
|
1352
|
+
}
|
|
1353
|
+
for (const id of scored) need(Object.hasOwn(stored, id), at(inside, id), "this held-out point has no prediction.", faults);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
/** This function adds the points of a fold to `foldOfPoint`, and it gives them. Each bad id gives a fault. */
|
|
1357
|
+
function heldPoints(value, path, f, points, foldOfPoint, faults) {
|
|
1358
|
+
const held = [];
|
|
1359
|
+
list(value, path, 1, faults).forEach((id, i) => {
|
|
1360
|
+
const inside = `${path}[${i}]`;
|
|
1361
|
+
const point = points.get(id);
|
|
1362
|
+
const earlier = foldOfPoint.get(id);
|
|
1363
|
+
if (point === void 0) faults.push({
|
|
1364
|
+
path: inside,
|
|
1365
|
+
message: `the record has no point ${shown(id)}.`
|
|
1366
|
+
});
|
|
1367
|
+
else if (earlier !== void 0) faults.push({
|
|
1368
|
+
path: inside,
|
|
1369
|
+
message: `the point "${point.id}" is also in fold ${earlier}.`
|
|
1370
|
+
});
|
|
1371
|
+
else {
|
|
1372
|
+
foldOfPoint.set(point.id, f);
|
|
1373
|
+
held.push(point);
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
return held;
|
|
1377
|
+
}
|
|
1378
|
+
/** This function gives the number of points of each group in a list. */
|
|
1379
|
+
function groupSizes(points) {
|
|
1380
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
1381
|
+
for (const p of points) sizes.set(groupKey(p), (sizes.get(groupKey(p)) ?? 0) + 1);
|
|
1382
|
+
return sizes;
|
|
1383
|
+
}
|
|
1384
|
+
/** This function examines the list of folds. `parseResponseRecord` tells the rules. */
|
|
1385
|
+
function checkFolds(value, data, faults) {
|
|
1386
|
+
const points = new Map(data.points.map((p) => [p.id, p]));
|
|
1387
|
+
const measures = data.measures.map((m) => m.id);
|
|
1388
|
+
const sizes = groupSizes(data.points);
|
|
1389
|
+
const foldOfPoint = /* @__PURE__ */ new Map();
|
|
1390
|
+
const covered = /* @__PURE__ */ new Set();
|
|
1391
|
+
const folds = list(value, "folds", 1, faults);
|
|
1392
|
+
const predicted = measures.filter((m) => folds.some((fold) => peek(peek(fold, "predictions"), m) !== void 0));
|
|
1393
|
+
folds.forEach((item, f) => {
|
|
1394
|
+
const path = `folds[${f}]`;
|
|
1395
|
+
const object = fields(item, path, ["heldOut", "errors"], ["predictions"], faults);
|
|
1396
|
+
if (object === void 0) return;
|
|
1397
|
+
const held = Object.hasOwn(object, "heldOut") ? heldPoints(object["heldOut"], at(path, "heldOut"), f, points, foldOfPoint, faults) : [];
|
|
1398
|
+
for (const [group, count] of groupSizes(held)) {
|
|
1399
|
+
const size = sizes.get(group);
|
|
1400
|
+
need(count === size, at(path, "heldOut"), `the ${group} has ${count} of its ${size} points in this fold, so it is on both sides of the split.`, faults);
|
|
1401
|
+
}
|
|
1402
|
+
if (Object.hasOwn(object, "predictions")) checkPredictions(object["predictions"], at(path, "predictions"), held, object["errors"], measures, faults);
|
|
1403
|
+
if (!Object.hasOwn(object, "errors")) return;
|
|
1404
|
+
const errors = fields(object["errors"], at(path, "errors"), [], measures, faults);
|
|
1405
|
+
if (errors === void 0) return;
|
|
1406
|
+
for (const measure of measures) {
|
|
1407
|
+
if (!Object.hasOwn(errors, measure)) continue;
|
|
1408
|
+
const count = held.filter((p) => p.measures[measure] !== null).length;
|
|
1409
|
+
checkFoldError(errors[measure], at(at(path, "errors"), measure), count, faults);
|
|
1410
|
+
if (count > 0) covered.add(measure);
|
|
1411
|
+
need(!predicted.includes(measure) || peek(object["predictions"], measure) !== void 0, at(path, "predictions"), `another fold has predictions of the measure "${measure}", so this fold needs them too.`, faults);
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
for (const measure of measures) need(covered.has(measure), "folds", `no fold has a held-out error of the measure "${measure}".`, faults);
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* This function examines unknown data, for example the result of `JSON.parse`, against `response-records/1`. It gives
|
|
1418
|
+
* `{ ok: true, record }`, with the same object as the record, or `{ ok: false, faults }`. Each fault has the path of
|
|
1419
|
+
* the bad field and a message. The function does not throw. Data that the checks cannot read, for example an object
|
|
1420
|
+
* with a loop, gives one fault.
|
|
1421
|
+
*
|
|
1422
|
+
* A field that the format does not have is a fault. Thus a wrong spelling of an optional field cannot go unseen.
|
|
1423
|
+
*
|
|
1424
|
+
* The function examines the models and the folds only when the data has no fault, because they refer to the data.
|
|
1425
|
+
* Thus a correction of the data can show more faults. These rules apply to the folds:
|
|
1426
|
+
* - A point is in one fold or in no fold.
|
|
1427
|
+
* - All the points of a group are in one fold, or all are in no fold. Thus no group is on both sides of a split.
|
|
1428
|
+
* - The count `n` of an error is the number of held-out points with a value of the measure.
|
|
1429
|
+
* - Each measure has an error in one fold or more.
|
|
1430
|
+
*
|
|
1431
|
+
* These rules apply to the predictions of a fold:
|
|
1432
|
+
* - Each prediction is a finite number of a held-out point of the fold with a value of the measure.
|
|
1433
|
+
* - The fold has a prediction of each such point, and an error of the measure.
|
|
1434
|
+
* - When one fold has predictions of a measure, each fold with an error of the measure has them.
|
|
1435
|
+
*
|
|
1436
|
+
* These rules apply to the reasons of a point:
|
|
1437
|
+
* - Each reason is on a known measure with no number. A measure with `null` and no reason is good.
|
|
1438
|
+
* - Each reason has a declaration in the field `reasons` of the record.
|
|
1439
|
+
*/
|
|
1440
|
+
function parseResponseRecord(value) {
|
|
1441
|
+
try {
|
|
1442
|
+
const faults = [...responseDataFaults(value, ["models", "folds"], [])];
|
|
1443
|
+
if (faults.length === 0) {
|
|
1444
|
+
const record = value;
|
|
1445
|
+
checkModels(record.models, record, faults);
|
|
1446
|
+
checkFolds(record.folds, record, faults);
|
|
1447
|
+
if (faults.length === 0) return {
|
|
1448
|
+
ok: true,
|
|
1449
|
+
record
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
return {
|
|
1453
|
+
ok: false,
|
|
1454
|
+
faults
|
|
1455
|
+
};
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
return {
|
|
1458
|
+
ok: false,
|
|
1459
|
+
faults: [{
|
|
1460
|
+
path: "",
|
|
1461
|
+
message: `the validator cannot read this data: ${String(error)}.`
|
|
1462
|
+
}]
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
// src/response-fit.ts
|
|
1467
|
+
/**
|
|
1468
|
+
* The fit of `response-records/1`. The function `fitResponse` splits the groups into folds. For each fold, it fits a
|
|
1469
|
+
* model on the other folds and scores the model on the fold. Then it fits the model of the record on all the points.
|
|
1470
|
+
*
|
|
1471
|
+
* The coordinates of a ridge model come from the declared ranges, not from the points. Thus each fold has the same
|
|
1472
|
+
* design, and no fact of a held-out point goes into a fit. The penalty of a fold comes from the other folds only.
|
|
1473
|
+
*/
|
|
1474
|
+
/** The default penalties of a ridge fit. The coordinates are from -1 to 1, so one list suits each record. */
|
|
1475
|
+
const RESPONSE_LAMBDAS = [
|
|
1476
|
+
1e-6,
|
|
1477
|
+
1e-4,
|
|
1478
|
+
.001,
|
|
1479
|
+
.01,
|
|
1480
|
+
.1,
|
|
1481
|
+
1,
|
|
1482
|
+
10,
|
|
1483
|
+
100
|
|
1484
|
+
];
|
|
1485
|
+
/** The largest number of coefficients of a ridge fit. The memory of each fold grows as the square of this number. */
|
|
1486
|
+
const RESPONSE_MAX_TERMS = 1024;
|
|
1487
|
+
/** This function compares two texts by their code units, so the order does not depend on a locale. */
|
|
1488
|
+
const byText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
1489
|
+
/** This function gives the input of a ridge model for a dimension. The coordinates run from -1 to 1 over the dimension. */
|
|
1490
|
+
function inputOf(dimension) {
|
|
1491
|
+
const conditional = dimension.when === void 0 ? {} : { conditional: true };
|
|
1492
|
+
if (dimension.kind === "categorical") return {
|
|
1493
|
+
kind: "level",
|
|
1494
|
+
dimension: dimension.id,
|
|
1495
|
+
levels: dimension.levels,
|
|
1496
|
+
...conditional
|
|
1497
|
+
};
|
|
1498
|
+
const [low, high] = axisEnds(dimension);
|
|
1499
|
+
const center = (low + high) / 2;
|
|
1500
|
+
const spread = high > low ? (high - low) / 2 : 1;
|
|
1501
|
+
if (!isNumberDimension(dimension)) return {
|
|
1502
|
+
kind: "rank",
|
|
1503
|
+
dimension: dimension.id,
|
|
1504
|
+
levels: dimension.levels,
|
|
1505
|
+
center,
|
|
1506
|
+
spread,
|
|
1507
|
+
...conditional
|
|
1508
|
+
};
|
|
1509
|
+
return {
|
|
1510
|
+
kind: "number",
|
|
1511
|
+
dimension: dimension.id,
|
|
1512
|
+
center,
|
|
1513
|
+
spread,
|
|
1514
|
+
...dimension.log === true ? { log: true } : {},
|
|
1515
|
+
...conditional
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
/** This function gives the factors of one dimension, each with its power. */
|
|
1519
|
+
function factorsOf(dimension, degree) {
|
|
1520
|
+
if (dimension.kind === "categorical") return dimension.levels.map((level) => [{
|
|
1521
|
+
dimension: dimension.id,
|
|
1522
|
+
level
|
|
1523
|
+
}, 1]);
|
|
1524
|
+
return Array.from({ length: degree }, (_, i) => [{
|
|
1525
|
+
dimension: dimension.id,
|
|
1526
|
+
power: i + 1
|
|
1527
|
+
}, i + 1]);
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* This function gives one column for each level of an ordinal parent inside a condition. An ordinal parent has no
|
|
1531
|
+
* one-hot columns of its own, so these columns carry the mean of each level with an absent child.
|
|
1532
|
+
*/
|
|
1533
|
+
function parentColumns(dimensions) {
|
|
1534
|
+
return dimensions.flatMap((parent) => {
|
|
1535
|
+
if (parent.kind !== "ordinal") return [];
|
|
1536
|
+
const conditions = dimensions.flatMap((d) => d.when?.dimension === parent.id ? [d.when.levels] : []);
|
|
1537
|
+
return parent.levels.filter((level) => conditions.some((c) => c.includes(level))).map((level) => [{
|
|
1538
|
+
dimension: parent.id,
|
|
1539
|
+
level
|
|
1540
|
+
}]);
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
/** This function gives true when the general product of two dimensions adds nothing: a child with its parent, or two children with no common level. */
|
|
1544
|
+
function separate(a, b) {
|
|
1545
|
+
if (a.when?.dimension === b.id || b.when?.dimension === a.id) return true;
|
|
1546
|
+
return a.when !== void 0 && a.when.dimension === b.when?.dimension && !a.when.levels.some((level) => b.when?.levels.includes(level));
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* This function gives the terms of the expansion: each factor, and each product of two dimensions up to `degree`. A
|
|
1550
|
+
* conditional dimension with two parent levels or more also has each power times each of these levels. Thus each
|
|
1551
|
+
* level has its own curve. With one parent level, the terms of the dimension are already the curve of this level.
|
|
1552
|
+
*/
|
|
1553
|
+
function expansion(dimensions, degree, interactions) {
|
|
1554
|
+
const each = dimensions.map((d) => factorsOf(d, degree));
|
|
1555
|
+
const terms = [...each.flatMap((factors) => factors.map(([factor]) => [factor])), ...parentColumns(dimensions)];
|
|
1556
|
+
if (!interactions) return terms;
|
|
1557
|
+
each.forEach((first, a) => {
|
|
1558
|
+
each.slice(a + 1).forEach((second, offset) => {
|
|
1559
|
+
if (separate(dimensions[a], dimensions[a + 1 + offset])) return;
|
|
1560
|
+
for (const [f, p] of first) for (const [g, q] of second) if (p + q <= degree) terms.push([f, g]);
|
|
1561
|
+
});
|
|
1562
|
+
});
|
|
1563
|
+
dimensions.forEach(({ when }, a) => {
|
|
1564
|
+
if (when === void 0 || when.levels.length < 2) return;
|
|
1565
|
+
for (const [factor] of each[a]) for (const level of when.levels) terms.push([factor, {
|
|
1566
|
+
dimension: when.dimension,
|
|
1567
|
+
level
|
|
1568
|
+
}]);
|
|
1569
|
+
});
|
|
1570
|
+
return terms;
|
|
1571
|
+
}
|
|
1572
|
+
/** This function gives the terms that are not 0 at one point or more. A fold gives a term with no point the weight 0. */
|
|
1573
|
+
function seenTerms(terms, dimensions, points) {
|
|
1574
|
+
const inputs = checkedInputs(dimensions, terms);
|
|
1575
|
+
const places = points.map((point) => ({
|
|
1576
|
+
at: coordinates(inputs, point.values),
|
|
1577
|
+
values: point.values
|
|
1578
|
+
}));
|
|
1579
|
+
return terms.filter((term) => places.some(({ at, values }) => product(term, at, values) !== 0));
|
|
1580
|
+
}
|
|
1581
|
+
/** This function gives the inputs of a ridge fit: one for each dimension of a term. It throws for a bad term. */
|
|
1582
|
+
function checkedInputs(dimensions, terms) {
|
|
1583
|
+
const used = new Set(terms.flatMap((t) => t.map((f) => f.dimension)));
|
|
1584
|
+
const inputs = dimensions.filter((d) => used.has(d.id)).map(inputOf);
|
|
1585
|
+
const known = new Map(inputs.map((input) => [input.dimension, input]));
|
|
1586
|
+
const byId = new Map(dimensions.map((d) => [d.id, d]));
|
|
1587
|
+
terms.forEach((term, i) => {
|
|
1588
|
+
const [fault] = factorFaults(term, `terms[${i}]`, known, byId);
|
|
1589
|
+
if (fault !== void 0) throw new RangeError(`fitResponse: ${fault.path}: ${fault.message}`);
|
|
1590
|
+
});
|
|
1591
|
+
return inputs;
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* This function gives the ridge fits of the rows of one measure. It keeps `X'X` and `X'y` of each fold, so a fit adds
|
|
1595
|
+
* the sums of its training folds. The first coefficient is the intercept, and it has no penalty.
|
|
1596
|
+
*/
|
|
1597
|
+
function ridgeLearner(rows, plan) {
|
|
1598
|
+
const { terms, inputs, folds, lambdas } = plan;
|
|
1599
|
+
const k = terms.length + 1;
|
|
1600
|
+
const design = new Float64Array(rows.length * k);
|
|
1601
|
+
const grams = Array.from({ length: folds }, () => new Float64Array(k * k));
|
|
1602
|
+
const moments = Array.from({ length: folds }, () => new Float64Array(k));
|
|
1603
|
+
rows.forEach((row, r) => {
|
|
1604
|
+
const at = coordinates(inputs, row.point.values);
|
|
1605
|
+
const offset = r * k;
|
|
1606
|
+
design[offset] = 1;
|
|
1607
|
+
terms.forEach((term, j) => {
|
|
1608
|
+
design[offset + j + 1] = product(term, at, row.point.values);
|
|
1609
|
+
});
|
|
1610
|
+
const gram = grams[row.fold];
|
|
1611
|
+
const moment = moments[row.fold];
|
|
1612
|
+
for (let a = 0; a < k; a++) {
|
|
1613
|
+
const value = design[offset + a];
|
|
1614
|
+
moment[a] = moment[a] + value * row.y;
|
|
1615
|
+
for (let b = 0; b <= a; b++) gram[a * k + b] = gram[a * k + b] + value * design[offset + b];
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
const predict = (beta, r) => {
|
|
1619
|
+
let sum = 0;
|
|
1620
|
+
for (let j = 0; j < k; j++) sum += beta[j] * design[r * k + j];
|
|
1621
|
+
return sum;
|
|
1622
|
+
};
|
|
1623
|
+
const solve = (left, lambda) => {
|
|
1624
|
+
const gram = new Float64Array(k * k);
|
|
1625
|
+
const moment = new Float64Array(k);
|
|
1626
|
+
for (let f = 0; f < folds; f++) {
|
|
1627
|
+
if (left.includes(f)) continue;
|
|
1628
|
+
const g = grams[f];
|
|
1629
|
+
const m = moments[f];
|
|
1630
|
+
for (let i = 0; i < k * k; i++) gram[i] = gram[i] + g[i];
|
|
1631
|
+
for (let i = 0; i < k; i++) moment[i] = moment[i] + m[i];
|
|
1632
|
+
}
|
|
1633
|
+
return ridgeSolve(gram, moment, lambda, { free: [0] });
|
|
1634
|
+
};
|
|
1635
|
+
const choose = (outer) => {
|
|
1636
|
+
const descending = [...lambdas].sort((a, b) => b - a);
|
|
1637
|
+
let best = descending[0];
|
|
1638
|
+
let bestError = Number.POSITIVE_INFINITY;
|
|
1639
|
+
for (const lambda of descending) {
|
|
1640
|
+
let error = 0;
|
|
1641
|
+
for (let f = 0; f < folds; f++) {
|
|
1642
|
+
if (f === outer) continue;
|
|
1643
|
+
const beta = solve([outer, f], lambda);
|
|
1644
|
+
rows.forEach((row, r) => {
|
|
1645
|
+
if (row.fold === f) error += (row.y - predict(beta, r)) ** 2;
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
if (error < bestError) [best, bestError] = [lambda, error];
|
|
1649
|
+
}
|
|
1650
|
+
return best;
|
|
1651
|
+
};
|
|
1652
|
+
return {
|
|
1653
|
+
learner: (fold) => {
|
|
1654
|
+
const beta = solve([fold], choose(fold));
|
|
1655
|
+
return (r) => predict(beta, r);
|
|
1656
|
+
},
|
|
1657
|
+
final: () => {
|
|
1658
|
+
const lambda = choose(void 0);
|
|
1659
|
+
return {
|
|
1660
|
+
lambda,
|
|
1661
|
+
beta: solve([], lambda)
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
/** This function gives the mean of the rows of each combination of levels, as cells in the order of their text. */
|
|
1667
|
+
function tableCells(rows, dimensions) {
|
|
1668
|
+
const cells = /* @__PURE__ */ new Map();
|
|
1669
|
+
for (const row of rows) {
|
|
1670
|
+
const at = dimensions.map((d) => row.point.values[d]);
|
|
1671
|
+
const key = JSON.stringify(at);
|
|
1672
|
+
const cell = cells.get(key);
|
|
1673
|
+
if (cell === void 0) cells.set(key, {
|
|
1674
|
+
at,
|
|
1675
|
+
ys: [row.y]
|
|
1676
|
+
});
|
|
1677
|
+
else cell.ys.push(row.y);
|
|
1678
|
+
}
|
|
1679
|
+
return [...cells.keys()].sort(byText).map((key) => {
|
|
1680
|
+
const { at, ys } = cells.get(key);
|
|
1681
|
+
return {
|
|
1682
|
+
at,
|
|
1683
|
+
value: mean(ys),
|
|
1684
|
+
n: ys.length
|
|
1685
|
+
};
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
/** This function gives the fits of a table: the mean of each cell, or the mean of the rows for a cell with no row. */
|
|
1689
|
+
function tableLearner(rows, dimensions) {
|
|
1690
|
+
const keys = rows.map((row) => JSON.stringify(dimensions.map((d) => row.point.values[d])));
|
|
1691
|
+
return (fold) => {
|
|
1692
|
+
const train = rows.filter((row) => row.fold !== fold);
|
|
1693
|
+
const cells = new Map(tableCells(train, dimensions).map((c) => [JSON.stringify(c.at), c.value]));
|
|
1694
|
+
const fallback = mean(train.map((row) => row.y));
|
|
1695
|
+
return (r) => cells.get(keys[r]) ?? fallback;
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
/** This function gives the score of a fold. A fold with no held-out row or no training row has no score. */
|
|
1699
|
+
function foldScore(rows, fold, learner) {
|
|
1700
|
+
const train = rows.filter((row) => row.fold !== fold).map((row) => row.y);
|
|
1701
|
+
const held = rows.flatMap((row, r) => row.fold === fold ? [r] : []);
|
|
1702
|
+
if (held.length === 0 || train.length === 0) return void 0;
|
|
1703
|
+
const predict = learner(fold);
|
|
1704
|
+
const baseline = mean(train);
|
|
1705
|
+
const predicted = held.map((r) => predict(r));
|
|
1706
|
+
const errors = held.map((r, i) => rows[r].y - predicted[i]);
|
|
1707
|
+
return {
|
|
1708
|
+
error: {
|
|
1709
|
+
n: held.length,
|
|
1710
|
+
mae: mean(errors.map(Math.abs)),
|
|
1711
|
+
rmse: Math.sqrt(mean(errors.map((e) => e * e))),
|
|
1712
|
+
baselineMae: mean(held.map((r) => Math.abs(rows[r].y - baseline)))
|
|
1713
|
+
},
|
|
1714
|
+
predictions: Object.fromEntries(held.map((r, i) => [rows[r].point.id, predicted[i]]))
|
|
1715
|
+
};
|
|
1716
|
+
}
|
|
1717
|
+
/** This function gives the rows of a measure. It throws a RangeError for a value that a log scale cannot read. */
|
|
1718
|
+
function rowsOf(points, measure, log, floor, foldOf) {
|
|
1719
|
+
return points.flatMap((point) => {
|
|
1720
|
+
const value = point.measures[measure];
|
|
1721
|
+
if (typeof value !== "number") return [];
|
|
1722
|
+
if (log && floor === void 0 && value <= 0) throw new RangeError(`fitResponse: the point "${point.id}" has the value ${value} of the log measure "${measure}". Give logFloor.`);
|
|
1723
|
+
return [{
|
|
1724
|
+
point,
|
|
1725
|
+
y: toModelScale(value, log, floor),
|
|
1726
|
+
fold: foldOf.get(groupKey(point))
|
|
1727
|
+
}];
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
/** This function gives the model of one measure on all its rows, and the score of each fold. */
|
|
1731
|
+
function fitMeasure(rows, measure, log, floor, plan) {
|
|
1732
|
+
if (rows.length === 0) throw new RangeError(`fitResponse: no point has a value of the measure "${measure.id}".`);
|
|
1733
|
+
const base = {
|
|
1734
|
+
measure: measure.id,
|
|
1735
|
+
scale: log ? "log" : "linear",
|
|
1736
|
+
...log && floor !== void 0 ? { floor } : {}
|
|
1737
|
+
};
|
|
1738
|
+
const ridge = plan.kind === "ridge" ? ridgeLearner(rows, plan) : void 0;
|
|
1739
|
+
const learner = ridge?.learner ?? tableLearner(rows, plan.ids);
|
|
1740
|
+
const scores = Array.from({ length: plan.folds }, (_, f) => foldScore(rows, f, learner));
|
|
1741
|
+
if (scores.every((s) => s === void 0)) throw new RangeError(`fitResponse: all the values of the measure "${measure.id}" are in one fold. Give more groups.`);
|
|
1742
|
+
const conditional = plan.conditional.length === 0 ? {} : { conditional: plan.conditional };
|
|
1743
|
+
if (ridge === void 0) return {
|
|
1744
|
+
model: {
|
|
1745
|
+
kind: "table",
|
|
1746
|
+
...base,
|
|
1747
|
+
dimensions: plan.ids,
|
|
1748
|
+
...conditional,
|
|
1749
|
+
cells: tableCells(rows, plan.ids),
|
|
1750
|
+
fallback: mean(rows.map((r) => r.y))
|
|
1751
|
+
},
|
|
1752
|
+
scores
|
|
1753
|
+
};
|
|
1754
|
+
const { lambda, beta } = ridge.final();
|
|
1755
|
+
if (!beta.every(Number.isFinite)) throw new RangeError(`fitResponse: the fit of the measure "${measure.id}" with lambda ${lambda} is singular. Give a larger lambda.`);
|
|
1756
|
+
const terms = plan.terms.map((factors, j) => ({
|
|
1757
|
+
factors,
|
|
1758
|
+
weight: beta[j + 1]
|
|
1759
|
+
}));
|
|
1760
|
+
return {
|
|
1761
|
+
model: {
|
|
1762
|
+
kind: "ridge",
|
|
1763
|
+
...base,
|
|
1764
|
+
lambda,
|
|
1765
|
+
inputs: plan.inputs,
|
|
1766
|
+
intercept: beta[0],
|
|
1767
|
+
terms
|
|
1768
|
+
},
|
|
1769
|
+
scores
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
/** This function examines the options that do not depend on the data, and it gives the number of folds and the penalties. */
|
|
1773
|
+
function checkedOptions(options) {
|
|
1774
|
+
const { folds = 5, degree = 2, lambdas = RESPONSE_LAMBDAS, logFloor } = options;
|
|
1775
|
+
if (!Number.isSafeInteger(folds) || folds < 3) throw new RangeError(`fitResponse: folds is a whole number of 3 or more, not ${folds}.`);
|
|
1776
|
+
if (!Number.isSafeInteger(degree) || degree < 1 || degree > 8) throw new RangeError(`fitResponse: degree is a whole number from 1 to 8, not ${degree}.`);
|
|
1777
|
+
if (lambdas.length === 0 || !lambdas.every((l) => Number.isFinite(l) && l > 0)) throw new RangeError("fitResponse: lambdas is a list of finite numbers above 0, with one item or more.");
|
|
1778
|
+
if (logFloor !== void 0 && !(Number.isFinite(logFloor) && logFloor > 0)) throw new RangeError(`fitResponse: logFloor is a finite number above 0, not ${logFloor}.`);
|
|
1779
|
+
return {
|
|
1780
|
+
folds,
|
|
1781
|
+
degree,
|
|
1782
|
+
lambdas
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
/** This function gives the kind of model. It throws a RangeError when a table cannot read a dimension. */
|
|
1786
|
+
function kindOf(dimensions, model) {
|
|
1787
|
+
const continuous = dimensions.find((d) => d.kind === "continuous");
|
|
1788
|
+
if (model === "table" && continuous !== void 0) throw new RangeError(`fitResponse: a table cannot read the continuous dimension "${continuous.id}".`);
|
|
1789
|
+
if (model !== "auto") return model;
|
|
1790
|
+
return dimensions.every((d) => d.kind === "categorical") ? "table" : "ridge";
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* This function fits each measure of `data`, and it gives a record with the models and the folds.
|
|
1794
|
+
*
|
|
1795
|
+
* The split: the groups, sorted and then shuffled with `seed`, go to the folds in turn. A point with no group is a
|
|
1796
|
+
* group of its own. Thus the repeats of one group are not on both sides of a split. The same split applies to each
|
|
1797
|
+
* measure. A record needs 3 groups or more.
|
|
1798
|
+
*
|
|
1799
|
+
* The model: a ridge fit over a stated expansion, or the mean of each cell of a table. The default expansion has
|
|
1800
|
+
* each power of each number dimension up to `degree`. It has one column for each level of a categorical dimension.
|
|
1801
|
+
* It also has the products of two dimensions up to `degree`. Each coordinate goes from -1 to 1 over the declared
|
|
1802
|
+
* range. The intercept has no penalty.
|
|
1803
|
+
*
|
|
1804
|
+
* A conditional dimension: a term with an absent dimension is 0. Thus the columns of the parent carry the mean of each
|
|
1805
|
+
* level with no value. A dimension with two parent levels or more also has each power times each of these levels, if
|
|
1806
|
+
* `interactions` is true. A table keeps `null` as a level of its cells.
|
|
1807
|
+
*
|
|
1808
|
+
* The penalty: each fold of a ridge fit chooses from `lambdas` with the other folds. Thus the held-out error of a
|
|
1809
|
+
* fold is not optimistic. The model of the record chooses with all the folds.
|
|
1810
|
+
*
|
|
1811
|
+
* The errors: a fold scores the model of the other folds on its held-out points, on the scale of the model. With the
|
|
1812
|
+
* option `outOfFold`, the fold also stores the prediction of each of these points.
|
|
1813
|
+
*
|
|
1814
|
+
* The reasons: a measure with a stated reason is `null` for the fit. The field `reasons` of the data stays in the record.
|
|
1815
|
+
*
|
|
1816
|
+
* The result does not depend on the order of the points, except for the field `points`, which keeps the order of
|
|
1817
|
+
* the data. The same data and options give the same bytes in one JavaScript engine.
|
|
1818
|
+
*
|
|
1819
|
+
* The function throws a RangeError for data with a fault, for a bad option, and for fewer than 3 groups. It also
|
|
1820
|
+
* throws for a measure with no value, and for a measure with all its values in one fold. It throws for a value of 0
|
|
1821
|
+
* or less on a log scale with no `logFloor`. It throws for more than `RESPONSE_MAX_TERMS` terms.
|
|
1822
|
+
*/
|
|
1823
|
+
function fitResponse(data, options = {}) {
|
|
1824
|
+
const [fault] = responseDataFaults(data, [], ["models", "folds"]);
|
|
1825
|
+
if (fault !== void 0) throw new RangeError(`fitResponse: ${fault.path === "" ? "the data" : fault.path}: ${fault.message}`);
|
|
1826
|
+
const { degree, lambdas, folds: wanted } = checkedOptions(options);
|
|
1827
|
+
const { dimensions, measures } = data;
|
|
1828
|
+
const log = new Set(options.log ?? []);
|
|
1829
|
+
for (const id of log) if (!measures.some((m) => m.id === id)) throw new RangeError(`fitResponse: the option log names the unknown measure "${id}".`);
|
|
1830
|
+
const kind = kindOf(dimensions, options.model ?? "auto");
|
|
1831
|
+
const interactions = options.interactions ?? true;
|
|
1832
|
+
const expanded = kind === "table" ? [] : options.terms ?? expansion(dimensions, degree, interactions !== false);
|
|
1833
|
+
const terms = interactions === "seen" && options.terms === void 0 ? seenTerms(expanded, dimensions, data.points) : expanded;
|
|
1834
|
+
if (terms.length + 1 > 1024) throw new RangeError(`fitResponse: the expansion has ${terms.length} terms, and the limit is 1023. Set interactions to false or "seen", or give terms.`);
|
|
1835
|
+
const inputs = checkedInputs(dimensions, terms);
|
|
1836
|
+
const points = [...data.points].sort((a, b) => byText(a.id, b.id));
|
|
1837
|
+
const groups = [...new Set(points.map(groupKey))].sort(byText);
|
|
1838
|
+
if (groups.length < 3) throw new RangeError(`fitResponse: the points have ${groups.length} groups, and a held-out split needs 3 or more.`);
|
|
1839
|
+
const folds = Math.min(wanted, groups.length);
|
|
1840
|
+
const shuffled = random(options.seed ?? 1).shuffle(groups);
|
|
1841
|
+
const foldOf = new Map(shuffled.map((group, i) => [group, i % folds]));
|
|
1842
|
+
const plan = {
|
|
1843
|
+
ids: dimensions.map((d) => d.id),
|
|
1844
|
+
conditional: dimensions.flatMap((d) => d.when === void 0 ? [] : [d.id]),
|
|
1845
|
+
kind,
|
|
1846
|
+
folds,
|
|
1847
|
+
lambdas,
|
|
1848
|
+
terms,
|
|
1849
|
+
inputs
|
|
1850
|
+
};
|
|
1851
|
+
const fits = measures.map((m) => fitMeasure(rowsOf(points, m.id, log.has(m.id), options.logFloor, foldOf), m, log.has(m.id), options.logFloor, plan));
|
|
1852
|
+
const foldRecords = Array.from({ length: folds }, (_, f) => {
|
|
1853
|
+
const scored = measures.flatMap((m, i) => {
|
|
1854
|
+
const score = fits[i]?.scores[f];
|
|
1855
|
+
return score === void 0 ? [] : [[m.id, score]];
|
|
1856
|
+
});
|
|
1857
|
+
return {
|
|
1858
|
+
heldOut: points.filter((p) => foldOf.get(groupKey(p)) === f).map((p) => p.id),
|
|
1859
|
+
errors: Object.fromEntries(scored.map(([id, score]) => [id, score.error])),
|
|
1860
|
+
...options.outOfFold === true ? { predictions: Object.fromEntries(scored.map(([id, score]) => [id, score.predictions])) } : {}
|
|
1861
|
+
};
|
|
1862
|
+
});
|
|
1863
|
+
const reasons = data.reasons === void 0 ? {} : { reasons: data.reasons };
|
|
1864
|
+
return {
|
|
1865
|
+
schema: RESPONSE_RECORDS_SCHEMA,
|
|
1866
|
+
name: data.name,
|
|
1867
|
+
dimensions,
|
|
1868
|
+
measures,
|
|
1869
|
+
...reasons,
|
|
1870
|
+
points: data.points,
|
|
1871
|
+
models: fits.map((fit) => fit.model),
|
|
1872
|
+
folds: foldRecords
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
439
1875
|
// src/tests.ts
|
|
440
1876
|
/**
|
|
441
1877
|
* The tests of a difference: the sign test, the paired permutation test, the Wilson interval of a proportion and
|
|
@@ -891,6 +2327,6 @@ function calibration(forecasts) {
|
|
|
891
2327
|
interval: wilson(hits, n)
|
|
892
2328
|
}));
|
|
893
2329
|
}
|
|
894
|
-
export { KENDALL_EXACT, Z95, binomialInterval, bootstrap, bootstrapClusters, bootstrapLeastSquares, bootstrapMedian, bootstrapMedianRatio, bootstrapTwo, brier, calibration, compareRatios, holm, kendallTau, leastSquares, mean, median, medianInterval, negativeBinomialInterval, noiseFloor, noiseVerdict, pairedPermutation, quantile, quantileSorted, ranks, ridgeSolve, sd, signTest, signTestPower, sorted, spearman, summarize, wilson };
|
|
2330
|
+
export { KENDALL_EXACT, RESPONSE_LAMBDAS, RESPONSE_MAX_POWER, RESPONSE_MAX_TERMS, RESPONSE_RECORDS_SCHEMA, Z95, binomialInterval, bootstrap, bootstrapClusters, bootstrapLeastSquares, bootstrapMedian, bootstrapMedianRatio, bootstrapTwo, brier, calibration, compareRatios, fitResponse, heldOutError, heldOutErrorAt, holm, kendallTau, leastSquares, mean, median, medianInterval, negativeBinomialInterval, noiseFloor, noiseVerdict, pairedPermutation, parseResponseRecord, predictResponse, quantile, quantileSorted, ranks, ridgeSolve, sd, signTest, signTestPower, sliceResponse, sorted, spearman, summarize, surfaceResponse, wilson };
|
|
895
2331
|
|
|
896
2332
|
//# sourceMappingURL=index.js.map
|