diff --git a/DESIGN.md b/DESIGN.md index 26d3e8cc..408b7acf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -106,9 +106,14 @@ Experiment Studio is a dense product workspace, not a marketing site. No landing ### Charts -- Series in order: `#2b33e9`, `#7f9ce8`, `#ffba08`, `#dfefe4` (`grouped-bar-chart.ts`, `charts-renderer.component.ts`). Axis lines and labels `#475569`, split lines `#e2e8f0`, data labels `--chart-label-color`. +- Series in order: `#2b33e9`, `#7f9ce8`, `#ffba08`, `#e34948`, `#4a3aa7`, `#e87ba4`, `#008300`, `#1baf7a` — one list in `visualisations/charts/chart-palette.ts`, used by `charts-renderer.component.ts` and `grouped-bar-chart.ts`. Slots 1–3 are the charter; 4–8 were chosen with the dataviz palette validator so every adjacent pair clears ΔE 15 for both deutan/protan and full colour vision. Assign in order, never cycle: a ninth series folds into "Other" or a table. Axis lines and labels `#475569`, split lines `#e2e8f0`, data labels `--chart-label-color`. - Only the first series reaches 3:1 on white, so every chart carries a legend or direct labels; colour alone never identifies a series. Orange and green bars get a 1px `#475569` edge. -- Known issue: `#dfefe4` is 1.19:1 on white and a fourth series nearly vanishes. Avoid a fourth series until that slot gets a replacement colour. +- Heatmaps (correlation, PCA weights, confusion matrix, k-means centres) share `DIVERGING_COLORS` (`#e34948` → `#f1f5f9` → `#2b33e9`, or its grey-to-blue half) and `cellLabelColor`: ink on every cell except the deep blue end (≥ 0.7), where white wins. White never reaches 4.5:1 anywhere on the red half. +- The adjacent-pair check covers bars and stacks. Lines that cross or dots side by side compare every pair, and slots 1 and 5 (`#2b33e9`, `#4a3aa7`) fail that at ΔE 11: past four series, name them in rows instead (the k-means centres heatmap) rather than in colour. +- `#7f9ce8`, `#ffba08`, `#e87ba4` and `#1baf7a` sit under 3:1 on white, so a chart that reaches them keeps its legend and the exact-value table under it. `#ffba08` is also lighter than the validator's band — the charter colour stays, with its 1px edge. +- `ChartRendererComponent` applies the house style under every chart's own options: the app font (canvas cannot read `--font-sans`; Alaska is left out), 13px/600 ink titles with 12px muted subtitles, a white tooltip card with a hairline border, and ECharts `aria` descriptions. Renderers set only what differs. +- A chart must fit a 360px compare column: put long category names on the y axis (they wrap), slant crowded x labels, keep captions to short lines. +- Lines are drawn as measured: no default smoothing (it invents bends between discrete points such as an elbow or ROC curve) and no default area fill. ## Adjacent-panel test diff --git a/angular.json b/angular.json index 442efd3c..eaee9348 100644 --- a/angular.json +++ b/angular.json @@ -34,7 +34,6 @@ "allowedCommonJsDependencies": [ "canvg", "core-js", - "echarts-gl", "html2canvas", "raf", "rgbcolor" diff --git a/docs/context/module-index.md b/docs/context/module-index.md index bf2d629f..3e025bd5 100644 --- a/docs/context/module-index.md +++ b/docs/context/module-index.md @@ -50,9 +50,9 @@ Notes: Browser validation often requires a backend with data models, algorithms, ## `src/app/pages/experiment-studio/visualisations` Purpose: Chart/table rendering for algorithm outputs, histograms, metadata browsers, and auto-rendered results. -Key files: `charts/chart-registry.ts`, `charts/chart-builder.service.ts`, `auto-renderer/algorithm-table-registry.ts`, renderer files under `charts/renderers`. +Key files: `charts/chart-registry.ts` (builder + section title per algorithm), `charts/chart-builder.service.ts`, `charts/chart-palette.ts` (the one 8-slot series palette), `auto-renderer/algorithm-table-registry.ts`, renderer files under `charts/renderers`, `regression/regression-view.ts` + `regression-result.component` (coefficient view for linear/logistic regression, LMM, binary GLMM), `summary/summary-figures.ts` + `key-figures.component` (headline figures for tests and cross-validated models), `result-format.ts` (p-value, ratio and number formatting, χ² tail). Used by: Algorithm result panels, dashboard detail/compare, and statistics views. -Rules: Register new algorithm outputs in the existing registries; preserve legacy aliases when changing algorithm keys. +Rules: Register new algorithm outputs in the existing registries; preserve legacy aliases when changing algorithm keys. `AlgorithmResultComponent` picks one layout: the regression view when `buildRegressionView` returns one (linear/logistic regression, LMM, GLMMs, Cox; it then replaces charts and tables), otherwise key figures from `buildSummaryFigures`, then the registry charts, then the tables. A table is never repeated under a summary that already shows its values: `tablesShownAsFigures` (summary-figures) works out from the result which rows or columns the figures actually print, and each chart entry's `replaces` names the chart (`byChart`, its title text) that stands in for a table, applied through `tablesReplacedByCharts` only when that chart rendered. Both are `TableTrim`s — a title alone covers the whole table, `columns`/`rows` only those — and `trimTable` applies them on screen; the result title goes on the builder's first table before anything is trimmed. Before covering a table, move any value it has beyond the summary into the figures or chart. "Show all values as tables" under a result brings every covered table back whole (the accessible path to values a chart only draws); exports always keep every table, with exact values: a cell the screen rounds is a `DisplayCell` (`text` shown, `value` exported) and a table that merges values on screen carries an `export` with one exact value per column; CSV, ZIP and PDF all go through `exportTable`. The first column and name columns (groups, classes, categories) print as sent, never digit-grouped. View builders are pure functions with specs; components only render. The PDF export reads the table registry, so tables stay complete even where a summary view leads on screen. Tests: Registry and renderer specs live in this subtree. Notes: Registry keys and legacy aliases are compatibility-sensitive; check stored experiment data before removing an alias. diff --git a/e2e/experiment-studio.flows.spec.ts b/e2e/experiment-studio.flows.spec.ts index da8740b8..f7aaabb7 100644 --- a/e2e/experiment-studio.flows.spec.ts +++ b/e2e/experiment-studio.flows.spec.ts @@ -464,7 +464,7 @@ test.describe('run and save lifecycle', () => { await expect(result).toContainText('R²'); await expect(result).toContainText('0.042'); await expect(result).toContainText('F(1, 98) = 4.31'); - const sexRow = result.getByRole('row', { name: /Sex\[Male\]/ }); + const sexRow = result.getByRole('row', { name: /Sex · Male/ }); await expect(sexRow).toContainText('2.05'); await expect(sexRow).toContainText('0.090 to 4.01'); await expect(result.getByRole('row', { name: /Intercept/ })).toContainText('71.20'); diff --git a/package-lock.json b/package-lock.json index 6bc47f56..cc93f2ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "@fortawesome/fontawesome-free": "^7.3.1", "d3": "^7.9.0", "echarts": "6.1.0", - "echarts-gl": "2.1.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.9", @@ -4066,11 +4065,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/claygl": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz", - "integrity": "sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ==" - }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -4811,19 +4805,6 @@ "zrender": "6.1.0" } }, - "node_modules/echarts-gl": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.1.0.tgz", - "integrity": "sha512-GxzAPTYJyOANbu7InkdGV7QLLpVyuQWNvN1yyEEIiqgM11ilAO2OkvPNgRdf854R9ZPt/C1HAgjO8Udrj61lOQ==", - "license": "MIT", - "dependencies": { - "claygl": "^1.2.1", - "zrender": "^5.1.1 || ^6.0.0" - }, - "peerDependencies": { - "echarts": "^5.1.2 || ^6.0.0" - } - }, "node_modules/echarts/node_modules/tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", diff --git a/package.json b/package.json index ba832ccd..e76fa06d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "@fortawesome/fontawesome-free": "^7.3.1", "d3": "^7.9.0", "echarts": "6.1.0", - "echarts-gl": "2.1.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.9", diff --git a/src/app/core/algorithm-result-enum-mapper.spec.ts b/src/app/core/algorithm-result-enum-mapper.spec.ts index a96fc46b..d0ae5ece 100644 --- a/src/app/core/algorithm-result-enum-mapper.spec.ts +++ b/src/app/core/algorithm-result-enum-mapper.spec.ts @@ -92,4 +92,55 @@ describe('mapAlgorithmResultEnums', () => { expect(mapped.x_labels).toEqual(['Alzheimer disease', 'Other diagnosis']); expect(mapped.y_labels).toEqual(['Female', 'Male']); }); + + it('maps standardized mean difference groups using the x-variable enum map', () => { + const mapped = mapAlgorithmResultEnums( + 'standardized_mean_difference', + { comparisons: [{ group1: '1', group2: '2', smd: -0.277 }] }, + { biol_sex: { '1': 'male', '2': 'female' } }, + { y: 'age', x: 'biol_sex' } + ); + + expect(mapped.comparisons).toEqual([{ group1: 'male', group2: 'female', smd: -0.277 }]); + }); + + it('labels k-means variables and the matching centre keys', () => { + const mapped = mapAlgorithmResultEnums( + 'kmeans', + { variables: ['age', 'nihss_24h'], clusters: [{ label: 'Cluster 0', center: { age: 79.5, nihss_24h: 2 } }] }, + {}, + {}, + { age: 'Age', nihss_24h: '24h score' } + ); + + expect(mapped.variables).toEqual(['Age', '24h score']); + expect(mapped.clusters[0].center).toEqual({ Age: 79.5, '24h score': 2 }); + }); + + it('labels the variable that opens each k-means profile line', () => { + const mapped = mapAlgorithmResultEnums( + 'kmeans', + { + variables: ['nihss', 'nihss_24h'], + clusters: [{ label: 'Cluster 0', center: {}, profile: ['nihss_24h is above the overall mean', 'nihss is below the overall mean'] }], + }, + {}, + {}, + { nihss: 'Admission score', nihss_24h: '24h score' } + ); + + expect(mapped.clusters[0].profile).toEqual(['24h score is above the overall mean', 'Admission score is below the overall mean']); + }); + + it('labels a mixed model grouping variable sent as a list', () => { + const mapped = mapAlgorithmResultEnums( + 'lmm', + { grouping_var: ['hospit_unit'], indep_vars: ['Intercept'] }, + {}, + {}, + { hospit_unit: 'Hospitalization unit' } + ); + + expect(mapped.grouping_var).toEqual(['Hospitalization unit']); + }); }); diff --git a/src/app/core/algorithm-result-enum-mapper.ts b/src/app/core/algorithm-result-enum-mapper.ts index fa9a4060..9a06f4b5 100644 --- a/src/app/core/algorithm-result-enum-mapper.ts +++ b/src/app/core/algorithm-result-enum-mapper.ts @@ -243,6 +243,8 @@ function mapMixedEffectsResult( if (typeof mapped?.grouping_var === 'string') { next.grouping_var = labelMap?.[mapped.grouping_var] ?? mapped.grouping_var; + } else if (Array.isArray(mapped?.grouping_var)) { + next.grouping_var = mapped.grouping_var.map((code: string) => labelMap?.[code] ?? code); } if (Array.isArray(mapped?.category_order)) { @@ -403,6 +405,13 @@ export function mapAlgorithmResultEnums( const confusionMapped = mapConfusionMatrix(classesMapped.result, safeEnumMaps, yVar); const effectiveMap = confusionMapped.map ?? classesMapped.map; + // Gaussian only: the categorical table looks category_count up by feature code. + if (labelMap && Array.isArray(confusionMapped.result?.feature_names) && !confusionMapped.result?.category_count) { + confusionMapped.result = { + ...confusionMapped.result, + feature_names: confusionMapped.result.feature_names.map((code: string) => labelMap[code] ?? code), + }; + } if (effectiveMap && confusionMapped.result?.classification_summary) { return { ...confusionMapped.result, @@ -429,6 +438,45 @@ export function mapAlgorithmResultEnums( } case 'histogram': return mapMultipleHistograms(result, safeEnumMaps, yVar); + case 'outlier_report': { + if (!labelMap || !Array.isArray(result?.featurewise)) return result; + return { + ...result, + featurewise: result.featurewise.map((row: any) => ({ ...row, variable: labelMap[row?.variable] ?? row?.variable })), + }; + } + case 'kmeans': { + if (!labelMap || !Array.isArray(result?.variables)) return result; + // Each profile line starts with a variable code: "nihss_adm_score is below the overall mean". + const profileLine = (line: unknown) => { + const text = String(line ?? ''); + const code = result.variables.find((v: string) => text.startsWith(`${v} `)); + return code && labelMap[code] ? labelMap[code] + text.slice(code.length) : text; + }; + return { + ...result, + variables: result.variables.map((code: string) => labelMap[code] ?? code), + clusters: Array.isArray(result.clusters) + ? result.clusters.map((cluster: any) => ({ + ...cluster, + center: mapObjectKeys(cluster?.center ?? {}, labelMap), + ...(Array.isArray(cluster?.profile) ? { profile: cluster.profile.map(profileLine) } : {}), + })) + : result.clusters, + }; + } + case 'standardized_mean_difference': { + const map = getEnumMapForVar(safeEnumMaps, xVar); + if (!map || !Array.isArray(result?.comparisons)) return result; + return { + ...result, + comparisons: result.comparisons.map((row: any) => ({ + ...row, + group1: map[normalizeKey(row.group1)] ?? row.group1, + group2: map[normalizeKey(row.group2)] ?? row.group2, + })), + }; + } case 'linear_regression': case 'linear_regression_cv': case 'logistic_regression': diff --git a/src/app/core/html.utils.ts b/src/app/core/html.utils.ts index fc244bad..b78ffe57 100644 --- a/src/app/core/html.utils.ts +++ b/src/app/core/html.utils.ts @@ -1,6 +1,7 @@ -/** Escapes a value for safe interpolation into strings bound with [innerHTML]. */ -export function escapeHtml(value: string): string { - return value +/** Escapes a value for safe interpolation into strings bound with [innerHTML] or returned by an + * ECharts tooltip formatter (also HTML). */ +export function escapeHtml(value: unknown): string { + return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') diff --git a/src/app/core/result-label.utils.ts b/src/app/core/result-label.utils.ts index 458034eb..17ef65b9 100644 --- a/src/app/core/result-label.utils.ts +++ b/src/app/core/result-label.utils.ts @@ -44,10 +44,11 @@ export function formulaLine(y: string[], x: string[], xSeparator = ', '): string return [...y, ...x].join(', '); } -/** A result's `n_obs` as a grouped count, or '' when absent or not a positive number. */ +/** A result's `n_obs` as a count grouped like the result figures (27 308, narrow no-break space), + * or '' when absent or not a positive number. */ export function rowsUsedLabel(nObs: unknown): string { if (typeof nObs !== 'number' || !Number.isFinite(nObs) || nObs <= 0) return ''; - return nObs.toLocaleString('en-US'); + return nObs.toLocaleString('en-US').replace(/,/g, '\u202f'); } /** Top-level `n_obs`, or the first nested one (one-way ANOVA keeps it on `anova_table`). */ diff --git a/src/app/models/algorithm-visualization-guide.md b/src/app/models/algorithm-visualization-guide.md index 9a2c7ab8..e9c66a9b 100644 --- a/src/app/models/algorithm-visualization-guide.md +++ b/src/app/models/algorithm-visualization-guide.md @@ -106,9 +106,8 @@ This document describes what the frontend currently renders for each algorithm a ### K-Means (`kmeans`) - Charts: - - 2D scatter when the result has 2 variables. - - 3D scatter when the result has 3 variables. - - Parallel coordinates fallback for any other number of variables. + - Bubble chart of the centres when the result has 2 variables (the Cluster Centers table stays). + - Heatmap of the centres, values in the cells, for 3 or more variables (it replaces the Cluster Centers table on screen). - Elbow curve when `elbow` is present. - Tables: - K-Means summary (k selection, selected k, observations, initialization, convergence). diff --git a/src/app/models/visualization-guide.md b/src/app/models/visualization-guide.md index f42d20d9..0691d940 100644 --- a/src/app/models/visualization-guide.md +++ b/src/app/models/visualization-guide.md @@ -168,9 +168,8 @@ Data includes: - `n_obs_interval`, `center_definition`, `privacy_note`, `intended_use`, `warnings`, `limitations` Proposed visualization: -- For 2 variables: Scatter plot of cluster centers. -- For 3 variables: 3D scatter plot of cluster centers. -- For any other number of variables: Parallel coordinates plot. +- For 2 variables: Bubble plot of cluster centers, bubble area by cluster size. +- For 3 or more variables: Heatmap of cluster centers (clusters × variables), each cell printing the centre's value. - When `elbow` is present: Elbow curve of `inertia_by_k` with the selected k marked. - Supporting tables: - K-Means summary (k selection, k, observations, initialization, convergence). diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.css index 8cd68bae..c09e05e7 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.css +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.css @@ -23,10 +23,6 @@ margin-bottom: 0.65rem; } -.results-section-intro--compact { - margin-bottom: 0.35rem; -} - .results-section-heading { margin: 0; font-size: 0.95rem; @@ -35,18 +31,46 @@ letter-spacing: 0.02em; } -.results-section-lead { - margin: 0.35rem 0 0; - font-size: 0.8rem; - line-height: 1.45; - color: #64748b; - max-width: 52rem; +/* The key figures and the note on how to read them, kept together in the list's gap. */ +.results-figures { + display: flex; + flex-direction: column; } .results-detail-section--with-chart { padding-top: 0.25rem; } +/* Text button under the results, in the CSV link's style. */ +.tables-toggle { + align-self: flex-start; + padding: 6px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--primary-color); + font: inherit; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; +} + +.tables-toggle:hover, +.tables-toggle:focus-visible { + background: #eef0fd; +} + +/* A visible focus ring, not only the hover tint (WCAG 2.4.7). */ +.tables-toggle:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + +/* Every table already shown above: drop the empty section so it adds no gap. */ +.results-detail-section:has(> app-auto-renderer:empty) { + display: none; +} + /* CHARTS SECTION */ .gen-charts { @@ -70,8 +94,12 @@ margin-top: 2px; } -.results-primary-visual { - order: -1; +/* How to read the figures or charts above, in the regression card's note style. */ +.results-note { + margin: 10px 2px 0; + font-size: 0.76rem; + line-height: 1.5; + color: var(--text-muted); } /* ERROR MESSAGE */ @@ -156,380 +184,3 @@ tr:hover td { white-space: nowrap; } } - -/* ========================================================================== - Linear-regression result (Results page review 1b) - ========================================================================== - - Key figures first, one coefficients card with an inline interval-vs-zero - column, and model-fit details collapsed underneath. Ink titles, primary blue - only on actions and significant effects. */ - -.lr-result { - display: flex; - flex-direction: column; - gap: 16px; -} - -.key-figures { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - background: #ffffff; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 12px; - overflow: hidden; -} - -.key-figure { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - padding: 14px 16px; - border-left: 1px solid rgba(148, 163, 184, 0.22); -} - -.key-figure:first-child { - border-left: 0; -} - -.key-figure-label { - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.05em; - text-transform: uppercase; - color: var(--text-muted); -} - -.key-figure-value { - font-size: 1.5rem; - font-weight: 700; - line-height: 1.2; - letter-spacing: -0.01em; - color: var(--text-main); - font-variant-numeric: tabular-nums; -} - -.key-figure-note { - font-size: 0.76rem; - line-height: 1.4; - color: var(--text-muted); - text-wrap: pretty; -} - -.coefficients-card { - background: #ffffff; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 12px; - overflow: hidden; -} - -.coefficients-head { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 12px; - padding: 12px 16px; -} - -.coefficients-copy { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 4px 10px; - min-width: 0; -} - -.coefficients-title { - margin: 0; - font-size: 1rem; - font-weight: 700; - color: var(--text-main); -} - -.coefficients-subtitle { - font-size: 0.78rem; - color: var(--text-muted); -} - -.coefficients-actions { - display: inline-flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.coef-columns button { - min-height: 26px; - padding: 0 12px; - font-size: 0.76rem; -} - -.csv-link { - display: inline-flex; - align-items: center; - height: 32px; - padding: 0 10px; - border: 0; - border-radius: 8px; - background: transparent; - color: var(--primary-color); - font: inherit; - font-size: 0.76rem; - font-weight: 600; - cursor: pointer; -} - -.csv-link:hover, -.csv-link:focus-visible { - background: #eef0fd; - outline: none; -} - -.coef-table { - display: grid; - min-width: 0; -} - -.coef-row { - display: grid; - grid-template-columns: minmax(0, 1.3fr) 0.7fr 1.1fr 1.3fr 0.6fr; - column-gap: 16px; - align-items: center; - padding: 11px 16px; - border-top: 1px solid rgba(148, 163, 184, 0.16); - font-size: 0.88rem; - color: var(--text-main); - min-width: 0; -} - -.coef-table.is-all-columns .coef-row { - grid-template-columns: minmax(0, 1.3fr) 0.7fr 0.7fr 0.55fr 1.1fr 1.3fr 0.6fr; -} - -.coef-row--head { - padding: 8px 16px; - border-top: 0; - border-bottom: 1px solid rgba(148, 163, 184, 0.22); - background: #f8fafc; - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text-muted); -} - -.coef-row:not(.coef-row--head):first-of-type { - border-top: 0; -} - -.coef-row.is-intercept { - border-top: 1px dashed rgba(148, 163, 184, 0.4); - color: #64748b; -} - -.coef-row.is-intercept .coef-variable, -.coef-row.is-intercept .coef-estimate { - font-weight: 400; -} - -.coef-variable { - min-width: 0; - font-weight: 600; - overflow-wrap: anywhere; -} - -.coef-row.is-intercept .coef-variable { - font-weight: 400; -} - -.is-numeric { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.is-center { - text-align: center; -} - -.coef-muted { - color: var(--text-muted); -} - -.coef-p { - font-weight: 400; -} - -.coef-p.is-significant { - color: #0f172a; - font-weight: 700; -} - -.coef-plot-cell { - display: flex; - align-items: center; - justify-content: center; - min-width: 0; -} - -.coef-plot { - position: relative; - display: block; - width: 100%; - height: 18px; -} - -.coef-plot-zero { - position: absolute; - top: 2px; - bottom: 2px; - left: 50%; - width: 1px; - background: rgba(148, 163, 184, 0.75); -} - -.coef-plot-interval { - position: absolute; - top: 7px; - height: 4px; - border-radius: 999px; - background: #cbd5e1; -} - -.coef-plot-interval.is-significant { - background: color-mix(in srgb, var(--primary-color) 62%, white); -} - -.coef-plot-marker { - position: absolute; - top: 4px; - width: 10px; - height: 10px; - margin-left: -5px; - border-radius: 50%; - background: #94a3b8; - box-shadow: 0 0 0 2px #ffffff; -} - -.coef-plot-marker.is-significant { - background: var(--primary-color); -} - -.coef-note { - margin: 0; - padding: 10px 16px 12px; - border-top: 1px solid rgba(148, 163, 184, 0.16); - font-size: 0.76rem; - line-height: 1.5; - color: var(--text-muted); -} - -.model-fit { - background: #ffffff; - border: 1px solid rgba(148, 163, 184, 0.28); - border-radius: 12px; - overflow: hidden; -} - -.model-fit-summary { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - padding: 14px 16px; - border: 0; - background: transparent; - font: inherit; - font-size: 0.88rem; - font-weight: 600; - color: var(--text-main); - cursor: pointer; - text-align: left; -} - -.model-fit-summary:hover, -.model-fit-summary:focus-visible { - background: #f8fafc; - outline: none; -} - -.model-fit-arrow { - width: 0.42rem; - height: 0.42rem; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: rotate(-45deg); - transition: transform 0.2s ease; - flex: 0 0 auto; -} - -.model-fit-arrow.is-open { - transform: rotate(45deg); -} - -.model-fit-glance { - margin-left: auto; - font-size: 0.78rem; - font-weight: 400; - color: var(--text-muted); -} - -.model-fit-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin: 0; - border-top: 1px solid rgba(148, 163, 184, 0.22); -} - -.model-fit-item { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - min-width: 0; - padding: 10px 16px; - border-bottom: 1px solid rgba(148, 163, 184, 0.14); -} - -.model-fit-item dt { - font-size: 0.8rem; - color: var(--text-muted); -} - -.model-fit-item dd { - margin: 0; - font-size: 0.85rem; - font-weight: 600; - color: var(--text-main); - text-align: right; - font-variant-numeric: tabular-nums; -} - -@media (max-width: 900px) { - .key-figures { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .key-figure { - border-left: 0; - border-top: 1px solid rgba(148, 163, 184, 0.22); - } - - .key-figure:first-child { - border-top: 0; - } - - .coef-table { - overflow-x: auto; - } - - .coef-row, - .coef-table.is-all-columns .coef-row { - min-width: 620px; - } - - .model-fit-grid { - grid-template-columns: minmax(0, 1fr); - } -} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.html index 0062af5b..9c2e18ec 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.html +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.html @@ -7,133 +7,42 @@ } - @if (linearRegression(); as model) { -
- @if (model.figures.length) { -
- @for (figure of model.figures; track figure.label) { -
- {{ figure.label }} - {{ figure.value }} - {{ figure.note }} -
- } -
+ @if (regression(); as view) { + + } @else { + @if (summaryFigures(); as figures) { +
+ + @if (figureNote(); as note) { +

{{ note }}

} +
+ } -
-
-
-

Coefficients

- - Effect on {{ model.dependent }} , other predictors held constant - -
-
- - -
-
- -
-
- Variable - Estimate - @if (coefficientColumns() === 'all') { - Std. error - t - } - 95% CI - Interval vs 0 - p -
- - @for (row of model.rows; track row.variable) { -
- {{ row.variable }} - {{ row.estimateText }} - @if (coefficientColumns() === 'all') { - {{ row.stdErrText }} - {{ row.tText }} - } - {{ row.ciText }} - - @if (!row.isIntercept) { - - } - - {{ row.pText }} -
- } -
- -

- Bold p-values are below 0.05. Each interval is drawn on its own scale around zero, so - compare direction and whether it crosses zero, not size between rows. -

-
- - @if (model.fitMetrics.length) { -
- - - @if (fitOpen()) { -
- @for (metric of model.fitMetrics; track metric.label) { -
-
{{ metric.label }}
-
{{ metric.value }}
-
- } -
- } -
- } -
- } @else { @if (renderedCharts().length > 0) { -
+
-

Effect estimates

-

Primary view for comparing factors. Tables below list the same estimates with exact values.

+

{{ chartTitle() }}

+ @if (chartNote(); as note) { +

{{ note }}

+ }
} @if (isRenderable()) { -
- @if (renderedCharts().length > 0) { -
-

Detailed results

-
- } +
+ [yVar]="yVar()" [xVar]="xVar()" [fallbackTitle]="fallbackResultTitle()" [covered]="tablesToCover()" + [heading]="hasSummaryAbove() ? 'Detailed results' : null" />
} + + @if (coveredTables().length) { + + } } diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.spec.ts index 51a131ac..47fb3dbd 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.spec.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.spec.ts @@ -1,5 +1,6 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideEchartsCore } from 'ngx-echarts'; import { AlgorithmResultComponent } from './algorithm-result.component'; describe('AlgorithmResultComponent linear-regression review layout', () => { @@ -30,7 +31,7 @@ describe('AlgorithmResultComponent linear-regression review layout', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [AlgorithmResultComponent], - providers: [provideZonelessChangeDetection()], + providers: [provideZonelessChangeDetection(), provideEchartsCore({ echarts: () => import('echarts') })], }).compileComponents(); fixture = TestBed.createComponent(AlgorithmResultComponent); @@ -78,10 +79,127 @@ describe('AlgorithmResultComponent linear-regression review layout', () => { expect(html.querySelector('.model-fit-grid')?.textContent).toContain('Log-likelihood'); }); + it('shows the logistic log-odds estimate, SE, z and p as key columns and the odds ratio with all columns', () => { + const html = render({ + dependent_var: 'Good outcome', + indep_vars: ['Intercept', 'Age'], + summary: { + coefficients: [7.06, -0.063], stderr: [0.13, 0.0016], z_scores: [54.8, -39.5], pvalues: [0, 0], + lower_ci: [6.81, -0.066], upper_ci: [7.31, -0.06], n_obs: 27308, + }, + }, 'logistic_regression'); + const headers = () => Array.from(html.querySelectorAll('.coef-row--head [role="columnheader"]')).map((el) => el.textContent?.trim()); + + expect(headers()).toEqual(['Variable', 'Estimate', 'Std. error', 'z', 'p-value']); + expect(html.querySelector('.coef-row:not(.coef-row--head) .coef-estimate')?.textContent).toBe('−0.063'); + + Array.from(html.querySelectorAll('.coef-columns button')).find((b) => b.textContent?.includes('All columns'))!.click(); + fixture.detectChanges(); + expect(headers()).toEqual(['Variable', 'Estimate', 'Std. error', 'z', 'Odds ratio', '95% CI', 'Interval vs 1', 'p-value']); + }); + + it('explains the figures and charts that need it under them', () => { + const cv = render({ dependent_var: 'MMSE', root_mean_sq_error: [3.1, 0.2], mean_abs_error: [2.4, 0.1], n_obs: [100, 101] }, 'linear_regression_cv'); + expect(cv.querySelector('.results-note')?.textContent).toContain('RMSE (root mean squared error)'); + + const nb = render({ + classes: ['no', 'yes'], feature_names: ['Age'], theta: [[78], [69]], var: [[131], [186]], class_count: [37366, 102452], + }, 'naive_bayes_gaussian'); + expect(nb.querySelector('.gen-charts .results-note')?.textContent).toContain('standard deviations'); + }); + it('keeps the generic renderer for non-linear-regression algorithms', () => { const html = render({ title: 'Some other result' }, 'kmeans'); expect(html.querySelector('.lr-result')).toBeFalsy(); expect(html.querySelector('app-auto-renderer')).toBeTruthy(); }); + + it('does not repeat a table whose values the key figures already show', () => { + const html = render({ t_stat: -3.9, df: 120, p: 0.0001, mean_diff: -1.2, se_diff: 0.3, ci_lower: -1.8, ci_upper: -0.6, cohens_d: -0.7 }, 'ttest_independent'); + + expect(html.querySelector('app-key-figures')).toBeTruthy(); + expect(html.querySelector('.gen-charts .results-section-heading')?.textContent).toBe('Mean difference'); + expect(html.querySelectorAll('.ar-card').length).toBe(0); + expect(html.querySelector('.ar-heading')).toBeFalsy(); + }); + + it('brings the covered tables back on request', () => { + const html = render({ t_stat: -3.9, df: 120, p: 0.0001, mean_diff: -1.2, se_diff: 0.3, ci_lower: -1.8, ci_upper: -0.6, cohens_d: -0.7 }, 'ttest_independent'); + const toggle = html.querySelector('.tables-toggle')!; + + expect(toggle.textContent?.trim()).toBe('Show all values as tables'); + // The label says what the button does next; a pressed state on top would contradict it. + expect(toggle.hasAttribute('aria-pressed')).toBe(false); + toggle.click(); + fixture.detectChanges(); + expect(html.querySelectorAll('.ar-card').length).toBe(1); + expect(toggle.textContent?.trim()).toBe('Hide tables repeated above'); + }); + + const cardTitles = (html: HTMLElement) => Array.from(html.querySelectorAll('.ar-card .ar-title')).map((el) => el.textContent?.trim()); + const rowLabels = (html: HTMLElement, title: string) => { + const card = Array.from(html.querySelectorAll('.ar-card')).find((el) => el.querySelector('.ar-title')?.textContent?.trim() === title); + return Array.from(card?.querySelectorAll('tbody tr') ?? []).map((row) => row.querySelector('td')?.textContent?.trim()); + }; + + it('keeps the t-test rows the key figures do not print', () => { + const html = render({ t_stat: -3.9, df: 120, p: 0.0001, mean_diff: -1.2, ci_lower: -1.8, cohens_d: -0.7, mean_x: 4.2 }, 'ttest_independent'); + + // One CI bound alone is not in the mean-difference figure, and mean_x has no figure. + expect(rowLabels(html, 'Independent T-Test')).toEqual(['95% CI Lower', 'Mean X']); + }); + + it('keeps the F diagnostic and undefined metrics under the linear regression CV figures', () => { + const html = render({ + dependent_var: 'y', + n_obs: [100, 101], + root_mean_sq_error: [1.2, 0.1], + r_squared: [null, null], + mean_abs_error: [0.7, 0.05], + f_stat: [12.3, 1.1], + }, 'linear_regression_cv'); + + expect(html.querySelectorAll('.key-figure').length).toBeGreaterThan(0); + expect(rowLabels(html, 'Error metrics')).toEqual(['R-squared', 'F diagnostic']); + }); + + describe('tables a chart replaces', () => { + const cluster = (id: string, center: Record) => ({ + cluster_id: id, label: `Cluster ${id}`, size_interval: '10-20', center, profile: [], quality: { compactness: null }, + }); + + it('hides the k-means centres table under the heatmap, which prints every centre', () => { + const html = render({ + variables: ['a', 'b', 'c'], + clusters: [cluster('0', { a: 1, b: 2, c: 3 }), cluster('1', { a: 3, b: 4, c: 5 })], + }, 'kmeans'); + + expect(html.querySelector('.gen-charts')).toBeTruthy(); + expect(cardTitles(html)).not.toContain('Cluster Centers'); + }); + + it('keeps the k-means centres table under the two-variable bubble chart, which only places them', () => { + const html = render({ + variables: ['a', 'b'], + clusters: [cluster('0', { a: 1, b: 2 }), cluster('1', { a: 3, b: 4 })], + }, 'kmeans'); + + expect(html.querySelector('.gen-charts')).toBeTruthy(); + expect(cardTitles(html)).toContain('Cluster Centers'); + }); + + it('keeps the naive Bayes class summary when only the profile chart rendered', () => { + const html = render({ + classes: ['A', 'B'], + class_count: [40, 60], + feature_names: ['x'], + theta: [[1], [2]], + var: [[1], [1]], + }, 'naive_bayes_gaussian'); + + expect(html.querySelector('.gen-charts')).toBeTruthy(); + expect(cardTitles(html)).toContain('Class Summary'); + }); + }); }); diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.ts index f886b308..4545f8c7 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-result/algorithm-result.component.ts @@ -3,83 +3,22 @@ import { Component, input, computed, inject, signal, ChangeDetectionStrategy } f import { CommonModule } from '@angular/common'; import { AutoRendererComponent } from '../../visualisations/auto-renderer/auto-renderer.component'; import { ChartRendererComponent } from '../../visualisations/charts/charts-renderer/charts-renderer.component'; +import { AlgorithmChartRegistry, tablesReplacedByCharts } from '../../visualisations/charts/chart-registry'; import { NgxEchartsModule } from 'ngx-echarts'; import { EnumMaps, LabelMap, mapAlgorithmResultEnums } from '../../../../core/algorithm-result-enum-mapper'; import { prettifyLabel } from '../../../../core/algorithm-mappers'; -import { CsvExportService } from '../../../../services/csv-export.service'; - -type CoefficientColumnMode = 'key' | 'all'; - -interface LinearCoefficientRow { - variable: string; - estimateText: string; - stdErrText: string; - tText: string; - ciText: string; - pText: string; - pValue: number | null; - /** Unrounded values for export; the *Text fields are display-only. */ - estimate: number | null; - stdErr: number | null; - t: number | null; - lower: number | null; - upper: number | null; - significant: boolean; - isIntercept: boolean; - plotLeft: number; - plotWidth: number; - markerLeft: number; -} - -interface LinearKeyFigure { - label: string; - value: string; - note?: string; -} - -interface LinearRegressionView { - dependent: string; - rows: LinearCoefficientRow[]; - figures: LinearKeyFigure[]; - fitMetrics: LinearKeyFigure[]; - fitGlance: string; -} - -function toFiniteNumber(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) { - return Number(value); - } - return null; -} - -function formatSignedNumber(value: number): string { - const sign = value < 0 ? '−' : ''; - const abs = Math.abs(value); - return `${sign}${abs.toFixed(abs < 1 ? 3 : 2)}`; -} - -function formatInteger(value: number): string { - return value.toLocaleString('en-US'); -} - -function formatSummaryNumber(value: number): string { - if (!Number.isFinite(value)) return ''; - if (Math.abs(value) >= 1000) return formatInteger(value); - return formatSignedNumber(value); -} - -function formatPValue(value: number | null): string { - if (value === null) return '—'; - if (value < 0.001) return '< 0.001'; - return value.toFixed(3); -} +import { RegressionResultComponent } from '../../visualisations/regression/regression-result.component'; +import { buildRegressionView } from '../../visualisations/regression/regression-view'; +import { KeyFiguresComponent } from '../../visualisations/summary/key-figures.component'; +import { buildSummaryFigures, FIGURE_NOTES, tablesShownAsFigures } from '../../visualisations/summary/summary-figures'; @Component({ selector: 'app-algorithm-result', imports: [CommonModule, AutoRendererComponent, ChartRendererComponent, + RegressionResultComponent, + KeyFiguresComponent, NgxEchartsModule], templateUrl: './algorithm-result.component.html', styleUrl: './algorithm-result.component.css', @@ -87,7 +26,6 @@ function formatPValue(value: number | null): string { }) export class AlgorithmResultComponent { private chartBuilder = inject(ChartBuilderService); - private readonly csvExport = inject(CsvExportService); result = input(null); schema = input([]); @@ -97,185 +35,31 @@ export class AlgorithmResultComponent { yVar = input(null); xVar = input(null); labelMap = input(null); + /** The run's algorithm parameters; names the groups a two-group test compared. */ + parameters = input | null>(null); - readonly coefficientColumns = signal('key'); - readonly fitOpen = signal(false); - - readonly linearRegression = computed(() => { - const algorithm = String(this.algorithm() ?? ''); - const result = this.mappedResult(); - if (!result || !algorithm.startsWith('linear_regression')) return null; - - const indepVars: string[] = Array.isArray(result.indep_vars) - ? result.indep_vars.map((entry: unknown) => String(entry ?? '')) - : []; - const coefficients: unknown[] = Array.isArray(result.coefficients) ? result.coefficients : []; - const stdErr: unknown[] = Array.isArray(result.std_err) ? result.std_err : []; - const tStats: unknown[] = Array.isArray(result.t_stats) ? result.t_stats : []; - const pValues: unknown[] = Array.isArray(result.pvalues) ? result.pvalues : []; - const lowerCi: unknown[] = Array.isArray(result.lower_ci) ? result.lower_ci : []; - const upperCi: unknown[] = Array.isArray(result.upper_ci) ? result.upper_ci : []; - - if (!indepVars.length || !coefficients.length) return null; - - const dependent = String(result.dependent_var ?? this.yVar() ?? 'outcome'); - const rawRows = indepVars.map((variable, index) => { - const estimate = toFiniteNumber(coefficients[index]); - const lower = toFiniteNumber(lowerCi[index]); - const upper = toFiniteNumber(upperCi[index]); - const pValue = toFiniteNumber(pValues[index]); - const stdErrValue = toFiniteNumber(stdErr[index]); - const tValue = toFiniteNumber(tStats[index]); - const isIntercept = /^intercept$/i.test(variable); - const scale = - Math.max( - Math.abs(estimate ?? 0), - Math.abs(lower ?? 0), - Math.abs(upper ?? 0) - ) * 1.15 || 1; - const position = (value: number) => Math.max(0, Math.min(100, 50 + (value / scale) * 50)); - const left = lower === null ? 50 : position(lower); - const right = upper === null ? 50 : position(upper); - const marker = estimate === null ? 50 : position(estimate); - - return { - variable, - estimateText: estimate === null ? '—' : formatSignedNumber(estimate), - stdErrText: stdErrValue === null ? '—' : formatSignedNumber(stdErrValue), - tText: tValue === null ? '—' : formatSignedNumber(tValue), - ciText: - lower === null || upper === null - ? '—' - : `${formatSignedNumber(lower)} to ${formatSignedNumber(upper)}`, - pText: formatPValue(pValue), - pValue, - estimate, - stdErr: stdErrValue, - t: tValue, - lower, - upper, - significant: pValue !== null && pValue < 0.05, - isIntercept, - plotLeft: left, - plotWidth: Math.max(1, right - left), - markerLeft: marker, - }; - }); - - const rows: LinearCoefficientRow[] = rawRows.sort((a, b) => { - if (a.isIntercept !== b.isIntercept) return a.isIntercept ? 1 : -1; - return Math.abs(b.estimate ?? 0) - Math.abs(a.estimate ?? 0); - }); - - const figures: LinearKeyFigure[] = []; - const rSquared = toFiniteNumber(result.r_squared); - const adjusted = toFiniteNumber(result.r_squared_adjusted); - const fStat = toFiniteNumber(result.f_stat); - const fPvalue = toFiniteNumber(result.f_pvalue); - const rse = toFiniteNumber(result.rse); - const nObs = toFiniteNumber(result.n_obs); - const dfModel = toFiniteNumber(result.df_model); - const dfResid = toFiniteNumber(result.df_resid); - - if (rSquared !== null) { - figures.push({ - label: 'R²', - value: rSquared.toFixed(3), - note: `${Math.round(rSquared * 100)}% of the variance in ${dependent} is explained`, - }); - } - if (adjusted !== null) { - const predictorCount = rows.filter((row) => !row.isIntercept).length; - figures.push({ - label: 'Adjusted R²', - value: adjusted.toFixed(3), - note: `Corrected for ${predictorCount} predictor${predictorCount === 1 ? '' : 's'}`, - }); - } - if (fPvalue !== null || fStat !== null) { - const statText = fStat === null ? '' : formatSummaryNumber(fStat); - const dfText = - dfModel !== null && dfResid !== null - ? `F(${formatInteger(dfModel)}, ${formatInteger(dfResid)})` - : 'Model F'; - figures.push({ - label: 'Model F-test', - value: fPvalue === null ? '—' : formatPValue(fPvalue), - note: `${dfText}${statText ? ` = ${statText}` : ''}`, - }); - } - if (rse !== null) { - figures.push({ - label: 'Residual SE', - value: formatSummaryNumber(rse), - note: `${dependent} points, typical prediction error`, - }); - } - if (nObs !== null) { - figures.push({ - label: 'Observations', - value: formatInteger(nObs), - note: 'Rows in the fitted model', - }); - } - - const summary = (value: unknown) => { - const number = toFiniteNumber(value); - return number === null ? '' : formatSummaryNumber(number); - }; - const fitMetrics: LinearKeyFigure[] = [ - ['Dependent variable', dependent], - ['Observations', nObs === null ? '' : formatInteger(nObs)], - ['df (model)', dfModel === null ? '' : formatInteger(dfModel)], - ['df (residual)', dfResid === null ? '' : formatInteger(dfResid)], - ['R²', rSquared === null ? '' : rSquared.toFixed(3)], - ['Adjusted R²', adjusted === null ? '' : adjusted.toFixed(3)], - ['F-statistic', summary(fStat)], - ['p-value (F)', fPvalue === null ? '' : formatPValue(fPvalue)], - ['Residual std. error', summary(rse)], - ['Log-likelihood', summary(result.ll)], - ['AIC', summary(result.aic)], - ['BIC', summary(result.bic)], - ] - .filter(([, value]) => value !== '') - .map(([label, value]) => ({ label, value })); - - const fitGlance = fitMetrics - .filter((metric) => ['AIC', 'BIC', 'Log-likelihood'].includes(metric.label)) - .map((metric) => `${metric.label.replace('Log-', 'log-')} ${metric.value}`) - .join(' · '); + readonly regression = computed(() => + this.errorMessage() ? null : buildRegressionView(this.algorithm(), this.mappedResult(), this.yVar(), this.positiveClassLabel()) + ); - return { dependent, rows, figures, fitMetrics, fitGlance }; + /** The positive_class parameter of a binary model as the outcome's enumeration label. */ + private readonly positiveClassLabel = computed(() => { + const value = this.parameters()?.['positive_class']; + if (value === null || value === undefined || value === '') return null; + return this.enumMaps()?.[this.yVar() ?? '']?.[String(value)] ?? String(value); }); - /** Raw, unrounded numbers with an ASCII minus and separate CI bounds, so the file - * reads as numeric columns in a spreadsheet or R; blanks stand for missing values. */ - downloadCoefficientsCsv(): void { - const model = this.linearRegression(); - if (!model) return; + readonly summaryFigures = computed(() => + this.errorMessage() ? null : buildSummaryFigures(this.algorithm(), this.mappedResult(), this.groupLabels()) + ); - const allColumns = this.coefficientColumns() === 'all'; - const headers = [ - 'Variable', - 'Estimate', - ...(allColumns ? ['Std. error', 't'] : []), - 'CI 95% lower', - 'CI 95% upper', - 'p', - ]; - const raw = (value: number | null) => (value === null ? '' : String(value)); - const rows = model.rows.map((row) => ({ - 'Variable': row.variable, - 'Estimate': raw(row.estimate), - 'Std. error': raw(row.stdErr), - 't': raw(row.t), - 'CI 95% lower': raw(row.lower), - 'CI 95% upper': raw(row.upper), - 'p': raw(row.pValue), - })); - const slug = model.dependent.toLowerCase().replace(/[^a-z0-9]+/g, '_') || 'coefficients'; - this.csvExport.exportToCsv(rows, headers, `${slug}_coefficients.csv`); - } + /** groupA / groupB parameters as the x variable's enumeration labels. */ + private readonly groupLabels = computed(() => { + const params = this.parameters(); + const enumMap = this.enumMaps()?.[this.xVar() ?? ''] ?? {}; + const label = (value: unknown) => (value === null || value === undefined || value === '' ? null : enumMap[String(value)] ?? String(value)); + return { groupA: label(params?.['groupA']), groupB: label(params?.['groupB']) }; + }); constructor() { } @@ -291,14 +75,17 @@ export class AlgorithmResultComponent { return !!this.result() && !!this.algorithm() && !this.errorMessage(); }); - mappedResult = computed(() => - this.errorMessage() - ? this.result() - : mapAlgorithmResultEnums(this.algorithm(), this.result(), this.enumMaps(), { - y: this.yVar(), - x: this.xVar(), - }, this.labelMap()) - ); + mappedResult = computed(() => { + if (this.errorMessage()) return this.result(); + const mapped = mapAlgorithmResultEnums(this.algorithm(), this.result(), this.enumMaps(), { + y: this.yVar(), + x: this.xVar(), + }, this.labelMap()); + // A binary tp/fp/fn/tn matrix names its classes after the positive class when the run says which. + const cm = mapped?.confusion_matrix; + const positive = this.positiveClassLabel(); + return positive && cm && cm.tp !== undefined ? { ...mapped, confusion_matrix: { ...cm, positive_label: positive } } : mapped; + }); fallbackResultTitle = computed(() => { const explicitTitle = this.mappedResult()?.title; @@ -313,6 +100,23 @@ export class AlgorithmResultComponent { }); + readonly chartTitle = computed(() => AlgorithmChartRegistry[this.algorithm()]?.title ?? 'Charts'); + readonly chartNote = computed(() => AlgorithmChartRegistry[this.algorithm()]?.note ?? null); + readonly figureNote = computed(() => FIGURE_NOTES[this.algorithm()] ?? null); + + /** What the figures or charts above already show of the tables, so it is not repeated below: + * only the values those figures print and the tables whose own chart rendered. */ + readonly coveredTables = computed(() => [ + ...(this.summaryFigures() ? tablesShownAsFigures(this.algorithm(), this.mappedResult()) : []), + ...tablesReplacedByCharts(this.algorithm(), this.renderedCharts()), + ]); + + readonly hasSummaryAbove = computed(() => !!this.summaryFigures() || this.renderedCharts().length > 0); + + /** The reader can ask for every value as a table again — what a chart shows is otherwise only pixels. */ + readonly showAllTables = signal(false); + readonly tablesToCover = computed(() => (this.showAllTables() ? [] : this.coveredTables())); + renderedCharts = computed(() => { if (!this.result() || !this.algorithm() || this.errorMessage()) return []; return this.chartBuilder.getChartsForAlgorithm(this.algorithm(), this.mappedResult(), this.fallbackResultTitle()); diff --git a/src/app/pages/experiment-studio/execution-panel/execution-panel.component.html b/src/app/pages/experiment-studio/execution-panel/execution-panel.component.html index 60e30ca1..09264d6e 100644 --- a/src/app/pages/experiment-studio/execution-panel/execution-panel.component.html +++ b/src/app/pages/experiment-studio/execution-panel/execution-panel.component.html @@ -96,7 +96,7 @@

{{ resultHeadline() }}

+ [algorithmLabel]="resultAlgorithmLabel()" [parameters]="setupSnapshot()?.parameters ?? null" />
diff --git a/src/app/pages/experiment-studio/execution-panel/execution-panel.component.spec.ts b/src/app/pages/experiment-studio/execution-panel/execution-panel.component.spec.ts index 85113916..74675476 100644 --- a/src/app/pages/experiment-studio/execution-panel/execution-panel.component.spec.ts +++ b/src/app/pages/experiment-studio/execution-panel/execution-panel.component.spec.ts @@ -13,7 +13,7 @@ import { ExecutionPanelComponent } from './execution-panel.component'; standalone: true, selector: 'app-algorithm-result', template: '
', - inputs: ['result', 'schema', 'algorithm', 'enumMaps', 'yVar', 'xVar', 'labelMap', 'algorithmLabel'], + inputs: ['result', 'schema', 'algorithm', 'enumMaps', 'yVar', 'xVar', 'labelMap', 'algorithmLabel', 'parameters'], }) class FakeResultComponent {} diff --git a/src/app/pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry.spec.ts b/src/app/pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry.spec.ts index ac34dbfb..c748b41a 100644 --- a/src/app/pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry.spec.ts +++ b/src/app/pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry.spec.ts @@ -1,4 +1,7 @@ -import { AlgorithmTableRegistry } from './algorithm-table-registry'; +import { cellText, exportTable, trimTable, AlgorithmTableRegistry } from './algorithm-table-registry'; + +/** The rows as the screen prints them. */ +const shown = (rows: unknown[][]) => rows.map((row) => row.map(cellText)); describe('AlgorithmTableRegistry', () => { it('supports linear_regression_cv metrics provided as mean/std objects', () => { @@ -170,7 +173,7 @@ describe('AlgorithmTableRegistry', () => { expect(canonical[0].rows.some((row) => row[0] === 'Intercept')).toBe(true); }); - it('renders category_log_prob tables for naive_bayes_categorical', () => { + it('renders categorical naive Bayes counts with class-conditional shares', () => { const tables = AlgorithmTableRegistry['naive_bayes_categorical']({ classes: ['0', '1'], class_count: [50, 60], @@ -181,7 +184,27 @@ describe('AlgorithmTableRegistry', () => { category_log_prob: { sex: [[-0.69, -0.69], [-0.65, -0.65]] }, }); - expect(tables.some((t) => t.title?.startsWith('Category Log Probabilities'))).toBe(true); + const sex = tables.find((t) => t.title === 'sex by class')!; + expect(sex.columns).toEqual(['Category', '0', '1']); + expect(sex.rows[0]).toEqual(['0', '25 · 50.2%', '30 · 52.2%']); + expect(shown(tables[0].rows)[0]).toEqual(['0', '50', '49.7%']); + }); + + it('exports categorical naive Bayes counts, P(category | class) and priors exactly, one value per column', () => { + const tables = AlgorithmTableRegistry['naive_bayes_categorical']({ + classes: ['0', '1'], + class_count: [50, 60], + class_log_prior: [-0.7, -0.6], + feature_names: ['sex'], + categories: { sex: ['0', '1'] }, + category_count: { sex: [[25, 25], [30, 30]] }, + category_log_prob: { sex: [[-0.69, -0.69], [-0.65, -0.65]] }, + }); + + const sex = exportTable(tables.find((t) => t.title === 'sex by class')!); + expect(sex.columns).toEqual(['Category', '0 count', '0 P(category | class)', '1 count', '1 P(category | class)']); + expect(sex.rows[0]).toEqual(['0', 25, Math.exp(-0.69), 30, Math.exp(-0.65)]); + expect(exportTable(tables[0]).rows[0]).toEqual(['0', '50', Math.exp(-0.7)]); }); it('renders pca summary and keeps pca_with_transformation alias', () => { @@ -223,11 +246,24 @@ describe('AlgorithmTableRegistry', () => { }); expect(tables.length).toBe(1); - expect(tables[0].rows).toContainEqual(['U statistic', '163.5']); - expect(tables[0].rows).toContainEqual(['p-value', '8.437e-6']); - expect(tables[0].rows).toContainEqual(['z-score', '4.454']); - expect(tables[0].rows).toContainEqual(['Group A sample size', '15']); - expect(tables[0].rows).toContainEqual(['Group B sample size', '80']); + const rows = shown(tables[0].rows); + expect(rows).toContainEqual(['U statistic', '163.5']); + expect(rows).toContainEqual(['p-value', '< 0.001']); + expect(rows).toContainEqual(['z-score', '4.454']); + expect(rows).toContainEqual(['P(A > B)', '0.14']); + expect(rows).toContainEqual(['Group A sample size', '15']); + expect(rows).toContainEqual(['Group B sample size', '80']); + // Exports carry what the screen rounds. + const exported = exportTable(tables[0]).rows; + expect(exported).toContainEqual(['p-value', 8.436804884099734e-6]); + expect(exported).toContainEqual(['P(A > B)', 163.5 / (15 * 80)]); + }); + + it('prints p = 0 as < 0.001 and leaves P(A > B) out without group sizes', () => { + const tables = AlgorithmTableRegistry['binned_mann_whitney_u_test']({ u_stat: 70, p_value: 0, z_score: 9 } as any); + + expect(shown(tables[0].rows)).toContainEqual(['p-value', '< 0.001']); + expect(tables[0].rows.map((row) => row[0])).not.toContain('P(A > B)'); }); it('renders standardized mean difference comparisons', () => { @@ -306,10 +342,34 @@ describe('AlgorithmTableRegistry', () => { }); expect(tables.length).toBe(1); - expect(tables[0].columns).toContain('Outlier %'); + expect(tables[0].columns).toEqual(['Variable', 'Dataset', 'Rule', 'Kept range', 'Below', 'Above', 'Outliers', 'Share']); + expect(tables[0].rows[0][2]).toBe('IQR × 1.5, both tails'); + expect(tables[0].rows[0][3]).toBe('— to 90'); + expect(tables[0].rows[0][4]).toBe('0'); expect(tables[0].rows[0][5]).toBe('Unavailable'); - expect(tables[0].rows[0][7]).toBe('0'); - expect(tables[0].rows[0][9]).toBe('0'); + expect(tables[0].rows[0][6]).toBe('0'); + expect(tables[0].rows[0][7]).toBe('0.0%'); + }); + + it('exports the outlier report with separate exact bounds and share', () => { + const tables = AlgorithmTableRegistry['outlier_report']({ + featurewise: [ + { + variable: 'age', + dataset: 'ds1', + data: { + strategy: 'gaussian', tail: 'both', fold: 3, + lower_bound: 12.345678, upper_bound: 90.123456, + lower_outlier_count: 2, upper_outlier_count: 7, total_outlier_count: 9, total_outlier_percentage: 8.91734, + }, + }, + ], + }); + + expect(tables[0].rows[0][3]).toBe('12.346 to 90.123'); + const exported = exportTable(tables[0]); + expect(exported.columns).toEqual(['Variable', 'Dataset', 'Rule', 'Lower bound', 'Upper bound', 'Below', 'Above', 'Outliers', 'Share (%)']); + expect(exported.rows[0]).toEqual(['age', 'ds1', 'Mean ± SD × 3, both tails', 12.345678, 90.123456, 2, 7, 9, 8.91734]); }); it('filters frontend metadata keys from ttest results', () => { @@ -351,16 +411,18 @@ describe('AlgorithmTableRegistry', () => { ci_lower: 0.033494668591674485, }); - expect(tables[0].rows).toContainEqual(['T-statistic', '3.888']); - expect(tables[0].rows).toContainEqual(['Degrees of Freedom', '12.000']); - expect(tables[0].rows).toContainEqual(['p-value', '0.000']); - expect(tables[0].rows).toContainEqual(['Mean Difference', '0.068']); + const rows = shown(tables[0].rows); + expect(rows).toContainEqual(['T-statistic', '3.888']); + expect(rows).toContainEqual(['Degrees of Freedom', '12.000']); + expect(rows).toContainEqual(['p-value', '< 0.001']); + expect(rows).toContainEqual(['Mean Difference', '0.068']); + expect(exportTable(tables[0]).rows).toContainEqual(['p-value', 1.017999869046271e-4]); }); it('renders classical Cox regression coefficients and summary', () => { const tables = AlgorithmTableRegistry['cox_regression_classical']({ - dependent_var: 'Age', - event_var: 'Sex', + dependent_var: 'Days to follow-up', + event_var: 'Death', indep_vars: ['Intercept', 'Age'], summary: { n_obs: 200, @@ -394,9 +456,10 @@ describe('AlgorithmTableRegistry', () => { expect(tables[0].rows.length).toBe(1); expect(tables[0].rows[0][0]).toBe('Age'); expect(tables[0].rows[0][1]).toBe('0.741'); - expect(tables[0].rows[0][3]).toBe('0.002'); - expect(tables[1].rows).toContainEqual(['Follow-up time', 'Sex']); - expect(tables[1].rows).toContainEqual(['Event indicator', 'Age']); + expect(cellText(tables[0].rows[0][3])).toBe('0.002'); + expect(exportTable(tables[0]).rows[0][3]).toBe(0.002); + expect(tables[1].rows).toContainEqual(['Follow-up time', 'Days to follow-up']); + expect(tables[1].rows).toContainEqual(['Event indicator', 'Death']); expect(tables[1].rows).toContainEqual(['Tied events handling', 'breslow']); expect(tables[1].rows).toContainEqual(['Model converged', 'Yes']); }); @@ -614,6 +677,7 @@ describe('AlgorithmTableRegistry', () => { 'Notes', ]); expect(tables[1].columns).toEqual(['Cluster', 'Size', 'a', 'b']); + expect(tables[2].columns).toEqual(['Cluster', 'Compactness', 'Profile']); expect(tables[0].rows).toContainEqual(['Observations', '100-200']); expect(tables[3].rows).toContainEqual(['Warning', 'w1']); expect(tables[3].rows).toContainEqual(['Limitation', 'l1']); @@ -622,4 +686,27 @@ describe('AlgorithmTableRegistry', () => { it('returns no kmeans tables without clusters', () => { expect(AlgorithmTableRegistry['kmeans']({ clusters: [] })).toEqual([]); }); + + it('formats every table p-value APA style and marks the Tukey clip bounds', () => { + const anova = AlgorithmTableRegistry['anova_oneway']({ + anova_table: { df_explained: 1, df_residual: 98, ss_explained: 10, ss_residual: 90, ms_explained: 10, ms_residual: 0.9, f_stat: 10.9, p_value: 1.1102230246251565e-16 }, + tuckey_test: [{ groupA: 'a', groupB: 'b', meanA: 1, meanB: 2, diff: -1, se: 0.1, t_stat: -10, p_tuckey: 0.001 }], + min_max_per_group: { categories: [], min: [], max: [] }, + } as any); + + expect(cellText(anova[0].rows[0][5])).toBe('< 0.001'); + expect(cellText(anova[1].rows[0][7])).toBe('≤ 0.001'); + expect(exportTable(anova[0]).rows[0][5]).toBe(1.1102230246251565e-16); + expect(exportTable(anova[1]).rows[0][7]).toBe(0.001); + }); + + it('trims covered columns and rows, and drops a table with nothing left', () => { + const table = { title: 'T', columns: ['Source', 'df', 'Sum Sq', 'F'], rows: [['A', 1, 10, 5], ['Residual', 98, 90, ''], ['average', 0, 0, 0]] }; + + expect(trimTable(table, { title: 'T', columns: ['df', 'F'], rows: ['average'] })).toEqual({ + title: 'T', columns: ['Source', 'Sum Sq'], rows: [['A', 10], ['Residual', 90]], + }); + expect(trimTable(table, { title: 'T' })).toBeNull(); + expect(trimTable(table, { title: 'T', columns: ['df', 'Sum Sq', 'F'] })).toBeNull(); + }); }); diff --git a/src/app/pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry.ts b/src/app/pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry.ts index b9faa278..b73c192f 100644 --- a/src/app/pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry.ts +++ b/src/app/pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry.ts @@ -11,30 +11,93 @@ import { } from '../../../../models/algorithm-results.model'; import { getFeaturewiseDescribeRows } from '../../../../core/describe-result.utils'; import { prettifyLabel } from '../../../../core/algorithm-mappers'; +import { formatInteger, formatPValue, readable, toFiniteNumber } from '../result-format'; +import { readConfusionMatrix } from '../charts/renderers/confusion-matrix-chart'; +import { readCorrelations } from '../charts/renderers/matrix-chart'; export interface TableSpec { title?: string; columns: string[]; rows: any[][]; layout?: 'compact' | 'full'; + /** The table as exported, when the screen merges values into one cell (a count and its share, + * a range): one exact value per column instead. Exports use it; the screen never does. */ + export?: { columns: string[]; rows: unknown[][] }; } -type TableBuilder = (result: any) => TableSpec[]; +/** A cell shown rounded (`< 0.001`, `8.9%`) and exported exactly: CSV, ZIP and PDF carry `value`. */ +export interface DisplayCell { + text: string; + value: number | string | null; +} + +function displayCell(text: string, value: number | string | null): DisplayCell { + return { text, value }; +} + +/** A number shown through `format` and exported as sent; blank when it is not a finite number. */ +function exactCell(value: unknown, format: (value: number) => string): DisplayCell | '' { + const numeric = toFiniteNumber(value); + return numeric === null ? '' : displayCell(format(numeric), numeric); +} -function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1); +function isDisplayCell(cell: unknown): cell is DisplayCell { + return !!cell && typeof cell === 'object' && 'text' in cell && 'value' in cell; } +/** What a cell shows on screen. */ +export function cellText(cell: unknown): unknown { + return isDisplayCell(cell) ? cell.text : cell; +} + +/** A table's exact values for CSV, ZIP and PDF: rounded cells give their value, merged tables their export. */ +export function exportTable(table: TableSpec): { title?: string; columns: string[]; rows: unknown[][] } { + if (table.export) return { title: table.title, ...table.export }; + return { + title: table.title, + columns: table.columns, + rows: (table.rows ?? []).map((row) => row.map((cell) => (isDisplayCell(cell) ? cell.value : cell))), + }; +} + +/** + * What a summary above a result already shows of one of its tables: the whole table, or some of + * its columns (by header) or rows (by first cell). Used on screen only; exports keep every table. + */ +export interface TableTrim { + title: string; + columns?: string[]; + rows?: string[]; +} + +/** The table without what the summary above shows; null when nothing of it is left to read. */ +export function trimTable(table: TableSpec, trim: TableTrim): TableSpec | null { + if (!trim.columns?.length && !trim.rows?.length) return null; + const dropColumns = new Set(trim.columns ?? []); + const dropRows = new Set(trim.rows ?? []); + const keep = table.columns.map((_, i) => i).filter((i) => !dropColumns.has(table.columns[i])); + const rows = table.rows + .filter((row) => !dropRows.has(String(cellText(row?.[0]) ?? ''))) + .map((row) => keep.map((i) => row[i])); + if (keep.length <= 1 || !rows.length) return null; + return { ...table, columns: keep.map((i) => table.columns[i]), rows }; +} + +type TableBuilder = (result: any) => TableSpec[]; + function formatDecimal(value: any): string { if (typeof value !== 'number' || isNaN(value)) return value ?? ''; const abs = Math.abs(value); if (abs === 0) return '0'; - // Too small or too big numbers -> scientific - if (abs < 1e-4 || abs >= 1_000_000) { + // Too small numbers -> scientific. Large ones stay whole: 1065587, not 1.066e+6 (four digits lost). + if (abs < 1e-4) { return value.toExponential(3); } + if (abs >= 1_000_000) { + return value.toFixed(0); + } const decimals = abs < 1 ? 4 : 3; let formatted = value.toFixed(decimals); @@ -60,13 +123,15 @@ function formatNullableOutlierValue(value: any): string { return formatDecimal(value); } -function formatAnovaPValue(value: any): string { - if (typeof value !== 'number' || isNaN(value)) return value ?? ''; - if (!Number.isFinite(value)) return String(value); - return value.toExponential(3); +/** One p-value style across every result table: `< 0.001` below that, three decimals above — + * 0, 1.11e-16 and 3.9e-238 are all float noise at that size. Blank when missing. */ +function formatTableP(value: unknown): DisplayCell | '' { + const numeric = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN; + return Number.isFinite(numeric) ? displayCell(formatPValue(numeric), numeric) : ''; } -function formatTTestKey(key: string): string { +/** The row label a t-test table gives one result key. */ +export function formatTTestKey(key: string): string { const map: Record = { mean_diff: 'Mean Difference', se_diff: 'Std. Error of Difference', @@ -111,13 +176,13 @@ function buildTTestRows( if (value !== null && typeof value === 'object') return false; return true; }) - .map(([k, v]) => [formatTTestKey(k), valueFormatter(v)]); + .map(([k, v]) => [formatTTestKey(k), k === 'p' || k === 'p_value' ? formatTableP(v) : valueFormatter(v)]); } function buildMetricRows(result: Record, labels: Record, keys: string[]): any[][] { return keys .filter((key) => result?.[key] !== undefined && result?.[key] !== null) - .map((key) => [labels[key] ?? key, formatDecimal(result[key])]); + .map((key) => [labels[key] ?? key, key === 'p_value' ? formatTableP(result[key]) : formatDecimal(result[key])]); } interface ContingencyContext { @@ -133,7 +198,8 @@ function resolveVariableDisplayLabel( if (!code) return ''; const trimmed = String(code).trim(); if (!trimmed) return ''; - return labelMap?.[trimmed] ?? trimmed; + // A created column has no data-model label; its code still reads as words. + return labelMap?.[trimmed] ?? readable(trimmed); } function buildContingencyCategoryTable( @@ -240,11 +306,17 @@ function formatCoxHazardRatio(value: number | undefined): string { return numeric.toFixed(3); } -function formatCoxPValue(value: number | undefined): string { +/** Tukey p comes from statsmodels' psturng, which clips to [0.001, 0.9]: the ends are bounds, not values. */ +function formatTukeyP(value: unknown): DisplayCell | '' { const numeric = Number(value); - if (!Number.isFinite(numeric)) return '—'; - if (numeric < 0.001) return '<0.001'; - return numeric.toFixed(3); + if (!Number.isFinite(numeric)) return ''; + if (numeric <= 0.001) return displayCell('≤ 0.001', numeric); + if (numeric >= 0.9) return displayCell('≥ 0.900', numeric); + return displayCell(numeric.toFixed(3), numeric); +} + +function formatCoxPValue(value: number | undefined): DisplayCell | string { + return formatTableP(value) || '—'; } function formatCoxHazardRatioCi(lower: number | undefined, upper: number | undefined): string { @@ -290,8 +362,9 @@ function buildCoxRegressionTables( }]; const summaryRows: any[][] = [ - ['Follow-up time', result.event_var], - ['Event indicator', result.dependent_var], + // Exaflow puts the follow-up time in y (dependent_var) and the event indicator in event_var. + ['Follow-up time', result.dependent_var], + ['Event indicator', result.event_var], ['Participants', formatDecimal(summary.n_obs)], ['Events', formatDecimal(summary.n_events)], ['Covariates in model', formatDecimal(summary.n_covariates)], @@ -403,9 +476,10 @@ export const AlgorithmTableRegistry: Record = { columns: ['Cluster', 'Size', ...variables], rows: clusters.map(c => [c.label, c.size_interval, ...variables.map(v => formatDecimal(c.center[v]))]), }, { + // The interpretation sentence only restates the profile and the compactness. title: 'Cluster Profiles', - columns: ['Cluster', 'Compactness', 'Profile', 'Interpretation'], - rows: clusters.map(c => [c.label, c.quality?.compactness ?? '-', (c.profile ?? []).join('; '), c.interpretation]), + columns: ['Cluster', 'Compactness', 'Profile'], + rows: clusters.map(c => [c.label, c.quality?.compactness ?? '-', (c.profile ?? []).join('; ')]), }]; const noteRows: any[][] = []; @@ -451,7 +525,7 @@ export const AlgorithmTableRegistry: Record = { formatDecimal(coefficients[idx]), formatDecimal(stdErr[idx]), formatDecimal(tStats[idx]), - formatDecimal(pValues[idx]), + formatTableP(pValues[idx]), formatDecimal(lowerCi[idx]), formatDecimal(upperCi[idx]), ]); @@ -702,7 +776,7 @@ export const AlgorithmTableRegistry: Record = { rows: rawClasses.map((_cls, i) => [ classLabels[i], formatDecimal(result.class_count?.[i]), - formatDecimal(result.class_prior?.[i]), + exactCell(result.class_prior?.[i], formatDecimal), ]), layout: 'full', }); @@ -733,54 +807,53 @@ export const AlgorithmTableRegistry: Record = { naive_bayes_gaussian_cv: (result: NaiveBayesCVResult) => { const summary = result?.classification_summary; - if (!summary) return []; - - const metrics = ['accuracy', 'precision', 'recall', 'fscore'] as const; - if (!summary.accuracy) return []; + if (!summary?.accuracy) return []; const classes = Object.keys(summary.accuracy); if (!classes.length) return []; - const folds = Object.keys(summary.accuracy[classes[0]]).filter(k => k !== 'average' && k !== 'stdev'); - - const rows = [...folds, 'average', 'stdev'].map(fold => { - const row: any[] = [fold]; - for (const metric of metrics) { - for (const cls of classes) { - row.push(formatDecimal(summary[metric][cls]?.[fold])); - } - } - const nObsVal = summary.n_obs?.[fold] ?? 0; - row.push(formatDecimal(nObsVal)); - return row; - }); + const confusion = readConfusionMatrix(result.confusion_matrix); + const support = (cls: string): number | null => { + const index = confusion?.labels.indexOf(cls) ?? -1; + return confusion && index >= 0 ? confusion.matrix[index].reduce((sum, value) => sum + value, 0) : null; + }; + // A class the confusion matrix never saw (an unused enumeration) has no metrics worth a row. + const shown = confusion ? classes.filter((cls) => (support(cls) ?? 0) > 0) : classes; + // Fixed decimals, so "0.710 ± 0.007" lines up with "0.832 ± 0.011" (the ± text is shown as written). + const meanSd = (block: Record | undefined) => + block?.['average'] === undefined ? '' : `${Number(block['average']).toFixed(3)} ± ${Number(block['stdev'] ?? 0).toFixed(3)}`; - const columns = ['Fold']; - for (const metric of metrics) { - for (const cls of classes) { - const name = `${capitalize(metric)} (${cls})`; - columns.push(name); - } - } - columns.push('Number of observations'); + const tables: TableSpec[] = [{ + title: 'Per-class metrics (mean ± SD over folds)', + columns: ['Class', 'Precision', 'Recall', 'F1', 'Support'], + rows: shown.map((cls) => [ + cls, + meanSd(summary.precision?.[cls]), + meanSd(summary.recall?.[cls]), + meanSd(summary.fscore?.[cls]), + formatDecimal(support(cls) ?? ''), + ]), + }]; - const tables: TableSpec[] = [ - { - title: 'Classification Metrics per Fold', - columns, - rows, - layout: 'full' - } - ]; + // Accuracy is one number per fold, repeated under every class key. + const accuracy = summary.accuracy[classes[0]]; + const folds = Object.keys(accuracy).filter((key) => key !== 'average' && key !== 'stdev'); + // n_obs per fold counts the rows the model trained on, not the held-out fold. + tables.push({ + title: 'Accuracy per fold', + columns: ['Fold', 'Accuracy', 'Training rows'], + layout: 'compact', + rows: [ + ...folds.map((fold) => [fold.replace(/^fold(\d+)$/, (_, n) => `Fold ${Number(n) + 1}`), formatDecimal(accuracy[fold]), formatDecimal(summary.n_obs?.[fold] ?? '')]), + ['Mean ± SD', meanSd(accuracy), ''], + ], + }); - if (result.confusion_matrix) { + if (confusion) { tables.push({ title: 'Confusion Matrix (Combined)', - columns: ['Actual \\ Predicted', ...result.confusion_matrix.labels], - rows: result.confusion_matrix.data.map((row, i) => [ - result.confusion_matrix.labels[i], - ...row.map(v => formatDecimal(v)) - ]) + columns: ['Actual \\ Predicted', ...confusion.labels], + rows: confusion.matrix.map((row, i) => [confusion.labels[i], ...row.map((v) => formatDecimal(v))]), }); } @@ -808,7 +881,7 @@ export const AlgorithmTableRegistry: Record = { formatDecimal(s.coefficients[i]), formatDecimal(s.stderr[i]), formatDecimal(s.z_scores[i]), - formatDecimal(s.pvalues[i]), + formatTableP(s.pvalues[i]), formatDecimal(s.lower_ci[i]), formatDecimal(s.upper_ci[i]), ]); @@ -845,10 +918,12 @@ export const AlgorithmTableRegistry: Record = { const s = result.summary; const n = s.n_obs.length; + const rowLabel = (name: string) => + name === 'average' ? 'Mean' : name === 'stdev' ? 'SD' : name.replace(/^fold_?(\d+)$/i, 'Fold $1'); const rows = []; for (let i = 0; i < n; i++) { rows.push([ - s.row_names[i], + rowLabel(String(s.row_names[i])), formatDecimal(s.n_obs[i]), formatDecimal(s.accuracy[i]), formatDecimal(s.precision[i]), @@ -859,7 +934,8 @@ export const AlgorithmTableRegistry: Record = { return [{ title: 'Logistic Regression CV Summary', - columns: ['Fold', 'Observations', 'Accuracy', 'Precision', 'Recall', 'F-Score'], + // n_obs per fold counts the rows the model trained on, not the held-out fold. + columns: ['Fold', 'Training rows', 'Accuracy', 'Precision', 'Recall', 'F-Score'], rows }]; }, @@ -881,66 +957,70 @@ export const AlgorithmTableRegistry: Record = { // Map class codes to labels const classLabels = rawClasses.map(c => yVar ? getEnumLabel(yVar, String(c)) : String(c)); + // Probabilities read as shares, not logs: exp(log prior) and exp(log P(category | class)). + const probability = (log: unknown): number | null => { + const value = Math.exp(Number(log)); + return Number.isFinite(value) ? value : null; + }; + const share = (log: unknown) => { + const value = probability(log); + return value === null ? '' : `${(value * 100).toFixed(1)}%`; + }; + // Class summary if (rawClasses.length > 0) { tables.push({ title: 'Class Summary', - columns: ['Class', 'Count', 'Log Prior'], + columns: ['Class', 'Count', 'Prior'], rows: rawClasses.map((_cls, i) => [ classLabels[i], formatDecimal(result.class_count[i]), - formatDecimal(result.class_log_prior[i]), + exactCell(probability(result.class_log_prior[i]), () => share(result.class_log_prior[i])), ]), layout: 'full', }); } - // Category tables + // One table per feature: each cell is the count and the model's P(category | class). + // A category no class ever had (an unused enumeration) is left out. for (const feature of result.feature_names) { const counts = result.category_count[feature]; - const logProbs = result.category_log_prob[feature]; + const logProbs = result.category_log_prob?.[feature]; const categories = result.categories[feature]; - - const featureLabel = getLabel(feature); - if (!counts || !categories) continue; - const numCats = categories.length; - const countRows = []; - const logProbRows = []; - - for (let c = 0; c < numCats; c++) { - const catVal = String(categories[c]); - const catLabel = getEnumLabel(feature, catVal); - - const countRow = [catLabel]; - const logRow = [catLabel]; - - for (let i = 0; i < rawClasses.length; i++) { - countRow.push(formatDecimal(counts[i][c])); - if (logProbs) logRow.push(formatDecimal(logProbs[i][c])); - } - countRows.push(countRow); - logProbRows.push(logRow); - } - - const colHeaders = ['Category', ...classLabels]; + const used = categories + .map((category, c) => ({ label: getEnumLabel(feature, String(category)), c })) + .filter(({ c }) => rawClasses.some((_cls, i) => Number(counts[i]?.[c]) > 0)); + const rows = used.map(({ label, c }) => [ + label, + ...rawClasses.map((_cls, i) => { + const count = formatInteger(Number(counts[i]?.[c])); + const categoryShare = logProbs ? share(logProbs[i]?.[c]) : ''; + return categoryShare ? `${count} · ${categoryShare}` : count; + }), + ]); tables.push({ - title: `Category Counts: ${featureLabel}`, - columns: colHeaders, - rows: countRows, - layout: 'full' + title: `${getLabel(feature)} by class`, + columns: ['Category', ...classLabels], + rows, + layout: 'full', + // Exported as a count and an exact P(category | class) column per class, not "120 · 31.4%". + export: { + columns: ['Category', ...classLabels.flatMap((label) => [ + `${label} count`, + ...(logProbs ? [`${label} P(category | class)`] : []), + ])], + rows: used.map(({ label, c }) => [ + label, + ...rawClasses.flatMap((_cls, i) => [ + toFiniteNumber(counts[i]?.[c]), + ...(logProbs ? [probability(logProbs[i]?.[c])] : []), + ]), + ]), + }, }); - - if (logProbs) { - tables.push({ - title: `Category Log Probabilities: ${featureLabel}`, - columns: colHeaders, - rows: logProbRows, - layout: 'full' - }); - } } return tables; @@ -955,12 +1035,33 @@ export const AlgorithmTableRegistry: Record = { if (!result) return []; const tables: TableSpec[] = []; - // Eigenvalues - if (result.eigenvalues && result.eigenvalues.length) { + const eigenvalues = (result.eigenvalues ?? []).map(Number); + const total = eigenvalues.reduce((sum, value) => sum + value, 0); + if (eigenvalues.length) { + let cumulative = 0; + const percent = (value: number) => (total > 0 ? ((value / total) * 100).toFixed(1) : ''); tables.push({ title: 'PCA Summary', - columns: ['Component', 'Eigenvalue'], - rows: result.eigenvalues.map((v, i) => [`PC${i + 1}`, formatDecimal(v)]) + columns: ['Component', 'Eigenvalue', 'Variance explained (%)', 'Cumulative (%)'], + rows: eigenvalues.map((value, i) => { + cumulative += value; + return [`PC${i + 1}`, formatDecimal(value), percent(value), percent(cumulative)]; + }), + }); + } + + // Rows of eigenvectors are components; the table turns them so each variable reads across. + const vectors = Array.isArray(result.eigenvectors) ? result.eigenvectors : []; + const nVariables = vectors[0]?.length ?? 0; + if (nVariables) { + const names: unknown[] = Array.isArray((result as any).variable_names) ? (result as any).variable_names : []; + tables.push({ + title: 'Component weights', + columns: ['Variable', ...vectors.map((_, i) => `PC${i + 1}`)], + rows: Array.from({ length: nVariables }, (_, j) => [ + String(names[j] ?? `Var${j + 1}`), + ...vectors.map((row) => formatDecimal(row[j])), + ]), }); } @@ -1054,7 +1155,7 @@ export const AlgorithmTableRegistry: Record = { title: 'ANOVA Summary', columns: ['Source', 'df', 'Sum Sq', 'Mean Sq', 'F', 'Prob>F'], rows: [ - ['Explained', formatAnovaMetric(result.anova_table.df_explained), formatAnovaMetric(result.anova_table.ss_explained), formatAnovaMetric(result.anova_table.ms_explained), formatAnovaMetric(result.anova_table.f_stat), formatAnovaPValue(result.anova_table.p_value)], + ['Explained', formatAnovaMetric(result.anova_table.df_explained), formatAnovaMetric(result.anova_table.ss_explained), formatAnovaMetric(result.anova_table.ms_explained), formatAnovaMetric(result.anova_table.f_stat), formatTableP(result.anova_table.p_value)], ['Residual', formatAnovaMetric(result.anova_table.df_residual), formatAnovaMetric(result.anova_table.ss_residual), formatAnovaMetric(result.anova_table.ms_residual), '', ''] ] }; @@ -1069,7 +1170,7 @@ export const AlgorithmTableRegistry: Record = { r.groupA, r.groupB, formatAnovaMetric(r.meanA), formatAnovaMetric(r.meanB), formatAnovaMetric(r.diff), formatAnovaMetric(r.se), - formatAnovaMetric(r.t_stat), formatAnovaPValue(r.p_tuckey) + formatAnovaMetric(r.t_stat), formatTukeyP(r.p_tuckey) ]) }); } @@ -1099,7 +1200,7 @@ export const AlgorithmTableRegistry: Record = { formatDecimal(result.sum_sq[i]), formatDecimal(result.df[i]), formatDecimal(result.f_stat[i]), - formatDecimal(result.f_pvalue[i]) + formatTableP(result.f_pvalue[i]) ]); return [{ @@ -1110,9 +1211,30 @@ export const AlgorithmTableRegistry: Record = { }, pearson_correlation: (result: PearsonResult) => { - // Guide says "Correlation Heatmap" is primary. - // We can return a small summary if needed, e.g. number of observations? const tables: TableSpec[] = []; + const matrices = readCorrelations(result); + + if (matrices) { + const { correlations, pValues, ciLower, ciUpper, cols, rows, square, cell } = matrices; + // Each pair once: in a square matrix keep the pairs below the diagonal. + const pairs = rows.flatMap((row) => cols + .filter((col) => !square || cols.indexOf(col) < cols.indexOf(row)) + .map((col) => ({ row, col, r: cell(correlations, row, col) })) + .filter((pair): pair is { row: string; col: string; r: number } => pair.r !== null)); + pairs.sort((a, b) => Math.abs(b.r) - Math.abs(a.r)); + + if (pairs.length) { + tables.push({ + title: 'Correlation pairs', + columns: ['Variable A', 'Variable B', 'r', '95% CI lower', '95% CI upper', 'p'], + rows: pairs.map(({ row, col, r }) => { + const p = cell(pValues, row, col); + return [row, col, formatDecimal(r), formatDecimal(cell(ciLower, row, col)), formatDecimal(cell(ciUpper, row, col)), formatTableP(p)]; + }), + }); + } + } + if (typeof result.n_obs === 'number') { tables.push({ title: 'Number of Observations', @@ -1211,40 +1333,62 @@ export const AlgorithmTableRegistry: Record = { : []; if (!featurewise.length) return []; + // One rule column and one kept-range column instead of strategy/tail/fold and two bounds, + // and each variable's datasets kept together. + const strategyLabels: Record = { gaussian: 'Mean ± SD', iqr: 'IQR', mad: 'MAD', quantile: 'Quantile' }; + const tailLabels: Record = { left: 'lower tail', right: 'upper tail', both: 'both tails' }; + const bound = (value: unknown) => (value === null || value === undefined ? '—' : formatDecimal(value)); + const share = (value: unknown) => { + const percent = toFiniteNumber(value); + return percent === null ? 'Unavailable' : `${percent.toFixed(1)}%`; + }; + const rows = featurewise.filter((row: any) => String(row?.dataset ?? '').trim().toLowerCase() !== 'all datasets'); + const variableOrder = [...new Set(rows.map((row: any) => row?.variable))]; + rows.sort((a: any, b: any) => variableOrder.indexOf(a?.variable) - variableOrder.indexOf(b?.variable)); + + const rule = (data: any) => { + const strategy = data.strategy ? strategyLabels[data.strategy] ?? data.strategy : 'Unavailable'; + const fold = data.fold === null || data.fold === undefined ? '' : ` × ${formatDecimal(data.fold)}`; + const tail = data.tail ? `, ${tailLabels[data.tail] ?? data.tail}` : ''; + return `${strategy}${fold}${tail}`; + }; + const exact = (value: unknown) => toFiniteNumber(value); + return [{ title: 'Outlier Report', - columns: [ - 'Variable', - 'Dataset', - 'Strategy', - 'Tail', - 'Fold', - 'Lower bound', - 'Upper bound', - 'Lower outliers', - 'Upper outliers', - 'Total outliers', - 'Outlier %', - ], - rows: featurewise - .filter((row: any) => String(row?.dataset ?? '').trim().toLowerCase() !== 'all datasets') - .map((row: any) => { + columns: ['Variable', 'Dataset', 'Rule', 'Kept range', 'Below', 'Above', 'Outliers', 'Share'], + rows: rows.map((row: any) => { const data = row?.data ?? {}; return [ row?.variable ?? '', row?.dataset ?? '', - data.strategy ?? 'Unavailable', - data.tail ?? 'Unavailable', - formatNullableOutlierValue(data.fold), - formatNullableOutlierValue(data.lower_bound), - formatNullableOutlierValue(data.upper_bound), + rule(data), + `${bound(data.lower_bound)} to ${bound(data.upper_bound)}`, formatNullableOutlierValue(data.lower_outlier_count), formatNullableOutlierValue(data.upper_outlier_count), formatNullableOutlierValue(data.total_outlier_count), - formatNullableOutlierValue(data.total_outlier_percentage), + // Exaflow sends the share as a percentage (8.917 for 8.9%). + share(data.total_outlier_percentage), ]; }), - layout: 'full', + // Exported with both bounds and the share exact, one value per column. + export: { + columns: ['Variable', 'Dataset', 'Rule', 'Lower bound', 'Upper bound', 'Below', 'Above', 'Outliers', 'Share (%)'], + rows: rows.map((row: any) => { + const data = row?.data ?? {}; + return [ + row?.variable ?? '', + row?.dataset ?? '', + rule(data), + exact(data.lower_bound), + exact(data.upper_bound), + exact(data.lower_outlier_count), + exact(data.upper_outlier_count), + exact(data.total_outlier_count), + exact(data.total_outlier_percentage), + ]; + }), + }, }]; }, @@ -1252,13 +1396,19 @@ export const AlgorithmTableRegistry: Record = { binned_mann_whitney_u_test: (result: BinnedMannWhitneyUTestResult) => { if (!result) return []; + // Exaflow reports U for group A, so U / (n1·n2) is the chance a group A value outranks a + // group B value, ties counted half: the effect size (Vargha–Delaney A) next to p. + const u = toFiniteNumber(result.u_stat); + const n1 = toFiniteNumber(result.n1); + const n2 = toFiniteNumber(result.n2); return [{ title: 'Binned Mann-Whitney U Test', columns: ['Metric', 'Value'], rows: [ ['U statistic', formatDecimal(result.u_stat)], - ['p-value', formatDecimal(result.p_value)], + ['p-value', formatTableP(result.p_value)], ['z-score', formatDecimal(result.z_score)], + ...(u !== null && n1 && n2 ? [['P(A > B)', displayCell((u / (n1 * n2)).toFixed(2), u / (n1 * n2))]] : []), ['Group A sample size', formatDecimal(result.n1)], ['Group B sample size', formatDecimal(result.n2)], ], diff --git a/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.css b/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.css index 1e5e4aa2..444100e0 100644 --- a/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.css +++ b/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.css @@ -134,12 +134,8 @@ font-weight: 600; color: var(--text-main); text-align: left; - overflow-wrap: anywhere; -} - -.ar-table td.ar-cell-text { - text-align: left; - font-family: var(--font-sans); + /* break-word, not anywhere: a squeezed column breaks long words only, never "Cluster 0" mid-word. */ + overflow-wrap: break-word; } .ar-table td.ar-role-factor { @@ -154,10 +150,19 @@ .ar-table td:not(:first-child), .ar-table thead th:not(:first-child) { + /* A gap before every column, so neighbours never touch ("0 11,130", "COMPACTNESSPROFILE"). */ + padding-left: 8px; text-align: right; font-variant-numeric: tabular-nums; } +/* Name columns read left; declared after the right-align rule it overrides (same specificity). */ +.ar-table td.ar-cell-text, +.ar-table thead th.ar-cell-text { + text-align: left; + font-family: var(--font-sans); +} + @media (max-width: 1024px) { .ar-container { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); @@ -174,3 +179,11 @@ min-width: 0; } } + +.ar-heading { + margin: 0 0 0.35rem; + font-size: 0.95rem; + font-weight: 700; + color: var(--text-main); + letter-spacing: 0.02em; +} diff --git a/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.html b/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.html index b04eceaa..093d1308 100644 --- a/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.html +++ b/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.html @@ -1,4 +1,7 @@ -@if (tableSpec()) { +@if (tableSpec()?.length) { +@if (heading()) { +

{{ heading() }}

+}
@for (table of tableSpec(); track table) {
@@ -13,19 +16,19 @@ @for (col of table.columns; track col) { - {{ col }} + {{ col }} } @for (row of table.rows; track row) { - @for (cell of row; track cell; let colIndex = $index) { + @for (cell of row; track $index; let colIndex = $index) { - {{ formatValue(cell) }} + [class.ar-cell-text]="isTextColumn(table.columns[colIndex])"> + {{ formatCell(cell, table.columns[colIndex], colIndex) }} } diff --git a/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.spec.ts b/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.spec.ts index 7e0c18b8..8b155372 100644 --- a/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.spec.ts +++ b/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { AutoRendererComponent } from './auto-renderer.component'; import { AlgorithmTableRegistry } from './algorithm-table-registry'; +import { CsvExportService } from '../../../../services/csv-export.service'; describe('AutoRendererComponent', () => { let fixture: ComponentFixture; @@ -124,6 +125,41 @@ describe('AutoRendererComponent', () => { expect(tables?.[1].title).toBe('Error metrics'); }); + it('titles the builder\'s first table before leaving any table out', () => { + setInputs({ + algorithm: 'linear_regression_cv', + covered: [{ title: 'Training set sample sizes' }], + value: { title: 'CV Report', n_obs: [100, 101], root_mean_sq_error: [1.1, 0.1] }, + }); + + // The covered first table took the result title with it; the next keeps its own name. + expect(cmp.tableSpec()?.map((table) => table.title)).toEqual(['Error metrics']); + }); + + it('leaves out tables the page already shows above', () => { + setInputs({ + algorithm: 'linear_regression_cv', + covered: [{ title: 'Error metrics' }], + heading: 'Detailed results', + value: { n_obs: [100, 101], root_mean_sq_error: [1.1, 0.1] }, + }); + + expect(cmp.tableSpec()?.map((table) => table.title)).toEqual(['Training set sample sizes']); + expect((fixture.nativeElement as HTMLElement).querySelector('.ar-heading')?.textContent).toBe('Detailed results'); + }); + + it('renders nothing, heading included, when every table is shown above', () => { + setInputs({ + algorithm: 'binned_mann_whitney_u_test', + covered: [{ title: 'Binned Mann-Whitney U Test' }], + heading: 'Detailed results', + value: { u_stat: 1, p_value: 0.5, z_score: 0.1, n1: 10, n2: 12 }, + }); + + expect(cmp.tableSpec()).toEqual([]); + expect((fixture.nativeElement as HTMLElement).children.length).toBe(0); + }); + it('uses fallback title when result title is missing', () => { setInputs({ algorithm: 'binned_mann_whitney_u_test', @@ -214,4 +250,35 @@ describe('AutoRendererComponent', () => { expect(cmp.tableSpec()?.length).toBeGreaterThan(0); }); }); + + it('groups thousands and uses a typographic minus on screen, plain numbers for CSV', () => { + expect(cmp.formatValue(1065586.73)).toBe('1\u202f065\u202f587'); + expect(cmp.formatValue('111854')).toBe('111\u202f854'); + expect(cmp.formatValue(-3.8885)).toBe('−3.889'); + expect(cmp.formatValue(0.00001234)).toBe('1.234e-5'); + expect(cmp.formatValue(1065586.73, false)).toBe('1065587'); + expect(cmp.formatValue(-3.8885, false)).toBe('-3.889'); + expect(cmp.formatValue('< 0.001')).toBe('< 0.001'); + }); + + it('prints row labels and name columns as sent, never digit-grouped', () => { + expect(cmp.formatCell('10001', 'Category', 0)).toBe('10001'); + expect(cmp.formatCell('2020', 'Group 2', 1)).toBe('2020'); + expect(cmp.formatCell('111854', 'Count', 1)).toBe('111\u202f854'); + expect(cmp.formatCell({ text: '< 0.001', value: 1e-16 }, 'p-value', 1)).toBe('< 0.001'); + }); + + it('exports a table\'s exact values where the screen rounds them', () => { + const download = vi.spyOn(TestBed.inject(CsvExportService), 'downloadCsv').mockImplementation(() => {}); + setInputs({ + algorithm: 'binned_mann_whitney_u_test', + value: { u_stat: 163.5, p_value: 8.436804884099734e-6, z_score: 4.45, n1: 15, n2: 80 }, + }); + + cmp.exportToCSV(cmp.tableSpec()![0]); + + const csv = download.mock.calls[0][0]; + expect(csv).toContain(`p-value,${8.436804884099734e-6}`); + expect(csv).toContain(`P(A > B),${163.5 / 1200}`); + }); }); diff --git a/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.ts b/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.ts index 6f5700d2..d26c2f4c 100644 --- a/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.ts +++ b/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.ts @@ -1,8 +1,13 @@ import { ChangeDetectionStrategy, Component, effect, inject, input, signal } from '@angular/core'; import { CsvExportService } from '../../../../services/csv-export.service'; -import { getAlgorithmTableBuilder, TableSpec } from './algorithm-table-registry'; +import { cellText, getAlgorithmTableBuilder, TableSpec, TableTrim, trimTable } from './algorithm-table-registry'; import { EnumMaps } from '../../../../core/algorithm-result-enum-mapper'; +import { groupThousands } from '../result-format'; +// 'Kept range' holds "a to b": left-aligned it stays clear of the right-aligned count beside it. +const TEXT_COLUMNS = new Set(['Role', 'Categories', 'Dataset', 'Rule', 'Kept range', 'Compactness', 'Profile', 'Type', 'Note']); +// Columns of names, not quantities: a group, class or category called "2020" or "10001" prints as sent. +const LABEL_COLUMNS = new Set(['Group', 'Group 1', 'Group 2', 'Group A', 'Group B', 'Class', 'Category', 'Cluster', 'Fold', 'Level']); @Component({ selector: 'app-auto-renderer', @@ -20,6 +25,11 @@ export class AutoRendererComponent { readonly enumMaps = input(null); readonly yVar = input(null); readonly xVar = input(null); + /** What the page already shows above (as figures or a chart): whole tables, or some of their + * columns or rows. Left out here; exports keep every table. */ + readonly covered = input([]); + /** Section heading drawn above the tables, only when at least one table is shown. */ + readonly heading = input(null); tableSpec = signal(null); error = signal(null); @@ -38,6 +48,7 @@ export class AutoRendererComponent { const yVar = this.yVar(); const xVar = this.xVar(); const fallbackTitle = this.fallbackTitle(); + const covered = this.covered(); if (!algorithm) { this.tableSpec.set(null); @@ -60,6 +71,7 @@ export class AutoRendererComponent { yVar, xVar, fallbackTitle, + covered, }); if (key === this.lastKey && this.tableSpec()) return; @@ -67,10 +79,18 @@ export class AutoRendererComponent { const enrichedValue = value && (labelMap || enumMaps || yVar || xVar) ? { ...value, __labelMap__: labelMap, __enumMaps__: enumMaps, __yVar__: yVar, __xVar__: xVar } : value; - const spec = builder(enrichedValue); + const tables = builder(enrichedValue); const explicitTitle = this.getResultTitle(enrichedValue); const resultTitle = explicitTitle ?? this.getFallbackTitle(); - this.tableSpec.set(this.applyResultTitle(spec, resultTitle, !!explicitTitle)); + // The result title goes on the first table the builder made, as in the exports, before any + // table is left out; what is covered is matched on the builder's own titles. + const titled = this.applyResultTitle(tables, resultTitle, !!explicitTitle); + const spec = titled.flatMap((table, index) => { + const trim = covered.find((entry) => entry.title === tables[index].title); + const kept = trim ? trimTable(table, trim) : table; + return kept ? [kept] : []; + }); + this.tableSpec.set(spec); this.error.set(null); this.lastKey = key; } catch (err) { @@ -104,8 +124,23 @@ export class AutoRendererComponent { return colCount <= 3 && rowCount <= 12; } - // helper - formatValue(value: any): string { + /** Name and prose columns read left-aligned; every other column holds numbers and aligns right. */ + isTextColumn(column: string | undefined): boolean { + return !!column && (TEXT_COLUMNS.has(column) || column.startsWith('Variable')); + } + + /** One cell on screen. Row labels and name columns print as sent; every other cell as a number. */ + formatCell(cell: unknown, column: string | undefined, columnIndex: number): string { + const shown = cellText(cell); + if (columnIndex === 0 || this.isTextColumn(column) || (!!column && LABEL_COLUMNS.has(column))) { + return shown === null || shown === undefined ? '' : String(shown); + } + return this.formatValue(shown); + } + + /** On screen: thousands grouped with a thin space (111 854; 1 065 587), trailing zeros trimmed, a typographic minus; + * scientific notation only below 1e-4. With `grouping` off (CSV) numbers stay plain for spreadsheets. */ + formatValue(value: any, grouping = true): string { if (value === null || value === undefined) return ''; // If number type is string, turn to number @@ -121,31 +156,22 @@ export class AutoRendererComponent { const num = maybeNum; const abs = Math.abs(num); if (abs === 0) return '0'; + if (abs < 1e-4) return num.toExponential(3); - // scientific numbers - if (abs < 1e-4 || abs >= 1_000_000) { - return num.toExponential(3); - } - - // Less decimals - const decimals = abs < 1 ? 4 : 3; - let fixed = num.toFixed(decimals); - - // Less zeros - fixed = fixed - .replace(/(\.\d*?[1-9])0+$/, '$1') - .replace(/\.0+$/, ''); - - if (fixed === '-0') fixed = '0'; - - return fixed; + // Large values keep no decimals, mid-size three, small four; trailing zeros go. + const decimals = abs >= 1_000_000 ? 0 : abs < 1 ? 4 : 3; + const text = num.toLocaleString('en-US', { + maximumFractionDigits: decimals, + useGrouping: grouping, + }); + if (text === '-0') return '0'; + // Typographic minus on screen, like the key figures; CSV keeps the ASCII hyphen. + return grouping ? groupThousands(text).replace(/^-/, '−') : text; } + /** The table's exact values: rounded cells (p-values, shares) export what the engine sent. */ exportToCSV(table: TableSpec) { - this.csvExport.exportTableCsv( - { ...table, rows: (table.rows ?? []).map((row) => row.map((cell) => this.formatValue(cell))) }, - this.algorithm() || 'export', - ); + this.csvExport.exportTableCsv(table, this.algorithm() || 'export'); } private getResultTitle(result: any): string | null { diff --git a/src/app/pages/experiment-studio/visualisations/charts/chart-builder.service.spec.ts b/src/app/pages/experiment-studio/visualisations/charts/chart-builder.service.spec.ts index eb01e4d2..aca02ef3 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/chart-builder.service.spec.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/chart-builder.service.spec.ts @@ -40,8 +40,9 @@ describe('ChartBuilderService', () => { }); expect(charts.length).toBe(2); - expect((charts[0] as any).title?.text).toBe('Eigenvectors'); - expect((charts[1] as any).title?.text).toBe('Scree Plot (Eigenvalues)'); + expect((charts[0] as any).title?.text).toBe('Component weights'); + expect((charts[1] as any).title?.text).toBe('Variance explained'); + expect((charts[1] as any).series[0].data).toEqual([66.7, 33.3]); }); it('uses fallback title when result title is missing', () => { @@ -58,7 +59,7 @@ describe('ChartBuilderService', () => { expect((charts[0] as any).title?.text).toBe('SVM Support Vector Distribution'); }); - it('renders pearson p-values and CI charts for legacy key aliases', () => { + it('draws one lower-triangle correlation heatmap and reads legacy p-value and CI aliases', () => { const matrix = { variables: ['v1', 'v2'], v1: [1, 0.25], @@ -67,17 +68,18 @@ describe('ChartBuilderService', () => { const charts = service.getChartsForAlgorithm('pearson_correlation', { correlations: matrix, - 'p-values': matrix, + 'p-values': { variables: ['v1', 'v2'], v1: [0, 0.0004], v2: [0.0004, 0] }, low_confidence_intervals: matrix, high_confidence_intervals: matrix, }); - expect(charts.length).toBe(4); - const titles = charts.map((c) => (c as any).title?.text); - expect(titles).toContain('Correlation Matrix'); - expect(titles).toContain('P-Values'); - expect(titles).toContain('Confidence Interval (Lower 95%)'); - expect(titles).toContain('Confidence Interval (Upper 95%)'); + expect(charts.length).toBe(1); + const series = (charts[0] as any).series[0]; + expect(series.data.map((cell: any) => cell.value)).toEqual([[0, 0, 0.25]]); + const tooltip = (charts[0] as any).tooltip.formatter({ value: [0, 0, 0.25] }); + expect(tooltip).toContain('v2 × v1'); + expect(tooltip).toContain('p < 0.001'); + expect(tooltip).toContain('95% CI 0.250 to 0.250'); }); it('renders describe box plots from featurewise rows', () => { diff --git a/src/app/pages/experiment-studio/visualisations/charts/chart-builder.service.ts b/src/app/pages/experiment-studio/visualisations/charts/chart-builder.service.ts index a161b77d..4ffe309e 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/chart-builder.service.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/chart-builder.service.ts @@ -2,21 +2,6 @@ import { Injectable, inject } from '@angular/core'; import { EChartsOption } from 'echarts'; import { AlgorithmChartRegistry } from './chart-registry'; import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; -// @ts-expect-error echarts-gl ships no types for its CommonJS dist bundle -import * as echartsGl from 'echarts-gl/dist/echarts-gl.js'; - -function getByPath(obj: any, path: string): any { - if (!path) return obj; - return path.split('.').reduce((o, key) => o?.[key], obj); -} - -function ensureEchartsGlRegistered(): void { - // echarts-gl omits dist/ from sideEffects; keep a live reference so the - // CommonJS bundle is evaluated and scatter3D/grid3D types register. - if (echartsGl == null) { - throw new Error('echarts-gl failed to load'); - } -} @Injectable({ providedIn: 'root' }) export class ChartBuilderService { @@ -24,17 +9,15 @@ export class ChartBuilderService { getChartsForAlgorithm(algorithm: string, result: any, _fallbackTitle?: string | null): EChartsOption[] { - ensureEchartsGlRegistered(); - const config = AlgorithmChartRegistry[algorithm] || AlgorithmChartRegistry['default']; - - // raw input - const input = getByPath(result, config.inputPath); + const config = AlgorithmChartRegistry[algorithm]; + if (!config) return []; // enrich with display names - const enrichedInput = this.enrichLabels(input); + const enrichedInput = this.enrichLabels(result); - // PCA specific enrichment: inject actual variable names for the heatmap - if (algorithm === 'pca' || algorithm === 'pca_with_transformation') { + // PCA: the studio's current selection names the heatmap columns, unless the caller + // (a saved experiment in the dashboard) already supplied that run's own variable names. + if ((algorithm === 'pca' || algorithm === 'pca_with_transformation') && !enrichedInput?.variable_names?.length) { const allSelected = this.experimentService.algorithmAssignableVariables(); if (allSelected.length > 0) { diff --git a/src/app/pages/experiment-studio/visualisations/charts/chart-palette.ts b/src/app/pages/experiment-studio/visualisations/charts/chart-palette.ts new file mode 100644 index 00000000..8a0a367e --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/charts/chart-palette.ts @@ -0,0 +1,29 @@ +/** + * Categorical series colours, assigned in this order and never cycled past the end. + * Slots 1-3 are the MIP charter (primary, primary-light, accent); 4-8 are hues picked + * so every adjacent pair stays apart for deuteranopia/protanopia (worst ΔE 15.2) and + * for full colour vision (worst ΔE 15.6). Slots 2, 3, 6 and 8 sit under 3:1 on white, + * so a chart using them always carries a legend and the table view below it. + */ +export const CHART_SERIES_COLORS = [ + '#2b33e9', + '#7f9ce8', + '#ffba08', + '#e34948', + '#4a3aa7', + '#e87ba4', + '#008300', + '#1baf7a', +]; + +/** Signed heatmap scale from -1 to 1: red below zero, neutral grey at zero, primary blue above. */ +export const DIVERGING_COLORS = ['#e34948', '#f1f5f9', '#2b33e9']; + +/** + * Text colour for a value printed on a cell shaded by DIVERGING_COLORS (or its grey-to-blue half), + * given the cell's place on that scale. Ink stays at 4.5:1 or better across the red half, so white + * only takes over on the deep blue end, where it beats ink. + */ +export function cellLabelColor(scalePosition: number): string { + return scalePosition >= 0.7 ? '#ffffff' : '#0f172a'; +} diff --git a/src/app/pages/experiment-studio/visualisations/charts/chart-registry.spec.ts b/src/app/pages/experiment-studio/visualisations/charts/chart-registry.spec.ts index aea4246d..dc219aa4 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/chart-registry.spec.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/chart-registry.spec.ts @@ -1,4 +1,4 @@ -import { AlgorithmChartRegistry } from './chart-registry'; +import { AlgorithmChartRegistry, tablesReplacedByCharts } from './chart-registry'; describe('AlgorithmChartRegistry', () => { it('contains canonical SVM key', () => { @@ -16,4 +16,16 @@ describe('AlgorithmChartRegistry', () => { expect((charts[0] as any).title?.text).toBe('SVM Support Vector Distribution'); }); + it('replaces a table only when the chart that shows it rendered', () => { + const chart = (text: string) => ({ title: { text } }); + + expect(tablesReplacedByCharts('pca', [chart('Component weights')])).toEqual([{ title: 'Component weights' }]); + expect(tablesReplacedByCharts('pca', [chart('Component weights'), chart('Variance explained')])) + .toEqual([{ title: 'PCA Summary' }, { title: 'Component weights' }]); + expect(tablesReplacedByCharts('kmeans', [chart('K-Means Centers')])).toEqual([]); + expect(tablesReplacedByCharts('kmeans', [chart('Cluster centres')])).toEqual([{ title: 'Cluster Centers' }]); + expect(tablesReplacedByCharts('naive_bayes_gaussian', [chart('Class profiles')])).toEqual([]); + expect(tablesReplacedByCharts('naive_bayes_gaussian_cv', [chart('Confusion Matrix')])).toEqual([{ title: 'Confusion Matrix (Combined)' }]); + expect(tablesReplacedByCharts('unknown', [chart('Confusion Matrix')])).toEqual([]); + }); }); diff --git a/src/app/pages/experiment-studio/visualisations/charts/chart-registry.ts b/src/app/pages/experiment-studio/visualisations/charts/chart-registry.ts index ac8212ed..2ecbab17 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/chart-registry.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/chart-registry.ts @@ -1,4 +1,5 @@ import { EChartsOption } from 'echarts'; +import { TableTrim } from '../auto-renderer/algorithm-table-registry'; import { buildRocCurveChart } from './renderers/roc-curve-chart'; import { buildMatrixChart } from './renderers/matrix-chart'; @@ -6,144 +7,90 @@ import { buildPCAHeatmapChart } from './renderers/pca-heatmap-chart'; import { buildNaiveBayesConfusionChart } from './renderers/naive-bayes-confusion-matrix-chart'; import { buildLogRegConfusionChart } from './renderers/log-reg-confusion-matrix-chart'; import { buildKMeansChart } from './renderers/k-means-chart'; -import { buildMeanPlotChart } from './renderers/mean-plot-chart'; +import { buildMeanPlotChart, buildTTestIntervalChart } from './renderers/mean-plot-chart'; import { buildBoxPlotChart } from './renderers/box-plot-chart'; import { buildSVMChart } from './renderers/svm-chart'; -// New Renderers -import { buildRegressionLineChart } from './renderers/regression-line-chart'; -import { buildTTestChart } from './renderers/t-test-chart'; - -import { buildCVMetricsChart } from './renderers/cv-metrics-chart'; import { buildHistogramChart } from './renderers/histogram-chart'; -import { buildNaiveBayesPriorsChart } from './renderers/naive-bayes-priors-chart'; -import { buildCoxHazardRatioForestChart } from './renderers/cox-hazard-ratio-forest-chart'; +import { buildNaiveBayesPriorsChart, buildNaiveBayesProfileChart } from './renderers/naive-bayes-priors-chart'; + +/** What one chart shows of a result table, named by that chart's title text. */ +interface ChartTableTrim extends TableTrim { + byChart: string; +} interface AlgorithmChartConfig { build: (input: any) => EChartsOption[]; - inputPath: string; + /** Heading of the chart section on the result page. */ + title: string; + /** What these charts show of the result tables, each left out on screen only when the chart named + * in `byChart` rendered: another chart of the same result does not stand in for it. */ + replaces?: ChartTableTrim[]; + /** How to read the charts, in plain language, shown under them. */ + note?: string; } +const PCA_REPLACES: ChartTableTrim[] = [ + { title: 'PCA Summary', byChart: 'Variance explained' }, + { title: 'Component weights', byChart: 'Component weights' }, +]; + function composeCharts(...builders: ((result: any) => EChartsOption[])[]): (result: any) => EChartsOption[] { return (result: any) => builders.flatMap(fn => fn(result)); } export const AlgorithmChartRegistry: Record = { - kmeans: { - build: buildKMeansChart, - inputPath: '', - }, - - linear_regression: { - build: buildRegressionLineChart, - inputPath: '', - }, - - linear_regression_cv: { - build: buildCVMetricsChart, - inputPath: '', - }, - - // Logistic Regression (Standard) - logistic_regression: { - build: buildRegressionLineChart, - inputPath: '', - }, - - + // The heatmap prints every centre in its cells; the two-variable bubble chart only places them, + // so with two variables the centres table stays. + kmeans: { build: buildKMeansChart, title: 'Cluster centres', replaces: [{ title: 'Cluster Centers', byChart: 'Cluster centres' }] }, logistic_regression_cv: { - build: composeCharts(buildLogRegConfusionChart, buildRocCurveChart), // CV Metrics chart could be added if supported - inputPath: '', + build: composeCharts(buildLogRegConfusionChart, buildRocCurveChart), + title: 'Classification performance', }, - naive_bayes_gaussian: { - build: buildNaiveBayesPriorsChart, - inputPath: '', + build: composeCharts(buildNaiveBayesPriorsChart, buildNaiveBayesProfileChart), + title: 'Classes', + replaces: [{ title: 'Class Summary', byChart: 'Class Prior Probabilities' }], + note: + "In the class profiles, each dot shows how far one class's average for a variable lies from that variable's overall average, " + + 'in standard deviations (0 is the overall average). The further apart the dots on a row, the better that variable tells the classes apart.', }, - naive_bayes_categorical: { build: buildNaiveBayesPriorsChart, - inputPath: '', + title: 'Class priors', + replaces: [{ title: 'Class Summary', byChart: 'Class Prior Probabilities' }], }, - naive_bayes_gaussian_cv: { build: buildNaiveBayesConfusionChart, - inputPath: '', + title: 'Classification performance', + replaces: [{ title: 'Confusion Matrix (Combined)', byChart: 'Confusion Matrix' }], }, - naive_bayes_categorical_cv: { build: buildNaiveBayesConfusionChart, - inputPath: '', - }, - - pearson_correlation: { - build: buildMatrixChart, - inputPath: '', - }, - - pca: { - build: buildPCAHeatmapChart, - inputPath: '', - }, - - pca_with_transformation: { - build: buildPCAHeatmapChart, - inputPath: '', - }, - - anova_oneway: { - build: buildMeanPlotChart, - inputPath: '', - }, - - anova_twoway: { - build: () => [], - inputPath: '', - }, - - ttest_independent: { - build: buildTTestChart, - inputPath: '', - }, - - ttest_paired: { - build: buildTTestChart, - inputPath: '', - }, - - ttest_onesample: { - build: buildTTestChart, - inputPath: '', - }, - - linear_svm: { - build: buildSVMChart, - inputPath: '', - }, - - describe: { - build: buildBoxPlotChart, - inputPath: '', - }, - - histogram: { - build: buildHistogramChart, - inputPath: '', - }, - - - cox_regression_classical: { - build: buildCoxHazardRatioForestChart, - inputPath: '', - }, + title: 'Classification performance', + replaces: [{ title: 'Confusion Matrix (Combined)', byChart: 'Confusion Matrix' }], + }, + // The pairs table keeps r: it is sorted by strength, and the value it is sorted by belongs in it. + pearson_correlation: { build: buildMatrixChart, title: 'Correlation matrix' }, + pca: { build: buildPCAHeatmapChart, title: 'Principal components', replaces: PCA_REPLACES }, + pca_with_transformation: { build: buildPCAHeatmapChart, title: 'Principal components', replaces: PCA_REPLACES }, + anova_oneway: { build: buildMeanPlotChart, title: 'Group means' }, + linear_svm: { build: buildSVMChart, title: 'Model weights' }, + describe: { build: buildBoxPlotChart, title: 'Distributions' }, + histogram: { build: buildHistogramChart, title: 'Distribution' }, + ttest_independent: { build: buildTTestIntervalChart, title: 'Mean difference' }, +}; - cox_regression_stacked: { - build: buildCoxHazardRatioForestChart, - inputPath: '', - }, +function chartTitle(chart: EChartsOption): string | null { + const title = Array.isArray(chart.title) ? chart.title[0] : chart.title; + return typeof title?.text === 'string' ? title.text : null; +} - default: { - build: () => [], - inputPath: '', - }, -}; +/** The result tables an algorithm's rendered charts stand in for on screen: each only when its chart is among them. */ +export function tablesReplacedByCharts(algorithm: string | null, charts: EChartsOption[]): TableTrim[] { + const replaces = algorithm ? AlgorithmChartRegistry[algorithm]?.replaces ?? [] : []; + const rendered = new Set(charts.map(chartTitle)); + return replaces + .filter((entry) => rendered.has(entry.byChart)) + .map(({ title, columns, rows }) => ({ title, ...(columns ? { columns } : {}), ...(rows ? { rows } : {}) })); +} diff --git a/src/app/pages/experiment-studio/visualisations/charts/charts-renderer/charts-renderer.component.spec.ts b/src/app/pages/experiment-studio/visualisations/charts/charts-renderer/charts-renderer.component.spec.ts new file mode 100644 index 00000000..c4f8e2cf --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/charts/charts-renderer/charts-renderer.component.spec.ts @@ -0,0 +1,51 @@ +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideEchartsCore } from 'ngx-echarts'; +import { ChartRendererComponent } from './charts-renderer.component'; + +describe('ChartRendererComponent house style', () => { + function themed(chart: Record): any { + TestBed.configureTestingModule({ + imports: [ChartRendererComponent], + providers: [provideZonelessChangeDetection(), provideEchartsCore({ echarts: () => import('echarts') })], + }); + const fixture = TestBed.createComponent(ChartRendererComponent); + fixture.componentRef.setInput('charts', [chart]); + fixture.detectChanges(); + return fixture.componentInstance.themedCharts[0]; + } + + it('adds the app font, title ink and tooltip card under the chart’s own settings', () => { + const chart = themed({ + title: { text: 'T', textStyle: { fontSize: 16 } }, + tooltip: { trigger: 'axis', backgroundColor: '#000' }, + series: [{ type: 'line', data: [1, 2] }], + }); + + expect(chart.textStyle.fontFamily).toContain('Inter'); + expect(chart.title.textStyle).toEqual({ fontSize: 16, fontWeight: 600, color: '#0f172a' }); + expect(chart.tooltip.backgroundColor).toBe('#000'); + expect(chart.tooltip.borderWidth).toBe(1); + expect(chart.aria.enabled).toBe(true); + // Lines keep their real shape: no default smoothing or area fill. + expect(chart.series[0].smooth).toBeUndefined(); + expect(chart.series[0].areaStyle).toBeUndefined(); + }); + + it('groups value-axis thousands with a thin space unless the axis formats its own labels', () => { + const chart = themed({ + xAxis: { type: 'category', data: ['a'] }, + yAxis: [{}, { type: 'value', axisLabel: { formatter: '{value} %' } }], + series: [], + }); + + expect(chart.xAxis.axisLabel).toBeUndefined(); + expect(chart.yAxis[0].axisLabel.formatter(12500)).toBe('12\u202f500'); + expect(chart.yAxis[0].axisLabel.formatter(0.25)).toBe('0.25'); + expect(chart.yAxis[1].axisLabel.formatter).toBe('{value} %'); + }); + + it('adds no tooltip to a chart that has none', () => { + expect(themed({ series: [] }).tooltip).toBeUndefined(); + }); +}); diff --git a/src/app/pages/experiment-studio/visualisations/charts/charts-renderer/charts-renderer.component.ts b/src/app/pages/experiment-studio/visualisations/charts/charts-renderer/charts-renderer.component.ts index 87d4340a..b7254796 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/charts-renderer/charts-renderer.component.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/charts-renderer/charts-renderer.component.ts @@ -3,6 +3,11 @@ import { CommonModule } from '@angular/common'; import { EChartsOption } from 'echarts'; import { NgxEchartsModule } from 'ngx-echarts'; import { SimpleChanges } from '@angular/core'; +import { CHART_SERIES_COLORS } from '../chart-palette'; +import { groupThousands } from '../../result-format'; + +/** --font-sans without the licensed Alaska faces, which canvas text cannot load either. */ +const CHART_FONT = "Inter, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"; @Component({ selector: 'app-chart-renderer', @@ -12,7 +17,6 @@ import { SimpleChanges } from '@angular/core'; changeDetection: ChangeDetectionStrategy.OnPush }) export class ChartRendererComponent implements OnChanges { - private readonly brandChartColors = ['#2B33E9', '#7F9CE8', '#FFBA08', '#DFEFE4']; readonly charts = input([]); themedCharts: EChartsOption[] = []; @@ -22,11 +26,56 @@ export class ChartRendererComponent implements OnChanges { this.themedCharts = this.charts().map((chart) => this.applyBrandChartDefaults(chart)); } + /** + * House style every chart starts from; a renderer's own settings win. Canvas text cannot read + * CSS, so the app font, title ink and tooltip card are set here once rather than per renderer. + */ private applyBrandChartDefaults(chart: EChartsOption): EChartsOption { + const title = chart.title; + const tooltip = chart.tooltip; return { ...chart, - color: this.brandChartColors, + // ECharts writes a text description of each chart for screen readers; no decal patterns. + aria: { enabled: true, decal: { show: false } }, + color: CHART_SERIES_COLORS, + textStyle: { fontFamily: CHART_FONT, ...this.objectValue(chart.textStyle) }, + ...(title && !Array.isArray(title) + ? { + title: { + ...title, + textStyle: { fontSize: 13, fontWeight: 600, color: '#0f172a', ...this.objectValue((title as Record)['textStyle']) }, + subtextStyle: { fontSize: 12, color: '#475569', ...this.objectValue((title as Record)['subtextStyle']) }, + }, + } + : {}), + ...(tooltip && !Array.isArray(tooltip) + ? { + tooltip: { + backgroundColor: '#ffffff', + borderColor: 'rgba(148, 163, 184, 0.45)', + borderWidth: 1, + extraCssText: 'border-radius: 8px; box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);', + ...tooltip, + textStyle: { color: '#0f172a', fontSize: 12, fontFamily: CHART_FONT, ...this.objectValue((tooltip as Record)['textStyle']) }, + }, + } + : {}), + ...(chart.xAxis ? { xAxis: this.groupAxisLabels(chart.xAxis, 'category') as EChartsOption['xAxis'] } : {}), + ...(chart.yAxis ? { yAxis: this.groupAxisLabels(chart.yAxis, 'value') as EChartsOption['yAxis'] } : {}), series: this.softenSeries(chart.series), + } as EChartsOption; + } + + /** Value-axis ticks grouped like the result tables (10 000): ECharts' own label puts a comma there. + * An axis with its own label formatter keeps it; `defaultType` is ECharts' type when none is set. */ + private groupAxisLabels(axis: unknown, defaultType: 'category' | 'value'): unknown { + if (Array.isArray(axis)) return axis.map((entry) => this.groupAxisLabels(entry, defaultType)); + const options = this.objectValue(axis); + const axisLabel = this.objectValue(options['axisLabel']); + if ((options['type'] ?? defaultType) !== 'value' || axisLabel['formatter']) return axis; + return { + ...options, + axisLabel: { ...axisLabel, formatter: (value: number) => groupThousands(value.toLocaleString('en-US', { maximumFractionDigits: 20 })) }, }; } @@ -52,14 +101,13 @@ export class ChartRendererComponent implements OnChanges { }; } + // Lines keep their series colour and their real shape: smoothing invents bends + // between discrete points (an elbow curve, a ROC curve) that the data does not have. if (type === 'line') { - next['smooth'] = next['smooth'] ?? true; next['lineStyle'] = { - width: 2.5, - color: this.brandChartColors[0], + width: 2, ...this.objectValue(next['lineStyle']), }; - next['areaStyle'] = next['areaStyle'] ?? { color: 'rgba(127, 156, 232, 0.12)' }; } return next; diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/box-plot-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/box-plot-chart.ts index 3deca97b..5a592e79 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/box-plot-chart.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/renderers/box-plot-chart.ts @@ -1,4 +1,5 @@ import { EChartsOption } from 'echarts'; +import { escapeHtml } from '../../../../../core/html.utils'; import { getDescribeDatasetLabels, getFeaturewiseDescribeRows, @@ -171,7 +172,7 @@ export function buildBoxPlotChart(result: any): EChartsOption[] { `
` + `${name}${fmt(value)}
`; const tooltipFor = (s: BoxStats, title: string, quartileNote = '') => - `
${title}
` + + `
${escapeHtml(title)}
` + tooltipRow('Max', s.max) + tooltipRow(`Q3${quartileNote}`, s.q3) + tooltipRow(`Median${quartileNote}`, s.median) + diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/chart-renderers-regressions.spec.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/chart-renderers-regressions.spec.ts index dacf8b39..c6ac307c 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/chart-renderers-regressions.spec.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/renderers/chart-renderers-regressions.spec.ts @@ -1,12 +1,28 @@ -import { buildConfusionMatrixChart } from './confusion-matrix-chart'; -import { buildCVMetricsChart } from './cv-metrics-chart'; -import { buildTTestChart } from './t-test-chart'; +import { buildConfusionMatrixChart, readConfusionMatrix } from './confusion-matrix-chart'; import { buildKMeansChart } from './k-means-chart'; -import { buildNaiveBayesPriorsChart } from './naive-bayes-priors-chart'; +import { buildPCAHeatmapChart } from './pca-heatmap-chart'; +import { buildTTestIntervalChart } from './mean-plot-chart'; +import { buildNaiveBayesPriorsChart, buildNaiveBayesProfileChart } from './naive-bayes-priors-chart'; import { buildBoxPlotChart } from './box-plot-chart'; import * as echarts from 'echarts'; describe('Chart renderers regressions', () => { + it('draws the t-test mean difference as a vertical interval, value on the y axis', () => { + const [chart] = buildTTestIntervalChart({ mean_diff: -3.454, ci_lower: -3.463, ci_upper: -3.445 }) as any[]; + + expect(chart.xAxis.type).toBe('category'); + expect(chart.xAxis.data).toEqual(['Mean difference']); + expect(chart.yAxis.type).toBe('value'); + expect(chart.series[0].data[0].value).toEqual(['Mean difference', -3.454]); + expect(chart.series[1].data[0].slice(1, 3)).toEqual([-3.463, -3.445]); + // Zero is far outside this interval, so no zero line stretches the axis. + expect(chart.series[0].markLine).toBeUndefined(); + + const [crossing] = buildTTestIntervalChart({ mean_diff: 0.2, ci_lower: -0.1, ci_upper: 0.5 }) as any[]; + expect(crossing.series[0].markLine.data).toEqual([{ yAxis: 0 }]); + expect(buildTTestIntervalChart({ mean_diff: 1 })).toEqual([]); + }); + it('fades the other box plot datasets as a whole while one is hovered', async () => { const stats = { min: 40, q1: 45, q2: 50, q3: 55, max: 60, mean: 51, std: 4.5 }; const [option] = buildBoxPlotChart({ @@ -86,74 +102,100 @@ describe('Chart renderers regressions', () => { }); expect(charts.length).toBe(1); - const heatmap = (charts[0] as any).series?.[0]?.data ?? []; + const cells = ((charts[0] as any).series?.[0]?.data ?? []).map((cell: any) => [cell.value[0], cell.value[1], cell.count]); - expect(heatmap).toContainEqual([0, 0, 5]); // TP - expect(heatmap).toContainEqual([1, 0, 2]); // FN - expect(heatmap).toContainEqual([0, 1, 1]); // FP - expect(heatmap).toContainEqual([1, 1, 7]); // TN + expect(cells).toContainEqual([0, 0, 5]); // TP + expect(cells).toContainEqual([1, 0, 2]); // FN + expect(cells).toContainEqual([0, 1, 1]); // FP + expect(cells).toContainEqual([1, 1, 7]); // TN }); - it('renders linear regression CV metrics when payload uses mean/std objects', () => { - const charts = buildCVMetricsChart({ - n_obs: [100, 95, 98, 101, 97], - root_mean_sq_error: { mean: 1.2, std: 0.1 }, - r_squared: { mean: 0.82, std: 0.03 }, - mean_abs_error: { mean: 0.7, std: 0.06 }, - f_stat: { mean: 9.5, std: 1.3 }, - }); + it('names a binary matrix after the positive class when the run gives it', () => { + expect(readConfusionMatrix({ tp: 5, fp: 1, fn: 2, tn: 7, positive_label: 'yes' })!.labels).toEqual(['yes', 'not yes']); + expect(readConfusionMatrix({ tp: 5, fp: 1, fn: 2, tn: 7 })!.labels).toEqual(['Positive', 'Negative']); + }); - expect(charts.length).toBe(1); - expect((charts[0] as any).title?.text).toBe('Cross-Validation Metric Summary'); - expect((charts[0] as any).series?.length).toBe(2); + it('drops classes absent from both sides and shades by share of the actual class', () => { + const confusion = readConfusionMatrix({ data: [[3, 1, 0], [2, 6, 0], [0, 0, 0]], labels: ['no', 'yes', 'unknown'] })!; + expect(confusion.labels).toEqual(['no', 'yes']); + + const charts = buildConfusionMatrixChart({ confusion_matrix: { data: [[3, 1, 0], [2, 6, 0], [0, 0, 0]], labels: ['no', 'yes', 'unknown'] } }); + const shares = ((charts[0] as any).series[0].data).map((cell: any) => cell.value[2]); + expect(shares).toEqual([0.75, 0.25, 0.25, 0.75]); }); - it('charts Exaflow [mean, std] CV summaries and skips [null, null] metrics', () => { - const charts = buildCVMetricsChart({ - n_obs: [100, 95], - root_mean_sq_error: [1.2, 0.1], - r_squared: [null, null], - mean_abs_error: [0.7, 0.06], - f_stat: [null, null], + it('draws three or more kmeans variables as a centres heatmap shaded around the size-weighted mean', () => { + const charts = buildKMeansChart({ + variables: ['v1', 'v2', 'v3'], + clusters: [ + { label: 'Cluster 0', size_interval: '30-30', center: { v1: 10, v2: 1, v3: 5 } }, + { label: 'Cluster 1', size_interval: '10-10', center: { v1: 50, v2: 1, v3: 1 } }, + ], + elbow: null, }); expect(charts.length).toBe(1); - expect((charts[0] as any).xAxis.data).toEqual(['RMSE', 'MAE']); - expect((charts[0] as any).series[0].data).toEqual([1.2, 0.7]); + const chart = charts[0] as any; + expect(chart.xAxis.data).toEqual(['v1', 'v2', 'v3']); + expect(chart.yAxis.data).toEqual(['Cluster 0 · ~30', 'Cluster 1 · ~10']); + // v1 overall mean (3·10 + 1·50) / 4 = 20: deviations −10 and +30, scaled by the larger. + const cells = chart.series[0].data.map((cell: any) => cell.value); + expect(cells[0]).toEqual([0, 0, -1 / 3, 10]); + expect(cells[3]).toEqual([0, 1, 1, 50]); + // A variable equal in every cluster sits at the mean. + expect(cells[1][2]).toBe(0); + // White text only on the deep blue end; ink reads better on the whole red half. + const labelColors = chart.series[0].data.map((cell: any) => cell.label.color); + expect(cells[5][2]).toBe(-1); + expect(labelColors[5]).toBe('#0f172a'); + expect(labelColors[3]).toBe('#ffffff'); }); - it('charts RMSE from the legacy mean_sq_error field', () => { - const charts = buildCVMetricsChart({ n_obs: [100, 95], mean_sq_error: [1.2, 0.1] }); + it('escapes labels in chart tooltips, which ECharts renders as HTML', () => { + const chart = buildKMeansChart({ + variables: ['', 'v2', 'v3'], + clusters: [{ label: 'Cluster 0', size_interval: '<10', center: { '': 1, v2: 1, v3: 1 } }], + elbow: null, + })[0] as any; - expect((charts[0] as any).xAxis.data).toEqual(['RMSE']); + const html = chart.tooltip.formatter({ value: [0, 0, 0, 1] }); + expect(html).not.toContain(' { - const charts = buildTTestChart({ - mean_diff: 0.5, - ci_lower: '-0.1', - ci_upper: '1.2', - p: 0.03, - }); + it('weights a kmeans cluster under the privacy threshold by half the threshold', () => { + const chart = buildKMeansChart({ + variables: ['v1', 'v2', 'v3'], + clusters: [ + { label: 'Cluster 0', size_interval: '30-30', center: { v1: 0, v2: 1, v3: 1 } }, + { label: 'Cluster 1', size_interval: '<10', center: { v1: 70, v2: 1, v3: 1 } }, + ], + elbow: null, + })[0] as any; - expect(charts.length).toBe(1); + expect(chart.yAxis.data).toEqual(['Cluster 0 · ~30', 'Cluster 1 · <10']); + // Weights 30 and 5: v1 mean (30·0 + 5·70) / 35 = 10, so deviations −10 and +60. + const cells = chart.series[0].data.map((cell: any) => cell.value); + expect(cells[0][2]).toBeCloseTo(-1 / 6, 10); + expect(cells[3][2]).toBe(1); }); - it('falls back to parallel coordinates for high-dimensional kmeans clusters', () => { + it('sizes two-variable kmeans bubbles by the midpoint of each size interval', () => { const charts = buildKMeansChart({ - variables: ['v1', 'v2', 'v3', 'v4'], + variables: ['Age', 'Score'], clusters: [ - { label: 'Cluster 0', center: { v1: 0.1, v2: 0.2, v3: 0.3, v4: 0.4 } }, - { label: 'Cluster 1', center: { v1: 0.5, v2: 0.6, v3: 0.7, v4: 0.8 } }, + { label: 'Cluster 0', size_interval: '90-110', center: { Age: 70, Score: 3 } }, + { label: 'Cluster 1', size_interval: '20-30', center: { Age: 50, Score: 4 } }, ], elbow: null, }); - expect(charts.length).toBe(1); - expect((charts[0] as any).title?.text).toContain('Parallel Coordinates'); - expect((charts[0] as any).series?.length).toBe(2); - expect((charts[0] as any).parallelAxis.map((a: any) => a.name)).toEqual(['v1', 'v2', 'v3', 'v4']); - expect((charts[0] as any).series?.[0]?.name).toBe('Cluster 0'); + const series = (charts[0] as any).series; + expect(series.map((s: any) => s.data[0])).toEqual([[70, 3], [50, 4]]); + // Area proportional to size: the largest cluster gets the full radius. + expect(series[0].symbolSize).toBe(48); + expect(series[1].symbolSize).toBeCloseTo(12 + 36 * Math.sqrt(25 / 100), 6); }); it('appends an elbow curve when kmeans used elbow selection', () => { @@ -184,4 +226,49 @@ describe('Chart renderers regressions', () => { expect(data[0]).toBeCloseTo(0.2, 3); expect(data[1]).toBeCloseTo(0.8, 3); }); + + it('places Gaussian naive Bayes class means in pooled SDs from the overall mean', () => { + const charts = buildNaiveBayesProfileChart({ + classes: ['no', 'yes'], + class_count: [1, 3], + feature_names: ['Score'], + theta: [[8], [4]], + var: [[4], [4]], + }); + + // Overall mean 5, pooled SD 2: "no" sits +1.5 SD, "yes" −0.5 SD. + const series = (charts[0] as any).series; + expect(series[0].data).toEqual([[1.5, 0]]); + expect(series[1].data).toEqual([[-0.5, 0]]); + }); + + it('draws a missing k-means centre value as missing, not NaN', () => { + const [heatmap] = buildKMeansChart({ + variables: ['a', 'b', 'c'], + clusters: [ + { label: 'Cluster 0', size_interval: '10-20', center: { a: 1, b: 2, c: 3 } }, + { label: 'Cluster 1', size_interval: '10-20', center: { a: 3, b: null, c: 5 } }, + ], + }) as any[]; + + const data = heatmap.series[0].data; + expect(data.every((cell: any) => cell.value.slice(0, 3).every((n: number) => Number.isFinite(n)))).toBe(true); + const missing = data.find((cell: any) => cell.value[0] === 1 && cell.value[1] === 1); + expect(missing.value[3]).toBeNull(); + expect(heatmap.series[0].label.formatter({ value: missing.value })).toBe('—'); + // Variable b's mean comes from the one cluster that reports it, so that cluster sits at it. + expect(data.find((cell: any) => cell.value[0] === 1 && cell.value[1] === 0).value[2]).toBe(0); + }); + + it('keeps each PCA variance share on its own component when an eigenvalue is missing', () => { + const charts = buildPCAHeatmapChart({ + eigenvectors: [[0.7, 0.7], [0.7, -0.7], [0.1, 0.2]], + eigenvalues: [3, null, 1], + variable_names: ['x', 'y'], + }) as any[]; + + const variance = charts.find((chart) => chart.title?.text === 'Variance explained'); + expect(variance.series[0].data).toEqual([75, null, 25]); + expect(variance.xAxis.data.map((label: string) => label.split('\n')[0])).toEqual(['PC1', 'PC2', 'PC3']); + }); }); diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/confusion-matrix-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/confusion-matrix-chart.ts index acc6fffc..0a6d6a3b 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/confusion-matrix-chart.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/renderers/confusion-matrix-chart.ts @@ -1,128 +1,128 @@ import { EChartsOption } from 'echarts'; +import { cellLabelColor } from '../chart-palette'; +import { escapeHtml } from '../../../../../core/html.utils'; +import { formatInteger } from '../../result-format'; -export function buildConfusionMatrixChart(result: any): EChartsOption[] { - let matrix: number[][] = []; - let classNames: string[] = []; +export interface ConfusionMatrix { + labels: string[]; + /** rows = actual class, columns = predicted class */ + matrix: number[][]; +} - const cm = result?.confusion_matrix; - if (!cm) return []; +/** + * Reads both Exaflow shapes — `{ data, labels }` and the binary `{ tp, fp, fn, tn }` — and drops + * classes that never occur on either side (an enumeration like "unknown" with an all-zero row + * and column says nothing about the model). + */ +export function readConfusionMatrix(cm: any): ConfusionMatrix | null { + let matrix: number[][] = []; + let labels: string[] = []; - // Format 1: { data: number[][], labels: string[] } - if (Array.isArray(cm.data) && Array.isArray(cm.labels)) { - matrix = cm.data; - classNames = cm.labels; - } - // Format 2: { tp, fp, fn, tn } - else if (cm.tp !== undefined && cm.fp !== undefined && cm.fn !== undefined && cm.tn !== undefined) { - // Rows = Actual [Positive, Negative], Columns = Predicted [Positive, Negative] + if (Array.isArray(cm?.data) && Array.isArray(cm?.labels)) { + matrix = cm.data.map((row: unknown[]) => (Array.isArray(row) ? row.map((value) => Number(value) || 0) : [])); + labels = cm.labels.map((label: unknown) => String(label)); + } else if (cm && cm.tp !== undefined && cm.fp !== undefined && cm.fn !== undefined && cm.tn !== undefined) { matrix = [ - [cm.tp, cm.fn], - [cm.fp, cm.tn] + [Number(cm.tp) || 0, Number(cm.fn) || 0], + [Number(cm.fp) || 0, Number(cm.tn) || 0], ]; - classNames = ['Positive', 'Negative']; + const positive = typeof cm.positive_label === 'string' && cm.positive_label ? cm.positive_label : null; + labels = positive ? [positive, `not ${positive}`] : ['Positive', 'Negative']; } - if (matrix.length === 0 || classNames.length === 0) { - console.warn('[ConfusionMatrix] Empty matrix or labels'); - return []; - } + if (!matrix.length || matrix.length !== labels.length) return null; - // Dimension Sanity Check - if (matrix.length !== classNames.length) { - console.warn(`[ConfusionMatrix] Dimension mismatch: ${matrix.length} rows vs ${classNames.length} labels`); - } + const keep = labels + .map((_, i) => i) + .filter((i) => matrix[i].some((value) => value !== 0) || matrix.some((row) => (row[i] ?? 0) !== 0)); + if (!keep.length) return null; - const heatmapData: [number, number, number][] = []; - for (let i = 0; i < matrix.length; i++) { - const row = matrix[i]; // row index i = Actual category index - if (!Array.isArray(row)) continue; - for (let j = 0; j < row.length; j++) { - // j = Predicted category index - heatmapData.push([j, i, row[j]]); // [col, row, value] -> [Predicted, Actual, Value] - } - } + return { + labels: keep.map((i) => labels[i]), + matrix: keep.map((i) => keep.map((j) => matrix[i][j] ?? 0)), + }; +} + +export function buildConfusionMatrixChart(result: any): EChartsOption[] { + const confusion = readConfusionMatrix(result?.confusion_matrix); + if (!confusion) return []; + const { labels, matrix } = confusion; + + // Shade by share of the actual class, so a large majority class does not wash out the rest. + const heatmapData = matrix.flatMap((row, i) => { + const total = row.reduce((sum, value) => sum + value, 0); + return row.map((count, j) => { + const share = total > 0 ? count / total : 0; + return { + value: [j, i, share], + count, + label: { color: cellLabelColor(share) }, + }; + }); + }); return [ { + mipChartHeight: 156 + 56 * labels.length, title: { text: 'Confusion Matrix', - subtext: 'Rows: Actual (True) | Columns: Predicted', + // Two short lines, so the caption fits a 360px compare column. + subtext: 'Rows: actual · columns: predicted\nShading: share of the actual class', left: 'center', - top: 0, - subtextStyle: { - color: '#666', - fontSize: 12, - }, }, tooltip: { position: 'top', formatter: (params: any) => { - const [predictedIdx, actualIdx, val] = params.value; - return `Actual (True): ${classNames[actualIdx]}
Predicted: ${classNames[predictedIdx]}
Count: ${val}`; + const [predictedIdx, actualIdx, share] = params.value; + const actual = escapeHtml(labels[actualIdx]); + return `Actual: ${actual}
Predicted: ${escapeHtml(labels[predictedIdx])}
Count: ${formatInteger(params.data.count)} (${(share * 100).toFixed(1)}% of actual ${actual})`; }, }, - grid: { - height: '60%', // Slightly smaller to ensure title + legend fit well - top: 60, // More top room for title/subtitle - left: 40, // Safe left buffer - right: 80, - bottom: 110, // Ample room for 45 deg rotated labels - containLabel: true, - }, + grid: { top: 80, left: 28, right: 16, bottom: 40, containLabel: true }, xAxis: { type: 'category', - data: classNames, - nameGap: 55, - splitArea: { show: true }, - axisLabel: { - fontSize: 12, - rotate: 45, - interval: 0 - }, + data: labels, + name: 'Predicted', + nameLocation: 'middle', + nameGap: 28, + nameTextStyle: { color: '#475569', fontWeight: 600 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: '#475569', interval: 0 }, }, yAxis: { type: 'category', - data: classNames, - nameGap: 100, - splitArea: { show: true }, - axisLabel: { - fontSize: 12, - rotate: 0, - interval: 0 - } + data: labels, + inverse: true, + name: 'Actual', + nameLocation: 'middle', + nameGap: 56, + nameTextStyle: { color: '#475569', fontWeight: 600 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: '#475569', interval: 0 }, }, visualMap: { + show: false, min: 0, - max: (() => { - const flat = matrix.flat().filter(v => typeof v === 'number' && !isNaN(v)); - return flat.length > 0 ? Math.max(...flat) : 1; - })(), - calculable: true, - orient: 'vertical', - right: 0, - top: 'center', + max: 1, + inRange: { color: ['#f1f5f9', '#2b33e9'] }, }, series: [ { name: 'Confusion Matrix', type: 'heatmap', data: heatmapData, + itemStyle: { borderColor: '#ffffff', borderWidth: 2 }, label: { show: true, - formatter: (params: any) => `${params.value[2]}`, - color: '#000', - fontSize: 14, - }, - emphasis: { - itemStyle: { - borderColor: '#333', - borderWidth: 1, - }, + fontSize: 13, + // ECharts' default line height sets the count and its share line on top of each other. + lineHeight: 18, + formatter: (params: any) => `${formatInteger(params.data.count)}\n${(params.value[2] * 100).toFixed(1)}%`, }, }, ], - }, + } as EChartsOption, ]; } - - diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/cox-hazard-ratio-forest-chart.spec.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/cox-hazard-ratio-forest-chart.spec.ts deleted file mode 100644 index 6984bbaf..00000000 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/cox-hazard-ratio-forest-chart.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - buildCoxHazardRatioForestChart, - extractCoxForestRows, - formatClinicianPValue, - isCoxInterceptTerm, - shortenCoxFactorLabel, -} from './cox-hazard-ratio-forest-chart'; - -describe('cox-hazard-ratio-forest-chart', () => { - it('skips intercept and keeps clinician-oriented rows', () => { - const rows = extractCoxForestRows({ - indep_vars: ['Intercept', 'Age'], - summary: { - hazard_ratios: [1.1, 0.74], - hr_lower_ci: [1.0, 0.61], - hr_upper_ci: [1.2, 0.9], - pvalues: [0.5, 0.002], - }, - }); - - expect(rows.length).toBe(1); - expect(rows[0].label).toBe('Age'); - expect(isCoxInterceptTerm('Intercept')).toBe(true); - }); - - it('shortens bracketed factor names for the y-axis', () => { - const short = shortenCoxFactorLabel( - 'Dataset Registry origin[SSR Swiss Stroke Registry (even)]' - ); - expect(short).toContain('·'); - expect(short).not.toContain('[SSR Swiss Stroke Registry (even)]'); - }); - - it('formats small p-values for clinicians', () => { - expect(formatClinicianPValue(0.0004)).toBe('<0.001'); - expect(formatClinicianPValue(0.04)).toBe('0.040'); - }); - - it('builds a chart when at least one factor is present', () => { - const charts = buildCoxHazardRatioForestChart({ - indep_vars: ['Sex[female]'], - summary: { - hazard_ratios: [0.96], - hr_lower_ci: [0.27], - hr_upper_ci: [3.41], - pvalues: [0.95], - }, - }); - - expect(charts.length).toBe(1); - expect(charts[0].title).toEqual(expect.objectContaining({ text: 'Hazard ratios (95% CI)' })); - }); -}); diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/cox-hazard-ratio-forest-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/cox-hazard-ratio-forest-chart.ts deleted file mode 100644 index c5a54638..00000000 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/cox-hazard-ratio-forest-chart.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { EChartsOption } from 'echarts'; -import { escapeHtml } from '../../../../../core/html.utils'; - -export interface CoxForestRow { - label: string; - hazardRatio: number; - ciLower: number; - ciUpper: number; - pValue: number; -} - -const SIGNIFICANT_COLOR = '#1d4ed8'; -const NEUTRAL_COLOR = '#64748b'; -const REFERENCE_LINE_COLOR = '#94a3b8'; - -export function formatClinicianPValue(pValue: number): string { - if (!Number.isFinite(pValue)) return 'N/A'; - if (pValue < 0.001) return '<0.001'; - return pValue.toFixed(3); -} - -function formatClinicianHazardRatio(value: number): string { - if (!Number.isFinite(value)) return 'N/A'; - if (value >= 100) return value.toFixed(1); - if (value >= 10) return value.toFixed(2); - return value.toFixed(3); -} - -export function isCoxInterceptTerm(name: string): boolean { - return String(name).trim().toLowerCase() === 'intercept'; -} - -/** Short y-axis labels; full names stay in tooltips. */ -export function shortenCoxFactorLabel(label: string, maxLen = 48): string { - const trimmed = String(label).trim(); - const bracket = trimmed.match(/^(.+?)\[(.+)\]$/); - if (bracket) { - const parent = bracket[1].trim(); - const level = bracket[2].trim(); - const shortParent = - parent.length > 22 ? `${parent.slice(0, 20).trim()}…` : parent; - const combined = `${shortParent} · ${level}`; - return combined.length > maxLen ? `${combined.slice(0, maxLen - 1)}…` : combined; - } - return trimmed.length > maxLen ? `${trimmed.slice(0, maxLen - 1)}…` : trimmed; -} - -export function extractCoxForestRows(result: any): CoxForestRow[] { - const indepVars: string[] = result?.indep_vars ?? []; - const summary = result?.summary; - if (!summary || !indepVars.length) return []; - - const hazardRatios: number[] = summary.hazard_ratios ?? []; - const ciLower: number[] = summary.hr_lower_ci ?? []; - const ciUpper: number[] = summary.hr_upper_ci ?? []; - const pvalues: number[] = summary.pvalues ?? []; - - const rows: CoxForestRow[] = []; - - indepVars.forEach((variable, index) => { - if (isCoxInterceptTerm(variable)) return; - - const hazardRatio = Number(hazardRatios[index]); - const lower = Number(ciLower[index]); - const upper = Number(ciUpper[index]); - const pValue = Number(pvalues[index]); - - if ( - !Number.isFinite(hazardRatio) || - !Number.isFinite(lower) || - !Number.isFinite(upper) || - hazardRatio <= 0 || - lower <= 0 || - upper <= 0 - ) { - return; - } - - rows.push({ - label: String(variable), - hazardRatio, - ciLower: lower, - ciUpper: upper, - pValue, - }); - }); - - return rows; -} - -export function buildCoxHazardRatioForestChart(result: any): EChartsOption[] { - const rows = extractCoxForestRows(result); - if (!rows.length) return []; - - const axisLabels = rows.map((row) => shortenCoxFactorLabel(row.label)); - const maxAxisLabelChars = Math.max(...axisLabels.map((label) => label.length), 12); - const seriesData = rows.map((row) => [ - row.hazardRatio, - row.ciLower, - row.ciUpper, - row.pValue, - ]); - - const axisValues = rows.flatMap((row) => [row.ciLower, row.ciUpper, row.hazardRatio, 1]); - const axisMin = Math.min(...axisValues) * 0.8; - const axisMax = Math.max(...axisValues) * 1.2; - - const chartHeight = Math.max(300, 128 + rows.length * 88); - - const option: EChartsOption = { - title: { - text: 'Hazard ratios (95% CI)', - subtext: 'Dashed line = no effect (HR 1). Values above 1 suggest higher event risk.', - left: 'center', - top: 4, - textStyle: { fontSize: 15, fontWeight: 600, color: '#0f4f4f' }, - subtextStyle: { fontSize: 11, color: '#64748b', lineHeight: 16 }, - }, - tooltip: { - trigger: 'item', - formatter: (params: any) => { - const index = params?.dataIndex ?? 0; - const row = rows[index]; - if (!row) return ''; - return ( - '' + escapeHtml(row.label) + '
' + - 'Hazard ratio: ' + formatClinicianHazardRatio(row.hazardRatio) + '
' + - '95% CI: ' + formatClinicianHazardRatio(row.ciLower) + ' – ' + - formatClinicianHazardRatio(row.ciUpper) + '
' + - 'p-value: ' + formatClinicianPValue(row.pValue) - ); - }, - }, - grid: { - top: 78, - bottom: 52, - left: Math.min(360, 28 + maxAxisLabelChars * 6.2), - right: 48, - }, - yAxis: { - type: 'category', - data: axisLabels, - inverse: true, - axisLine: { show: false }, - axisTick: { show: false }, - axisLabel: { - width: 340, - overflow: 'truncate', - fontSize: 12, - color: '#334155', - lineHeight: 16, - }, - }, - xAxis: { - type: 'log', - name: 'Hazard ratio (log scale)', - nameLocation: 'middle', - nameGap: 34, - min: Math.max(axisMin, 0.01), - max: axisMax, - axisLabel: { - hideOverlap: true, - margin: 10, - formatter: (val: number) => formatClinicianHazardRatio(val), - }, - splitLine: { lineStyle: { type: 'dashed', color: '#e2e8f0' } }, - }, - graphic: [ - { - type: 'text', - right: 20, - top: 82, - style: { - text: 'No effect → HR 1', - fill: REFERENCE_LINE_COLOR, - fontSize: 11, - fontWeight: 500, - }, - }, - ], - series: [ - { - type: 'custom', - renderItem: (params: any, api: any) => { - const categoryIndex = params.dataIndex; - const hazardRatio = api.value(0); - const lower = api.value(1); - const upper = api.value(2); - const pValue = api.value(3); - const y = api.coord([hazardRatio, categoryIndex])[1]; - const xPoint = api.coord([hazardRatio, categoryIndex])[0]; - const xLow = api.coord([lower, categoryIndex])[0]; - const xHigh = api.coord([upper, categoryIndex])[0]; - const color = Number.isFinite(pValue) && pValue < 0.05 ? SIGNIFICANT_COLOR : NEUTRAL_COLOR; - - return { - type: 'group', - children: [ - { - type: 'line', - shape: { x1: xLow, y1: y, x2: xHigh, y2: y }, - style: { stroke: color, lineWidth: 2 }, - }, - { - type: 'line', - shape: { x1: xLow, y1: y - 5, x2: xLow, y2: y + 5 }, - style: { stroke: color, lineWidth: 2 }, - }, - { - type: 'line', - shape: { x1: xHigh, y1: y - 5, x2: xHigh, y2: y + 5 }, - style: { stroke: color, lineWidth: 2 }, - }, - { - type: 'circle', - shape: { cx: xPoint, cy: y, r: 5 }, - style: { fill: color, stroke: '#fff', lineWidth: 1 }, - }, - ], - }; - }, - data: seriesData, - encode: { - x: [0, 1, 2], - y: -1, - }, - markLine: { - silent: true, - symbol: 'none', - data: [{ xAxis: 1 }], - lineStyle: { color: REFERENCE_LINE_COLOR, type: 'dashed', width: 2 }, - label: { show: false }, - }, - }, - ], - }; - - (option as EChartsOption & { mipChartHeight?: number }).mipChartHeight = chartHeight; - - return [option]; -} diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/cv-metrics-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/cv-metrics-chart.ts deleted file mode 100644 index 0c05d3fa..00000000 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/cv-metrics-chart.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { EChartsOption } from 'echarts'; - -const finite = (value: unknown): number | null => - typeof value === 'number' && Number.isFinite(value) ? value : null; - -export function buildCVMetricsChart(result: any): EChartsOption[] { - // linear_regression_cv summaries arrive as [mean, std] (or { mean, std }). - // Exaflow sends [null, null] for undefined metrics; those are left out of the chart. - if (!result) return []; - - const metricDefs = [ - // mean_sq_error is the pre-rename name of the same RMSE value in stored experiments. - { label: 'RMSE', raw: result.root_mean_sq_error ?? result.mean_sq_error }, - { label: 'R²', raw: result.r_squared }, - { label: 'MAE', raw: result.mean_abs_error }, - { label: 'F diagnostic', raw: result.f_stat }, - ]; - - const summaryMetrics = metricDefs - .map(({ label, raw }) => { - const [mean, std] = (Array.isArray(raw) ? raw : [raw?.mean, raw?.std]).map(finite); - if (mean === null || std === null) return null; - return { label, mean, std }; - }) - .filter((m): m is { label: string; mean: number; std: number } => m !== null); - - if (summaryMetrics.length === 0) return []; - - return [ - { - title: { - text: 'Cross-Validation Metric Summary', - left: 'center', - }, - tooltip: { - trigger: 'axis', - axisPointer: { type: 'shadow' }, - }, - legend: { - top: 30, - }, - grid: { - top: 80, - left: 60, - right: 30, - bottom: 60, - }, - xAxis: { - type: 'category', - data: summaryMetrics.map((m) => m.label), - axisLabel: { interval: 0, rotate: 20 }, - }, - yAxis: { - type: 'value', - }, - series: [ - { - name: 'Mean', - type: 'bar', - data: summaryMetrics.map((m) => m.mean), - label: { - show: true, - position: 'top', - formatter: (p: any) => Number(p.value).toFixed(3), - }, - }, - { - name: 'Std Dev', - type: 'bar', - data: summaryMetrics.map((m) => m.std), - label: { - show: true, - position: 'top', - formatter: (p: any) => Number(p.value).toFixed(3), - }, - }, - ], - }, - ]; -} diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/grouped-bar-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/grouped-bar-chart.ts index ab775377..8800540a 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/grouped-bar-chart.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/renderers/grouped-bar-chart.ts @@ -1,12 +1,6 @@ import { EChartsOption } from 'echarts'; - -// Color palette for bar chart segments - MIP brand colors -const BAR_COLORS = [ - '#2b33e9', // MIP Dark Blue - '#7f9ce8', // MIP Light Blue - '#ffba08', // MIP Orange - '#dfefe4', // MIP Light Green -]; +import { CHART_SERIES_COLORS as BAR_COLORS } from '../chart-palette'; +import { escapeHtml } from '../../../../../core/html.utils'; /** * Builds a grouped bar chart for nominal variable distributions. @@ -113,11 +107,11 @@ export function buildGroupedBarChart( }, formatter: (params: any) => { const dataset = params[0]?.axisValue ?? ''; - let html = `${dataset}
`; + let html = `${escapeHtml(dataset)}
`; for (const p of params) { const pct = p.data?.value ?? p.value; const cnt = p.data?.count ?? 0; - html += `${p.marker} ${p.seriesName}: ${pct}% (n=${cnt})
`; + html += `${p.marker} ${escapeHtml(p.seriesName)}: ${pct}% (n=${cnt})
`; } return html; }, diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/k-means-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/k-means-chart.ts index 45ef2561..e9258ff8 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/k-means-chart.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/renderers/k-means-chart.ts @@ -1,20 +1,32 @@ import { EChartsOption } from 'echarts'; +import { formatInteger, formatSummaryNumber, toFiniteNumber } from '../../result-format'; +import { DIVERGING_COLORS, cellLabelColor } from '../chart-palette'; +import { escapeHtml } from '../../../../../core/html.utils'; + +const AXIS_COLOR = '#475569'; export function buildKMeansChart(output: any): EChartsOption[] { const clusters = output?.clusters; - const title = 'K-Means Centers'; if (!Array.isArray(clusters) || clusters.length === 0) return []; const variables: string[] = output.variables ?? []; const names: string[] = clusters.map((c: any) => c.label); - const centers: number[][] = clusters.map((c: any) => variables.map((v: string) => c.center[v])); + // A centre value the engine did not send (or sent as null) stays null: drawn as missing, never NaN. + const centers: (number | null)[][] = clusters.map((c: any) => variables.map((v: string) => toFiniteNumber(c?.center?.[v]))); + const sizes: (number | null)[] = clusters.map((c: any) => intervalMidpoint(c.size_interval)); + // A range prints as its midpoint ("~66 245"); "<10" and an empty cluster's "0" print as sent. + const sizeTexts: (string | null)[] = clusters.map((c: any, i: number) => { + const interval = String(c.size_interval ?? '').trim(); + if (sizes[i] === null) return null; + return interval.includes('-') ? `~${formatInteger(Math.round(sizes[i]!))}` : interval; + }); + // Each cluster's name carries its size, so the centres table is not needed on screen. + const sizedNames = names.map((name, i) => (sizeTexts[i] === null ? name : `${name} · ${sizeTexts[i]}`)); const charts = variables.length === 2 - ? buildKMeans2DChart(centers as [number, number][], names, variables, title) - : variables.length === 3 - ? buildKMeans3DChart(centers as [number, number, number][], variables, title) - : buildKMeansParallelCoordinatesChart(centers, names, variables, title); + ? buildKMeans2DChart(centers as [number | null, number | null][], names, sizedNames, variables, sizes, sizeTexts) + : buildKMeansCentresHeatmap(centers, sizedNames, variables, sizes); if (output.elbow) { charts.push(buildKMeansElbowChart(output.elbow)); @@ -23,155 +35,182 @@ export function buildKMeansChart(output: any): EChartsOption[] { return charts; } -function buildKMeans2DChart( - centers: [number, number][], - names: string[], - variables: string[], - title: string -): EChartsOption[] { - const series = centers.map(([x, y]: [number, number], i: number) => ({ - name: names[i], - type: 'scatter', - data: [[x, y]], - symbolSize: 20, - label: { - show: true, - formatter: names[i], - position: 'top', - }, - })); - - return [ - { - title: { - text: title, - left: 'center', - }, - xAxis: { - type: 'value', - name: variables[0], - }, - yAxis: { - type: 'value', - name: variables[1], - }, - series: series as any, - }, - ]; +/** Cluster sizes arrive as privacy-safe intervals ("87520-87529", "<10"); the midpoint is close + * enough to scale a bubble or weight a mean. */ +function intervalMidpoint(interval: unknown): number | null { + const match = String(interval ?? '').match(/^\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*$/); + if (match) return (Number(match[1]) + Number(match[2])) / 2; + const below = String(interval ?? '').match(/^\s*<\s*(\d+(?:\.\d+)?)\s*$/); + if (below) return Number(below[1]) / 2; + const single = Number(interval); + return Number.isFinite(single) ? single : null; } -function buildKMeans3DChart( - centers: [number, number, number][], +/** Two variables: one bubble per centre, its area proportional to the cluster's size. */ +function buildKMeans2DChart( + centers: [number | null, number | null][], + names: string[], + legendNames: string[], variables: string[], - title: string + sizes: (number | null)[], + sizeTexts: (string | null)[] ): EChartsOption[] { - const series: any[] = [ - { - type: 'scatter3D', - data: centers, - symbolSize: 20, - }, - ]; + const largest = Math.max(1, ...sizes.map((size) => size ?? 0)); + const radius = (size: number | null) => (size === null ? 18 : 12 + 36 * Math.sqrt(size / largest)); + const axis = (name: string) => ({ + type: 'value' as const, + name, + nameLocation: 'middle' as const, + nameGap: 32, + scale: true, + // Room around the extreme centres so their bubbles do not sit on the axes. + boundaryGap: ['15%', '15%'] as [string, string], + nameTextStyle: { color: '#475569', fontWeight: 600 }, + axisLabel: { color: '#475569' }, + splitLine: { lineStyle: { color: '#e2e8f0' } }, + }); return [ { - title: { - text: `${title} (3D)`, - left: 'center', - }, - xAxis3D: { - type: 'value', - name: variables[0], - }, - yAxis3D: { - type: 'value', - name: variables[1], - }, - zAxis3D: { - type: 'value', - name: variables[2], - }, - grid3D: { - boxWidth: 100, - boxDepth: 100, - light: { - main: { - intensity: 1.2, - }, - ambient: { - intensity: 0.3, - }, - }, - }, + mipChartHeight: 380, + title: { text: 'K-Means Centers', show: false }, + legend: { top: 0, textStyle: { color: '#475569' } }, tooltip: { - formatter: (params: any) => { - const [x, y, z] = params.value; - return `${variables[0]}: ${x}
${variables[1]}: ${y}
${variables[2]}: ${z}`; + trigger: 'item', + formatter: (p: any) => { + const size = sizeTexts[p.seriesIndex]; + return `${escapeHtml(names[p.seriesIndex])}
${escapeHtml(variables[0])}: ${p.value[0].toFixed(2)}
${escapeHtml(variables[1])}: ${p.value[1].toFixed(2)}` + + (size === null ? '' : `
${escapeHtml(size)} rows`); }, }, - series: series as any, // Type assertion to bypass TS type check + grid: { top: 64, left: 24, right: 32, bottom: 48, containLabel: true }, + xAxis: axis(variables[0]), + yAxis: { ...axis(variables[1]), nameLocation: 'end', nameGap: 14, nameTextStyle: { color: '#475569', fontWeight: 600, align: 'left' } }, + series: centers.map(([x, y], i) => ({ + name: legendNames[i], + type: 'scatter', + // A centre missing either coordinate has no place on the plot; its legend entry stays. + data: x === null || y === null ? [] : [[x, y]], + symbolSize: radius(sizes[i]), + itemStyle: { opacity: 0.85, borderColor: '#ffffff', borderWidth: 2 }, + label: { show: true, position: 'top', color: '#0f172a', formatter: names[i] }, + })) as any, }, ]; } -function buildKMeansParallelCoordinatesChart( - centers: number[][], +/** + * Three or more variables: one row per cluster, one column per variable, each cell the centre's + * value. Rows name the clusters, so no colour has to tell five or more lines apart (no categorical + * palette stays distinct across every pair at that count). Shading compares each centre with the + * variable's overall mean (centres weighted by cluster size), scaled per column because units differ. + */ +function buildKMeansCentresHeatmap( + centers: (number | null)[][], names: string[], variables: string[], - title: string + sizes: (number | null)[] ): EChartsOption[] { - if (!centers.length || !Array.isArray(centers[0])) return []; + if (!centers.length || !variables.length) return []; - const dims = centers[0].length; - const parallelAxis = Array.from({ length: dims }, (_, i) => ({ - dim: i, - name: variables[i], - })); + // An empty cluster ("0") weighs nothing; an unreadable size drops the weighting for every cluster. + const sized = sizes.every((size) => size !== null) && sizes.some((size) => size! > 0); + const weights = sized ? (sizes as number[]) : centers.map(() => 1); + // Each variable's mean over the clusters that report it; a missing centre shades neutral. + const deviations = variables.map((_, v) => { + const reported = centers.map((_center, c) => c).filter((c) => centers[c][v] !== null); + const totalWeight = reported.reduce((sum, c) => sum + weights[c], 0); + const mean = totalWeight > 0 ? reported.reduce((sum, c) => sum + weights[c] * centers[c][v]!, 0) / totalWeight : null; + const raw = centers.map((center) => (center[v] === null || mean === null ? null : center[v]! - mean)); + const largest = Math.max(0, ...raw.map((value) => Math.abs(value ?? 0))); + return raw.map((value) => (value !== null && largest > 0 ? value / largest : 0)); + }); + + const data = centers.flatMap((center, c) => + variables.map((_, v) => { + const shade = deviations[v][c]; + return { + value: [v, c, shade, center[v]], + label: { color: cellLabelColor(shade) }, + }; + }) + ); return [ { - title: { - text: `${title} (Parallel Coordinates)`, - left: 'center', - }, + mipChartHeight: 110 + 44 * centers.length, + // The section heading names the chart and the colour bar under it says what the shading means. + title: { text: 'Cluster centres', show: false }, tooltip: { - trigger: 'item', + position: 'top', + formatter: (p: any) => { + const [v, c, shade, value] = p.value; + if (value === null) return `${escapeHtml(names[c])}
${escapeHtml(variables[v])}: not reported`; + const side = shade > 0 ? 'above' : shade < 0 ? 'below' : 'at'; + return `${escapeHtml(names[c])}
${escapeHtml(variables[v])}: ${formatSummaryNumber(value)}
${side} the overall mean`; + }, }, - legend: { - top: 30, - type: 'scroll', + grid: { top: 8, left: 8, right: 16, bottom: 48, containLabel: true }, + xAxis: { + type: 'category', + data: variables, + position: 'top', + axisLine: { show: false }, + axisTick: { show: false }, + // Narrow enough to wrap two-word names before they meet in a 360px compare column. + axisLabel: { color: AXIS_COLOR, interval: 0, width: 72, overflow: 'break', lineHeight: 15 }, }, - parallel: { - top: 90, - left: 70, - right: 70, - bottom: 60, + yAxis: { + type: 'category', + data: names, + inverse: true, + axisLine: { show: false }, + axisTick: { show: false }, + // Name over size ("Cluster 0" / "~66 245"): one long line got clipped at the chart's left edge. + axisLabel: { color: AXIS_COLOR, interval: 0, lineHeight: 16, formatter: (name: string) => name.replace(' · ', '\n') }, }, - parallelAxis, - series: centers.map((center, i) => ({ - type: 'parallel', - name: names[i], - data: [center], - lineStyle: { - width: 2, - opacity: 0.85, + visualMap: { + dimension: 2, + min: -1, + max: 1, + orient: 'horizontal', + left: 'center', + bottom: 4, + itemWidth: 12, + itemHeight: 160, + text: ['Above mean', 'Below mean'], + textStyle: { color: AXIS_COLOR }, + inRange: { color: DIVERGING_COLORS }, + }, + series: [ + { + name: 'Cluster centres', + type: 'heatmap', + data, + itemStyle: { borderColor: '#ffffff', borderWidth: 2 }, + label: { show: true, fontSize: 12, formatter: (p: any) => (p.value[3] === null ? '—' : formatSummaryNumber(p.value[3])) }, }, - })), - }, + ], + } as EChartsOption, ]; } function buildKMeansElbowChart(elbow: any): EChartsOption { const sortedKs = Object.keys(elbow.inertia_by_k).sort((a, b) => Number(a) - Number(b)); + const selected = String(elbow.selected_k); return { + mipChartHeight: 340, + // The y-axis measure is the subtitle: as an axis name it ran into the title in a 360px column. title: { text: 'Elbow Curve', + subtext: 'Within-cluster sum of squares for each k', left: 'center', }, tooltip: { trigger: 'axis', }, + grid: { top: 64, right: 32, bottom: 48, left: 88 }, xAxis: { type: 'category', name: 'k', @@ -179,20 +218,19 @@ function buildKMeansElbowChart(elbow: any): EChartsOption { }, yAxis: { type: 'value', - name: 'Inertia', }, series: [ { type: 'line', - name: 'Inertia', + name: 'Within-cluster sum of squares', data: sortedKs.map(k => elbow.inertia_by_k[k]), + symbolSize: 6, markPoint: { - data: [ - { - coord: [String(elbow.selected_k), elbow.inertia_by_k[String(elbow.selected_k)]], - name: 'Selected k', - }, - ], + symbol: 'circle', + symbolSize: 12, + itemStyle: { borderColor: '#ffffff', borderWidth: 2 }, + label: { show: true, position: 'top', distance: 8, color: '#0f172a', fontWeight: 600, formatter: `k = ${selected}` }, + data: [{ coord: [selected, elbow.inertia_by_k[selected]], name: 'Selected k' }], }, }, ], diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/matrix-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/matrix-chart.ts index aee6f3bc..4f7e1b58 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/matrix-chart.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/renderers/matrix-chart.ts @@ -1,126 +1,133 @@ import { EChartsOption } from 'echarts'; +import { formatPValue } from '../../result-format'; +import { DIVERGING_COLORS, cellLabelColor } from '../chart-palette'; +import { escapeHtml } from '../../../../../core/html.utils'; -export function buildMatrixChart(result: any): EChartsOption[] { - const charts: EChartsOption[] = []; +const AXIS_COLOR = '#475569'; + +type Matrix = Record & { variables?: string[] }; + +/** A Pearson result's correlation, p-value and CI matrices, read the same way by the chart and the pairs table. */ +export interface CorrelationMatrices { + correlations: Matrix; + pValues: Matrix | null; + ciLower: Matrix | null; + ciUpper: Matrix | null; + /** Column variables, in the order the engine sent them. */ + cols: string[]; + /** Row variables, in the order the engine sent them. */ + rows: string[]; + /** Rows and columns name the same variables: every pair appears twice and the diagonal is 1. */ + square: boolean; + /** One finite value of a matrix, or null. */ + cell: (matrix: Matrix | null, row: string, col: string) => number | null; +} - const pickMatrix = (...keys: string[]): any => { +export function readCorrelations(result: any): CorrelationMatrices | null { + const pickMatrix = (...keys: string[]): Matrix | null => { for (const key of keys) { const candidate = result?.[key]; - if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) { - return candidate; - } + if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) return candidate; } return null; }; - const generateChart = (matrix: any, title: string, min: number, max: number, colors?: string[]) => { - const cols: string[] = matrix?.variables ?? []; - const rows: string[] = Object.keys(matrix).filter(k => k !== 'variables'); - - if (!cols.length || !rows.length) return; - - const heatmapData: [number, number, number][] = []; - - for (let i = 0; i < rows.length; i++) { - const rowVar = rows[i]; - const values = matrix[rowVar]; - - if (!Array.isArray(values)) continue; - - for (let j = 0; j < cols.length; j++) { - const value = values[j]; - if (value !== null && value !== undefined) { - heatmapData.push([j, i, value]); - } - } - } - - charts.push({ - title: { - text: title, - left: 'center' - }, - tooltip: { position: 'top' }, - grid: { height: '65%', top: '15%', left: '30%', right: '10%', bottom: '25%' }, - xAxis: { - type: 'category', - data: cols, - splitArea: { show: true }, - nameLocation: 'middle', - nameGap: 50, - axisLabel: { - fontSize: 12, - rotate: 45, - interval: 0, - width: 100, - overflow: 'break' - } - }, - yAxis: { - type: 'category', - data: rows, - splitArea: { show: true }, - nameLocation: 'middle', - nameGap: 80, - axisLabel: { - fontSize: 12, - interval: 0, - width: 100, - overflow: 'break' - } - }, - visualMap: { - min, - max, - calculable: true, - orient: 'vertical', - left: '90%', - top: 'center', - inRange: colors ? { color: colors } : undefined - }, - series: [ - { - name: title, - type: 'heatmap', - data: heatmapData, - label: { - show: true, - fontSize: 14, - formatter: (params: any) => params.value[2]?.toFixed(2) - }, - emphasis: { - itemStyle: { - shadowBlur: 10, - shadowColor: 'rgba(0, 0, 0, 0.5)' - } - } - } - ] - }); - }; - - // 1. Correlation Matrix const correlations = pickMatrix('correlations'); - if (correlations) { - generateChart(correlations, 'Correlation Matrix', -1, 1); - } + if (!correlations) return null; + const cols: string[] = correlations.variables ?? []; + const rows = Object.keys(correlations).filter((key) => key !== 'variables'); + const cell = (matrix: Matrix | null, row: string, col: string): number | null => { + const values = matrix?.[row]; + const index = (matrix?.variables ?? cols).indexOf(col); + const value = Array.isArray(values) && index >= 0 ? Number(values[index]) : NaN; + return Number.isFinite(value) ? value : null; + }; + return { + correlations, + // Backwards compatibility: saved Pearson results may use historical p-value and CI aliases. + pValues: pickMatrix('p_values', 'p-values', 'pvalues'), + ciLower: pickMatrix('ci_lo', 'low_confidence_intervals'), + ciUpper: pickMatrix('ci_hi', 'high_confidence_intervals'), + cols, + rows, + square: rows.length === cols.length && rows.every((row) => cols.includes(row)), + cell, + }; +} - // 2. P-Values Matrix - // Backwards compatibility: saved Pearson results may use historical p-value and CI aliases. - const pValues = pickMatrix('p_values', 'p-values', 'pvalues'); - if (pValues) { - generateChart(pValues, 'P-Values', 0, 1, ['#d73027', '#fee090', '#ffffbf']); - } +/** + * One correlation heatmap. A square matrix keeps only the lower triangle (each pair once, + * no diagonal of 1s); a y-by-x matrix is drawn whole. p-values and the 95% CI travel in + * the tooltip, and the pairs table under the chart lists them in full. + */ +export function buildMatrixChart(result: any): EChartsOption[] { + const matrices = readCorrelations(result); + if (!matrices || !matrices.cols.length || !matrices.rows.length) return []; + const { correlations, pValues, ciLower, ciUpper, square, cell } = matrices; + const rows = square ? matrices.cols.slice(1) : matrices.rows; + const cols = square ? matrices.cols.slice(0, -1) : matrices.cols; - // 3. Confidence Intervals - const ciLower = pickMatrix('ci_lo', 'low_confidence_intervals'); - if (ciLower) { - generateChart(ciLower, 'Confidence Interval (Lower 95%)', -1, 1); - } - const ciUpper = pickMatrix('ci_hi', 'high_confidence_intervals'); - if (ciUpper) { - generateChart(ciUpper, 'Confidence Interval (Upper 95%)', -1, 1); - } + const data = rows.flatMap((row, i) => + cols.flatMap((col, j) => { + if (square && j > i) return []; + const r = cell(correlations, row, col); + if (r === null) return []; + return [{ value: [j, i, r], label: { color: cellLabelColor(r) } }]; + }) + ); - return charts; + return [{ + mipChartHeight: 110 + 48 * rows.length, + tooltip: { + position: 'top', + formatter: (params: any) => { + const [j, i, r] = params.value; + const row = rows[i]; + const col = cols[j]; + const lines = [`${escapeHtml(row)} × ${escapeHtml(col)}`, `r = ${r.toFixed(3)}`]; + const lower = cell(ciLower, row, col); + const upper = cell(ciUpper, row, col); + if (lower !== null && upper !== null) lines.push(`95% CI ${lower.toFixed(3)} to ${upper.toFixed(3)}`); + const p = cell(pValues, row, col); + if (p !== null) lines.push(`p ${formatPValue(p).startsWith('<') ? '' : '= '}${formatPValue(p)}`); + return lines.join('
'); + }, + }, + grid: { top: 16, left: 8, right: 24, bottom: 48, containLabel: true }, + xAxis: { + type: 'category', + data: cols, + axisLine: { show: false }, + axisTick: { show: false }, + // Slanted so neighbouring names never run together, even in a 360px compare column. + axisLabel: { color: AXIS_COLOR, interval: 0, rotate: 30, width: 110, overflow: 'truncate' }, + }, + yAxis: { + type: 'category', + data: rows, + inverse: true, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: AXIS_COLOR, interval: 0, width: 140, overflow: 'break' }, + }, + visualMap: { + min: -1, + max: 1, + orient: 'horizontal', + left: 'center', + bottom: 4, + itemWidth: 12, + itemHeight: 160, + text: ['r = +1', '−1'], + textStyle: { color: AXIS_COLOR }, + inRange: { color: DIVERGING_COLORS }, + }, + series: [{ + name: 'Pearson r', + type: 'heatmap', + data, + itemStyle: { borderColor: '#ffffff', borderWidth: 2 }, + label: { show: true, fontSize: 12, formatter: (params: any) => params.value[2]?.toFixed(2) }, + }], + } as EChartsOption]; } diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/mean-plot-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/mean-plot-chart.ts index 8f3502b2..c6f7cbfd 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/mean-plot-chart.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/renderers/mean-plot-chart.ts @@ -1,32 +1,78 @@ import { EChartsOption } from 'echarts'; +import { escapeHtml } from '../../../../../core/html.utils'; +import { groupThousands } from '../../result-format'; +interface IntervalPoint { + group: string; + mean: number; + low: number; + high: number; +} + +interface IntervalLabels { + x: string; + y: string; + /** Legend names of the point and its whisker, and the whisker's name in the tooltip. */ + point: string; + /** Name of the point's value in the tooltip ("Mean"). */ + value: string; + interval: string; + intervalShort: string; +} + +/** Group means with whiskers at ± 1 sample SD — Exaflow's ci_info "m-s"/"m+s" are mean ∓ SD, not a CI. */ export function buildMeanPlotChart(result: any): EChartsOption[] { const ciInfo = result?.ci_info; if (!ciInfo || !ciInfo.means || !ciInfo['m-s'] || !ciInfo['m+s']) return []; - const groups = Object.keys(ciInfo.means); + const data = Object.keys(ciInfo.means).map((group) => ({ + group, + mean: ciInfo.means[group], + low: ciInfo['m-s'][group], + high: ciInfo['m+s'][group], + })); - const data = groups.map((group) => { - const mean = ciInfo.means[group]; - const low = ciInfo['m-s'][group]; - const high = ciInfo['m+s'][group]; - return { group, mean, low, high }; + return intervalPlot(data, { + x: result?.anova_table?.x_label ?? 'Group', + y: result?.anova_table?.y_label ?? 'Mean ± SD', + point: 'Group mean', + value: 'Mean', + interval: '± 1 standard deviation', + intervalShort: '± 1 SD', }); +} - // labels anova_table - const xLabel = result?.anova_table?.x_label ?? 'Group'; - const yLabel = result?.anova_table?.y_label ?? 'Mean ± CI'; - const pValue = Number(result?.anova_table?.p_value); +/** + * Independent t-test: the mean difference (group A − group B) with its 95% CI as one vertical + * interval, value on the y axis, with a dashed zero line when the interval reaches it. + */ +export function buildTTestIntervalChart(result: any): EChartsOption[] { + const [mean, low, high] = [result?.mean_diff, result?.ci_lower, result?.ci_upper].map(Number); + if (![mean, low, high].every(Number.isFinite)) return []; + const [option] = intervalPlot([{ group: 'Mean difference', mean, low, high }], { + x: '', + y: 'Difference in means', + point: 'Mean difference', + value: 'Difference', + interval: '95% confidence interval', + intervalShort: '95% CI', + }, 320); + if (low <= 0 && high >= 0) { + (option.series as any[])[0].markLine = { + silent: true, + symbol: 'none', + label: { show: false }, + lineStyle: { color: '#94a3b8', type: 'dashed' }, + data: [{ yAxis: 0 }], + }; + } + return [option]; +} +/** Points with vertical whiskers, one per category. */ +function intervalPlot(data: IntervalPoint[], labels: IntervalLabels, height = 380): EChartsOption[] { const fmt = (v: number) => - Number.isFinite(v) - ? (Number.isInteger(v) ? String(v) : v.toFixed(3).replace(/\.?0+$/, '')) - : 'N/A'; - - const minY = Math.min(...data.map(d => d.low)); - const maxY = Math.max(...data.map(d => d.high)); - const span = Math.max(1e-6, maxY - minY); - const margin = span * 0.1; + Number.isFinite(v) ? groupThousands(v.toLocaleString('en-US', { maximumFractionDigits: 3 })) : 'N/A'; const meanMap: Record = Object.fromEntries(data.map(d => [d.group, d.mean])); @@ -35,21 +81,12 @@ export function buildMeanPlotChart(result: any): EChartsOption[] { const axisColor = '#64748b'; const textColor = '#0f172a'; - const option: EChartsOption = { + const option: EChartsOption & { mipChartHeight: number } = { + mipChartHeight: height, color: [meanColor, intervalColor], backgroundColor: 'transparent', - title: { - text: `Group Means (95% CI)${Number.isFinite(pValue) ? `\np-value: ${pValue.toExponential(3)}` : ''}`, - left: 'center', - textStyle: { - color: textColor, - fontSize: 14, - fontWeight: 700, - lineHeight: 20, - } - }, legend: { - top: 50, + top: 0, left: 'center', itemGap: 18, itemWidth: 18, @@ -59,9 +96,9 @@ export function buildMeanPlotChart(result: any): EChartsOption[] { fontSize: 12, fontWeight: 600, }, - data: ['Group mean', '95% confidence interval'], + data: [labels.point, labels.interval], }, - grid: { top: 96, right: 32, bottom: 72, left: 96 }, + grid: { top: 44, right: 32, bottom: 64, left: 96 }, tooltip: { trigger: 'item', backgroundColor: 'rgba(255, 255, 255, 0.96)', @@ -72,14 +109,14 @@ export function buildMeanPlotChart(result: any): EChartsOption[] { if (p.seriesType === 'scatter') { const group = p.name ?? (Array.isArray(p.value) ? p.value[0] : ''); const mean = Array.isArray(p.value) ? p.value[1] : meanMap[group]; - return `${group}
Mean: ${fmt(mean)}`; + return `${escapeHtml(group)}
${labels.value}: ${fmt(mean)}`; } if (p.seriesType === 'custom' && Array.isArray(p.value)) { const group = p.value[4]; const low = p.value[1]; const high = p.value[2]; const mean = meanMap[group]; - return `${group}
Mean: ${fmt(mean)}
CI: [${fmt(low)} – ${fmt(high)}]`; + return `${escapeHtml(group)}
${labels.value}: ${fmt(mean)}
${labels.intervalShort}: ${fmt(low)} – ${fmt(high)}`; } return ''; } @@ -87,7 +124,7 @@ export function buildMeanPlotChart(result: any): EChartsOption[] { xAxis: { type: 'category', data: data.map(d => d.group), - name: xLabel, + name: labels.x, nameLocation: 'middle', nameGap: 40, axisLine: { lineStyle: { color: '#cbd5e1' } }, @@ -105,11 +142,10 @@ export function buildMeanPlotChart(result: any): EChartsOption[] { }, yAxis: { type: 'value', - name: yLabel, + name: labels.y, nameLocation: 'middle', nameGap: 60, - min: Math.floor(minY - margin), - max: Math.ceil(maxY + margin), + scale: true, axisPointer: { show: false }, axisLine: { show: false }, axisTick: { show: false }, @@ -121,7 +157,6 @@ export function buildMeanPlotChart(result: any): EChartsOption[] { }, axisLabel: { color: axisColor, - formatter: (value: number) => (value < minY ? '' : value.toFixed(0)) }, nameTextStyle: { color: axisColor, @@ -130,7 +165,7 @@ export function buildMeanPlotChart(result: any): EChartsOption[] { }, series: [ { - name: 'Group mean', + name: labels.point, type: 'scatter', data: data.map(d => ({ name: d.group, value: [d.group, d.mean] })), symbolSize: 14, @@ -156,7 +191,7 @@ export function buildMeanPlotChart(result: any): EChartsOption[] { } }, { - name: '95% confidence interval', + name: labels.interval, type: 'custom', renderItem: (_params: any, api: any) => { const index = api.value(0) as number; diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/naive-bayes-priors-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/naive-bayes-priors-chart.ts index 72ea5327..bba754ea 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/naive-bayes-priors-chart.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/renderers/naive-bayes-priors-chart.ts @@ -1,4 +1,6 @@ import { EChartsOption } from 'echarts'; +import { escapeHtml } from '../../../../../core/html.utils'; +import { formatInteger } from '../../result-format'; function normalize(values: number[]): number[] { const safe = values.map((v) => (Number.isFinite(v) ? v : 0)); @@ -22,12 +24,15 @@ export function buildNaiveBayesPriorsChart(result: any): EChartsOption[] { } if (!priors.length || priors.length !== classes.length) return []; + const counts: unknown[] = Array.isArray(result?.class_count) ? result.class_count : []; + // One horizontal bar per class: a handful of shares does not need a 500px column chart. return [ { + mipChartHeight: 72 + 40 * classes.length, title: { text: 'Class Prior Probabilities', - left: 'center', + show: false, }, tooltip: { trigger: 'axis', @@ -35,44 +40,123 @@ export function buildNaiveBayesPriorsChart(result: any): EChartsOption[] { formatter: (params: any) => { const p = Array.isArray(params) ? params[0] : params; const value = Number(p?.value); - return `${p?.name}
Prior: ${(value * 100).toFixed(2)}%`; + return `${escapeHtml(p?.name)}
Prior: ${(value * 100).toFixed(2)}%`; }, }, grid: { - top: 56, - left: 56, - right: 28, - bottom: 56, + top: 8, + left: 8, + right: 120, + bottom: 28, + containLabel: true, }, - xAxis: { + yAxis: { type: 'category', data: classes, - axisLabel: { - interval: 0, - rotate: classes.length > 6 ? 25 : 0, - }, + inverse: true, + axisTick: { show: false }, + axisLabel: { color: '#475569', interval: 0 }, }, - yAxis: { + xAxis: { type: 'value', - name: 'Probability', min: 0, max: 1, + axisLabel: { color: '#475569', formatter: (value: number) => `${Math.round(value * 100)}%` }, + splitLine: { lineStyle: { color: '#e2e8f0' } }, }, series: [ { type: 'bar', data: priors, - barMaxWidth: 48, + barMaxWidth: 22, label: { show: true, - position: 'top', - formatter: (p: any) => `${(Number(p.value) * 100).toFixed(1)}%`, + position: 'right', + color: '#0f172a', + // Prior and training count together, so the class summary table is not needed on screen. + formatter: (p: any) => { + const count = Number(counts[p.dataIndex]); + const share = `${(Number(p.value) * 100).toFixed(1)}%`; + return Number.isFinite(count) ? `${share} · ${formatInteger(count)} rows` : share; + }, }, itemStyle: { color: '#2b33e9', + borderRadius: [0, 4, 4, 0], }, }, ], - }, + } as EChartsOption, + ]; +} + +/** + * Gaussian naive Bayes class profiles: for each feature, each class's mean expressed in pooled + * within-class SDs from the overall mean, (θ_c − μ) / s_pooled. One shared scale makes features + * in different units comparable and shows which ones separate the classes. + */ +export function buildNaiveBayesProfileChart(result: any): EChartsOption[] { + const classes: string[] = Array.isArray(result?.classes) ? result.classes.map(String) : []; + const features: string[] = Array.isArray(result?.feature_names) ? result.feature_names.map(String) : []; + const theta: number[][] = Array.isArray(result?.theta) ? result.theta : []; + const variance: number[][] = Array.isArray(result?.var) ? result.var : []; + const counts: number[] = Array.isArray(result?.class_count) ? result.class_count.map(Number) : []; + if (!classes.length || !features.length || theta.length !== classes.length || variance.length !== classes.length) return []; + + const total = counts.reduce((sum, n) => sum + (Number.isFinite(n) ? n : 0), 0); + const weight = (c: number) => (total > 0 ? counts[c] / total : 1 / classes.length); + const standardized = features.map((_, f) => { + const mean = classes.reduce((sum, _c, c) => sum + weight(c) * Number(theta[c][f]), 0); + const pooledSd = Math.sqrt(classes.reduce((sum, _c, c) => sum + weight(c) * Number(variance[c][f]), 0)); + return classes.map((_c, c) => (pooledSd > 0 ? (Number(theta[c][f]) - mean) / pooledSd : 0)); + }); + if (standardized.flat().some((value) => !Number.isFinite(value))) return []; + + return [ + { + mipChartHeight: 112 + 44 * features.length, + title: { + text: 'Class profiles', + subtext: 'Class mean minus overall mean, in pooled within-class SDs', + left: 'center', + }, + legend: { bottom: 0, textStyle: { color: '#475569' } }, + tooltip: { + trigger: 'item', + formatter: (p: any) => { + const f = p.dataIndex; + const c = p.seriesIndex; + return `${escapeHtml(features[f])} · ${escapeHtml(classes[c])}
Mean: ${Number(theta[c][f]).toFixed(2)} (SD ${Math.sqrt(Number(variance[c][f])).toFixed(2)})
${p.value[0] >= 0 ? '+' : '−'}${Math.abs(p.value[0]).toFixed(2)} SD from the overall mean`; + }, + }, + // Room above the first row for its class-name labels. + grid: { top: 72, left: 8, right: 24, bottom: 36, containLabel: true }, + xAxis: { + type: 'value', + axisLabel: { color: '#475569', formatter: (v: number) => `${v > 0 ? '+' : ''}${v}` }, + splitLine: { lineStyle: { color: '#e2e8f0' } }, + }, + yAxis: { + type: 'category', + data: features, + inverse: true, + axisTick: { show: false }, + axisLabel: { color: '#475569' }, + splitLine: { show: true, lineStyle: { color: '#f1f5f9' } }, + }, + series: classes.map((name, c) => ({ + name, + type: 'scatter', + symbolSize: 12, + itemStyle: { borderColor: '#ffffff', borderWidth: 2 }, + // Class names on the first row's dots, so colour alone never tells the classes apart. + label: { show: true, position: 'top', color: '#0f172a', formatter: (p: any) => (p.dataIndex === 0 ? name : '') }, + labelLayout: { hideOverlap: true }, + data: features.map((_, f) => [standardized[f][c], f]), + ...(c === 0 + ? { markLine: { silent: true, symbol: 'none', label: { show: false }, lineStyle: { color: '#94a3b8', type: 'dashed' }, data: [{ xAxis: 0 }] } } + : {}), + })), + } as EChartsOption, ]; } diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/pca-heatmap-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/pca-heatmap-chart.ts index e2fc9786..2d895778 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/pca-heatmap-chart.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/renderers/pca-heatmap-chart.ts @@ -1,4 +1,18 @@ import { EChartsOption } from "echarts"; +import { CHART_SERIES_COLORS, DIVERGING_COLORS, cellLabelColor } from '../chart-palette'; +import { escapeHtml } from '../../../../../core/html.utils'; + +const AXIS_COLOR = '#475569'; +const TEXT_COLOR = '#0f172a'; + +/** Share of total variance per component, in percent, at the component's own index: + * a missing eigenvalue is null there, so later components keep their shares. */ +function varianceShares(eigenvalues: unknown): (number | null)[] { + if (!Array.isArray(eigenvalues)) return []; + const values = eigenvalues.map((value) => (value === null || value === undefined || value === '' ? NaN : Number(value))); + const total = values.filter((value) => Number.isFinite(value)).reduce((sum, value) => sum + value, 0); + return total > 0 ? values.map((value) => (Number.isFinite(value) ? (value / total) * 100 : null)) : []; +} export function buildPCAHeatmapChart(result: any): EChartsOption[] { const matrix = result?.eigenvectors; @@ -29,140 +43,146 @@ export function buildPCAHeatmapChart(result: any): EChartsOption[] { variableNames = [...variableNames, ...padding]; } } - const componentNames = Array.from({ length: nComponents }, (_, i) => `PC${i + 1}`); - const heatmapData: [number, number, number][] = []; + const shares = varianceShares(result?.eigenvalues); + const componentNames = Array.from({ length: nComponents }, (_, i) => `PC${i + 1}`); + const componentLabels = componentNames.map((name, i) => + shares[i] == null ? name : `${name} · ${shares[i]!.toFixed(1)}%` + ); - for (let i = 0; i < nComponents; i++) { - for (let j = 0; j < nVariables; j++) { - const value = matrix[i][j]; - heatmapData.push([j, i, value]); - } - } + // Variables as rows and components as columns: long variable names wrap on the left, and + // the short component headers fit even a 360px compare column. + const componentHeaders = componentNames.map((name, i) => + shares[i] == null ? name : `${name}\n${shares[i]!.toFixed(1)}%` + ); + const heatmapData = matrix.flatMap((row: number[], component: number) => + row.map((value, variable) => ({ + value: [component, variable, value], + label: { color: cellLabelColor(value) }, + })) + ); const charts: EChartsOption[] = []; - // 1. Loadings Heatmap charts.push({ + mipChartHeight: 150 + 40 * nVariables, title: { - text: 'Eigenvectors', - left: 'center' + // Unit eigenvectors, not loadings (eigenvector × √eigenvalue): named for what they are. + text: 'Component weights', + subtext: "Each variable's eigenvector weight per component", + left: 'center', }, tooltip: { position: 'top', formatter: (params: any) => { - const [x, y, val] = params.value; - return `PC: ${componentNames[y]}
Var: ${variableNames[x]}
Weight: ${val.toFixed(3)}`; + const [component, variable, val] = params.value; + return `${componentLabels[component]}
${escapeHtml(variableNames[variable])}: ${val.toFixed(3)}`; }, }, grid: { - height: '65%', - top: '15%', - left: '10%', - right: '10%', - bottom: '25%', + top: 84, + left: 8, + right: 16, + bottom: 56, containLabel: true, }, xAxis: { type: 'category', - data: variableNames, - splitArea: { show: true }, - axisLabel: { - fontSize: 12, - rotate: 45, - interval: 0, - width: 100, - overflow: 'break' - }, + data: componentHeaders, + position: 'top', + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: AXIS_COLOR, interval: 0, lineHeight: 15 }, }, yAxis: { type: 'category', - data: componentNames, - splitArea: { show: true }, - axisLabel: { - fontSize: 12, // Consistent font size - interval: 0 - } + data: variableNames, + inverse: true, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: AXIS_COLOR, interval: 0, width: 120, overflow: 'break' }, }, visualMap: { min: -1, max: 1, - calculable: true, - orient: 'vertical', - left: '95%', - top: 'center', + orient: 'horizontal', + left: 'center', + bottom: 4, + itemWidth: 12, + itemHeight: 160, + text: ['+1', '−1'], + textStyle: { color: AXIS_COLOR }, + inRange: { color: DIVERGING_COLORS }, }, series: [ { - name: 'Eigenvector weights', + name: 'Component weights', type: 'heatmap', data: heatmapData, + itemStyle: { borderColor: '#ffffff', borderWidth: 2 }, label: { show: true, - fontSize: 14, + fontSize: 12, formatter: (params: any) => params.value[2]?.toFixed(2), }, - emphasis: { - itemStyle: { - shadowBlur: 10, - shadowColor: 'rgba(0, 0, 0, 0.5)', - }, - }, }, ], - }); + } as EChartsOption); - // 2. Scree Plot (Eigenvalues) - const eigenvalues = result?.eigenvalues; - if (Array.isArray(eigenvalues) && eigenvalues.length > 0) { + if (shares.some((share) => share !== null)) { + let running = 0; + const cumulative = shares.map((share) => (share === null ? null : (running += share))); + const round1 = (value: number | null) => (value === null ? null : Math.round(value * 10) / 10); + // One bar per eigenvalue, named by its index, so a gap leaves the components after it in place. + const varianceComponents = shares.map((_, i) => `PC${i + 1}`); charts.push({ + mipChartHeight: 340, title: { - text: 'Scree Plot (Eigenvalues)', - left: 'center' + text: 'Variance explained', + left: 'center', }, + legend: { top: 32, data: ['Per component', 'Cumulative'], textStyle: { color: AXIS_COLOR } }, tooltip: { trigger: 'axis', - axisPointer: { type: 'shadow' } - }, - grid: { - height: '65%', - top: '15%', - left: '10%', - right: '10%', - bottom: '15%', - containLabel: true + axisPointer: { type: 'shadow' }, + valueFormatter: (value: any) => (Number.isFinite(Number(value)) && value !== null ? `${Number(value).toFixed(1)}%` : '—'), }, + grid: { top: 80, left: 8, right: 16, bottom: 12, containLabel: true }, xAxis: { type: 'category', - data: componentNames, - axisLabel: { interval: 0, rotate: 30 } + // Eigenvalue under each component, so the PCA summary table is not needed on screen. + data: varianceComponents.map((name, i) => { + const eigenvalue = Number(result.eigenvalues[i]); + return Number.isFinite(eigenvalue) ? `${name}\nλ ${eigenvalue.toFixed(2)}` : name; + }), + axisLabel: { color: AXIS_COLOR, interval: 0, lineHeight: 16 }, }, yAxis: { type: 'value', - name: 'Eigenvalue', - nameLocation: 'middle', - nameGap: 50 + max: 100, + axisLabel: { color: AXIS_COLOR, formatter: '{value}%' }, + splitLine: { lineStyle: { color: '#e2e8f0' } }, }, series: [ { - name: 'Eigenvalue', + name: 'Per component', type: 'bar', - data: eigenvalues.map((v: number) => parseFloat(v.toFixed(3))), - itemStyle: { color: '#5470c6' }, - label: { - show: true, - position: 'top' - } + barMaxWidth: 48, + data: shares.map(round1), + itemStyle: { color: CHART_SERIES_COLORS[0] }, + // One decimal like the weights' column headers: "14.0%", not "14%". + label: { show: true, position: 'top', color: TEXT_COLOR, formatter: (p: any) => (Number.isFinite(Number(p.value)) && p.value !== null ? `${Number(p.value).toFixed(1)}%` : '') }, }, { - name: 'Eigenvalue (Line)', + name: 'Cumulative', type: 'line', - data: eigenvalues.map((v: number) => parseFloat(v.toFixed(3))), - itemStyle: { color: '#ee6666' }, + data: cumulative.map(round1), + itemStyle: { color: CHART_SERIES_COLORS[2] }, + lineStyle: { color: CHART_SERIES_COLORS[2] }, symbol: 'circle', - symbolSize: 8 - } - ] - }); + symbolSize: 8, + }, + ], + } as EChartsOption); } return charts; diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/regression-line-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/regression-line-chart.ts deleted file mode 100644 index 11104af2..00000000 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/regression-line-chart.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { EChartsOption } from 'echarts'; - -/** - * Builds a regression line chart for single-predictor Linear or Logistic Regression. - * - * Linear: y = intercept + β·x (straight line) - * Logistic: P = 1 / (1 + e^-(intercept + β·x)) (sigmoid curve) - * - * For multi-predictor models (more than 1 independent variable besides Intercept) - * no chart is returned — only the coefficient table is meaningful. - */ -export function buildRegressionLineChart(result: any): EChartsOption[] { - const isLogistic = !!result?.summary; - - let variables: string[]; - let coefficients: number[]; - let title: string; - let yLabel: string; - - if (isLogistic) { - // Logistic Regression payload - variables = result.indep_vars ?? []; - coefficients = result.summary?.coefficients ?? []; - title = 'Logistic Regression'; - yLabel = 'P(y = 1)'; - } else if (result?.indep_vars && result?.coefficients) { - // Linear Regression payload - variables = result.indep_vars ?? []; - coefficients = result.coefficients ?? []; - title = 'Linear Regression'; - yLabel = result.dependent_var ?? 'y'; - } else { - return []; - } - - // We expect [Intercept, variable] – exactly one predictor besides the intercept. - if (variables.length !== 2 || coefficients.length !== 2) return []; - - const intercept = coefficients[0]; - const slope = coefficients[1]; - const xLabel = variables[1]; // first entry is "Intercept" - - if (!Number.isFinite(intercept) || !Number.isFinite(slope)) return []; - - // Generate a reasonable x-range centred around where the line is interesting. - // For linear: pick a range that spans ~10 units of slope effect. - // For logistic: pick so that the sigmoid transition region is visible. - let xMin: number; - let xMax: number; - - if (isLogistic) { - // The sigmoid midpoint is at x = -intercept/slope - const midpoint = slope !== 0 ? -intercept / slope : 0; - // Show ±5/|slope| around the midpoint (covers ~99% of the curve) - const span = slope !== 0 ? 5 / Math.abs(slope) : 10; - xMin = midpoint - span; - xMax = midpoint + span; - } else { - // For linear, centre around x=0 with a reasonable span - const span = slope !== 0 ? Math.max(10, 5 / Math.abs(slope)) : 10; - xMin = -span; - xMax = span; - } - - // Generate points along the line/curve - const numPoints = 100; - const step = (xMax - xMin) / (numPoints - 1); - const lineData: [number, number][] = []; - - for (let i = 0; i < numPoints; i++) { - const x = xMin + i * step; - let y: number; - - if (isLogistic) { - const z = intercept + slope * x; - y = 1 / (1 + Math.exp(-z)); - } else { - y = intercept + slope * x; - } - - lineData.push([x, y]); - } - - const fmt = (v: number): string => - Number.isFinite(v) ? v.toFixed(4) : 'N/A'; - - const option: EChartsOption = { - title: { - text: title, - left: 'center', - }, - tooltip: { - trigger: 'axis', - formatter: (params: any) => { - const p = Array.isArray(params) ? params[0] : params; - if (!p?.data) return ''; - const [x, y] = p.data as [number, number]; - return `${xLabel} = ${fmt(x)}
${yLabel} = ${fmt(y)}`; - }, - }, - grid: { - top: 60, - left: 20, - right: 40, - bottom: 20, - containLabel: true, - }, - xAxis: { - type: 'value', - name: xLabel, - nameLocation: 'middle', - nameGap: 30, - splitLine: { show: true, lineStyle: { type: 'dashed' } }, - }, - yAxis: { - type: 'value', - name: yLabel, - nameLocation: 'middle', - nameGap: 50, - ...(isLogistic ? { min: 0, max: 1 } : {}), - splitLine: { show: true }, - }, - series: [ - { - type: 'line', - data: lineData, - smooth: true, - showSymbol: false, - lineStyle: { width: 2.5, color: '#2B33E9' }, - areaStyle: { color: 'rgba(127, 156, 232, 0.12)' }, - }, - ], - }; - - return [option]; -} diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/roc-curve-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/roc-curve-chart.ts index 6b85ebfb..e78afb72 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/roc-curve-chart.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/renderers/roc-curve-chart.ts @@ -1,5 +1,6 @@ import { EChartsOption } from "echarts"; import type { LineSeriesOption } from 'echarts'; +import { escapeHtml } from '../../../../../core/html.utils'; export function buildRocCurveChart(result: any): EChartsOption[] { @@ -10,13 +11,15 @@ export function buildRocCurveChart(result: any): EChartsOption[] { } const series: LineSeriesOption[] = []; + // Short legend entries ("Fold 1 · 0.864", the AUC after the dot) so five folds fit one row. + const aucByName: Record = {}; for (const fold of rocCurves) { const fpr = fold.fpr; const tpr = fold.tpr; const auc = fold.auc; - const name = fold.name || `Fold`; - const label = (typeof auc === 'number' && !isNaN(auc)) ? `${name} (AUC: ${auc.toFixed(3)})` : name; + const name = String(fold.name || 'Fold').replace(/^fold_?(\d+)$/i, 'Fold $1'); + if (typeof auc === 'number' && !isNaN(auc)) aucByName[name] = auc.toFixed(3); if (!Array.isArray(fpr) || !Array.isArray(tpr) || fpr.length !== tpr.length) { console.warn(`[ROC Chart] Invalid data for ${name}`); @@ -26,10 +29,9 @@ export function buildRocCurveChart(result: any): EChartsOption[] { const points = fpr.map((x, i) => [x, tpr[i]]); series.push({ - name: label, + name, type: 'line' as const, data: points, - smooth: true, showSymbol: false, emphasis: { focus: 'series', @@ -37,15 +39,31 @@ export function buildRocCurveChart(result: any): EChartsOption[] { }); } + // Chance line: a classifier that guesses sits on the diagonal (AUC 0.5). + series.push({ + name: 'Chance', + type: 'line' as const, + data: [[0, 0], [1, 1]], + showSymbol: false, + silent: true, + lineStyle: { color: '#94a3b8', type: 'dashed', width: 1 }, + itemStyle: { color: '#94a3b8' }, + tooltip: { show: false }, + }); + return [ { + mipChartHeight: 440, title: { text: 'ROC Curve', left: 'center', }, grid: { containLabel: true, - bottom: '20%', + top: 76, + left: 40, + right: 24, + bottom: 32, }, xAxis: { type: 'value', @@ -65,14 +83,17 @@ export function buildRocCurveChart(result: any): EChartsOption[] { }, tooltip: { trigger: 'axis', - formatter: (params: any) => { - const p = Array.isArray(params) ? params[0] : params; - const { seriesName, value } = p; - return `${seriesName}
FPR: ${value[0].toFixed(2)}
TPR: ${value[1].toFixed(2)}`; - }, + // Every fold's nearest point, not only the first fold's. + formatter: (params: any) => (Array.isArray(params) ? params : [params]) + .filter((p: any) => p.seriesName !== 'Chance') + .map((p: any) => `${p.marker}${escapeHtml(p.seriesName)}: FPR ${p.value[0].toFixed(2)}, TPR ${p.value[1].toFixed(2)}`) + .join('
'), }, legend: { - top: 'bottom', + top: 32, + type: 'scroll', + textStyle: { color: '#475569' }, + formatter: (name: string) => (aucByName[name] ? `${name} · ${aucByName[name]}` : name), }, series, }, diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/svm-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/svm-chart.ts index 2108a595..de1384a2 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/svm-chart.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/renderers/svm-chart.ts @@ -1,4 +1,5 @@ import { EChartsOption } from 'echarts'; +import { escapeHtml } from '../../../../../core/html.utils'; /** * Scientific SVM visualization: @@ -43,7 +44,7 @@ export function buildSVMChart(result: any): EChartsOption[] { formatter: (params: any) => { const p = params[0]; return ` - Range: ${p.name}
+ Range: ${escapeHtml(p.name)}
Count: ${p.value} `; }, diff --git a/src/app/pages/experiment-studio/visualisations/charts/renderers/t-test-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/t-test-chart.ts deleted file mode 100644 index 4793d2fc..00000000 --- a/src/app/pages/experiment-studio/visualisations/charts/renderers/t-test-chart.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { EChartsOption } from 'echarts'; - -export function buildTTestChart(result: any): EChartsOption[] { - // Visualizes Mean Difference with 95% CI - const meanDiff = Number(result?.mean_diff); - const lower = Number(result?.ci_lower); - const upper = Number(result?.ci_upper); - const pValue = Number(result?.p); - - if (!Number.isFinite(meanDiff) || !Number.isFinite(lower) || !Number.isFinite(upper)) return []; - - const title = `Mean Difference (95% CI)\np-value: ${Number.isFinite(pValue) ? pValue.toExponential(3) : 'N/A'}`; - - const option: EChartsOption = { - title: { - text: title, - left: 'center', - textStyle: { fontSize: 14 } - }, - tooltip: { - show: true, - formatter: () => { - return `Mean Difference: ${meanDiff.toFixed(4)}
` + - `95% CI: [${lower.toFixed(4)}, ${upper.toFixed(4)}]`; - } - }, - grid: { - top: 80, - bottom: 50, - left: 100, - right: 50 - }, - yAxis: { - type: 'category', - data: ['Diff'], - axisLine: { show: false }, - axisTick: { show: false } - }, - xAxis: { - type: 'value', - scale: true, - axisLabel: { - formatter: (val: number) => val.toFixed(2) - } - }, - series: [ - { - type: 'custom', - renderItem: (_params: any, api: any) => { - const y = api.coord([0, 0])[1]; // y index 0 - - const val = api.value(0); - const low = api.value(1); - const high = api.value(2); - - const xVal = api.coord([val, 0])[0]; - const xLow = api.coord([low, 0])[0]; - const xHigh = api.coord([high, 0])[0]; - - return { - type: 'group', - children: [ - { - type: 'line', - shape: { x1: xLow, y1: y, x2: xHigh, y2: y }, - style: { stroke: '#333', lineWidth: 2 } - }, - { - type: 'line', - shape: { x1: xLow, y1: y - 5, x2: xLow, y2: y + 5 }, - style: { stroke: '#333', lineWidth: 2 } - }, - { - type: 'line', - shape: { x1: xHigh, y1: y - 5, x2: xHigh, y2: y + 5 }, - style: { stroke: '#333', lineWidth: 2 } - }, - { - type: 'circle', - shape: { cx: xVal, cy: y, r: 6 }, - style: { fill: '#5470c6', stroke: '#333' } - } - ] - }; - }, - // Pass data so axis scales correctly - data: [[meanDiff, lower, upper]], - // Map all dimensions to X axis for scaling - encode: { - x: [0, 1, 2], - y: -1 - } - }, - // Markline at 0 - { - type: 'line', - markLine: { - silent: true, - symbol: 'none', - data: [{ xAxis: 0 }], - lineStyle: { color: '#999', type: 'dashed' } - } - } - ] - }; - - return [option]; -} diff --git a/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.css b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.css new file mode 100644 index 00000000..85186b83 --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.css @@ -0,0 +1,344 @@ +:host { + display: block; +} + +.lr-result { + display: flex; + flex-direction: column; + gap: 16px; +} + +.coefficients-card { + background: #ffffff; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 12px; + overflow: hidden; +} + +.coefficients-head { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding: 12px 16px; +} + +.coefficients-copy { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 10px; + min-width: 0; +} + +.coefficients-title { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--text-main); +} + +.coefficients-subtitle { + font-size: 0.78rem; + color: var(--text-muted); +} + +.coefficients-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.coef-columns button { + min-height: 26px; + padding: 0 12px; + font-size: 0.76rem; +} + +.csv-link { + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--primary-color); + font: inherit; + font-size: 0.76rem; + font-weight: 600; + cursor: pointer; +} + +.csv-link:hover, +.csv-link:focus-visible { + background: #eef0fd; +} + +/* A visible focus ring, as on the column toggle (WCAG 2.4.7). */ +.csv-link:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + +.coef-table { + display: grid; + min-width: 0; +} + +.coef-row { + display: grid; + /* Tracks per shown column, set by the component. */ + grid-template-columns: var(--coef-columns); + column-gap: 16px; + align-items: center; + padding: 11px 16px; + border-top: 1px solid rgba(148, 163, 184, 0.16); + font-size: 0.88rem; + color: var(--text-main); + min-width: 0; +} + +.coef-row--head { + padding: 8px 16px; + border-top: 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.22); + background: #f8fafc; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); +} + +.coef-row:not(.coef-row--head):first-of-type { + border-top: 0; +} + +.coef-row.is-intercept { + border-top: 1px dashed rgba(148, 163, 184, 0.4); + color: #64748b; +} + +.coef-row.is-intercept .coef-variable, +.coef-row.is-intercept .coef-estimate { + font-weight: 400; +} + +.coef-variable { + min-width: 0; + font-weight: 600; + overflow-wrap: anywhere; +} + +.coef-row.is-intercept .coef-variable { + font-weight: 400; +} + +.coef-level { + font-weight: 400; + color: var(--text-muted); +} + +.is-numeric { + white-space: nowrap; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.is-center { + text-align: center; +} + +.coef-muted { + color: var(--text-muted); +} + +.coef-p { + font-weight: 400; +} + +.coef-p.is-significant { + color: #0f172a; + font-weight: 700; +} + +.coef-plot-cell { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; +} + +.coef-plot { + position: relative; + display: block; + width: 100%; + height: 18px; +} + +.coef-plot-zero { + position: absolute; + top: 2px; + bottom: 2px; + left: 50%; + width: 1px; + background: rgba(148, 163, 184, 0.75); +} + +.coef-plot-interval { + position: absolute; + top: 7px; + height: 4px; + border-radius: 999px; + background: #cbd5e1; +} + +.coef-plot-interval.is-significant { + background: color-mix(in srgb, var(--primary-color) 62%, white); +} + +.coef-plot-marker { + position: absolute; + top: 4px; + width: 10px; + height: 10px; + margin-left: -5px; + border-radius: 50%; + background: #94a3b8; + box-shadow: 0 0 0 2px #ffffff; +} + +.coef-plot-marker.is-significant { + background: var(--primary-color); +} + +.coef-note { + margin: 0; + padding: 10px 16px 12px; + border-top: 1px solid rgba(148, 163, 184, 0.16); + font-size: 0.76rem; + line-height: 1.5; + color: var(--text-muted); +} + +.model-fit { + background: #ffffff; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 12px; + overflow: hidden; +} + +.model-fit-summary { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 14px 16px; + border: 0; + background: transparent; + font: inherit; + font-size: 0.88rem; + font-weight: 600; + color: var(--text-main); + cursor: pointer; + text-align: left; +} + +.model-fit-summary:hover, +.model-fit-summary:focus-visible { + background: #f8fafc; +} + +/* A visible focus ring, as on the column toggle (WCAG 2.4.7). */ +.model-fit-summary:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + +.model-fit-arrow { + width: 0.42rem; + height: 0.42rem; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.2s ease; + flex: 0 0 auto; +} + +.model-fit-arrow.is-open { + transform: rotate(45deg); +} + +.model-fit-glance { + margin-left: auto; + font-size: 0.78rem; + font-weight: 400; + color: var(--text-muted); +} + +.model-fit-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 0; + border-top: 1px solid rgba(148, 163, 184, 0.22); +} + +.model-fit-item { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + min-width: 0; + padding: 10px 16px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); +} + +.model-fit-item dt { + font-size: 0.8rem; + color: var(--text-muted); +} + +.model-fit-item dd { + margin: 0; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-main); + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* Narrow by container, not viewport: the same card sits in the studio, the dashboard + detail and 360px compare columns. Below 620px the interval sketch goes (the CI and p + carry the same answer) so the key columns fit without wrapping; the + wider All columns view scrolls sideways. */ +.lr-result { + container-type: inline-size; +} + +@container (max-width: 620px) { + .coef-row { + grid-template-columns: var(--coef-columns-narrow); + column-gap: 12px; + padding-inline: 12px; + } + + .coef-row > .coef-plot-cell, + .coef-row > .is-center { + display: none; + } + + .coef-table.is-all-columns { + overflow-x: auto; + } + + .coef-table.is-all-columns .coef-row { + min-width: 560px; + } + + .model-fit-grid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.html b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.html new file mode 100644 index 00000000..2de714fb --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.html @@ -0,0 +1,103 @@ +@let model = view(); +
+ @if (model.figures.length) { + + } + +
+
+
+

Coefficients

+ {{ model.subtitle }} +
+
+ + +
+
+ +
+
+ Variable + @for (column of columns(); track column) { + {{ columnLabel(column) }} + } +
+ + @for (row of model.rows; track row.variable) { +
+ {{ row.term }}@if (row.level) { · {{ row.level }}} + @for (column of columns(); track column; let lead = $first) { + @switch (column) { + @case ('interval') { + + @if (!row.isIntercept) { + + } + + } + @case ('p') { + {{ row.pText }} + } + @default { + {{ cellText(row, column) }} + } + } + } +
+ } +
+ +

+ Bold p-values are below 0.05. + @if (showsInterval()) { + Each interval is drawn on its own scale around + {{ model.nullValue === 1 ? ratioPhrase() + ' of 1 (log scale)' : 'zero' }}, so compare direction and whether it + crosses {{ model.nullValue }}, not size between rows. + } + @if (hasLevels()) { + A category row compares that level with the variable's reference level, the one not listed. + } +

+
+ + @if (model.fitMetrics.length) { +
+ + + @if (fitOpen()) { +
+ @for (metric of model.fitMetrics; track metric.label) { +
+
{{ metric.label }}
+
{{ metric.value }}
+
+ } +
+ } +
+ } +
diff --git a/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.spec.ts b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.spec.ts new file mode 100644 index 00000000..6b4ce4ec --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.spec.ts @@ -0,0 +1,47 @@ +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CsvExportService } from '../../../../services/csv-export.service'; +import { RegressionResultComponent } from './regression-result.component'; +import { buildRegressionView } from './regression-view'; + +describe('RegressionResultComponent', () => { + const logistic = { + dependent_var: '3m mRS good outcome', + indep_vars: ['Intercept', 'IVT[yes]', 'Age'], + summary: { + n_obs: 27308, + coefficients: [7.0586, 0.4794, -0.063], + stderr: [0.1287, 0.0405, 0.0016], + lower_ci: [6.8063, 0.4, -0.0662], + upper_ci: [7.311, 0.5587, -0.0599], + z_scores: [54.82, 11.84, -39.52], + pvalues: [0, 2.3e-32, 0], + df_model: 2, + df_resid: 27305, + ll0: -16452.2, + ll: -10977.8, + }, + }; + + it('exports the odds ratio and its CI in the coefficients CSV while the table shows only the key columns', () => { + TestBed.configureTestingModule({ imports: [RegressionResultComponent], providers: [provideZonelessChangeDetection()] }); + const exportToCsv = vi.spyOn(TestBed.inject(CsvExportService), 'exportToCsv').mockImplementation(() => {}); + const view = buildRegressionView('logistic_regression', logistic, null)!; + const fixture = TestBed.createComponent(RegressionResultComponent); + fixture.componentRef.setInput('view', view); + fixture.detectChanges(); + + expect(fixture.componentInstance.coefficientColumns()).toBe('key'); + (fixture.nativeElement as HTMLElement).querySelector('.csv-link')!.click(); + + const [rows, headers, filename] = exportToCsv.mock.calls[0]; + expect(headers).toEqual(['Variable', 'Estimate', 'Std. error', view.statLabel, 'Odds ratio', 'CI 95% lower', 'CI 95% upper', 'p-value']); + expect(filename).toBe('3m_mrs_good_outcome_coefficients.csv'); + const ivt = rows.find((row) => row['Variable'] === 'IVT[yes]')!; + expect(ivt['Estimate']).toBe('0.4794'); + expect(ivt['Odds ratio']).toBe(String(Math.exp(0.4794))); + expect(ivt['CI 95% lower']).toBe(String(view.rows[0].lower)); + expect(ivt['CI 95% upper']).toBe(String(view.rows[0].upper)); + expect(ivt['p-value']).toBe('2.3e-32'); + }); +}); diff --git a/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.ts b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.ts new file mode 100644 index 00000000..d19651b5 --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.ts @@ -0,0 +1,99 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core'; +import { CsvExportService } from '../../../../services/csv-export.service'; +import { KeyFiguresComponent } from '../summary/key-figures.component'; +import { CoefficientColumn, CoefficientRow, RegressionView } from './regression-view'; + +type CoefficientColumnMode = 'key' | 'all'; +type TextColumn = Exclude; + +/** Grid track of each column after the variable name. */ +const COLUMN_WIDTHS: Record = { + estimate: '0.7fr', + coefficient: '0.7fr', + stdErr: '0.7fr', + stat: '0.55fr', + ci: '1.1fr', + interval: '1.3fr', + p: '0.6fr', +}; + +const COLUMN_TEXT: Record = { + estimate: 'estimateText', + coefficient: 'coefficientText', + stdErr: 'stdErrText', + stat: 'statText', + ci: 'ciText', + p: 'pText', +}; + +/** Key figures, one coefficients card with an interval-vs-zero column, and collapsed model-fit details. */ +@Component({ + selector: 'app-regression-result', + imports: [KeyFiguresComponent], + templateUrl: './regression-result.component.html', + styleUrl: './regression-result.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegressionResultComponent { + private readonly csvExport = inject(CsvExportService); + + readonly view = input.required(); + readonly coefficientColumns = signal('key'); + readonly fitOpen = signal(false); + readonly columns = computed(() => (this.coefficientColumns() === 'all' ? this.view().allColumns : this.view().keyColumns)); + readonly showsInterval = computed(() => this.columns().includes('interval')); + /** Grid tracks for the container's wide and narrow (< 620px, no interval sketch) layouts. */ + readonly gridColumns = computed(() => ['minmax(0, 1.3fr)', ...this.columns().map((column) => COLUMN_WIDTHS[column])].join(' ')); + readonly narrowGridColumns = computed(() => + ['minmax(96px, 1fr)', ...this.columns().filter((column) => column !== 'interval').map(() => 'auto')].join(' '), + ); + readonly hasLevels = computed(() => this.view().rows.some((row) => row.level !== null)); + /** "an odds ratio", "a hazard ratio". */ + readonly ratioPhrase = computed(() => { + const label = this.view().estimateLabel.toLowerCase(); + return `${/^[aeiou]/.test(label) ? 'an' : 'a'} ${label}`; + }); + + columnLabel(column: CoefficientColumn): string { + const model = this.view(); + switch (column) { + case 'estimate': return model.estimateLabel; + case 'coefficient': return model.coefficientLabel ?? ''; + case 'stdErr': return 'Std. error'; + case 'stat': return model.statLabel; + case 'ci': return '95% CI'; + case 'interval': return `Interval vs ${model.nullValue}`; + case 'p': return 'p-value'; + } + } + + cellText(row: CoefficientRow, column: TextColumn): string { + return String(row[COLUMN_TEXT[column]]); + } + + /** Every column the model has (an odds ratio and its CI too while the table shows only the key + * columns) as raw, unrounded numbers with an ASCII minus and separate CI bounds, so the file reads + * as numeric columns in a spreadsheet or R; blanks stand for missing values. */ + downloadCoefficientsCsv(): void { + const model = this.view(); + const raw = (value: number | null) => (value === null ? '' : String(value)); + const fields: [string, (row: CoefficientRow) => number | null][] = model.allColumns.flatMap((column) => { + switch (column) { + case 'interval': return []; + case 'ci': return [['CI 95% lower', (row: CoefficientRow) => row.lower], ['CI 95% upper', (row: CoefficientRow) => row.upper]]; + case 'estimate': return [[this.columnLabel(column), (row: CoefficientRow) => row.estimate]]; + case 'coefficient': return [[this.columnLabel(column), (row: CoefficientRow) => row.coefficient]]; + case 'stdErr': return [[this.columnLabel(column), (row: CoefficientRow) => row.stdErr]]; + case 'stat': return [[this.columnLabel(column), (row: CoefficientRow) => row.stat]]; + case 'p': return [[this.columnLabel(column), (row: CoefficientRow) => row.pValue]]; + } + }); + const headers = ['Variable', ...fields.map(([header]) => header)]; + const rows = model.rows.map((row) => ({ + 'Variable': row.variable, + ...Object.fromEntries(fields.map(([header, value]) => [header, raw(value(row))])), + })); + const slug = model.dependent.toLowerCase().replace(/[^a-z0-9]+/g, '_') || 'coefficients'; + this.csvExport.exportToCsv(rows, headers, `${slug}_coefficients.csv`); + } +} diff --git a/src/app/pages/experiment-studio/visualisations/regression/regression-view.spec.ts b/src/app/pages/experiment-studio/visualisations/regression/regression-view.spec.ts new file mode 100644 index 00000000..279bb6cf --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/regression/regression-view.spec.ts @@ -0,0 +1,246 @@ +import { chiSquarePValue, formatPValue, formatRatio, ratioDigits } from '../result-format'; +import { buildRegressionView } from './regression-view'; + +describe('result-format', () => { + it('matches χ² upper-tail reference values', () => { + expect(chiSquarePValue(3.841459, 1)).toBeCloseTo(0.05, 6); + expect(chiSquarePValue(2, 2)).toBeCloseTo(Math.exp(-1), 10); + expect(chiSquarePValue(18.307038, 10)).toBeCloseTo(0.05, 6); + // df = 4 has a closed form: e^(-x/2) * (1 + x/2). + expect(chiSquarePValue(0.5, 4)).toBeCloseTo(Math.exp(-0.25) * 1.25, 10); + expect(chiSquarePValue(10948.8, 7)).toBe(0); + expect(chiSquarePValue(0, 3)).toBe(1); + }); + + it('formats p-values APA style and ratios at 3 significant digits', () => { + expect(formatPValue(1.1102230246251565e-16)).toBe('< 0.001'); + expect(formatPValue(0.8119)).toBe('0.812'); + expect(formatRatio(0.98999)).toBe('0.990'); + expect(formatRatio(903.51)).toBe('904'); + expect(formatRatio(1163.4)).toBe('1\u202f163'); + expect(ratioDigits(1.03513, 1.03648)).toBe(4); + expect(ratioDigits(1.49, 1.75)).toBe(3); + }); +}); + +describe('buildRegressionView', () => { + const logistic = { + dependent_var: '3m mRS good outcome', + indep_vars: ['Intercept', 'IVT[yes]', 'Age'], + summary: { + n_obs: 27308, + coefficients: [7.0586, 0.4794, -0.063], + stderr: [0.1287, 0.0405, 0.0016], + lower_ci: [6.8063, 0.4, -0.0662], + upper_ci: [7.311, 0.5587, -0.0599], + z_scores: [54.82, 11.84, -39.52], + pvalues: [0, 2.3e-32, 0], + df_model: 2, + df_resid: 27305, + r_squared_cs: 0.33, + r_squared_mcf: 0.3327, + ll0: -16452.2, + ll: -10977.8, + aic: 21971.6, + bic: 22037.3, + }, + }; + + it('reports logistic coefficients as odds ratios, sorted by log-odds size', () => { + const view = buildRegressionView('logistic_regression', logistic, null)!; + + expect(view.estimateLabel).toBe('Odds ratio'); + expect(view.nullValue).toBe(1); + // Key columns read like a model printout; the odds ratio, its CI and the sketch wait for all columns. + expect(view.coefficientLabel).toBe('Estimate'); + expect(view.keyColumns).toEqual(['coefficient', 'stdErr', 'stat', 'p']); + expect(view.allColumns).toEqual(['coefficient', 'stdErr', 'stat', 'estimate', 'ci', 'interval', 'p']); + expect(view.rows.map((row) => row.variable)).toEqual(['IVT[yes]', 'Age', 'Intercept']); + const ivt = view.rows[0]; + expect([ivt.term, ivt.level]).toEqual(['IVT', 'yes']); + expect(view.rows[1].level).toBeNull(); + expect(ivt.estimate).toBeCloseTo(Math.exp(0.4794), 10); + expect(ivt.coefficient).toBe(0.4794); + expect(ivt.ciText).toBe('1.49 to 1.75'); + // Plot sits on the log-odds scale: a positive coefficient lands right of the null line. + expect(ivt.markerLeft).toBeGreaterThan(50); + expect(view.rows[1].markerLeft).toBeLessThan(50); + }); + + it('keeps Exaflow F-test text when the statistic is not a finite number', () => { + const perfect = buildRegressionView('linear_regression', { + dependent_var: 'y', + indep_vars: ['Intercept', 'x'], + coefficients: [0, 1], + df_model: 1, + df_resid: 8, + f_stat: null, + f_pvalue: 0, + f_stat_display: 'Perfect fit', + f_pvalue_display: '<0.001', + f_stat_note: 'Residual sum of squares is zero.', + }, null)!; + const perfectFigure = perfect.figures.find((figure) => figure.label === 'Model F-test')!; + expect(perfectFigure.value).toBe('< 0.001'); + expect(perfectFigure.note).toBe('Perfect fit. Residual sum of squares is zero.'); + expect(Object.fromEntries(perfect.fitMetrics.map((metric) => [metric.label, metric.value]))['F-statistic']).toBe('Perfect fit'); + + const undefinedFit = buildRegressionView('linear_regression', { + indep_vars: ['Intercept'], + coefficients: [1], + f_stat: Number.NaN, + f_pvalue: Number.NaN, + f_stat_display: 'Undefined', + f_pvalue_display: 'Undefined', + f_stat_note: 'The overall F-statistic is not defined.', + }, null)!; + const undefinedFigure = undefinedFit.figures.find((figure) => figure.label === 'Model F-test')!; + expect(undefinedFigure).toEqual({ + label: 'Model F-test', + value: 'Undefined', + note: 'Undefined. The overall F-statistic is not defined.', + }); + + const finite = buildRegressionView('linear_regression', { + indep_vars: ['Intercept', 'x'], + coefficients: [1, 2], + df_model: 1, + df_resid: 8, + f_stat: 12.5, + f_pvalue: 0.002, + f_stat_display: '12.500', + f_stat_note: 'Overall F-test for the fitted regression model.', + }, null)!; + expect(finite.figures.find((figure) => figure.label === 'Model F-test')).toEqual({ + label: 'Model F-test', + value: '0.002', + note: 'F(1, 8) = 12.50', + }); + }); + + it('reads created-column dummy codes as words', () => { + const view = buildRegressionView('linear_regression', { + indep_vars: ['Intercept', 'kmeans_cluster[cluster_2]', 'stroke_territory_cohort[PCS]'], + coefficients: [80, -46, 1], + }, null)!; + + expect(view.rows.map((row) => [row.term, row.level])).toEqual([ + ['K-means cluster', 'Cluster 2'], + ['Stroke territory cohort', 'PCS'], + ['Intercept', null], + ]); + }); + + it('names the positive class in the logistic subtitle when the run gives it', () => { + expect(buildRegressionView('logistic_regression', logistic, null, 'yes')!.subtitle) + .toBe('Effect on the odds that 3m mRS good outcome is yes, other predictors held constant'); + expect(buildRegressionView('logistic_regression', logistic, null)!.subtitle) + .toBe('Effect on the odds of 3m mRS good outcome, other predictors held constant'); + }); + + it('derives the model likelihood-ratio test from ll and ll0', () => { + const view = buildRegressionView('logistic_regression', logistic, null)!; + const test = view.figures.find((figure) => figure.label === 'Model LR test')!; + + expect(test.value).toBe('< 0.001'); + expect(test.note).toBe('χ²(2) = 10\u202f949'); + expect(view.fitMetrics.map((metric) => metric.label)).toContain('Null log-likelihood'); + }); + + it('summarises a linear mixed model with the ICC from its variance components', () => { + const view = buildRegressionView('lmm', { + dependent_var: '3m mRS', + grouping_var: ['Unit'], + indep_vars: ['Intercept', 'Age'], + n_obs: 1000, + n_groups: 14, + coefficients: [-1.1, 0.031], + std_err: [0.08, 0.0003], + t_stats: [-13.5, 101.8], + pvalues: [1e-41, 0], + lower_ci: [-1.27, 0.0305], + upper_ci: [-0.95, 0.0317], + sigma2: 3, + sigma_u2: 1, + converged: true, + }, null)!; + + expect(view.estimateLabel).toBe('Estimate'); + expect(view.keyColumns).toEqual(['estimate', 'stdErr', 'interval', 'p']); + expect(view.figures[0]).toEqual({ label: 'ICC', value: '0.250', note: '25.0% of the variance lies between groups' }); + expect(view.figures.some((figure) => figure.label === 'Converged')).toBe(false); + }); + + it('reports a binary GLMM as odds ratios and leads with non-convergence', () => { + const view = buildRegressionView('glmm_binary', { + dependent_var: 'Good outcome', + grouping_var: 'Unit', + indep_vars: ['Intercept', 'Age'], + coefficients: [5.36, -0.0616], + z_stats: [121.4, -111.1], + pvalues: [0, 0], + lower_ci: [5.27, -0.0627], + upper_ci: [5.44, -0.0605], + sigma_u2: Math.PI * Math.PI / 3, + converged: false, + n_iter: 50, + }, null)!; + + expect(view.estimateLabel).toBe('Odds ratio'); + expect(view.figures[0].label).toBe('Converged'); + expect(view.figures[0].note).toBe('Stopped after 50 iterations; treat estimates with caution'); + // Latent-scale ICC: σu² / (σu² + π²/3). + expect(view.figures[1].value).toBe('0.500'); + }); + + it('reads the ordinal GLMM as odds of a higher category and lists its order and cutpoints', () => { + const view = buildRegressionView('glmm_ordinal', { + dependent_var: '3m mRS', + grouping_var: ['Unit'], + indep_vars: ['Intercept', 'Age'], + category_order: ['0.0', '1.0', '2.0'], + coefficients: [-2.39, 0.0352], + z_stats: [-23.2, 105.7], + pvalues: [1e-119, 0], + lower_ci: [-2.59, 0.0345], + upper_ci: [-2.19, 0.0358], + cutpoints: [0, 1.1058], + sigma_u2: 0.1377, + converged: true, + }, null)!; + + expect(view.estimateLabel).toBe('Odds ratio'); + expect(view.subtitle).toContain('odds of a higher 3m mRS category'); + const fit = Object.fromEntries(view.fitMetrics.map((metric) => [metric.label, metric.value])); + expect(fit['Category order']).toBe('0 < 1 < 2'); + expect(fit['Cutpoints']).toBe('0.000, 1.11'); + }); + + it('reports Cox hazard ratios and leads with events per covariate', () => { + const view = buildRegressionView('cox_regression_classical', { + dependent_var: 'Days to follow-up', + event_var: 'Death', + indep_vars: ['Sex[female]', 'Age'], + summary: { + n_obs: 582, n_events: 18, n_covariates: 3, converged: true, + coefficients: [0.154, 0.0387], std_err: [0.283, 0.0133], z_scores: [0.54, 2.91], pvalues: [0.586, 0.0037], + lower_ci: [-0.401, 0.0126], upper_ci: [0.709, 0.0647], + }, + }, null)!; + + expect(view.estimateLabel).toBe('Hazard ratio'); + expect(view.rows.map((row) => row.term)).toEqual(['Sex', 'Age']); + expect(view.rows[0].estimate).toBeCloseTo(Math.exp(0.154), 10); + const figures = Object.fromEntries(view.figures.map((figure) => [figure.label, figure])); + expect(figures['Events'].note).toBe('3.1% of 582 participants'); + expect(figures['Events per covariate'].note).toBe('Below the usual 10; estimates may be unstable'); + const fit = Object.fromEntries(view.fitMetrics.map((metric) => [metric.label, metric.value])); + expect(fit['Follow-up time']).toBe('Days to follow-up'); + expect(fit['Event indicator']).toBe('Death'); + }); + + it('returns null for algorithms without a coefficient view or incomplete payloads', () => { + expect(buildRegressionView('kmeans', logistic, null)).toBeNull(); + expect(buildRegressionView('logistic_regression', { indep_vars: ['Intercept'] }, null)).toBeNull(); + }); +}); diff --git a/src/app/pages/experiment-studio/visualisations/regression/regression-view.ts b/src/app/pages/experiment-studio/visualisations/regression/regression-view.ts new file mode 100644 index 00000000..b6964c01 --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/regression/regression-view.ts @@ -0,0 +1,545 @@ +import { + chiSquarePValue, + formatInteger, + formatPValue, + formatRatio, + ratioDigits, + readable, + formatSignedNumber, + formatSummaryNumber, + toFiniteNumber, +} from '../result-format'; +import { KeyFigure } from '../summary/key-figures.component'; + +export interface CoefficientRow { + /** As the result names it ("Sex[female]"); kept for the CSV. */ + variable: string; + /** Display split of a dummy term: "Sex" and "female"; level is null for a numeric predictor. */ + term: string; + level: string | null; + estimateText: string; + /** The model-scale coefficient when the estimate column shows a transform of it (log-odds behind an odds ratio). */ + coefficientText: string; + stdErrText: string; + statText: string; + ciText: string; + pText: string; + pValue: number | null; + /** Unrounded values for export; the *Text fields are display-only. */ + estimate: number | null; + coefficient: number | null; + stdErr: number | null; + stat: number | null; + lower: number | null; + upper: number | null; + significant: boolean; + isIntercept: boolean; + plotLeft: number; + plotWidth: number; + markerLeft: number; +} + +/** A coefficients-table column after the variable name; `interval` is the sketch against the null value. */ +export type CoefficientColumn = 'estimate' | 'coefficient' | 'stdErr' | 'stat' | 'ci' | 'interval' | 'p'; + +export interface RegressionView { + dependent: string; + /** Header of the estimate column and its CSV column ("Estimate", "Odds ratio"). */ + estimateLabel: string; + /** Header of the model-scale coefficient (log-odds, log hazard; "Estimate" for logistic regression); null when the estimate is the coefficient. */ + coefficientLabel: string | null; + /** Where "no effect" sits on the estimate scale: 0 for a coefficient, 1 for a ratio. */ + nullValue: 0 | 1; + /** Test statistic column ("t" for OLS, "z" for the logistic Wald test). */ + statLabel: string; + /** Columns of the Key and All views, in display order; the first one reads bold. */ + keyColumns: CoefficientColumn[]; + allColumns: CoefficientColumn[]; + subtitle: string; + rows: CoefficientRow[]; + figures: KeyFigure[]; + fitMetrics: KeyFigure[]; + fitGlance: string; +} + +const arrayOf = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); + +const KEY_COLUMNS: CoefficientColumn[] = ['estimate', 'ci', 'interval', 'p']; +const ALL_COLUMNS: CoefficientColumn[] = ['estimate', 'stdErr', 'stat', 'ci', 'interval', 'p']; +/** Ratio models: the ratio leads, its log-scale coefficient follows. */ +const ALL_RATIO_COLUMNS: CoefficientColumn[] = ['estimate', 'coefficient', 'stdErr', 'stat', 'ci', 'interval', 'p']; + +/** "Sex[female]" → { term: "Sex", level: "female" }: the bracket is model syntax, not a label. */ +function splitTerm(variable: string): { term: string; level: string | null } { + const match = variable.match(/^(.+?)\[(.+)\]$/); + return match ? { term: readable(match[1]), level: readable(match[2]) } : { term: readable(variable), level: null }; +} + +interface CoefficientInput { + variables: string[]; + estimates: unknown[]; + stdErr: unknown[]; + stats: unknown[]; + pValues: unknown[]; + lower: unknown[]; + upper: unknown[]; +} + +/** + * Rows sorted by effect size with the intercept last. Each interval is drawn on its own scale around + * zero on the model scale; `toEstimate` maps a coefficient and its CI bounds to the reported scale + * (exp for odds ratios), so a ratio's plot is its log scale around 1. + */ +function buildRows(input: CoefficientInput, toEstimate?: (value: number) => number): CoefficientRow[] { + const shown = (value: number | null) => (value === null || !toEstimate ? value : toEstimate(value)); + const rows = input.variables.map((variable, index) => { + const coefficient = toFiniteNumber(input.estimates[index]); + const lowerCoefficient = toFiniteNumber(input.lower[index]); + const upperCoefficient = toFiniteNumber(input.upper[index]); + const estimate = shown(coefficient); + const lower = shown(lowerCoefficient); + const upper = shown(upperCoefficient); + const digits = ratioDigits(lower, upper); + const formatShown = (value: number) => (toEstimate ? formatRatio(value, digits) : formatSignedNumber(value)); + const pValue = toFiniteNumber(input.pValues[index]); + const stdErr = toFiniteNumber(input.stdErr[index]); + const stat = toFiniteNumber(input.stats[index]); + const scale = + Math.max(Math.abs(coefficient ?? 0), Math.abs(lowerCoefficient ?? 0), Math.abs(upperCoefficient ?? 0)) * 1.15 || 1; + const position = (value: number) => Math.max(0, Math.min(100, 50 + (value / scale) * 50)); + const left = lowerCoefficient === null ? 50 : position(lowerCoefficient); + const right = upperCoefficient === null ? 50 : position(upperCoefficient); + + return { + variable, + ...splitTerm(variable), + estimateText: estimate === null ? '—' : formatShown(estimate), + coefficientText: coefficient === null ? '—' : formatSignedNumber(coefficient), + stdErrText: stdErr === null ? '—' : formatSignedNumber(stdErr), + statText: stat === null ? '—' : formatSignedNumber(stat), + ciText: lower === null || upper === null ? '—' : `${formatShown(lower)} to ${formatShown(upper)}`, + pText: formatPValue(pValue), + pValue, + estimate, + coefficient, + stdErr, + stat, + lower, + upper, + significant: pValue !== null && pValue < 0.05, + isIntercept: /^intercept$/i.test(variable), + plotLeft: left, + plotWidth: Math.max(1, right - left), + markerLeft: coefficient === null ? 50 : position(coefficient), + }; + }); + + return rows.sort((a, b) => { + if (a.isIntercept !== b.isIntercept) return a.isIntercept ? 1 : -1; + return Math.abs(b.coefficient ?? 0) - Math.abs(a.coefficient ?? 0); + }); +} + +/** Label/value pairs with blanks dropped, plus the AIC · BIC · log-likelihood glance line. */ +function fitSummary(pairs: [string, string][]): Pick { + const fitMetrics = pairs.filter(([, value]) => value !== '').map(([label, value]) => ({ label, value })); + const fitGlance = fitMetrics + .filter((metric) => ['AIC', 'BIC', 'Log-likelihood'].includes(metric.label)) + .map((metric) => `${metric.label.replace('Log-', 'log-')} ${metric.value}`) + .join(' · '); + return { fitMetrics, fitGlance }; +} + +const summary = (value: unknown) => { + const number = toFiniteNumber(value); + return number === null ? '' : formatSummaryNumber(number); +}; + +const textField = (value: unknown) => (typeof value === 'string' ? value.trim() : ''); + +const integer = (value: number | null) => (value === null ? '' : formatInteger(value)); + +function linearView(result: any, yVar: string | null): RegressionView | null { + const variables = arrayOf(result.indep_vars).map((entry) => String(entry ?? '')); + const estimates = arrayOf(result.coefficients); + if (!variables.length || !estimates.length) return null; + + const dependent = String(result.dependent_var ?? yVar ?? 'outcome'); + const rows = buildRows({ + variables, + estimates, + stdErr: arrayOf(result.std_err), + stats: arrayOf(result.t_stats), + pValues: arrayOf(result.pvalues), + lower: arrayOf(result.lower_ci), + upper: arrayOf(result.upper_ci), + }); + + const rSquared = toFiniteNumber(result.r_squared); + const adjusted = toFiniteNumber(result.r_squared_adjusted); + // JSON turns Infinity/NaN into null, so a perfect fit or an undefined test survives only as Exaflow's display text. + const fStat = toFiniteNumber(result.f_stat); + const fPvalue = toFiniteNumber(result.f_pvalue); + const fStatText = fStat === null ? textField(result.f_stat_display) : formatSummaryNumber(fStat); + const fPvalueText = fPvalue === null ? textField(result.f_pvalue_display) : formatPValue(fPvalue); + const fStatNote = fStat === null ? textField(result.f_stat_note) : ''; + const rse = toFiniteNumber(result.rse); + const nObs = toFiniteNumber(result.n_obs); + const dfModel = toFiniteNumber(result.df_model); + const dfResid = toFiniteNumber(result.df_resid); + + const figures: KeyFigure[] = []; + if (rSquared !== null) { + figures.push({ + label: 'R²', + value: rSquared.toFixed(3), + note: `${Math.round(rSquared * 100)}% of the variance in ${dependent} is explained`, + }); + } + if (adjusted !== null) { + const predictorCount = rows.filter((row) => !row.isIntercept).length; + figures.push({ + label: 'Adjusted R²', + value: adjusted.toFixed(3), + note: `Corrected for ${predictorCount} predictor${predictorCount === 1 ? '' : 's'}`, + }); + } + if (fPvalueText || fStatText) { + const dfText = dfModel !== null && dfResid !== null ? `F(${formatInteger(dfModel)}, ${formatInteger(dfResid)})` : 'Model F'; + const finiteNote = `${dfText}${fStatText ? ` = ${fStatText}` : ''}`; + figures.push({ + label: 'Model F-test', + value: fPvalueText || '—', + note: fStatNote ? (fStatText ? `${fStatText}. ${fStatNote}` : fStatNote) : finiteNote, + }); + } + if (rse !== null) { + figures.push({ label: 'Residual SE', value: formatSummaryNumber(rse), note: `${dependent} points, typical prediction error` }); + } + if (nObs !== null) { + figures.push({ label: 'Observations', value: formatInteger(nObs), note: 'Rows in the fitted model' }); + } + + return { + dependent, + estimateLabel: 'Estimate', + coefficientLabel: null, + nullValue: 0, + statLabel: 't', + keyColumns: KEY_COLUMNS, + allColumns: ALL_COLUMNS, + subtitle: `Effect on ${dependent}, other predictors held constant`, + rows, + figures, + ...fitSummary([ + ['Dependent variable', dependent], + ['Observations', integer(nObs)], + ['df (model)', integer(dfModel)], + ['df (residual)', integer(dfResid)], + ['R²', rSquared === null ? '' : rSquared.toFixed(3)], + ['Adjusted R²', adjusted === null ? '' : adjusted.toFixed(3)], + ['F-statistic', fStatText], + ['p-value (F)', fPvalueText], + ['Residual std. error', summary(rse)], + ['Log-likelihood', summary(result.ll)], + ['AIC', summary(result.aic)], + ['BIC', summary(result.bic)], + ]), + }; +} + +/** Exaflow nests the logistic fit under `summary`; indep_vars and dependent_var stay top-level. */ +function logisticView(result: any, yVar: string | null, positiveClass: string | null): RegressionView | null { + const fit = result.summary; + const variables = arrayOf(result.indep_vars).map((entry) => String(entry ?? '')); + const estimates = arrayOf(fit?.coefficients); + if (!fit || !variables.length || !estimates.length) return null; + + const dependent = String(result.dependent_var ?? yVar ?? 'outcome'); + const rows = buildRows( + { + variables, + estimates, + stdErr: arrayOf(fit.stderr), + stats: arrayOf(fit.z_scores), + pValues: arrayOf(fit.pvalues), + lower: arrayOf(fit.lower_ci), + upper: arrayOf(fit.upper_ci), + }, + Math.exp, + ); + + const mcFadden = toFiniteNumber(fit.r_squared_mcf); + const coxSnell = toFiniteNumber(fit.r_squared_cs); + const ll = toFiniteNumber(fit.ll); + const ll0 = toFiniteNumber(fit.ll0); + const nObs = toFiniteNumber(fit.n_obs); + const dfModel = toFiniteNumber(fit.df_model); + const dfResid = toFiniteNumber(fit.df_resid); + const aic = toFiniteNumber(fit.aic); + // Likelihood-ratio test of the fitted model against the intercept-only model. + const lrStat = ll !== null && ll0 !== null ? 2 * (ll - ll0) : null; + const lrPvalue = lrStat !== null && dfModel !== null && dfModel > 0 ? chiSquarePValue(lrStat, dfModel) : null; + + const figures: KeyFigure[] = []; + if (mcFadden !== null) { + figures.push({ + label: 'Pseudo R² (McFadden)', + value: mcFadden.toFixed(3), + note: 'Log-likelihood gained over the intercept-only model', + }); + } + if (lrPvalue !== null && lrStat !== null) { + figures.push({ + label: 'Model LR test', + value: formatPValue(lrPvalue), + note: `χ²(${formatInteger(dfModel!)}) = ${formatSummaryNumber(lrStat)}`, + }); + } + if (aic !== null) { + figures.push({ label: 'AIC', value: formatSummaryNumber(aic), note: 'Lower fits better when comparing models' }); + } + if (nObs !== null) { + figures.push({ label: 'Observations', value: formatInteger(nObs), note: 'Rows in the fitted model' }); + } + + return { + dependent, + estimateLabel: 'Odds ratio', + coefficientLabel: 'Estimate', + nullValue: 1, + statLabel: 'z', + // Reviewer convention: the log-odds estimate with its SE, z and p first, as in a model printout; + // the odds ratio with its CI and interval sketch only with all columns. + keyColumns: ['coefficient', 'stdErr', 'stat', 'p'], + allColumns: ['coefficient', 'stdErr', 'stat', 'estimate', 'ci', 'interval', 'p'], + subtitle: `Effect on the odds ${outcomeOdds(dependent, positiveClass)}, other predictors held constant`, + rows, + figures, + ...fitSummary([ + ['Dependent variable', dependent], + ['Observations', integer(nObs)], + ['df (model)', integer(dfModel)], + ['df (residual)', integer(dfResid)], + ['Pseudo R² (McFadden)', mcFadden === null ? '' : mcFadden.toFixed(3)], + ['Pseudo R² (Cox–Snell)', coxSnell === null ? '' : coxSnell.toFixed(3)], + ['LR χ²', summary(lrStat)], + ['p-value (LR)', lrPvalue === null ? '' : formatPValue(lrPvalue)], + ['Log-likelihood', summary(ll)], + ['Null log-likelihood', summary(ll0)], + ['AIC', summary(aic)], + ['BIC', summary(fit.bic)], + ]), + }; +} + +/** "that 3m mRS good outcome is yes" when the run names its positive class, else "of 3m mRS good outcome". */ +function outcomeOdds(dependent: string, positiveClass: string | null): string { + return positiveClass ? `that ${dependent} is ${positiveClass}` : `of ${dependent}`; +} + +/** Exaflow sends grouping_var as a one-item list; older payloads as a string. */ +function groupingLabel(result: any): string { + const raw = Array.isArray(result.grouping_var) ? result.grouping_var.join(', ') : result.grouping_var; + return typeof raw === 'string' && raw ? raw : 'group'; +} + +/** Exaflow echoes integer categories as floats ("2.0"); show them as entered. */ +function categoryLabel(value: unknown): string { + const text = String(value ?? ''); + return /^-?\d+\.0+$/.test(text) ? text.replace(/\.0+$/, '') : text; +} + +/** A non-converged mixed model is the first thing a reader needs to know. */ +function convergenceFigure(result: any): KeyFigure[] { + if (result.converged !== false) return []; + const iterations = toFiniteNumber(result.n_iter); + return [{ + label: 'Converged', + value: 'No', + note: `Stopped${iterations === null ? '' : ` after ${formatInteger(iterations)} iterations`}; treat estimates with caution`, + }]; +} + +type MixedKind = 'linear' | 'binary' | 'ordinal'; + +/** + * Mixed models (lmm, glmm_binary, glmm_ordinal): fixed effects in the coefficients card and the + * random intercept summarised as an ICC — the share of variance that lies between groups. For the + * logit GLMMs the residual variance is the logistic distribution's π²/3 (latent-scale ICC). + * The ordinal model is cumulative logit, P(Y ≤ j) = σ(κⱼ − η), so exp(β) multiplies the odds of + * a higher outcome category. + */ +function mixedView(result: any, yVar: string | null, kind: MixedKind, positiveClass: string | null = null): RegressionView | null { + const logit = kind !== 'linear'; + const variables = arrayOf(result.indep_vars).map((entry) => String(entry ?? '')); + const estimates = arrayOf(result.coefficients); + if (!variables.length || !estimates.length) return null; + + const dependent = String(result.dependent_var ?? yVar ?? 'outcome'); + const grouping = groupingLabel(result); + const rows = buildRows( + { + variables, + estimates, + stdErr: arrayOf(result.std_err), + stats: arrayOf(logit ? result.z_stats : result.t_stats), + pValues: arrayOf(result.pvalues), + lower: arrayOf(result.lower_ci), + upper: arrayOf(result.upper_ci), + }, + logit ? Math.exp : undefined, + ); + + const sigmaU2 = toFiniteNumber(result.sigma_u2); + const sigma2 = logit ? (Math.PI * Math.PI) / 3 : toFiniteNumber(result.sigma2); + const nObs = toFiniteNumber(result.n_obs); + const nGroups = toFiniteNumber(result.n_groups); + const aic = toFiniteNumber(result.aic); + const ll = toFiniteNumber(logit ? result.ll_laplace : result.ll_reml); + + const figures: KeyFigure[] = [...convergenceFigure(result)]; + if (sigmaU2 !== null && sigma2 !== null && sigmaU2 + sigma2 > 0) { + const icc = sigmaU2 / (sigmaU2 + sigma2); + figures.push({ + label: 'ICC', + value: icc.toFixed(3), + note: `${(icc * 100).toFixed(1)}% of the ${logit ? 'latent ' : ''}variance lies between groups`, + }); + } + if (nGroups !== null) figures.push({ label: 'Groups', value: formatInteger(nGroups), note: `Levels of ${grouping}` }); + if (nObs !== null) figures.push({ label: 'Observations', value: formatInteger(nObs), note: 'Rows in the fitted model' }); + if (aic !== null) figures.push({ label: 'AIC', value: formatSummaryNumber(aic), note: 'Lower fits better when comparing models' }); + + return { + dependent, + estimateLabel: logit ? 'Odds ratio' : 'Estimate', + coefficientLabel: logit ? 'Log-odds' : null, + nullValue: logit ? 1 : 0, + statLabel: logit ? 'z' : 't', + // The linear mixed model reads by its standard error rather than the CI. + keyColumns: logit ? KEY_COLUMNS : ['estimate', 'stdErr', 'interval', 'p'], + allColumns: logit ? ALL_RATIO_COLUMNS : ALL_COLUMNS, + subtitle: `${ + kind === 'ordinal' + ? `Effect on the odds of a higher ${dependent} category` + : logit ? `Effect on the odds ${outcomeOdds(dependent, positiveClass)}` : `Effect on ${dependent}` + }, other predictors held constant, with a random intercept per ${grouping}`, + rows, + figures, + ...fitSummary([ + ['Dependent variable', dependent], + ['Grouping variable', grouping], + ['Category order', Array.isArray(result.category_order) ? result.category_order.map(categoryLabel).join(' < ') : ''], + ['Cutpoints', Array.isArray(result.cutpoints) ? result.cutpoints.map((value: unknown) => summary(value) || '—').join(', ') : ''], + ['Observations', integer(nObs)], + ['Groups', integer(nGroups)], + ['df (model)', integer(toFiniteNumber(result.df_model))], + ['df (residual)', integer(toFiniteNumber(result.df_resid))], + ['Residual variance', logit ? '' : summary(result.sigma2)], + ['Random intercept variance', summary(sigmaU2)], + [logit ? 'Laplace log-likelihood' : 'REML log-likelihood', summary(ll)], + ['AIC', summary(aic)], + ['BIC', summary(result.bic)], + ['Converged', result.converged === undefined ? '' : result.converged ? 'Yes' : 'No'], + ]), + }; +} + +/** + * Cox regression (classical and stacked): hazard ratios in the coefficients card, led by how + * much information the model had — events, not rows, limit a survival model. + */ +function coxView(result: any, yVar: string | null): RegressionView | null { + const fit = result.summary; + const variables = arrayOf(result.indep_vars).map((entry) => String(entry ?? '')); + const estimates = arrayOf(fit?.coefficients); + if (!fit || !variables.length || !estimates.length) return null; + + const time = String(result.dependent_var ?? yVar ?? 'time'); + const event = String(result.event_var ?? 'the event'); + const rows = buildRows( + { + variables, + estimates, + stdErr: arrayOf(fit.std_err), + stats: arrayOf(fit.z_scores), + pValues: arrayOf(fit.pvalues), + lower: arrayOf(fit.lower_ci), + upper: arrayOf(fit.upper_ci), + }, + Math.exp, + ).filter((row) => !row.isIntercept); + + const events = toFiniteNumber(fit.n_events); + const participants = toFiniteNumber(fit.n_obs); + const covariates = toFiniteNumber(fit.n_covariates); + const mcFadden = toFiniteNumber(fit.r_squared_mcf); + + const figures: KeyFigure[] = [...convergenceFigure(fit)]; + if (events !== null) { + figures.push({ + label: 'Events', + value: formatInteger(events), + note: participants ? `${((events / participants) * 100).toFixed(1)}% of ${formatInteger(participants)} participants` : undefined, + }); + } + if (events !== null && covariates) { + // The usual rule of thumb asks for about ten events per covariate. + const perCovariate = events / covariates; + figures.push({ + label: 'Events per covariate', + value: perCovariate.toFixed(1), + note: perCovariate < 10 ? 'Below the usual 10; estimates may be unstable' : `${formatInteger(covariates)} covariates in the model`, + }); + } + if (mcFadden !== null) { + figures.push({ label: 'Pseudo R² (McFadden)', value: mcFadden.toFixed(3), note: 'Stacked model against its intercept-only fit' }); + } + + return { + dependent: event, + estimateLabel: 'Hazard ratio', + coefficientLabel: 'Log hazard', + nullValue: 1, + statLabel: 'z', + keyColumns: KEY_COLUMNS, + allColumns: ALL_RATIO_COLUMNS, + subtitle: `Effect on the hazard of ${event} over ${time}, other predictors held constant`, + rows, + figures, + ...fitSummary([ + ['Follow-up time', time], + ['Event indicator', event], + ['Participants', integer(participants)], + ['Events', integer(events)], + ['Covariates', integer(covariates)], + ['Distinct event times', integer(toFiniteNumber(fit.n_unique_event_times))], + ['Analysis rows', integer(toFiniteNumber(fit.n_stacked_rows))], + ['Tied events', typeof fit.ties === 'string' ? fit.ties : ''], + ['Pseudo R² (Cox–Snell)', toFiniteNumber(fit.r_squared_cs) === null ? '' : Number(fit.r_squared_cs).toFixed(3)], + ['Pseudo R² (McFadden)', mcFadden === null ? '' : mcFadden.toFixed(3)], + ['Log-likelihood', summary(fit.ll)], + ['AIC', summary(fit.aic)], + ['BIC', summary(fit.bic)], + ['Converged', fit.converged === undefined ? '' : fit.converged ? 'Yes' : 'No'], + ]), + }; +} + +/** The coefficient/fit view for a result, or null when the algorithm has none or the payload is incomplete. */ +export function buildRegressionView( + algorithm: string | null, + result: any, + yVar: string | null, + positiveClass: string | null = null, +): RegressionView | null { + if (!result || typeof result !== 'object') return null; + if (algorithm === 'linear_regression') return linearView(result, yVar); + if (algorithm === 'logistic_regression') return logisticView(result, yVar, positiveClass); + if (algorithm === 'lmm') return mixedView(result, yVar, 'linear'); + if (algorithm === 'glmm_binary') return mixedView(result, yVar, 'binary', positiveClass); + if (algorithm === 'glmm_ordinal') return mixedView(result, yVar, 'ordinal'); + if (algorithm === 'cox_regression_classical' || algorithm === 'cox_regression_stacked') return coxView(result, yVar); + return null; +} diff --git a/src/app/pages/experiment-studio/visualisations/result-format.ts b/src/app/pages/experiment-studio/visualisations/result-format.ts new file mode 100644 index 00000000..a7fdd824 --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/result-format.ts @@ -0,0 +1,121 @@ +/** Number formatting shared by the summary views (key figures, coefficient rows). */ + +export function toFiniteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) { + return Number(value); + } + return null; +} + +/** Typographic minus, 3 decimals below 1 and 2 above. */ +export function formatSignedNumber(value: number): string { + const sign = value < 0 ? '−' : ''; + const abs = Math.abs(value); + return `${sign}${abs.toFixed(abs < 1 ? 3 : 2)}`; +} + +/** Narrow no-break space between digit groups (27 308): the SI/ISO 80000 separator, which reads + * the same in English and in locales where a comma is the decimal mark, and never wraps. */ +export const THOUSANDS_SEPARATOR = '\u202f'; + +/** An en-US formatted number with its grouping commas swapped for the separator: "1,065,587.5" → "1 065 587.5". */ +export function groupThousands(text: string): string { + return text.replace(/,/g, THOUSANDS_SEPARATOR); +} + +export function formatInteger(value: number): string { + return groupThousands(value.toLocaleString('en-US')); +} + +/** Grouped integers from 1 000 up, signed decimals below. */ +export function formatSummaryNumber(value: number): string { + if (!Number.isFinite(value)) return ''; + if (Math.abs(value) >= 1000) return formatInteger(Math.round(value)); + return formatSignedNumber(value); +} + +/** Ratios (odds, hazard) at 3 significant digits by default: 0.725, 1.07, 904, then grouped integers from 1 000. */ +export function formatRatio(value: number, digits = 3): string { + if (!Number.isFinite(value)) return ''; + if (value >= 1000) return formatInteger(Math.round(value)); + return value.toPrecision(digits); +} + +/** The fewest significant digits (3 to 6) at which a ratio's CI bounds read differently, + * so a tight interval such as 1.0345–1.0358 does not print as "1.04 to 1.04". */ +export function ratioDigits(lower: number | null, upper: number | null): number { + if (lower === null || upper === null) return 3; + for (let digits = 3; digits < 6; digits++) { + if (formatRatio(lower, digits) !== formatRatio(upper, digits)) return digits; + } + return 6; +} + +/** APA style: `< 0.001` instead of 0, 1.1e-16 or 3.9e-238 — values that small are float noise. */ +export function formatPValue(value: number | null): string { + if (value === null) return '—'; + if (value < 0.001) return '< 0.001'; + return value.toFixed(3); +} + +/** ln Γ(x), Lanczos approximation (g = 7), accurate to ~15 digits for x > 0. */ +function logGamma(x: number): number { + const c = [ + 0.99999999999980993, 676.5203681218851, -1259.1392167224028, 771.32342877765313, + -176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6, + 1.5056327351493116e-7, + ]; + if (x < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * x)) - logGamma(1 - x); + const shifted = x - 1; + const t = shifted + 7.5; + let sum = c[0]; + for (let i = 1; i < c.length; i++) sum += c[i] / (shifted + i); + return 0.5 * Math.log(2 * Math.PI) + (shifted + 0.5) * Math.log(t) - t + Math.log(sum); +} + +/** Upper tail P(χ²(df) ≥ x): the regularized upper incomplete gamma Q(df/2, x/2), + * by series below a + 1 and Lentz's continued fraction above (Numerical Recipes 6.2). */ +export function chiSquarePValue(x: number, df: number): number { + if (!(x > 0) || !(df > 0)) return 1; + const a = df / 2; + const z = x / 2; + const lead = Math.exp(-z + a * Math.log(z) - logGamma(a)); + const tiny = 1e-300; + + if (z < a + 1) { + let term = 1 / a; + let sum = term; + for (let n = 1; n < 1000 && term > sum * 1e-15; n++) { + term *= z / (a + n); + sum += term; + } + return Math.min(1, Math.max(0, 1 - sum * lead)); + } + + let b = z + 1 - a; + let c = 1 / tiny; + let d = 1 / b; + let h = d; + for (let i = 1; i < 1000; i++) { + const an = -i * (i - a); + b += 2; + d = an * d + b; + if (Math.abs(d) < tiny) d = tiny; + c = b + an / c; + if (Math.abs(c) < tiny) c = tiny; + d = 1 / d; + const delta = d * c; + h *= delta; + if (Math.abs(delta - 1) < 1e-15) break; + } + return Math.min(1, Math.max(0, lead * h)); +} + +/** A raw snake_case code (a created column such as stroke_territory_cohort, or a level such as + * cluster_2) reads as words; labelled names pass through unchanged. */ +export function readable(code: string): string { + if (!/^[a-z0-9]+(_[a-z0-9]+)+$/.test(code)) return code; + const words = code.replace(/_/g, ' ').replace(/^kmeans\b/, 'K-means'); + return words.charAt(0).toUpperCase() + words.slice(1); +} diff --git a/src/app/pages/experiment-studio/visualisations/summary/key-figures.component.css b/src/app/pages/experiment-studio/visualisations/summary/key-figures.component.css new file mode 100644 index 00000000..605d0a7c --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/summary/key-figures.component.css @@ -0,0 +1,49 @@ +:host { + display: block; +} + +/* Wraps on the container's width, not the viewport's: the same strip sits in the + studio, the dashboard detail and 360px compare columns. Each cell draws its left + and top hairline as an inset-free shadow that the card's overflow clips at the edge. */ +.key-figures { + display: flex; + flex-wrap: wrap; + background: #ffffff; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 12px; + overflow: hidden; +} + +.key-figure { + display: flex; + flex: 1 1 120px; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: 14px 16px; + box-shadow: -1px 0 0 rgba(148, 163, 184, 0.22), 0 -1px 0 rgba(148, 163, 184, 0.22); +} + +.key-figure-label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); +} + +.key-figure-value { + font-size: 1.5rem; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.01em; + color: var(--text-main); + font-variant-numeric: tabular-nums; +} + +.key-figure-note { + font-size: 0.76rem; + line-height: 1.4; + color: var(--text-muted); + text-wrap: pretty; +} diff --git a/src/app/pages/experiment-studio/visualisations/summary/key-figures.component.ts b/src/app/pages/experiment-studio/visualisations/summary/key-figures.component.ts new file mode 100644 index 00000000..65266525 --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/summary/key-figures.component.ts @@ -0,0 +1,30 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +export interface KeyFigure { + label: string; + value: string; + note?: string; +} + +/** The headline numbers of a result: one strip of label / value / note cells. */ +@Component({ + selector: 'app-key-figures', + template: ` +
+ @for (figure of figures(); track figure.label) { +
+ {{ figure.label }} + {{ figure.value }} + @if (figure.note) { + {{ figure.note }} + } +
+ } +
+ `, + styleUrl: './key-figures.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class KeyFiguresComponent { + readonly figures = input.required(); +} diff --git a/src/app/pages/experiment-studio/visualisations/summary/summary-figures.spec.ts b/src/app/pages/experiment-studio/visualisations/summary/summary-figures.spec.ts new file mode 100644 index 00000000..114b5ae4 --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/summary/summary-figures.spec.ts @@ -0,0 +1,123 @@ +import { buildSummaryFigures, tablesShownAsFigures } from './summary-figures'; + +const byLabel = (figures: { label: string; value: string; note?: string }[]) => + Object.fromEntries(figures.map((figure) => [figure.label, figure])); + +describe('buildSummaryFigures', () => { + it('summarises a t-test with the CI, the t statistic and the size of Cohen\'s d', () => { + const figures = byLabel(buildSummaryFigures('ttest_independent', { + t_stat: -739.928, df: 195004, p: 0, mean_diff: -3.454, ci_lower: -3.463, ci_upper: -3.4452, cohens_d: -3.756, + })!); + + expect(figures['Mean difference'].note).toBe('95% CI −3.46 to −3.45'); + expect(figures['p-value'].value).toBe('< 0.001'); + expect(figures['p-value'].note).toBe('t(195\u202f004) = −739.93'); + expect(figures["Cohen's d"].note).toBe('Large effect'); + }); + + it('derives Cramér\'s V and the sample size from the expected counts', () => { + const figures = byLabel(buildSummaryFigures('chi_squared', { + chi2: 1085.879, p_value: 3.87e-238, dof: 1, + expected: [[16328.81, 3623.19], [6020.19, 1335.81]], + })!); + + expect(figures["Cramér's V"].value).toBe('0.199'); + expect(figures['Observations'].value).toBe('27\u202f308'); + }); + + it('reports eta squared for one-way ANOVA and partial eta squared per two-way term', () => { + const oneWay = byLabel(buildSummaryFigures('anova_oneway', { + anova_table: { n_obs: 100, y_label: 'Age', df_explained: 1, df_residual: 98, ss_explained: 10, ss_residual: 90, f_stat: 10.9, p_value: 0.0013 }, + ci_info: { means: { male: 70, female: 74 } }, + })!); + expect(oneWay['Eta squared'].value).toBe('0.100'); + expect(oneWay['Groups'].value).toBe('2'); + + const twoWay = buildSummaryFigures('anova_twoway', { + terms: ['Sex', 'Unit', 'Sex:Unit', 'Residuals'], + sum_sq: [10, 30, 5, 90], df: [1, 2, 2, 94], f_stat: [10.4, 15.7, 2.6, null], f_pvalue: [0.002, 0.0000001, 0.08, null], + })!; + expect(twoWay.map((figure) => figure.label)).toEqual(['Sex', 'Unit', 'Sex × Unit']); + expect(twoWay[2].value).toBe('0.080'); + expect(twoWay[0].note).toBe('F(1, 94) = 10.40 · partial η² 0.100'); + }); + + it('returns null for algorithms without a test summary', () => { + expect(buildSummaryFigures('kmeans', { clusters: [] })).toBeNull(); + expect(buildSummaryFigures('ttest_independent', {})).toBeNull(); + }); + + it('sets CV accuracy against the majority baseline and class-balanced scores', () => { + const figures = byLabel(buildSummaryFigures('naive_bayes_gaussian_cv', { + confusion_matrix: { data: [[18159, 19207, 0], [7414, 95038, 0], [0, 0, 0]], labels: ['no', 'yes', 'unknown'] }, + classification_summary: { + accuracy: { no: { fold0: 0.82, fold1: 0.80, average: 0.81, stdev: 0.0089 } }, + }, + })!); + + expect(figures['Accuracy'].value).toBe('81.0%'); + expect(figures['Accuracy'].note).toBe('± 0.9 pts across 2 folds · +7.7 pts over the baseline'); + expect(figures['Majority baseline'].value).toBe('73.3%'); + expect(figures['Majority baseline'].note).toBe('Accuracy of always predicting yes'); + expect(figures['Balanced accuracy'].value).toBe('70.7%'); + }); + + it('reports linear regression CV error from [mean, sd] pairs and skips undefined metrics', () => { + const figures = byLabel(buildSummaryFigures('linear_regression_cv', { + dependent_var: 'Age', + n_obs: [199461, 199462, 199463], + root_mean_sq_error: [13.4115, 0.1331], + r_squared: [null, null], + mean_abs_error: { mean: 10.65, std: 0.027 }, + f_stat: [3903.46, 325.48], + })!); + + expect(Object.keys(figures)).toEqual(['RMSE', 'MAE', 'Folds']); + expect(figures['RMSE'].value).toBe('13.41'); + expect(figures['RMSE'].note).toBe('Age units, ± 0.13 across folds'); + expect(figures['Folds'].note).toBe('~199\u202f462 training rows each'); + }); + + it('reads RMSE from the legacy mean_sq_error field', () => { + const figures = buildSummaryFigures('linear_regression_cv', { mean_sq_error: [1.2, 0.1] })!; + expect(figures[0].label).toBe('RMSE'); + }); + + it('names the compared groups when the run parameters are known', () => { + const context = { groupA: 'yes', groupB: 'no' }; + const ttest = byLabel(buildSummaryFigures('ttest_independent', { mean_diff: -3.45, ci_lower: -3.46, ci_upper: -3.44, p: 0 }, context)!); + expect(ttest['Mean difference'].note).toBe('yes − no · 95% CI −3.46 to −3.44'); + }); +}); + +describe('tablesShownAsFigures', () => { + it('covers only the t-test rows the figures print', () => { + expect(tablesShownAsFigures('ttest_paired', { t_stat: 2, df: 9, p: 0.04, mean_diff: 1.2, ci_lower: 0.1, ci_upper: 2.3, se_diff: 0.5 })).toEqual([{ + title: 'Paired T-Test', + rows: ['Mean Difference', '95% CI Lower', '95% CI Upper', 'Std. Error of Difference', 'p-value', 'T-statistic', 'Degrees of Freedom'], + }]); + // Without a mean difference there is no figure for its CI or SE, and without p none for t or df. + expect(tablesShownAsFigures('ttest_onesample', { t_stat: 2, df: 9, ci_lower: 0.1, ci_upper: 2.3, cohens_d: 0.4 })).toEqual([ + { title: 'One-Sample T-Test', rows: ["Cohen's d"] }, + ]); + }); + + it('leaves the F diagnostic and undefined linear regression CV metrics in their table', () => { + expect(tablesShownAsFigures('linear_regression_cv', { + root_mean_sq_error: [1.2, 0.1], mean_abs_error: { mean: 0.8, std: 0.05 }, r_squared: [null, null], f_stat: [12, 1], + })).toEqual([{ title: 'Error metrics', rows: ['Root mean squared error', 'Mean absolute error'] }]); + expect(tablesShownAsFigures('linear_regression_cv', { r_squared: [null, null], f_stat: [12, 1] })).toEqual([]); + }); + + it('covers ANOVA columns, SMD and the chi-squared summary only when the figures carry their values', () => { + expect(tablesShownAsFigures('anova_oneway', { anova_table: { p_value: 0.01, f_stat: 5, df_explained: 2, df_residual: 40 } })) + .toEqual([{ title: 'ANOVA Summary', columns: ['Prob>F', 'F', 'df'] }]); + expect(tablesShownAsFigures('anova_oneway', { anova_table: { f_stat: 5 } })).toEqual([]); + expect(tablesShownAsFigures('anova_twoway', { terms: ['A', 'Residual'], f_pvalue: [null, null], f_stat: [2, null], df: [1, 30] })).toEqual([]); + expect(tablesShownAsFigures('standardized_mean_difference', { comparisons: [{ group1: 'a', group2: 'b', smd: 0.2 }, { group1: 'a', group2: 'c', smd: null }] })) + .toEqual([]); + expect(tablesShownAsFigures('chi_squared', { p_value: 0.02, chi2: 6.1, dof: 2 })) + .toEqual([{ title: 'Chi-Squared Test Summary', rows: ['p-value', 'Chi-Squared statistic', 'Degrees of freedom'] }]); + expect(tablesShownAsFigures('kmeans', { clusters: [] })).toEqual([]); + }); +}); diff --git a/src/app/pages/experiment-studio/visualisations/summary/summary-figures.ts b/src/app/pages/experiment-studio/visualisations/summary/summary-figures.ts new file mode 100644 index 00000000..2c37b53c --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/summary/summary-figures.ts @@ -0,0 +1,458 @@ +import { formatInteger, formatPValue, formatRatio, formatSignedNumber, formatSummaryNumber, toFiniteNumber } from '../result-format'; +import { readConfusionMatrix } from '../charts/renderers/confusion-matrix-chart'; +import { formatTTestKey, TableTrim } from '../auto-renderer/algorithm-table-registry'; +import { KeyFigure } from './key-figures.component'; + +/** Cohen's conventions for d / SMD: 0.2 small, 0.5 medium, 0.8 large. */ +function standardizedMagnitude(value: number): string { + const abs = Math.abs(value); + if (abs < 0.2) return 'Negligible'; + if (abs < 0.5) return 'Small'; + if (abs < 0.8) return 'Medium'; + return 'Large'; +} + +/** Cohen's conventions for η²: 0.01 small, 0.06 medium, 0.14 large. */ +function etaMagnitude(value: number): string { + if (value < 0.01) return 'Negligible'; + if (value < 0.06) return 'Small'; + if (value < 0.14) return 'Medium'; + return 'Large'; +} + +/** Cohen's w thresholds (0.1 / 0.3 / 0.5) scaled by √(min(r, c) − 1), as for Cramér's V. */ +function cramerMagnitude(value: number, dfStar: number): string { + const scale = Math.sqrt(Math.max(1, dfStar)); + if (value < 0.1 / scale) return 'Negligible'; + if (value < 0.3 / scale) return 'Small'; + if (value < 0.5 / scale) return 'Medium'; + return 'Large'; +} + +const first = (result: any, keys: string[]): number | null => { + for (const key of keys) { + const value = toFiniteNumber(result?.[key]); + if (value !== null) return value; + } + return null; +}; + +/** Which groups a two-group test compared, when the caller knows the run's parameters. */ +export interface SummaryContext { + groupA: string | null; + groupB: string | null; +} + +function ttestFigures(result: any, context?: SummaryContext): KeyFigure[] { + const figures: KeyFigure[] = []; + const meanDiff = first(result, ['mean_diff']); + const lower = first(result, ['ci_lower']); + const upper = first(result, ['ci_upper']); + const t = first(result, ['t_stat']); + const df = first(result, ['df', 'dof']); + const p = first(result, ['p', 'p_value']); + const d = first(result, ['cohens_d', 'cohen_d']); + const nObs = first(result, ['n_obs']); + const se = first(result, ['se_diff', 'se_difference', 'std_err_diff']); + const sd = first(result, ['std']); + + if (meanDiff !== null) { + // Exaflow's independent t-test reports mean(group A) − mean(group B). + const groups = context?.groupA && context?.groupB ? `${context.groupA} − ${context.groupB}` : ''; + const ci = lower !== null && upper !== null ? `95% CI ${formatSignedNumber(lower)} to ${formatSignedNumber(upper)}` : ''; + figures.push({ + label: 'Mean difference', + value: formatSignedNumber(meanDiff), + note: [groups, ci, se === null ? '' : `SE ${se.toPrecision(2)}`].filter(Boolean).join(' · ') || undefined, + }); + } + if (p !== null) { + const stat = t === null ? '' : `t${df === null ? '' : `(${formatInteger(df)})`} = ${formatSummaryNumber(t)}`; + figures.push({ label: 'p-value', value: formatPValue(p), note: stat || undefined }); + } + if (d !== null) { + figures.push({ label: "Cohen's d", value: formatSignedNumber(d), note: `${standardizedMagnitude(d)} effect` }); + } + if (nObs !== null) { + figures.push({ label: 'Observations', value: formatInteger(nObs), note: sd === null ? 'Rows in the test' : `Rows in the test · SD ${formatSignedNumber(sd)}` }); + } + return figures; +} + +function chiSquaredFigures(result: any): KeyFigure[] { + const figures: KeyFigure[] = []; + const chi2 = first(result, ['chi2']); + const dof = first(result, ['dof']); + const p = first(result, ['p_value']); + const expected: unknown[][] = Array.isArray(result?.expected) ? result.expected : []; + // Expected counts keep the observed margins, so their sum is the sample size. + const n = expected.flat().reduce((sum, cell) => sum + (toFiniteNumber(cell) ?? 0), 0); + const rows = expected.length; + const cols = Array.isArray(expected[0]) ? expected[0].length : 0; + + if (p !== null) { + const stat = chi2 === null ? undefined : `χ²${dof === null ? '' : `(${formatInteger(dof)})`} = ${formatSummaryNumber(chi2)}`; + figures.push({ label: 'p-value', value: formatPValue(p), note: stat }); + } + if (chi2 !== null && n > 0 && rows > 1 && cols > 1) { + const dfStar = Math.min(rows, cols) - 1; + const v = Math.sqrt(chi2 / (n * dfStar)); + figures.push({ label: "Cramér's V", value: v.toFixed(3), note: `${cramerMagnitude(v, dfStar)} association` }); + } + if (n > 0) { + figures.push({ label: 'Observations', value: formatInteger(Math.round(n)), note: `${rows} × ${cols} table` }); + } + return figures; +} + +function fisherFigures(result: any): KeyFigure[] { + const figures: KeyFigure[] = []; + const p = first(result, ['p_value']); + const oddsRatio = first(result, ['odds_ratio']); + if (p !== null) figures.push({ label: 'p-value', value: formatPValue(p), note: "Fisher's exact test" }); + if (oddsRatio !== null) figures.push({ label: 'Odds ratio', value: formatRatio(oddsRatio), note: 'Sample odds ratio of the 2 × 2 table' }); + return figures; +} + +function anovaOneWayFigures(result: any): KeyFigure[] { + const table = result?.anova_table; + if (!table || typeof table !== 'object') return []; + const figures: KeyFigure[] = []; + const f = first(table, ['f_stat']); + const p = first(table, ['p_value']); + const dfBetween = first(table, ['df_explained']); + const dfWithin = first(table, ['df_residual']); + const ssBetween = first(table, ['ss_explained']); + const ssWithin = first(table, ['ss_residual']); + const nObs = first(table, ['n_obs']); + const groups = Object.keys(result?.ci_info?.means ?? {}).length; + const outcome = typeof table.y_label === 'string' && table.y_label ? table.y_label : 'the outcome'; + + if (p !== null) { + const df = dfBetween !== null && dfWithin !== null ? `(${formatInteger(dfBetween)}, ${formatInteger(dfWithin)})` : ''; + figures.push({ label: 'p-value', value: formatPValue(p), note: f === null ? undefined : `F${df} = ${formatSummaryNumber(f)}` }); + } + if (ssBetween !== null && ssWithin !== null && ssBetween + ssWithin > 0) { + const eta = ssBetween / (ssBetween + ssWithin); + figures.push({ + // Spelled out: the uppercase label style turns η into Η. + label: 'Eta squared', + value: eta.toFixed(3), + note: `η², ${etaMagnitude(eta).toLowerCase()} effect · ${(eta * 100).toFixed(1)}% of the variance in ${outcome} lies between groups`, + }); + } + if (groups) figures.push({ label: 'Groups', value: formatInteger(groups), note: 'Compared in the test' }); + if (nObs !== null) figures.push({ label: 'Observations', value: formatInteger(nObs), note: 'Rows in the test' }); + return figures; +} + +/** One cell per model term: its p-value, F and partial η² against the residual. */ +function anovaTwoWayFigures(result: any): KeyFigure[] { + const terms: unknown[] = Array.isArray(result?.terms) ? result.terms : []; + const at = (key: string, index: number) => toFiniteNumber(Array.isArray(result?.[key]) ? result[key][index] : null); + const residualIndex = terms.findIndex((term) => /^residuals?$/i.test(String(term))); + const ssResidual = residualIndex >= 0 ? at('sum_sq', residualIndex) : null; + const dfResidual = residualIndex >= 0 ? at('df', residualIndex) : null; + + return terms.flatMap((term, index) => { + if (index === residualIndex) return []; + const p = at('f_pvalue', index); + if (p === null) return []; + const f = at('f_stat', index); + const df = at('df', index); + const ss = at('sum_sq', index); + const parts = [ + f === null ? '' : `F${df !== null && dfResidual !== null ? `(${formatInteger(df)}, ${formatInteger(dfResidual)})` : ''} = ${formatSummaryNumber(f)}`, + ss !== null && ssResidual !== null && ss + ssResidual > 0 ? `partial η² ${(ss / (ss + ssResidual)).toFixed(3)}` : '', + ]; + return [{ label: String(term).replace(/:/g, ' × '), value: formatPValue(p), note: parts.filter(Boolean).join(' · ') || undefined }]; + }); +} + +function smdFigures(result: any): KeyFigure[] { + const comparisons: any[] = Array.isArray(result?.comparisons) ? result.comparisons : []; + return comparisons.flatMap((comparison) => { + const smd = toFiniteNumber(comparison?.smd); + if (smd === null) return []; + return [{ + label: `${comparison.group1 ?? 'Group 1'} vs ${comparison.group2 ?? 'Group 2'}`, + value: formatSignedNumber(smd), + note: `${standardizedMagnitude(smd)} difference${Math.abs(smd) >= 0.1 ? ' · above the 0.1 balance threshold' : ''}`, + }]; + }); +} + +const percent = (value: number) => `${(value * 100).toFixed(1)}%`; + +/** Fold-average accuracy (and its SD) from either CV summary shape: + * naive Bayes `classification_summary.accuracy[class].{average, stdev, fold*}`, + * logistic regression `summary.{row_names, accuracy[]}`. */ +function foldAccuracy(result: any): { mean: number; sd: number | null; folds: number } | null { + const perClass = result?.classification_summary?.accuracy; + if (perClass && typeof perClass === 'object') { + const first = Object.values(perClass)[0] as any; + const mean = toFiniteNumber(first?.average); + if (mean === null) return null; + return { mean, sd: toFiniteNumber(first?.stdev), folds: Object.keys(first).filter((key) => key.startsWith('fold')).length }; + } + const rows: unknown[] = Array.isArray(result?.summary?.row_names) ? result.summary.row_names : []; + const accuracy: unknown[] = Array.isArray(result?.summary?.accuracy) ? result.summary.accuracy : []; + const mean = toFiniteNumber(accuracy[rows.indexOf('average')]); + if (mean === null) return null; + return { + mean, + sd: toFiniteNumber(accuracy[rows.indexOf('stdev')]), + folds: rows.filter((row) => String(row).startsWith('fold')).length, + }; +} + +/** The largest actual class of a pooled confusion matrix and its share: what always guessing it would score. */ +function majorityClass(confusion: { labels: string[]; matrix: number[][] }): { label: string; share: number } | null { + const totals = confusion.matrix.map((row) => row.reduce((sum, value) => sum + value, 0)); + const total = totals.reduce((sum, value) => sum + value, 0); + if (!(total > 0)) return null; + const index = totals.reduce((best, value, i) => (value > totals[best] ? i : best), 0); + return { label: confusion.labels[index], share: totals[index] / total }; +} + +/** + * Cross-validated classifiers: fold accuracy next to the majority-class baseline it has to beat, + * then balanced accuracy and macro F1 from the pooled confusion matrix (both weigh every class + * equally, so a large majority class cannot carry them), and the mean ROC AUC when folds report one. + */ +function classificationCvFigures(result: any): KeyFigure[] { + const figures: KeyFigure[] = []; + const accuracy = foldAccuracy(result); + const confusion = readConfusionMatrix(result?.confusion_matrix); + + const majority = confusion ? majorityClass(confusion) : null; + const baseline = majority?.share ?? null; + + if (accuracy) { + const spread = accuracy.sd === null ? '' : `± ${(accuracy.sd * 100).toFixed(1)} pts`; + const folds = accuracy.folds ? `across ${accuracy.folds} folds` : ''; + const gain = baseline === null ? '' : `${accuracy.mean >= baseline ? '+' : '−'}${(Math.abs(accuracy.mean - baseline) * 100).toFixed(1)} pts over the baseline`; + figures.push({ + label: 'Accuracy', + value: percent(accuracy.mean), + note: [[spread, folds].filter(Boolean).join(' '), gain].filter(Boolean).join(' · ') || undefined, + }); + } + + if (confusion) { + const { labels, matrix } = confusion; + const rowTotals = matrix.map((row) => row.reduce((sum, value) => sum + value, 0)); + const colTotals = labels.map((_, j) => matrix.reduce((sum, row) => sum + row[j], 0)); + const supported = labels.map((_, i) => i).filter((i) => rowTotals[i] > 0); + + if (majority) { + figures.push({ + label: 'Majority baseline', + value: percent(majority.share), + note: `Accuracy of always predicting ${majority.label}`, + }); + } + if (supported.length > 1) { + const recall = supported.map((i) => matrix[i][i] / rowTotals[i]); + const f1 = supported.map((i, k) => { + const precision = colTotals[i] > 0 ? matrix[i][i] / colTotals[i] : 0; + return precision + recall[k] > 0 ? (2 * precision * recall[k]) / (precision + recall[k]) : 0; + }); + const mean = (values: number[]) => values.reduce((sum, value) => sum + value, 0) / values.length; + figures.push({ label: 'Balanced accuracy', value: percent(mean(recall)), note: 'Mean recall over classes, pooled folds' }); + figures.push({ label: 'Macro F1', value: mean(f1).toFixed(3), note: 'Mean F1 over classes, pooled folds' }); + } + } + + // Logistic regression's positive-class precision, recall and F1 stay in its fold table + // (with their mean and SD rows): a sixth figure wraps the strip onto a second line. + const aucs = (Array.isArray(result?.roc_curves) ? result.roc_curves : []) + .map((curve: any) => toFiniteNumber(curve?.auc)) + .filter((auc: number | null): auc is number => auc !== null); + if (aucs.length) { + const mean = aucs.reduce((sum: number, auc: number) => sum + auc, 0) / aucs.length; + figures.push({ label: 'ROC AUC', value: mean.toFixed(3), note: `Mean over ${aucs.length} folds` }); + } + + return figures; +} + +/** + * Out-of-fold error for linear regression CV. Exaflow sends each metric as [mean, sd] + * (older runs { mean, std }; [null, null] when undefined). The F statistic is left out: + * it describes each training fit, not how well the model predicts held-out rows. + */ +function linearRegressionCvFigures(result: any): KeyFigure[] { + const meanSd = (raw: any): [number, number | null] | null => { + const [mean, sd] = (Array.isArray(raw) ? raw : [raw?.mean, raw?.std]).map(toFiniteNumber); + return mean === null ? null : [mean, sd]; + }; + const spread = (sd: number | null, digits: number) => (sd === null ? undefined : `± ${sd.toFixed(digits)} across folds`); + const outcome = typeof result?.dependent_var === 'string' ? result.dependent_var : 'outcome'; + const figures: KeyFigure[] = []; + + // mean_sq_error is the pre-rename name of the same RMSE value in stored experiments. + const rmse = meanSd(result?.root_mean_sq_error ?? result?.mean_sq_error); + if (rmse) figures.push({ label: 'RMSE', value: formatSummaryNumber(rmse[0]), note: `${outcome} units, ${spread(rmse[1], 2) ?? 'held-out rows'}` }); + const mae = meanSd(result?.mean_abs_error); + if (mae) figures.push({ label: 'MAE', value: formatSummaryNumber(mae[0]), note: `${outcome} units, ${spread(mae[1], 2) ?? 'held-out rows'}` }); + const r2 = meanSd(result?.r_squared); + if (r2) { + figures.push({ + label: 'Out-of-fold R²', + value: r2[0].toFixed(3), + note: `${(r2[0] * 100).toFixed(1)}% of the variance in held-out rows${r2[1] === null ? '' : `, ± ${r2[1].toFixed(3)}`}`, + }); + } + const folds: unknown[] = Array.isArray(result?.n_obs) ? result.n_obs : []; + const sizes = folds.map(toFiniteNumber).filter((n): n is number => n !== null); + if (sizes.length) { + const mean = sizes.reduce((sum, n) => sum + n, 0) / sizes.length; + figures.push({ label: 'Folds', value: formatInteger(sizes.length), note: `~${formatInteger(Math.round(mean))} training rows each` }); + } + return figures; +} + +const BUILDERS: Record KeyFigure[]> = { + linear_regression_cv: linearRegressionCvFigures, + naive_bayes_gaussian_cv: classificationCvFigures, + naive_bayes_categorical_cv: classificationCvFigures, + logistic_regression_cv: classificationCvFigures, + ttest_independent: ttestFigures, + ttest_paired: (result) => ttestFigures(result), + ttest_onesample: (result) => ttestFigures(result), + chi_squared: chiSquaredFigures, + fisher_exact: fisherFigures, + anova_oneway: anovaOneWayFigures, + anova_twoway: anovaTwoWayFigures, + standardized_mean_difference: smdFigures, +}; + +/** What a figure means, in plain language, for figures a reader may not know; shown under them. */ +export const FIGURE_NOTES: Record = { + linear_regression_cv: + 'RMSE (root mean squared error) is the typical size of a prediction error on rows the model did not see, in the outcome\'s units; ' + + 'squaring makes large misses count more. MAE (mean absolute error) is the average size of that error, so an RMSE well above the MAE points to a few large misses.', +}; + +/** The t-test rows the figures show: each key only when its figure (or that figure's note) prints it. */ +function ttestShownRows(result: any): string[] { + const has = (key: string) => toFiniteNumber(result?.[key]) !== null; + const firstKey = (keys: string[]) => keys.find(has) ?? null; + const meanDiff = has('mean_diff'); + const p = firstKey(['p', 'p_value']); + const t = has('t_stat'); + const df = firstKey(['df', 'dof']); + const d = firstKey(['cohens_d', 'cohen_d']); + const nObs = has('n_obs'); + const se = firstKey(['se_diff', 'se_difference', 'std_err_diff']); + const keys = [ + ...(meanDiff ? ['mean_diff'] : []), + ...(meanDiff && has('ci_lower') && has('ci_upper') ? ['ci_lower', 'ci_upper'] : []), + ...(meanDiff && se ? [se] : []), + ...(p ? [p] : []), + ...(p && t ? ['t_stat'] : []), + ...(p && t && df ? [df] : []), + ...(d ? [d] : []), + ...(nObs ? ['n_obs'] : []), + ...(nObs && has('std') ? ['std'] : []), + ]; + return keys.map(formatTTestKey); +} + +/** One summary table's rows the figures show, by label, each only when its value is a finite number. */ +function metricRows(result: any, rows: Array<[label: string, key: string, shown: boolean]>): string[] { + return rows.filter(([, key, shown]) => shown && toFiniteNumber(result?.[key]) !== null).map(([label]) => label); +} + +/** Linear regression CV: an error metric's row once its figure shows both the mean and the SD. + * The F diagnostic has no figure, and an undefined metric ([null, null]) keeps its row. */ +function linearRegressionCvShownRows(result: any): string[] { + const known = (raw: any) => { + const [mean, sd] = (Array.isArray(raw) ? raw : [raw?.mean, raw?.std]).map(toFiniteNumber); + return mean !== null && sd !== null; + }; + return [ + ...(known(result?.root_mean_sq_error ?? result?.mean_sq_error) ? ['Root mean squared error'] : []), + ...(known(result?.mean_abs_error) ? ['Mean absolute error'] : []), + ...(known(result?.r_squared) ? ['R-squared'] : []), + ]; +} + +/** ANOVA columns the figures show: p, F and both degrees of freedom of every model term, or none. */ +function anovaTwoWayShownColumns(result: any): string[] { + const terms: unknown[] = Array.isArray(result?.terms) ? result.terms : []; + const at = (key: string, index: number) => toFiniteNumber(Array.isArray(result?.[key]) ? result[key][index] : null); + const residual = terms.findIndex((term) => /^residuals?$/i.test(String(term))); + const modelTerms = terms.map((_, i) => i).filter((i) => i !== residual); + const complete = residual >= 0 && at('df', residual) !== null && modelTerms.length > 0 && + modelTerms.every((i) => at('f_pvalue', i) !== null && at('f_stat', i) !== null && at('df', i) !== null); + return complete ? ['df', 'F', 'Prob>F'] : []; +} + +function anovaOneWayShownColumns(result: any): string[] { + const table = result?.anova_table; + const has = (key: string) => toFiniteNumber(table?.[key]) !== null; + if (!has('p_value')) return []; + return ['Prob>F', ...(has('f_stat') ? ['F'] : []), ...(has('f_stat') && has('df_explained') && has('df_residual') ? ['df'] : [])]; +} + +function rowsTrim(title: string, rows: string[]): TableTrim[] { + return rows.length ? [{ title, rows }] : []; +} + +function columnsTrim(title: string, columns: string[]): TableTrim[] { + return columns.length ? [{ title, columns }] : []; +} + +/** + * What the figures show of each result's tables, so the result view does not repeat it on screen + * (exports keep every table). Worked out from the result itself: a row or column is covered only + * when the figures print its value, so nothing a figure leaves out is hidden with it. + */ +const SHOWN_AS_FIGURES: Record TableTrim[]> = { + ttest_independent: (result) => rowsTrim('Independent T-Test', ttestShownRows(result)), + ttest_paired: (result) => rowsTrim('Paired T-Test', ttestShownRows(result)), + ttest_onesample: (result) => rowsTrim('One-Sample T-Test', ttestShownRows(result)), + chi_squared: (result) => { + const p = toFiniteNumber(result?.p_value) !== null; + const chi2 = p && toFiniteNumber(result?.chi2) !== null; + return rowsTrim('Chi-Squared Test Summary', metricRows(result, [ + ['p-value', 'p_value', p], + ['Chi-Squared statistic', 'chi2', chi2], + ['Degrees of freedom', 'dof', chi2], + ])); + }, + fisher_exact: (result) => rowsTrim("Fisher's Exact Test Summary", metricRows(result, [ + ['p-value', 'p_value', true], + ['Odds ratio', 'odds_ratio', true], + ])), + // One figure per comparison: the table goes only when every comparison has its figure. + standardized_mean_difference: (result) => { + const comparisons: any[] = Array.isArray(result?.comparisons) ? result.comparisons : []; + return comparisons.length && comparisons.every((comparison) => toFiniteNumber(comparison?.smd) !== null) + ? [{ title: 'Standardized Mean Difference' }] + : []; + }, + linear_regression_cv: (result) => rowsTrim('Error metrics', linearRegressionCvShownRows(result)), + // F, its degrees of freedom and p are in the figures; the sums and mean squares stay. + anova_oneway: (result) => columnsTrim('ANOVA Summary', anovaOneWayShownColumns(result)), + anova_twoway: (result) => columnsTrim('Two-Way ANOVA', anovaTwoWayShownColumns(result)), + // Fold mean and SD are in the accuracy figure; the per-fold rows stay. + naive_bayes_gaussian_cv: (result) => rowsTrim('Accuracy per fold', foldAccuracy(result)?.sd != null ? ['Mean ± SD'] : []), + naive_bayes_categorical_cv: (result) => rowsTrim('Accuracy per fold', foldAccuracy(result)?.sd != null ? ['Mean ± SD'] : []), +}; + +/** The parts of a result's tables its figures already show. */ +export function tablesShownAsFigures(algorithm: string | null, result: any): TableTrim[] { + const shown = algorithm ? SHOWN_AS_FIGURES[algorithm] : undefined; + return shown && result && typeof result === 'object' ? shown(result) : []; +} + +/** Headline figures for a result (hypothesis tests, cross-validated classifiers), or null when the algorithm has none. */ +export function buildSummaryFigures(algorithm: string | null, result: any, context?: SummaryContext): KeyFigure[] | null { + const builder = algorithm ? BUILDERS[algorithm] : undefined; + if (!builder || !result || typeof result !== 'object') return null; + const figures = builder(result, context); + return figures.length ? figures : null; +} diff --git a/src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.html b/src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.html index a5c9961e..5db1e484 100644 --- a/src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.html +++ b/src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.html @@ -208,7 +208,7 @@

Results

} @else if (experimentResult()) { + [labelMap]="labelMap()" [parameters]="runParameters()" /> } @else {

No results available for this experiment yet.

} diff --git a/src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.spec.ts b/src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.spec.ts new file mode 100644 index 00000000..c145ce85 --- /dev/null +++ b/src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.spec.ts @@ -0,0 +1,68 @@ +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideEchartsCore } from 'ngx-echarts'; +import { of } from 'rxjs'; + +import { Experiment } from '../../../models/experiments-dashboard.model'; +import { ExperimentLabelService } from '../../../services/experiment-label.service'; +import { ExperimentsDashboardService } from '../../../services/experiments-dashboard.service'; +import { ExperimentStudioService } from '../../../services/experiment-studio.service'; +import { ExperimentDetailsComponent } from './experiment-detail.component'; + +describe('ExperimentDetailsComponent', () => { + const experiment: Experiment = { + id: 'run-1', + name: 'Age by sex', + dateCreated: new Date('2026-01-01T00:00:00.000Z'), + status: 'success', + algorithmName: 'ttest_independent', + author: 'Marie Curie', + authorEmail: 'marie.curie@chuv.ch', + isShared: false, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ExperimentDetailsComponent], + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + provideEchartsCore({ echarts: () => import('echarts') }), + { + provide: ExperimentsDashboardService, + useValue: { + getExperimentResult: () => of({ + result: { t_stat: -3.9, df: 120, p: 0.0001, mean_diff: -1.2, ci_lower: -1.8, ci_upper: -0.6 }, + analysis: { algorithm: { x: ['sex'], y: ['age'], parameters: { groupA: 'M', groupB: 'F' } } }, + }), + }, + }, + { provide: ExperimentLabelService, useValue: { getLabelMap: async () => ({}), getEnumMaps: async () => ({}) } }, + { + provide: ExperimentStudioService, + useValue: { + backendAlgorithms: signal({}), + algorithmAssignableVariables: () => [], + selectedFilters: () => [], + getDatasetLabelMap: () => ({}), + formatPreprocessingConfig: () => '', + formatPreprocessingEntries: () => [], + }, + }, + ], + }).compileComponents(); + }); + + it('hands the run parameters to the result, which names the groups the t-test compared', async () => { + const fixture = TestBed.createComponent(ExperimentDetailsComponent); + fixture.componentRef.setInput('selectedExperiment', experiment); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.componentInstance.runParameters()).toEqual({ groupA: 'M', groupB: 'F' }); + const figures = (fixture.nativeElement as HTMLElement).querySelector('app-key-figures'); + expect(figures?.textContent).toContain('M − F'); + }); +}); diff --git a/src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.ts b/src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.ts index 6da1d5c0..8f07e028 100644 --- a/src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.ts +++ b/src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.ts @@ -286,6 +286,8 @@ export class ExperimentDetailsComponent { ); } + readonly runParameters = computed(() => this.fullExperimentSignal()?.analysis?.algorithm?.parameters ?? null); + readonly hasStoredParameters = computed(() => Object.values(this.fullExperimentSignal()?.analysis?.algorithm?.parameters ?? {}) .some((value) => !this.isEmptyParameterValue(value)) diff --git a/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.html b/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.html index 4feb70dd..82b1e6cd 100644 --- a/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.html +++ b/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.html @@ -124,7 +124,8 @@

Compare experiments

+ [xVar]="getXVarFor(column.item.exp)" [labelMap]="getLabelMapFor(column.item.exp)" + [parameters]="column.item.state.parameters ?? null">
} @else { No results available. } diff --git a/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.spec.ts b/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.spec.ts index d7b0277c..443c8fa8 100644 --- a/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.spec.ts +++ b/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.spec.ts @@ -1,6 +1,7 @@ import { provideZonelessChangeDetection, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { EMPTY } from 'rxjs'; +import { provideEchartsCore } from 'ngx-echarts'; +import { EMPTY, of } from 'rxjs'; import { Experiment } from '../../../models/experiments-dashboard.model'; import { ExperimentLabelService } from '../../../services/experiment-label.service'; @@ -30,6 +31,7 @@ describe('ExperimentsCompareComponent', () => { let fixture: ComponentFixture; let component: ExperimentsCompareComponent; let foldersService: FakeExperimentFoldersService; + let dashboard: { experiments: unknown; getExperimentResult: (id: string) => unknown }; const root = () => fixture.nativeElement as HTMLElement; const headline = () => root().querySelector('.compare-header p')!.textContent!.trim(); @@ -52,23 +54,26 @@ describe('ExperimentsCompareComponent', () => { beforeEach(async () => { foldersService = new FakeExperimentFoldersService(); + dashboard = { experiments: signal([]), getExperimentResult: () => EMPTY }; await TestBed.configureTestingModule({ imports: [ExperimentsCompareComponent], providers: [ provideZonelessChangeDetection(), + provideEchartsCore({ echarts: () => import('echarts') }), { provide: ExperimentFoldersService, useValue: foldersService }, - { - provide: ExperimentsDashboardService, - useValue: { experiments: signal([]), getExperimentResult: () => EMPTY }, - }, + { provide: ExperimentsDashboardService, useValue: dashboard }, { provide: ExperimentLabelService, useValue: { getLabelMap: async () => ({}), getEnumMaps: async () => ({}) }, }, { provide: ExperimentStudioService, - useValue: { backendAlgorithms: signal>({ ttest: { label: 'T-test' } }) }, + useValue: { + backendAlgorithms: signal>({ ttest: { label: 'T-test' } }), + algorithmAssignableVariables: () => [], + selectedFilters: () => [], + }, }, ], }).compileComponents(); @@ -107,6 +112,19 @@ describe('ExperimentsCompareComponent', () => { }); }); + describe('results', () => { + it('hands each run its own parameters, so a t-test column names the groups it compared', () => { + dashboard.getExperimentResult = () => of({ + result: { t_stat: -3.9, df: 120, p: 0.0001, mean_diff: -1.2, ci_lower: -1.8, ci_upper: -0.6 }, + analysis: { algorithm: { parameters: { groupA: 'M', groupB: 'F' } } }, + }); + showRuns([experiment('a', 'ttest_independent')]); + fixture.detectChanges(); + + expect(root().querySelector('.compare-column app-key-figures')?.textContent).toContain('M − F'); + }); + }); + describe('columns', () => { it('gives every ungrouped run a column, grouped and ordered by its algorithm', () => { showRuns([ diff --git a/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.ts b/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.ts index 419b3013..87b519c4 100644 --- a/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.ts +++ b/src/app/pages/experiments-dashboard/experiments-compare/experiments-compare.component.ts @@ -17,6 +17,8 @@ interface CompareResultState { loading: boolean; error: string | null; result: any | null; + /** The run's algorithm parameters, so a two-group test can name its groups. */ + parameters?: Record | null; } interface CompareItem { @@ -188,6 +190,7 @@ export class ExperimentsCompareComponent implements OnDestroy { loading: false, error: null, result: res?.result ?? res, + parameters: res?.analysis?.algorithm?.parameters ?? null, }, })); }, diff --git a/src/app/services/csv-export.service.spec.ts b/src/app/services/csv-export.service.spec.ts index f6e7c5ae..228d277f 100644 --- a/src/app/services/csv-export.service.spec.ts +++ b/src/app/services/csv-export.service.spec.ts @@ -65,4 +65,25 @@ describe('CsvExportService', () => { expect(readme).not.toContain('Filters'); expect(readme).not.toContain('Preprocessing'); }); + + it('zips exact values where the screen rounds or merges them', async () => { + const service = new CsvExportService(); + let saved: { blob: Blob; name: string } | null = null; + vi.spyOn(service as any, 'download').mockImplementation((blob: any, name: any) => (saved = { blob, name })); + + await service.exportExperimentZip({ + filename: 'run', + details: { experimentName: 'run', algorithm: 'T-test', datasets: [], filters: [], preprocessing: '', mipVersion: null } as any, + tables: [ + { title: 'Summary', columns: ['Metric', 'Value'], rows: [['p-value', { text: '< 0.001', value: 1.2e-9 }]] }, + { title: 'Merged', columns: ['Category', 'A'], rows: [['x', '3 · 50.0%']], export: { columns: ['Category', 'A count', 'A share'], rows: [['x', 3, 0.5]] } }, + ], + result: null, + }); + + const zip = await JSZip.loadAsync(saved!.blob); + expect(await zip.file('tables/01_summary.csv')!.async('string')).toBe('\uFEFFMetric,Value\r\np-value,1.2e-9'); + expect(await zip.file('tables/02_merged.csv')!.async('string')).toBe('\uFEFFCategory,A count,A share\r\nx,3,0.5'); + expect(await zip.file('README.txt')!.async('string')).toContain('Merged: 1 row; columns: Category, A count, A share'); + }); }); diff --git a/src/app/services/csv-export.service.ts b/src/app/services/csv-export.service.ts index 5d6bc25a..dbed30e1 100644 --- a/src/app/services/csv-export.service.ts +++ b/src/app/services/csv-export.service.ts @@ -6,7 +6,7 @@ import { fileSlug, formatExportDate, } from '../core/export.utils'; -import { TableSpec } from '../pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry'; +import { exportTable, TableSpec } from '../pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry'; import { HISTOGRAM_RESIDUAL_LABEL, HistogramGroupSeries, @@ -69,10 +69,11 @@ export class CsvExportService { this.downloadCsv(toCsv(headers, data.map((row) => headers.map((header) => row[header]))), filename); } - /** One result table as shown on screen. */ + /** One result table with exact values: what the screen rounds (p-values, shares) is exported as sent. */ exportTableCsv(table: TableSpec, fallbackName: string): void { if (!table?.columns || !table.rows) return; - this.downloadCsv(toCsv(table.columns, table.rows), `${fileSlug(table.title || fallbackName, 'table').toLowerCase()}.csv`); + const exported = exportTable(table); + this.downloadCsv(toCsv(exported.columns, exported.rows), `${fileSlug(table.title || fallbackName, 'table').toLowerCase()}.csv`); } /** @@ -87,8 +88,9 @@ export class CsvExportService { const tableFiles = payload.tables.map((table, index) => { const title = table.title || `Results table ${index + 1}`; const name = `tables/${String(index + 1).padStart(2, '0')}_${fileSlug(title, 'table').toLowerCase()}.csv`; - zip.file(name, BOM + toCsv(table.columns, table.rows)); - return { name, title, table }; + const exported = exportTable(table); + zip.file(name, BOM + toCsv(exported.columns, exported.rows)); + return { name, title, table: exported }; }); zip.file('result.json', JSON.stringify(payload.result ?? null, null, 2)); zip.file('README.txt', this.experimentReadme(payload.details, tableFiles)); @@ -103,7 +105,7 @@ export class CsvExportService { private experimentReadme( details: ExperimentExportDetails, - tableFiles: Array<{ name: string; title: string; table: TableSpec }>, + tableFiles: Array<{ name: string; title: string; table: { columns: string[]; rows: unknown[][] } }>, ): string { const setup = filledContextRows(experimentContextRows(details)).flatMap(([label, values]) => values.length > 1 ? [`${label}:`, ...values.map((value) => ` - ${value}`)] : [`${label}: ${values[0]}`] @@ -112,7 +114,7 @@ export class CsvExportService { ...tableFiles.map(({ name, title, table }) => `${name}\n ${title}: ${table.rows.length} row${table.rows.length === 1 ? '' : 's'}; columns: ${table.columns.join(', ')}` ), - 'result.json\n The full result returned by the engine, unrounded. The tables round values for reading.', + 'result.json\n The full result returned by the engine, unrounded. The tables round most values for reading; p-values, shares and probabilities in them are exact.', ]; return [ details.experimentName, diff --git a/src/app/services/pdf-export.service.ts b/src/app/services/pdf-export.service.ts index c24c051d..0cf56c07 100644 --- a/src/app/services/pdf-export.service.ts +++ b/src/app/services/pdf-export.service.ts @@ -12,7 +12,7 @@ import { fileSlug, formatExportDate, } from '../core/export.utils'; -import { getAlgorithmTableBuilder, TableSpec } from '../pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry'; +import { exportTable, getAlgorithmTableBuilder, TableSpec } from '../pages/experiment-studio/visualisations/auto-renderer/algorithm-table-registry'; interface ExperimentPdfPayload { filename: string; @@ -181,10 +181,13 @@ class PdfReport { table(spec: TableSpec, fallbackTitle = 'Results table'): void { this.ensure(30); this.subheading(spec.title || fallbackTitle); + // Exact values, like the CSV export: the report is for checking numbers, not reading them at a glance. + const exported = exportTable(spec); autoTable(this.doc, { startY: this.y, - head: [spec.columns ?? []], - body: (spec.rows ?? []).map((row) => row.map((cell) => (cell === null || cell === undefined ? '' : String(cell)))), + head: [exported.columns ?? []], + // jsPDF's built-in Helvetica has no narrow no-break space (the on-screen thousands separator). + body: exported.rows.map((row) => row.map((cell) => (cell === null || cell === undefined ? '' : String(cell).replace(/\u202f/g, ' ')))), margin: { left: this.margin.left, right: this.margin.right, top: this.margin.top, bottom: this.margin.bottom }, styles: { fontSize: 8, cellPadding: 1.8, lineColor: [226, 232, 240], lineWidth: 0.1 }, headStyles: { fillColor: BRAND, textColor: 255, fontStyle: 'bold' }, diff --git a/src/test-setup.ts b/src/test-setup.ts index 04ec5b76..f2a23a60 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -1,5 +1,4 @@ const noisyMessagePatterns = [ - /^geo3D exists\.?$/, /^No algorithm selected\.?$/, /^✘ mock_algo:/, /^✘ mock_notblank_algo:/,