@mstone6969/graph 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -14
- package/dist/ExcelWriter-3ZSYUH4T.cjs +154 -0
- package/dist/GraphType.js +24 -38
- package/dist/PdfWriter-6HJFYMCO.cjs +205 -0
- package/dist/catalog-YWX6FHRM.cjs +8 -0
- package/dist/catalog.js +5 -1
- package/dist/charts/bars/index.js +2 -12
- package/dist/charts/comparison/EffectForestPlot.js +1 -1
- package/dist/charts/comparison/index.js +1 -8
- package/dist/charts/demography/Nomogram.js +3 -3
- package/dist/charts/density/HexbinChart.js +6 -4
- package/dist/charts/diagrams/ArchitectureCanvas.js +68 -0
- package/dist/charts/diagrams/ArchitectureDiagram.js +331 -0
- package/dist/charts/diagrams/ArchitectureLayout.js +106 -0
- package/dist/charts/diagrams/ArchitectureLinkLabels.js +78 -0
- package/dist/charts/diagrams/ArchitectureModel.js +108 -0
- package/dist/charts/diagrams/ArchitectureRouter.js +348 -0
- package/dist/charts/diagrams/ArchitectureSamples.js +312 -0
- package/dist/charts/diagrams/ArchitectureShapes.js +130 -0
- package/dist/charts/diagrams/ArchitectureText.js +25 -0
- package/dist/charts/diagrams/DiagramText.js +38 -0
- package/dist/charts/diagrams/EntityRelationshipDiagram.js +465 -0
- package/dist/charts/diagrams/ErCrowsFoot.js +33 -0
- package/dist/charts/diagrams/ErSamples.js +205 -0
- package/dist/charts/diagrams/ErSchema.js +122 -0
- package/dist/charts/diagrams/LogicCircuit.js +154 -0
- package/dist/charts/diagrams/LogicCircuitLayout.js +600 -0
- package/dist/charts/diagrams/LogicCircuitSamples.js +66 -0
- package/dist/charts/diagrams/LogicGateDiagram.js +216 -0
- package/dist/charts/diagrams/LogicGateSymbol.js +113 -0
- package/dist/charts/diagrams/NetworkTopologyDiagram.js +247 -0
- package/dist/charts/diagrams/SequenceDiagram.js +351 -0
- package/dist/charts/diagrams/SequenceLayout.js +422 -0
- package/dist/charts/diagrams/SequenceModel.js +221 -0
- package/dist/charts/diagrams/TopologyLayout.js +143 -0
- package/dist/charts/diagrams/architectureEnums.js +50 -0
- package/dist/charts/diagrams/index.js +26 -0
- package/dist/charts/diagrams/logicEnums.js +42 -0
- package/dist/charts/diagrams/sequenceEnums.js +40 -0
- package/dist/charts/distribution/HistogramChart.js +25 -3
- package/dist/charts/distribution/index.js +1 -3
- package/dist/charts/earthscience/HovmollerDiagram.js +477 -0
- package/dist/charts/earthscience/HovmollerEnums.js +42 -0
- package/dist/charts/earthscience/HovmollerField.js +180 -0
- package/dist/charts/earthscience/HovmollerSample.js +40 -0
- package/dist/charts/earthscience/HovmollerSpeed.js +71 -0
- package/dist/charts/earthscience/index.js +10 -2
- package/dist/charts/engineering/ConstellationDiagram.js +26 -6
- package/dist/charts/engineering/EyeDiagram.js +2 -1
- package/dist/charts/engineering/PoleZeroPlot.js +32 -8
- package/dist/charts/financial/RelativeStrengthRotation.js +434 -0
- package/dist/charts/financial/RelativeStrengthRotationMath.js +71 -0
- package/dist/charts/financial/RelativeStrengthRotationSamples.js +27 -0
- package/dist/charts/financial/index.js +5 -3
- package/dist/charts/flow/index.js +1 -5
- package/dist/charts/frameworks/AnsoffMatrix.js +523 -0
- package/dist/charts/frameworks/FrameworkText.js +98 -0
- package/dist/charts/frameworks/NpsGauge.js +279 -0
- package/dist/charts/frameworks/NpsSurvey.js +58 -0
- package/dist/charts/frameworks/PerceptualMap.js +419 -0
- package/dist/charts/frameworks/PerceptualProjection.js +142 -0
- package/dist/charts/frameworks/PositioningSamples.js +191 -0
- package/dist/charts/frameworks/RaciMatrix.js +458 -0
- package/dist/charts/frameworks/RaciPlan.js +81 -0
- package/dist/charts/frameworks/RaciSamples.js +76 -0
- package/dist/charts/frameworks/SwotMatrix.js +358 -0
- package/dist/charts/frameworks/SwotSamples.js +85 -0
- package/dist/charts/frameworks/VanWestendorpChart.js +187 -0
- package/dist/charts/frameworks/VanWestendorpSurvey.js +197 -0
- package/dist/charts/frameworks/index.js +26 -0
- package/dist/charts/frameworks/raciEnums.js +30 -0
- package/dist/charts/frameworks/swotEnums.js +34 -0
- package/dist/charts/genomics/CircosPlot.js +1 -1
- package/dist/charts/geoflows/GeoFlowChart.js +2 -2
- package/dist/charts/geoflows/GeoFlowSample.js +2 -1
- package/dist/charts/geomaps/FloorPlanMap.js +324 -0
- package/dist/charts/geomaps/FloorPlanModel.js +179 -0
- package/dist/charts/geomaps/FloorPlanSamples.js +416 -0
- package/dist/charts/geomaps/GeoLand.js +2 -2
- package/dist/charts/geomaps/GeoSamples.js +2 -1
- package/dist/charts/geomaps/GraticuleMap.js +2 -2
- package/dist/charts/geomaps/RoomLabelFit.js +200 -0
- package/dist/charts/geomaps/TissotMap.js +2 -2
- package/dist/charts/geomaps/index.js +8 -4
- package/dist/charts/hierarchy/SunburstChart.js +17 -10
- package/dist/charts/hierarchy/index.js +2 -11
- package/dist/charts/indicators/BulletChart.js +8 -2
- package/dist/charts/indicators/SpeedometerChart.js +1 -1
- package/dist/charts/indicators/ThermometerChart.js +1 -1
- package/dist/charts/lifescience/LoessFit.js +113 -0
- package/dist/charts/lifescience/MaPlot.js +337 -0
- package/dist/charts/lifescience/MaPlotSamples.js +35 -0
- package/dist/charts/lifescience/OncoGlyph.js +137 -0
- package/dist/charts/lifescience/OncoPrint.js +373 -0
- package/dist/charts/lifescience/OncoPrintMatrix.js +107 -0
- package/dist/charts/lifescience/OncoPrintSamples.js +142 -0
- package/dist/charts/lifescience/PedigreeChart.js +306 -0
- package/dist/charts/lifescience/PedigreeLayout.js +261 -0
- package/dist/charts/lifescience/PedigreeRows.js +70 -0
- package/dist/charts/lifescience/PedigreeSamples.js +161 -0
- package/dist/charts/lifescience/PedigreeSymbol.js +47 -0
- package/dist/charts/lifescience/RamachandranPlot.js +364 -0
- package/dist/charts/lifescience/RamachandranRegions.js +2712 -0
- package/dist/charts/lifescience/RamachandranSamples.js +235 -0
- package/dist/charts/lifescience/index.js +25 -0
- package/dist/charts/lifescience/pedigreeEnums.js +21 -0
- package/dist/charts/lifescience/ramachandranEnums.js +34 -0
- package/dist/charts/linesareas/DualAxisChart.js +1 -1
- package/dist/charts/linesareas/LineEnums.js +1 -15
- package/dist/charts/linesareas/MultiLineChart.js +1 -1
- package/dist/charts/linesareas/TrendChart.js +15 -5
- package/dist/charts/linesareas/index.js +2 -6
- package/dist/charts/massive/MassiveChart.js +3 -2
- package/dist/charts/mathematical/FourierSeriesChart.js +7 -1
- package/dist/charts/mathematical/index.js +2 -6
- package/dist/charts/matrix/GridPlot.js +27 -2
- package/dist/charts/matrix/TilegramChart.js +2 -2
- package/dist/charts/mlcharts/AttentionGrid.js +79 -0
- package/dist/charts/mlcharts/AttentionHeatmap.js +329 -0
- package/dist/charts/mlcharts/AttentionModel.js +132 -0
- package/dist/charts/mlcharts/AttentionSamples.js +82 -0
- package/dist/charts/mlcharts/CellNumber.js +8 -0
- package/dist/charts/mlcharts/CurveDiagnosticSamples.js +95 -0
- package/dist/charts/mlcharts/CurvePickMark.js +31 -0
- package/dist/charts/mlcharts/DecisionBoundaryChart.js +316 -0
- package/dist/charts/mlcharts/DecisionSamples.js +60 -0
- package/dist/charts/mlcharts/DecisionSurface.js +130 -0
- package/dist/charts/mlcharts/DistinctTicks.js +13 -0
- package/dist/charts/mlcharts/ElbowMath.js +153 -0
- package/dist/charts/mlcharts/ElbowPlot.js +273 -0
- package/dist/charts/mlcharts/ExplainabilitySamples.js +70 -0
- package/dist/charts/mlcharts/FeatureImportanceChart.js +150 -0
- package/dist/charts/mlcharts/ImportanceRanking.js +98 -0
- package/dist/charts/mlcharts/LearningCurveChart.js +276 -0
- package/dist/charts/mlcharts/LearningCurveMath.js +92 -0
- package/dist/charts/mlcharts/PartialDependence.js +92 -0
- package/dist/charts/mlcharts/PartialDependencePlot.js +225 -0
- package/dist/charts/mlcharts/WideLabel.js +28 -0
- package/dist/charts/mlcharts/curveDiagnosticEnums.js +37 -0
- package/dist/charts/mlcharts/index.js +32 -0
- package/dist/charts/proportion/index.js +5 -9
- package/dist/charts/scatter/CategoryScatterBase.js +1 -4
- package/dist/charts/scatter/ConnectedScatter.js +7 -4
- package/dist/charts/scatter/MarginalScatter.js +13 -8
- package/dist/charts/scatter/index.js +4 -17
- package/dist/charts/scientific/SpectrogramChart.js +9 -3
- package/dist/charts/scientific/index.js +2 -2
- package/dist/charts/signals/index.js +3 -13
- package/dist/charts/statistics/LorenzCurve.js +7 -4
- package/dist/charts/threebars/Candlestick3DChart.js +18 -2
- package/dist/charts/threeglobe/GlobeChart.js +2 -2
- package/dist/charts/threeglobe/GlobeSamples.js +2 -1
- package/dist/charts/threeglobe/RingGlobe.js +1 -1
- package/dist/charts/threenetwork/ConeTreeChart.js +2 -1
- package/dist/charts/threenetwork/Gantt3DChart.js +3 -2
- package/dist/charts/threesurfaces/Ridgeline3DChart.js +22 -4
- package/dist/charts/threevolume/LorenzAttractor.js +19 -0
- package/dist/charts/threevolume/VolumeRaycastChart.js +4 -3
- package/dist/charts/threevolume/volumeEnums.js +1 -0
- package/dist/charts/timeline/index.js +2 -10
- package/dist/chunk-753UBLV2.cjs +33 -0
- package/dist/chunk-VX54A7KA.cjs +17 -0
- package/dist/{chunk-W5GAAAZY.cjs → chunk-WAODDPYX.cjs} +24874 -17620
- package/dist/chunk-Z3JPIJSC.cjs +338 -0
- package/dist/core/CartesianChart.js +87 -11
- package/dist/core/Chart.js +63 -15
- package/dist/core/Chart2D.js +54 -17
- package/dist/core/Chart3D.js +56 -20
- package/dist/core/ChartExportError.js +2 -0
- package/dist/core/ChartExporter.js +50 -8
- package/dist/core/ColumnCheck.js +53 -0
- package/dist/core/Context2D.js +29 -15
- package/dist/core/Context3D.js +7 -2
- package/dist/core/ExcelWriter.js +152 -0
- package/dist/core/ExportText.js +28 -0
- package/dist/core/MarkOrigin.js +88 -0
- package/dist/core/MarkStyle.js +294 -0
- package/dist/core/Palette.js +27 -7
- package/dist/core/PdfPage.js +13 -0
- package/dist/core/PdfWriter.js +245 -0
- package/dist/core/Scales.js +52 -2
- package/dist/core/SceneOverlay.js +4 -4
- package/dist/core/TextFit.js +37 -0
- package/dist/core/Theme.js +28 -1
- package/dist/core/enums.js +33 -1
- package/dist/core/pathMorph.js +15 -0
- package/dist/data/SampleData.js +1 -17
- package/dist/data/WorldSample.js +23 -0
- package/dist/index.cjs +810 -691
- package/dist/index.d.cts +8390 -2925
- package/dist/index.d.ts +8392 -2927
- package/dist/index.js +106 -56
- package/dist/react/ChartView.js +18 -8
- package/dist/react/GraphHandle.js +2 -0
- package/dist/react/components.js +74 -116
- package/package.json +13 -3
- package/dist/catalog-4UP2YFNR.cjs +0 -7
- package/dist/charts/bars/BulletBar.js +0 -184
- package/dist/charts/bars/ErrorBarColumn.js +0 -147
- package/dist/charts/bars/HistogramBar.js +0 -217
- package/dist/charts/bars/MarimekkoBar.js +0 -191
- package/dist/charts/bars/ParetoBar.js +0 -143
- package/dist/charts/comparison/PanelChart.js +0 -70
- package/dist/charts/comparison/SmallMultipleBars.js +0 -134
- package/dist/charts/comparison/SpanChart.js +0 -118
- package/dist/charts/comparison/TrellisChart.js +0 -158
- package/dist/charts/distribution/RidgeSlabs3DChart.js +0 -169
- package/dist/charts/financial/Candlestick3D.js +0 -157
- package/dist/charts/flow/BumpFlow.js +0 -212
- package/dist/charts/flow/StreamFlow.js +0 -174
- package/dist/charts/geomaps/ThiessenMap.js +0 -106
- package/dist/charts/hierarchy/ConeTree3D.js +0 -135
- package/dist/charts/hierarchy/Hierarchy3DChart.js +0 -27
- package/dist/charts/hierarchy/SpherePack3D.js +0 -81
- package/dist/charts/hierarchy/SunburstTower3D.js +0 -93
- package/dist/charts/hierarchy/TreemapSkyline3D.js +0 -98
- package/dist/charts/linesareas/ConnectedScatterTime.js +0 -206
- package/dist/charts/linesareas/SmallMultipleLines.js +0 -153
- package/dist/charts/mathematical/ParametricSurface3D.js +0 -234
- package/dist/charts/mathematical/StrangeAttractor3D.js +0 -251
- package/dist/charts/proportion/ConeFunnel.js +0 -126
- package/dist/charts/proportion/ExtrudedPieChart.js +0 -123
- package/dist/charts/scatter/BeeswarmScatter.js +0 -151
- package/dist/charts/scatter/BinnedScatterBase.js +0 -51
- package/dist/charts/scatter/ContourScatter.js +0 -116
- package/dist/charts/scatter/GridBinScatter.js +0 -98
- package/dist/charts/scatter/HexbinScatter.js +0 -74
- package/dist/charts/scatter/StripScatter.js +0 -107
- package/dist/charts/scatter/SunflowerScatter.js +0 -109
- package/dist/charts/signals/ConstellationChart.js +0 -153
- package/dist/charts/signals/CorrelogramChart.js +0 -171
- package/dist/charts/signals/EyeDiagramChart.js +0 -155
- package/dist/charts/signals/PoleZeroChart.js +0 -164
- package/dist/charts/signals/SpectrogramChart.js +0 -79
- package/dist/charts/timeline/CalendarSkylineChart.js +0 -128
- package/dist/charts/timeline/ScheduleBlocksChart.js +0 -119
- package/dist/charts/timeline/SpiralTimeline.js +0 -154
- package/dist/charts/timeline/StreamTimeline.js +0 -141
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { CartesianChart } from "../../core/CartesianChart.js";
|
|
2
|
+
import { ChartFamily, DataShape, Dimension, ScaleKind } from "../../core/enums.js";
|
|
3
|
+
import { Scales } from "../../core/Scales.js";
|
|
4
|
+
import { TrendHover } from "../linesareas/TrendHover.js";
|
|
5
|
+
import { cellNumber } from "./CellNumber.js";
|
|
6
|
+
import { CurveDiagnosticSamples } from "./CurveDiagnosticSamples.js";
|
|
7
|
+
import { MetricGoal, OverfitShading, RunSpread } from "./curveDiagnosticEnums.js";
|
|
8
|
+
import { CurvePickMark } from "./CurvePickMark.js";
|
|
9
|
+
import { DistinctTicks } from "./DistinctTicks.js";
|
|
10
|
+
import { LearningCurveMath } from "./LearningCurveMath.js";
|
|
11
|
+
import { WideLabel } from "./WideLabel.js";
|
|
12
|
+
import { area, extent, line, min, scaleLinear, ticks } from "d3";
|
|
13
|
+
// src/charts/mlcharts/LearningCurveChart.ts
|
|
14
|
+
var TRAINING = "Training";
|
|
15
|
+
var VALIDATION = "Validation";
|
|
16
|
+
var LINE = 16;
|
|
17
|
+
var GAP_NAME = {
|
|
18
|
+
[OverfitShading.None]: "",
|
|
19
|
+
[OverfitShading.Generalization]: "Generalization gap",
|
|
20
|
+
[OverfitShading.AfterBest]: "Overfit gap"
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Diagnoses training over epochs: mean training and validation metric with a spread band across runs, the best validation
|
|
24
|
+
* epoch, and the generalization gap. Follows the usual early-stopping reading (Prechelt 1998): the best epoch is the
|
|
25
|
+
* earliest with the best mean validation metric, computed after any smoothing, and overfitting is validation worsening
|
|
26
|
+
* while training still improves. Smoothing is TensorBoard's debiased EMA. Runs that stop early shrink the mean's sample.
|
|
27
|
+
*/
|
|
28
|
+
var LearningCurveChart = class extends CartesianChart {
|
|
29
|
+
static meta = {
|
|
30
|
+
id: "learning-curve",
|
|
31
|
+
name: "Learning curve",
|
|
32
|
+
family: ChartFamily.Trend,
|
|
33
|
+
dimension: Dimension.Two,
|
|
34
|
+
shape: DataShape.Table,
|
|
35
|
+
description: "Shows a training and validation metric by epoch across runs, with a spread band, the best epoch and the overfitting gap.",
|
|
36
|
+
tags: [
|
|
37
|
+
"machine learning",
|
|
38
|
+
"training",
|
|
39
|
+
"validation",
|
|
40
|
+
"overfitting",
|
|
41
|
+
"early stopping",
|
|
42
|
+
"loss curve"
|
|
43
|
+
],
|
|
44
|
+
sample: (seed) => CurveDiagnosticSamples.trainingRuns(seed),
|
|
45
|
+
variants: {
|
|
46
|
+
spread: [
|
|
47
|
+
RunSpread.StandardDeviation,
|
|
48
|
+
RunSpread.Confidence95,
|
|
49
|
+
RunSpread.Range
|
|
50
|
+
],
|
|
51
|
+
gap: [
|
|
52
|
+
OverfitShading.AfterBest,
|
|
53
|
+
OverfitShading.Generalization,
|
|
54
|
+
OverfitShading.None
|
|
55
|
+
],
|
|
56
|
+
best: [true, false],
|
|
57
|
+
runs: [false, true],
|
|
58
|
+
scale: [ScaleKind.Linear, ScaleKind.Log],
|
|
59
|
+
smoothing: [0, .6]
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
#view = null;
|
|
63
|
+
#valueAxis = null;
|
|
64
|
+
defaults() {
|
|
65
|
+
return {
|
|
66
|
+
x: "epoch",
|
|
67
|
+
y: "value",
|
|
68
|
+
run: "run",
|
|
69
|
+
split: "split",
|
|
70
|
+
trainSplit: "train",
|
|
71
|
+
validationSplit: "validation",
|
|
72
|
+
xLabel: "Epoch",
|
|
73
|
+
yLabel: "Loss",
|
|
74
|
+
goal: MetricGoal.Minimize,
|
|
75
|
+
spread: RunSpread.StandardDeviation,
|
|
76
|
+
gap: OverfitShading.AfterBest,
|
|
77
|
+
best: true,
|
|
78
|
+
runs: false,
|
|
79
|
+
scale: ScaleKind.Linear,
|
|
80
|
+
smoothing: 0
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
get logarithmic() {
|
|
84
|
+
return this.options.scale === ScaleKind.Log;
|
|
85
|
+
}
|
|
86
|
+
xTicks() {
|
|
87
|
+
const stats = [...this.#view?.train.stats ?? [], ...this.#view?.validation.stats ?? []];
|
|
88
|
+
const [lo = 0, hi = 1] = extent(stats, (stat) => stat.epoch);
|
|
89
|
+
return lo === hi ? 2 : Math.min(7, Math.ceil(hi - lo));
|
|
90
|
+
}
|
|
91
|
+
curvesOf(split) {
|
|
92
|
+
const { data, x, y, run, split: column } = this.options, wanted = String(split);
|
|
93
|
+
const smoothing = Math.min(.999, Math.max(0, this.options.smoothing || 0));
|
|
94
|
+
const runs = /* @__PURE__ */ new Map();
|
|
95
|
+
for (const row of Array.isArray(data) ? data : []) {
|
|
96
|
+
if (!row || String(row[column]) !== wanted) continue;
|
|
97
|
+
const epoch = cellNumber(row[x]), value = cellNumber(row[y]);
|
|
98
|
+
if (epoch === null || value === null || this.logarithmic && value <= 0) continue;
|
|
99
|
+
const name = String(row[run] ?? ""), rows = runs.get(name) ?? runs.set(name, /* @__PURE__ */ new Map()).get(name);
|
|
100
|
+
(rows.get(epoch) ?? rows.set(epoch, []).get(epoch)).push(value);
|
|
101
|
+
}
|
|
102
|
+
return [...runs].map(([name, rows]) => {
|
|
103
|
+
const epochs = [...rows.keys()].sort((a, b) => a - b);
|
|
104
|
+
const values = LearningCurveMath.smooth(epochs.map((epoch) => rows.get(epoch).reduce((a, b) => a + b, 0) / rows.get(epoch).length), smoothing);
|
|
105
|
+
return {
|
|
106
|
+
run: name,
|
|
107
|
+
points: epochs.map((epoch, i) => ({
|
|
108
|
+
epoch,
|
|
109
|
+
value: values[i]
|
|
110
|
+
}))
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** Rows the split names failed to match, so a curve is not lost without a word. Another split, like test, is fine beside both. */
|
|
115
|
+
get absentSplits() {
|
|
116
|
+
const { data, split, trainSplit, validationSplit } = this.options;
|
|
117
|
+
const named = [String(trainSplit), String(validationSplit)];
|
|
118
|
+
const seen = new Set((Array.isArray(data) ? data : []).flatMap((row) => row?.[split] === null || row?.[split] === void 0 ? [] : [String(row[split])]));
|
|
119
|
+
const absent = named.filter((name) => !seen.has(name)), others = [...seen].filter((name) => !named.includes(name));
|
|
120
|
+
return absent.length && others.length ? `No '${absent.join("' or '")}' rows. Found: ${others.slice(0, 4).join(", ")}` : "";
|
|
121
|
+
}
|
|
122
|
+
read() {
|
|
123
|
+
const { trainSplit, validationSplit, spread, goal, gap } = this.options;
|
|
124
|
+
const parts = [{
|
|
125
|
+
key: TRAINING,
|
|
126
|
+
curves: this.curvesOf(trainSplit)
|
|
127
|
+
}, {
|
|
128
|
+
key: VALIDATION,
|
|
129
|
+
curves: this.curvesOf(validationSplit)
|
|
130
|
+
}];
|
|
131
|
+
const floor = LearningCurveMath.floor(parts.flatMap(({ curves }) => curves.flatMap((curve) => curve.points.map((point) => point.value))), this.logarithmic);
|
|
132
|
+
const [train, validation] = parts.map(({ key, curves }) => ({
|
|
133
|
+
key,
|
|
134
|
+
curves,
|
|
135
|
+
stats: LearningCurveMath.summarize(curves, spread).map((stat) => floor === void 0 ? stat : {
|
|
136
|
+
...stat,
|
|
137
|
+
low: Math.max(stat.low, floor)
|
|
138
|
+
})
|
|
139
|
+
}));
|
|
140
|
+
const best = LearningCurveMath.best(validation.stats, goal);
|
|
141
|
+
return {
|
|
142
|
+
train,
|
|
143
|
+
validation,
|
|
144
|
+
best,
|
|
145
|
+
gaps: LearningCurveMath.gaps(train.stats, validation.stats, goal, gap, best?.epoch ?? null)
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
scales(ctx) {
|
|
149
|
+
const { width, height } = ctx.plot;
|
|
150
|
+
const { train, validation } = this.#view = this.read();
|
|
151
|
+
const splits = [train, validation], log = this.logarithmic;
|
|
152
|
+
const epochs = splits.flatMap(({ stats }) => stats.map((stat) => stat.epoch));
|
|
153
|
+
const values = splits.flatMap(({ stats, curves }) => [...stats.flatMap((stat) => [stat.low, stat.high]), ...this.options.runs ? curves.flatMap((curve) => curve.points.map((point) => point.value)) : []]);
|
|
154
|
+
let [left, right] = extent(epochs), [lo, hi] = extent(values);
|
|
155
|
+
if (left === void 0 || right === void 0) [left, right] = [0, 1];
|
|
156
|
+
if (left === right) [left, right] = [left - 1, right + 1];
|
|
157
|
+
if (lo === void 0 || hi === void 0) [lo, hi] = log ? [1, 10] : [0, 1];
|
|
158
|
+
if (lo === hi) {
|
|
159
|
+
const pad = Math.abs(lo) * .1 || 1;
|
|
160
|
+
[lo, hi] = [log ? lo / 2 : lo - pad, hi + pad];
|
|
161
|
+
}
|
|
162
|
+
const scale = Scales.continuous(this.options.scale).domain([lo, hi]).range([height, 0]);
|
|
163
|
+
const y = !log ? scale.nice() : hi / lo < 10 ? Object.assign(scale, { ticks: () => ticks(lo, hi, this.yTicks()) }) : scale;
|
|
164
|
+
this.#valueAxis = y;
|
|
165
|
+
return {
|
|
166
|
+
x: scaleLinear([left, right], [0, width]),
|
|
167
|
+
y
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** On a wide log axis only 1, 2 and 5 keep a label; the rest would collide. */
|
|
171
|
+
tickFormat(scale) {
|
|
172
|
+
const format = super.tickFormat(scale), [lo, hi] = scale.domain?.() ?? [];
|
|
173
|
+
if (!this.logarithmic || scale !== this.#valueAxis) return DistinctTicks.format(scale, format);
|
|
174
|
+
if (!format || !(hi / lo > 20)) return format;
|
|
175
|
+
return (value) => [
|
|
176
|
+
1,
|
|
177
|
+
2,
|
|
178
|
+
5
|
|
179
|
+
].includes(Math.round(+value / 10 ** Math.floor(Math.log10(+value) + 1e-9))) ? format(value) : "";
|
|
180
|
+
}
|
|
181
|
+
legendItems() {
|
|
182
|
+
const gap = this.#view?.gaps.length ? [{
|
|
183
|
+
key: GAP_NAME[this.options.gap],
|
|
184
|
+
color: this.palette.diverging(.7)
|
|
185
|
+
}] : [];
|
|
186
|
+
return [...super.legendItems(), ...gap];
|
|
187
|
+
}
|
|
188
|
+
plotMarks(ctx, axes) {
|
|
189
|
+
const { goal } = this.options;
|
|
190
|
+
const view = this.#view, { train, validation, best, gaps } = view;
|
|
191
|
+
const px = (epoch) => axes.x(epoch) ?? 0, py = (value) => axes.y(value) ?? 0;
|
|
192
|
+
const splits = [train, validation].filter((split) => split.stats.length);
|
|
193
|
+
const color = (split) => ctx.palette.color(split.key);
|
|
194
|
+
for (const split of splits) color(split);
|
|
195
|
+
const gapShape = area().x((d) => px(d.epoch)).y0((d) => py(d.train)).y1((d) => py(d.validation));
|
|
196
|
+
const gapFlat = area().x((d) => px(d.epoch)).y0((d) => py(d.train)).y1((d) => py(d.train));
|
|
197
|
+
const gapFills = ctx.marks("gap", "path", gaps, (_, i) => i).attr("fill", ctx.palette.diverging(.7)).attr("fill-opacity", .3);
|
|
198
|
+
ctx.set(gapFills, { d: (d) => gapShape(d) ?? "" }, { d: (d) => gapFlat(d) ?? "" });
|
|
199
|
+
const band = area().x((d) => px(d.epoch)).y0((d) => py(d.low)).y1((d) => py(d.high));
|
|
200
|
+
const flat = area().x((d) => px(d.epoch)).y0((d) => py(d.center)).y1((d) => py(d.center));
|
|
201
|
+
const bands = ctx.marks("bands", "path", splits.filter((split) => split.curves.length > 1), (d) => d.key).attr("fill", color).attr("fill-opacity", .2);
|
|
202
|
+
ctx.set(bands, { d: (d) => band(d.stats) ?? "" }, { d: (d) => flat(d.stats) ?? "" });
|
|
203
|
+
const trace = line().x((d) => px(d.epoch)).y((d) => py(d.value));
|
|
204
|
+
const every = this.options.runs ? splits.flatMap((split) => split.curves.map((curve) => ({
|
|
205
|
+
...curve,
|
|
206
|
+
split
|
|
207
|
+
}))) : [];
|
|
208
|
+
const runs = ctx.marks("runs", "path", every, (d) => `${d.split.key}/${d.run}`).attr("fill", "none").attr("stroke", (d) => color(d.split)).attr("stroke-width", 1).attr("stroke-opacity", .4).attr("stroke-linejoin", "round");
|
|
209
|
+
ctx.set(runs, { d: (d) => trace(d.points) ?? "" });
|
|
210
|
+
const center = line().x((d) => px(d.epoch)).y((d) => py(d.center));
|
|
211
|
+
const means = ctx.marks("means", "path", splits, (d) => d.key).attr("fill", "none").attr("stroke", color).attr("stroke-width", 2).attr("stroke-linejoin", "round").attr("stroke-linecap", "round");
|
|
212
|
+
ctx.set(means, { d: (d) => center(d.stats) ?? "" });
|
|
213
|
+
ctx.drawOn(means);
|
|
214
|
+
this.drawLoneEpochs(ctx, splits.filter((split) => split.stats.length === 1), px, py);
|
|
215
|
+
const pick = this.options.best && best ? {
|
|
216
|
+
x: px(best.epoch),
|
|
217
|
+
y: py(best.center),
|
|
218
|
+
text: `Best epoch ${TrendHover.title(best.epoch)} · ${TrendHover.value(best.center)}`
|
|
219
|
+
} : null;
|
|
220
|
+
CurvePickMark.draw(ctx, "best", pick, goal === MetricGoal.Maximize ? ctx.plot.height - 8 : 12);
|
|
221
|
+
this.drawSplitNote(ctx);
|
|
222
|
+
TrendHover.attach(ctx, this.slices(ctx, view, px, py), ctx.plot.width, ctx.plot.height);
|
|
223
|
+
}
|
|
224
|
+
/** A line through one epoch draws nothing, so that mean is a dot and its band a stem. */
|
|
225
|
+
drawLoneEpochs(ctx, splits, px, py) {
|
|
226
|
+
const color = (split) => ctx.palette.color(split.key), only = (split) => split.stats[0];
|
|
227
|
+
const stems = ctx.marks("lone-bands", "line", splits.filter((split) => only(split).high > only(split).low), (d) => d.key).attr("stroke", color).attr("stroke-width", 2).attr("stroke-opacity", .4);
|
|
228
|
+
ctx.set(stems, {
|
|
229
|
+
x1: (d) => px(only(d).epoch),
|
|
230
|
+
x2: (d) => px(only(d).epoch),
|
|
231
|
+
y1: (d) => py(only(d).low),
|
|
232
|
+
y2: (d) => py(only(d).high)
|
|
233
|
+
});
|
|
234
|
+
const dots = ctx.marks("lone-means", "circle", splits, (d) => d.key).attr("fill", color).attr("stroke", ctx.theme.surface).attr("stroke-width", 2);
|
|
235
|
+
ctx.set(dots, {
|
|
236
|
+
cx: (d) => px(only(d).epoch),
|
|
237
|
+
cy: (d) => py(only(d).center),
|
|
238
|
+
r: 5
|
|
239
|
+
}, { r: 0 });
|
|
240
|
+
}
|
|
241
|
+
/** Sits where a curve with this goal ends up empty, and clear of the best-epoch label. */
|
|
242
|
+
drawSplitNote(ctx) {
|
|
243
|
+
const { width, height } = ctx.plot, note = this.absentSplits;
|
|
244
|
+
const { goal, best } = this.options, clear = best && this.#view?.best ? LINE : 0;
|
|
245
|
+
const y = goal === MetricGoal.Maximize ? height - 8 - clear : 12 + clear;
|
|
246
|
+
ctx.text(ctx.marks("missing-split", "text", note ? [WideLabel.fit(note, width - 4)] : []), 11, "muted").attr("text-anchor", "end").attr("x", width - 2).attr("y", y).attr("paint-order", "stroke").attr("stroke", ctx.theme.surface).attr("stroke-width", 3).text((d) => d);
|
|
247
|
+
}
|
|
248
|
+
slices(ctx, { train, validation }, px, py) {
|
|
249
|
+
const trained = new Map(train.stats.map((stat) => [stat.epoch, stat])), held = new Map(validation.stats.map((stat) => [stat.epoch, stat]));
|
|
250
|
+
const spreadRow = (name, stat) => [`${name} band`, `${TrendHover.value(stat.low)} – ${TrendHover.value(stat.high)}`];
|
|
251
|
+
return [.../* @__PURE__ */ new Set([...trained.keys(), ...held.keys()])].sort((a, b) => a - b).map((epoch) => {
|
|
252
|
+
const a = trained.get(epoch), b = held.get(epoch);
|
|
253
|
+
const rows = [];
|
|
254
|
+
if (a) rows.push([TRAINING, TrendHover.value(a.center)], spreadRow(TRAINING, a));
|
|
255
|
+
if (b) rows.push([VALIDATION, TrendHover.value(b.center)], spreadRow(VALIDATION, b));
|
|
256
|
+
if (a && b) rows.push(["Gap", TrendHover.value(Math.abs(b.center - a.center))]);
|
|
257
|
+
rows.push(["Runs", min([a?.runs ?? Infinity, b?.runs ?? Infinity])]);
|
|
258
|
+
const marks = [...a ? [{
|
|
259
|
+
x: px(epoch),
|
|
260
|
+
y: py(a.center),
|
|
261
|
+
color: ctx.palette.color(TRAINING)
|
|
262
|
+
}] : [], ...b ? [{
|
|
263
|
+
x: px(epoch),
|
|
264
|
+
y: py(b.center),
|
|
265
|
+
color: ctx.palette.color(VALIDATION)
|
|
266
|
+
}] : []];
|
|
267
|
+
return {
|
|
268
|
+
x: px(epoch),
|
|
269
|
+
title: `Epoch ${TrendHover.title(epoch)}`,
|
|
270
|
+
rows,
|
|
271
|
+
marks
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
export { LearningCurveChart };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { StatMath } from "../statistics/StatMath.js";
|
|
2
|
+
import { MetricGoal, OverfitShading, RunSpread } from "./curveDiagnosticEnums.js";
|
|
3
|
+
import { mean, min, sum } from "d3";
|
|
4
|
+
// src/charts/mlcharts/LearningCurveMath.ts
|
|
5
|
+
/** The calculations behind a learning curve, free of drawing. */
|
|
6
|
+
var LearningCurveMath = class {
|
|
7
|
+
/** TensorBoard's debiased exponential moving average. `weight` 0 returns the input; the first value is never pulled towards zero. */
|
|
8
|
+
static smooth(values, weight) {
|
|
9
|
+
if (!(weight > 0)) return [...values];
|
|
10
|
+
let last = 0;
|
|
11
|
+
return values.map((value, i) => {
|
|
12
|
+
last = last * weight + (1 - weight) * value;
|
|
13
|
+
return last / (1 - weight ** (i + 1));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/** Band around `center`: mean ± one sample SD, a Student-t 95% CI of the mean, or the observed min and max. One run has no width. */
|
|
17
|
+
static interval(values, center, spread) {
|
|
18
|
+
const n = values.length;
|
|
19
|
+
if (n < 2) return [center, center];
|
|
20
|
+
if (spread === RunSpread.Range) return [Math.min(...values), Math.max(...values)];
|
|
21
|
+
const sd = Math.sqrt(sum(values, (value) => (value - center) ** 2) / (n - 1));
|
|
22
|
+
const reach = spread === RunSpread.Confidence95 ? StatMath.tQuantile(.975, n - 1) * sd / Math.sqrt(n) : sd;
|
|
23
|
+
return [center - reach, center + reach];
|
|
24
|
+
}
|
|
25
|
+
/** Mean and band per epoch. Runs that stopped early leave later epochs with a smaller `runs`. */
|
|
26
|
+
static summarize(curves, spread) {
|
|
27
|
+
const epochs = /* @__PURE__ */ new Map();
|
|
28
|
+
for (const { points } of curves) for (const { epoch, value } of points) (epochs.get(epoch) ?? epochs.set(epoch, []).get(epoch)).push(value);
|
|
29
|
+
return [...epochs].sort(([a], [b]) => a - b).map(([epoch, values]) => {
|
|
30
|
+
const center = mean(values);
|
|
31
|
+
const [low, high] = this.interval(values, center, spread);
|
|
32
|
+
return {
|
|
33
|
+
epoch,
|
|
34
|
+
center,
|
|
35
|
+
low,
|
|
36
|
+
high,
|
|
37
|
+
runs: values.length
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** The earliest epoch with the best mean, the checkpoint early stopping would restore. */
|
|
42
|
+
static best(stats, goal) {
|
|
43
|
+
const sign = goal === MetricGoal.Minimize ? 1 : -1;
|
|
44
|
+
return stats.reduce((top, stat) => !top || sign * stat.center < sign * top.center ? stat : top, null);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Stretches where validation is worse than training, ending on the exact crossings of the two straight mean lines.
|
|
48
|
+
* `AfterBest` keeps only epochs from the best one on, where further training no longer helps validation.
|
|
49
|
+
*/
|
|
50
|
+
static gaps(train, validation, goal, shading, best) {
|
|
51
|
+
if (shading === OverfitShading.None) return [];
|
|
52
|
+
const sign = goal === MetricGoal.Minimize ? 1 : -1;
|
|
53
|
+
const trained = new Map(train.map((stat) => [stat.epoch, stat.center]));
|
|
54
|
+
const shared = validation.flatMap(({ epoch, center }) => {
|
|
55
|
+
const value = trained.get(epoch);
|
|
56
|
+
return value === void 0 || shading === OverfitShading.AfterBest && (best === null || epoch < best) ? [] : [{
|
|
57
|
+
epoch,
|
|
58
|
+
train: value,
|
|
59
|
+
validation: center
|
|
60
|
+
}];
|
|
61
|
+
});
|
|
62
|
+
const excess = (point) => sign * (point.validation - point.train);
|
|
63
|
+
const stretches = [];
|
|
64
|
+
let open = [];
|
|
65
|
+
shared.forEach((point, i) => {
|
|
66
|
+
const before = shared[i - 1];
|
|
67
|
+
if (before && excess(before) > 0 !== excess(point) > 0) {
|
|
68
|
+
const share = excess(before) / (excess(before) - excess(point));
|
|
69
|
+
const level = before.train + share * (point.train - before.train);
|
|
70
|
+
const crossing = {
|
|
71
|
+
epoch: before.epoch + share * (point.epoch - before.epoch),
|
|
72
|
+
train: level,
|
|
73
|
+
validation: level
|
|
74
|
+
};
|
|
75
|
+
if (excess(point) > 0) open = [crossing];
|
|
76
|
+
else {
|
|
77
|
+
stretches.push([...open, crossing]);
|
|
78
|
+
open = [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (excess(point) > 0) open.push(point);
|
|
82
|
+
});
|
|
83
|
+
if (open.length) stretches.push(open);
|
|
84
|
+
return stretches.filter((stretch) => stretch.length > 1);
|
|
85
|
+
}
|
|
86
|
+
/** Lowest value a log axis or a non-negative metric lets a band reach: `undefined` when nothing bounds it. */
|
|
87
|
+
static floor(values, logarithmic) {
|
|
88
|
+
if (logarithmic) return min(values.filter((value) => value > 0));
|
|
89
|
+
return min(values) >= 0 ? 0 : void 0;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
export { LearningCurveMath };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { BarRows } from "../bars/BarRows.js";
|
|
2
|
+
import { ascending, mean } from "d3";
|
|
3
|
+
// src/charts/mlcharts/PartialDependence.ts
|
|
4
|
+
var PLOTTING_POSITION = .4;
|
|
5
|
+
/** Where a model is evaluated: evenly across the value range, or at evenly spaced quantiles. */
|
|
6
|
+
var PartialDependenceGrid = /* @__PURE__ */ function(PartialDependenceGrid) {
|
|
7
|
+
PartialDependenceGrid["Uniform"] = "uniform";
|
|
8
|
+
PartialDependenceGrid["Quantile"] = "quantile";
|
|
9
|
+
return PartialDependenceGrid;
|
|
10
|
+
}({});
|
|
11
|
+
/** The partial dependence calculation of Friedman (2001) with ICE curves (Goldstein et al. 2015). */
|
|
12
|
+
var PartialDependence = class PartialDependence {
|
|
13
|
+
grid;
|
|
14
|
+
curves;
|
|
15
|
+
observed;
|
|
16
|
+
/** The average at each grid value, over the instances predicted there. */
|
|
17
|
+
average;
|
|
18
|
+
constructor(grid, curves, observed) {
|
|
19
|
+
this.grid = grid;
|
|
20
|
+
this.curves = curves;
|
|
21
|
+
this.observed = observed;
|
|
22
|
+
this.average = grid.flatMap((x, i) => {
|
|
23
|
+
const values = curves.flatMap((curve) => curve.values[i] ?? []);
|
|
24
|
+
return values.length ? [{
|
|
25
|
+
x,
|
|
26
|
+
y: mean(values),
|
|
27
|
+
instances: values.length
|
|
28
|
+
}] : [];
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** The Cunnane quantile of `scipy.stats.mstats.mquantiles` with its default plotting positions, which `partial_dependence` uses. */
|
|
32
|
+
static quantile(sorted, p) {
|
|
33
|
+
const n = sorted.length, position = n * p + PLOTTING_POSITION + p * (1 - 2 * PLOTTING_POSITION);
|
|
34
|
+
const k = Math.floor(Math.min(n - 1, Math.max(1, position))), share = Math.min(1, Math.max(0, position - k));
|
|
35
|
+
return (1 - share) * sorted[k - 1] + share * sorted[k];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Grid values as scikit-learn's `_grid_from_X`: the distinct values when there are fewer than `points`,
|
|
39
|
+
* otherwise `points` steps between the `range` quantiles of the data. The Quantile method is this library's.
|
|
40
|
+
*/
|
|
41
|
+
static gridOf(values, { method, points, range }) {
|
|
42
|
+
const sorted = [...values].sort(ascending), distinct = [...new Set(sorted)];
|
|
43
|
+
const count = Math.max(2, Math.floor(points) || 2);
|
|
44
|
+
if (distinct.length < count || sorted.length < 2) return distinct;
|
|
45
|
+
const at = (p) => this.quantile(sorted, p);
|
|
46
|
+
const [from, to] = [Math.min(...range), Math.max(...range)];
|
|
47
|
+
const steps = Array.from({ length: count }, (_, i) => i / (count - 1));
|
|
48
|
+
const grid = method === "quantile" ? steps.map((t) => at(from + (to - from) * t)) : steps.map((t) => at(from) + (at(to) - at(from)) * t);
|
|
49
|
+
return [...new Set(grid)];
|
|
50
|
+
}
|
|
51
|
+
/** From a `predict` callback: every row is swept across the grid with the feature overwritten and the rest held. */
|
|
52
|
+
static fromModel(rows, x, predict, grid) {
|
|
53
|
+
const observed = rows.flatMap((row) => BarRows.number(row[x]) ?? []);
|
|
54
|
+
const axis = this.gridOf(observed, grid);
|
|
55
|
+
const curves = rows.map((row, i) => ({
|
|
56
|
+
instance: String(i + 1),
|
|
57
|
+
values: axis.map((value) => BarRows.number(predict({
|
|
58
|
+
...row,
|
|
59
|
+
[x]: value
|
|
60
|
+
})))
|
|
61
|
+
}));
|
|
62
|
+
return new PartialDependence(axis, curves, observed.sort(ascending));
|
|
63
|
+
}
|
|
64
|
+
/** From rows that already hold predictions, one per instance and grid value. Repeats of a pair are averaged. */
|
|
65
|
+
static fromPredictions(rows, { x, y, instance, observed }) {
|
|
66
|
+
const sweeps = /* @__PURE__ */ new Map(), seen = /* @__PURE__ */ new Map();
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
const at = BarRows.number(row[x]), value = BarRows.number(row[y]), id = BarRows.label(row[instance]);
|
|
69
|
+
if (at === null || value === null) continue;
|
|
70
|
+
const sweep = sweeps.get(id) ?? sweeps.set(id, /* @__PURE__ */ new Map()).get(id);
|
|
71
|
+
sweep.set(at, [...sweep.get(at) ?? [], value]);
|
|
72
|
+
const feature = BarRows.number(row[observed]);
|
|
73
|
+
if (feature !== null && !seen.has(id)) seen.set(id, feature);
|
|
74
|
+
}
|
|
75
|
+
const grid = [...new Set([...sweeps.values()].flatMap((sweep) => [...sweep.keys()]))].sort(ascending);
|
|
76
|
+
const curves = [...sweeps].map(([id, sweep]) => ({
|
|
77
|
+
instance: id,
|
|
78
|
+
values: grid.map((at) => mean(sweep.get(at) ?? []) ?? null)
|
|
79
|
+
}));
|
|
80
|
+
return new PartialDependence(grid, curves, [...seen.values()].sort(ascending));
|
|
81
|
+
}
|
|
82
|
+
/** Centred ICE: each curve minus its value at the lowest grid value every instance reaches. */
|
|
83
|
+
centered() {
|
|
84
|
+
const index = Math.max(0, this.grid.findIndex((_, i) => this.curves.every((curve) => curve.values[i] !== null)));
|
|
85
|
+
const curves = this.curves.filter((curve) => curve.values[index] !== null).map(({ instance, values }) => ({
|
|
86
|
+
instance,
|
|
87
|
+
values: values.map((value) => value === null ? null : value - values[index])
|
|
88
|
+
}));
|
|
89
|
+
return new PartialDependence(this.grid, curves, this.observed);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
export { PartialDependence, PartialDependenceGrid };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { CartesianChart } from "../../core/CartesianChart.js";
|
|
2
|
+
import { ChartFamily, DataShape, Dimension } from "../../core/enums.js";
|
|
3
|
+
import { TrendHover } from "../linesareas/TrendHover.js";
|
|
4
|
+
import { DistinctTicks } from "./DistinctTicks.js";
|
|
5
|
+
import { ExplainabilitySamples } from "./ExplainabilitySamples.js";
|
|
6
|
+
import { PartialDependence, PartialDependenceGrid } from "./PartialDependence.js";
|
|
7
|
+
import { extent, format, line, max, quantileSorted, scaleLinear } from "d3";
|
|
8
|
+
// src/charts/mlcharts/PartialDependencePlot.ts
|
|
9
|
+
/** Which curves are drawn: the names follow scikit-learn's `kind`. */
|
|
10
|
+
var PartialDependenceKind = /* @__PURE__ */ function(PartialDependenceKind) {
|
|
11
|
+
PartialDependenceKind["Both"] = "both";
|
|
12
|
+
PartialDependenceKind["Average"] = "average";
|
|
13
|
+
PartialDependenceKind["Individual"] = "individual";
|
|
14
|
+
return PartialDependenceKind;
|
|
15
|
+
}({});
|
|
16
|
+
/** Ticks along the x axis showing where the feature has data. */
|
|
17
|
+
var PartialDependenceRug = /* @__PURE__ */ function(PartialDependenceRug) {
|
|
18
|
+
PartialDependenceRug["Observations"] = "observations";
|
|
19
|
+
PartialDependenceRug["Deciles"] = "deciles";
|
|
20
|
+
PartialDependenceRug["None"] = "none";
|
|
21
|
+
return PartialDependenceRug;
|
|
22
|
+
}({});
|
|
23
|
+
var AVERAGE = "Average (PDP)";
|
|
24
|
+
var INDIVIDUAL = "Individual (ICE)";
|
|
25
|
+
var RUG_HEIGHT = 8;
|
|
26
|
+
var RUG_ROOM = 12;
|
|
27
|
+
var RUG_LIMIT = 400;
|
|
28
|
+
var DECILES = Array.from({ length: 9 }, (_, i) => (i + 1) / 10);
|
|
29
|
+
var figure = format(".4~g");
|
|
30
|
+
/**
|
|
31
|
+
* Shows how a model's prediction moves with one numeric feature, others held at their observed
|
|
32
|
+
* values: partial dependence (Friedman 2001) with ICE curves (Goldstein et al. 2015). Pass rows of
|
|
33
|
+
* precomputed predictions, or background rows and `predict`. Curves that fan or cross mean the
|
|
34
|
+
* average hides an interaction, and any PDP is unreliable where the rug is empty or the feature
|
|
35
|
+
* is correlated with others (Apley & Zhu 2020). The axis spans the grid, so `gridRange` keeps outliers off it.
|
|
36
|
+
*/
|
|
37
|
+
var PartialDependencePlot = class extends CartesianChart {
|
|
38
|
+
static meta = {
|
|
39
|
+
id: "partial-dependence",
|
|
40
|
+
name: "Partial dependence plot",
|
|
41
|
+
family: ChartFamily.Statistical,
|
|
42
|
+
dimension: Dimension.Two,
|
|
43
|
+
shape: DataShape.Table,
|
|
44
|
+
description: "Shows a model's average prediction across one feature, with individual conditional expectation curves and a rug of the data.",
|
|
45
|
+
tags: [
|
|
46
|
+
"machine learning",
|
|
47
|
+
"explainability",
|
|
48
|
+
"pdp",
|
|
49
|
+
"ice",
|
|
50
|
+
"marginal effect"
|
|
51
|
+
],
|
|
52
|
+
sample: (seed) => ExplainabilitySamples.iceCurves(seed),
|
|
53
|
+
variants: {
|
|
54
|
+
kind: Object.values(PartialDependenceKind),
|
|
55
|
+
centered: [false, true],
|
|
56
|
+
rug: Object.values(PartialDependenceRug)
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
#cache = null;
|
|
60
|
+
#titled = "";
|
|
61
|
+
defaults() {
|
|
62
|
+
return {
|
|
63
|
+
x: "x",
|
|
64
|
+
y: "prediction",
|
|
65
|
+
instance: "instance",
|
|
66
|
+
observed: "observed",
|
|
67
|
+
gridMethod: PartialDependenceGrid.Uniform,
|
|
68
|
+
gridPoints: 50,
|
|
69
|
+
gridRange: [.05, .95],
|
|
70
|
+
kind: "both",
|
|
71
|
+
centered: false,
|
|
72
|
+
rug: "observations",
|
|
73
|
+
maxCurves: 80
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
render() {
|
|
77
|
+
const { yLabel, centered } = this.options;
|
|
78
|
+
if (yLabel === void 0 || yLabel === this.#titled) this.options = {
|
|
79
|
+
...this.options,
|
|
80
|
+
yLabel: this.#titled = centered ? "Change in prediction" : "Prediction"
|
|
81
|
+
};
|
|
82
|
+
return super.render();
|
|
83
|
+
}
|
|
84
|
+
/** Sweeping rows through a model is costly, and zoom and resize repaint without changing it. */
|
|
85
|
+
get model() {
|
|
86
|
+
const { data, x, y, instance, observed, predict, gridMethod, gridPoints, gridRange, centered } = this.options;
|
|
87
|
+
const rows = Array.isArray(data) ? data.filter(Boolean) : [];
|
|
88
|
+
const key = [
|
|
89
|
+
data,
|
|
90
|
+
rows.length,
|
|
91
|
+
x,
|
|
92
|
+
y,
|
|
93
|
+
instance,
|
|
94
|
+
observed,
|
|
95
|
+
predict,
|
|
96
|
+
gridMethod,
|
|
97
|
+
gridPoints,
|
|
98
|
+
gridRange?.[0],
|
|
99
|
+
gridRange?.[1],
|
|
100
|
+
centered
|
|
101
|
+
];
|
|
102
|
+
if (this.#cache?.key.every((value, i) => value === key[i])) return this.#cache.model;
|
|
103
|
+
const base = predict ? PartialDependence.fromModel(rows, x, predict, {
|
|
104
|
+
method: gridMethod,
|
|
105
|
+
points: gridPoints,
|
|
106
|
+
range: gridRange
|
|
107
|
+
}) : PartialDependence.fromPredictions(rows, {
|
|
108
|
+
x,
|
|
109
|
+
y,
|
|
110
|
+
instance,
|
|
111
|
+
observed
|
|
112
|
+
});
|
|
113
|
+
const model = centered ? base.centered() : base;
|
|
114
|
+
this.#cache = {
|
|
115
|
+
key,
|
|
116
|
+
model
|
|
117
|
+
};
|
|
118
|
+
return model;
|
|
119
|
+
}
|
|
120
|
+
get drawnCurves() {
|
|
121
|
+
const { curves } = this.model;
|
|
122
|
+
if (this.options.kind === "average") return [];
|
|
123
|
+
const limit = Math.max(1, Math.floor(this.options.maxCurves) || 1);
|
|
124
|
+
if (curves.length <= limit) return curves;
|
|
125
|
+
return Array.from({ length: limit }, (_, i) => curves[Math.floor(i * curves.length / limit)]);
|
|
126
|
+
}
|
|
127
|
+
get rugValues() {
|
|
128
|
+
const { observed, grid } = this.model, { rug } = this.options;
|
|
129
|
+
if (rug === "none" || !observed.length || !grid.length) return [];
|
|
130
|
+
const within = (value) => value >= grid[0] && value <= grid.at(-1);
|
|
131
|
+
if (rug === "deciles") return DECILES.map((p) => quantileSorted(observed, p)).filter(within);
|
|
132
|
+
const kept = observed.filter(within), stride = Math.max(1, kept.length / RUG_LIMIT);
|
|
133
|
+
return Array.from({ length: Math.min(kept.length, RUG_LIMIT) }, (_, i) => kept[Math.floor(i * stride)]);
|
|
134
|
+
}
|
|
135
|
+
table() {
|
|
136
|
+
return {
|
|
137
|
+
columns: [
|
|
138
|
+
this.options.x,
|
|
139
|
+
"partial_dependence",
|
|
140
|
+
"instances"
|
|
141
|
+
],
|
|
142
|
+
rows: this.model.average.map((point) => [
|
|
143
|
+
point.x,
|
|
144
|
+
point.y,
|
|
145
|
+
point.instances
|
|
146
|
+
])
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
tickFormat(scale) {
|
|
150
|
+
const base = super.tickFormat(scale);
|
|
151
|
+
if (!base) return base;
|
|
152
|
+
const fine = scale;
|
|
153
|
+
return (max(fine.domain(), Math.abs) ?? 0) < 10 ? (value) => fine.tickFormat(this.xTicks())(+value) : DistinctTicks.format(scale, base);
|
|
154
|
+
}
|
|
155
|
+
scales(ctx) {
|
|
156
|
+
const { width, height } = ctx.plot;
|
|
157
|
+
const { grid, average } = this.model;
|
|
158
|
+
const showAverage = this.options.kind !== "individual";
|
|
159
|
+
const heights = [...this.drawnCurves.flatMap((curve) => curve.values.flatMap((value) => value ?? [])), ...showAverage ? average.map((point) => point.y) : []];
|
|
160
|
+
const [left = 0, right = 1] = extent(grid);
|
|
161
|
+
const [low = 0, high = 1] = extent(heights);
|
|
162
|
+
const room = this.options.rug === "none" ? 0 : RUG_ROOM;
|
|
163
|
+
return {
|
|
164
|
+
x: scaleLinear().domain(left === right ? [left - 1, right + 1] : [left, right]).range([0, width]),
|
|
165
|
+
y: scaleLinear().domain(low === high ? [low - 1, high + 1] : [low, high]).nice().range([height - room, 0])
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
plotMarks(ctx, axes) {
|
|
169
|
+
const { kind, centered } = this.options, { grid, average } = this.model;
|
|
170
|
+
const showAverage = kind !== "individual", showCurves = kind !== "average";
|
|
171
|
+
const averageInk = showAverage && average.length ? ctx.palette.color(AVERAGE) : "";
|
|
172
|
+
const curveInk = showCurves && average.length ? ctx.palette.color(INDIVIDUAL) : "";
|
|
173
|
+
const trace = line().defined((value) => value !== null).x((_, i) => axes.x(grid[i])).y((value) => axes.y(value));
|
|
174
|
+
const path = line().x((point) => axes.x(point.x)).y((point) => axes.y(point.y));
|
|
175
|
+
this.drawAnchor(ctx, axes, centered);
|
|
176
|
+
this.drawRug(ctx, axes);
|
|
177
|
+
const curves = ctx.marks("ice", "path", this.drawnCurves, (curve) => curve.instance).attr("fill", "none").attr("stroke", curveInk).attr("stroke-width", 1).attr("stroke-opacity", showAverage ? .32 : .5).attr("pointer-events", "none");
|
|
178
|
+
ctx.set(curves, { d: (curve) => trace(curve.values) }, { d: (curve) => trace(curve.values) });
|
|
179
|
+
const lines = showAverage && average.length > 1 ? [average] : [];
|
|
180
|
+
const halo = ctx.marks("average-halo", "path", lines).attr("fill", "none").attr("stroke", ctx.theme.surface).attr("stroke-width", 5).attr("stroke-linejoin", "round").attr("pointer-events", "none");
|
|
181
|
+
const stroke = ctx.marks("average", "path", lines).attr("fill", "none").attr("stroke", averageInk).attr("stroke-width", 2).attr("stroke-linecap", "round").attr("stroke-linejoin", "round").attr("pointer-events", "none");
|
|
182
|
+
ctx.set(halo, { d: (points) => path(points) }, { d: (points) => path(points) });
|
|
183
|
+
stroke.filter(function() {
|
|
184
|
+
return !this.getAttribute("d");
|
|
185
|
+
}).attr("d", (points) => path(points));
|
|
186
|
+
ctx.drawOn(stroke);
|
|
187
|
+
ctx.set(stroke, { d: (points) => path(points) });
|
|
188
|
+
const lone = ctx.marks("lone-point", "circle", showAverage && average.length === 1 ? average : []).attr("fill", averageInk).attr("stroke", ctx.theme.surface).attr("stroke-width", 2);
|
|
189
|
+
ctx.set(lone, {
|
|
190
|
+
cx: (point) => axes.x(point.x),
|
|
191
|
+
cy: (point) => axes.y(point.y),
|
|
192
|
+
r: 5
|
|
193
|
+
});
|
|
194
|
+
TrendHover.attach(ctx, average.map((point) => ({
|
|
195
|
+
x: axes.x(point.x),
|
|
196
|
+
title: `${this.options.xLabel ?? this.options.x} ${figure(point.x)}`,
|
|
197
|
+
rows: [["Average", figure(point.y)], ["Instances", point.instances]],
|
|
198
|
+
marks: showAverage ? [{
|
|
199
|
+
x: axes.x(point.x),
|
|
200
|
+
y: axes.y(point.y),
|
|
201
|
+
color: averageInk
|
|
202
|
+
}] : []
|
|
203
|
+
})), ctx.plot.width, ctx.plot.height);
|
|
204
|
+
}
|
|
205
|
+
drawAnchor(ctx, axes, centered) {
|
|
206
|
+
const rule = ctx.marks("anchor", "line", centered && this.model.curves.length ? [0] : []).attr("stroke", ctx.theme.axis).attr("stroke-width", 1).attr("stroke-dasharray", "4 3");
|
|
207
|
+
ctx.set(rule, {
|
|
208
|
+
x1: 0,
|
|
209
|
+
x2: ctx.plot.width,
|
|
210
|
+
y1: axes.y(0),
|
|
211
|
+
y2: axes.y(0)
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
drawRug(ctx, axes) {
|
|
215
|
+
const { height } = ctx.plot, deciles = this.options.rug === "deciles";
|
|
216
|
+
const ticks = ctx.marks("rug", "line", this.rugValues).attr("stroke", ctx.theme.inkSecondary).attr("stroke-width", deciles ? 2 : 1).attr("stroke-opacity", deciles ? 1 : .55).attr("pointer-events", "none");
|
|
217
|
+
ctx.set(ticks, {
|
|
218
|
+
x1: (value) => axes.x(value),
|
|
219
|
+
x2: (value) => axes.x(value),
|
|
220
|
+
y1: height,
|
|
221
|
+
y2: height - RUG_HEIGHT
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
export { PartialDependenceKind, PartialDependencePlot, PartialDependenceRug };
|