From 1fd5e2814b8887c49daf4cf6e53f30a7d8548697 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 01/15] fix(results): stop swapping Cox follow-up time and event indicator Exaflow sends the follow-up time as dependent_var (y) and the event indicator as event_var. The Cox summary table printed them the other way round. --- .../auto-renderer/algorithm-table-registry.spec.ts | 8 ++++---- .../auto-renderer/algorithm-table-registry.ts | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) 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..1cc81137 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 @@ -359,8 +359,8 @@ describe('AlgorithmTableRegistry', () => { 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, @@ -395,8 +395,8 @@ describe('AlgorithmTableRegistry', () => { 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(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']); }); 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..49c88fbd 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 @@ -290,8 +290,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)], From cace962ab1fec06cee2c2f01712dac27a82752b7 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 02/15] refactor(results): add shared number formatting for result views result-format.ts holds the p-value (APA, "< 0.001"), ratio, signed-number and integer formatting, the narrow no-break thousands separator, the chi-squared upper tail and readable() for snake_case codes, so the table registry, charts and summary views format numbers one way. escapeHtml now takes any value, for use in ECharts tooltip formatters. The rows-used count groups thousands with the same separator, and the PDF export swaps that separator for a plain space, which jsPDF's Helvetica lacks. --- src/app/core/html.utils.ts | 7 +- src/app/core/result-label.utils.ts | 5 +- .../visualisations/result-format.ts | 121 ++++++++++++++++++ src/app/services/pdf-export.service.ts | 3 +- 4 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 src/app/pages/experiment-studio/visualisations/result-format.ts 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/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/services/pdf-export.service.ts b/src/app/services/pdf-export.service.ts index c24c051d..3b88b05d 100644 --- a/src/app/services/pdf-export.service.ts +++ b/src/app/services/pdf-export.service.ts @@ -184,7 +184,8 @@ class PdfReport { autoTable(this.doc, { startY: this.y, head: [spec.columns ?? []], - body: (spec.rows ?? []).map((row) => row.map((cell) => (cell === null || cell === undefined ? '' : String(cell)))), + // jsPDF's built-in Helvetica has no narrow no-break space (the on-screen thousands separator). + body: (spec.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' }, From 43b6f968dddb05edad9b8248f1f219a75ff803ef Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 03/15] feat(charts): one series palette and house style for every chart CHART_SERIES_COLORS replaces the four-colour lists in the chart renderer and the grouped bar chart. Slots 4-8 replace #dfefe4, which nearly vanished on white. chart-palette.ts also holds the diverging heatmap scale and the cell label colour for it. ChartRendererComponent applies the house style under each chart's own options: the app font, title and subtitle ink, a white tooltip card, ECharts aria descriptions, and thin-space thousands on value axes. Lines are no longer smoothed or area-filled by default. --- .../visualisations/charts/chart-palette.ts | 30 ++++++++++ .../charts-renderer.component.spec.ts | 51 ++++++++++++++++ .../charts-renderer.component.ts | 60 +++++++++++++++++-- .../charts/renderers/grouped-bar-chart.ts | 9 +-- 4 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 src/app/pages/experiment-studio/visualisations/charts/chart-palette.ts create mode 100644 src/app/pages/experiment-studio/visualisations/charts/charts-renderer/charts-renderer.component.spec.ts 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..04620ff4 --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/charts/chart-palette.ts @@ -0,0 +1,30 @@ +/** + * 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 { + // ponytail: both colours dip to ~4.2:1 right at 0.7; a darker blue stop would close that gap. + return scalePosition >= 0.7 ? '#ffffff' : '#0f172a'; +} 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/grouped-bar-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/grouped-bar-chart.ts index ab775377..3a82e266 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,5 @@ 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'; /** * Builds a grouped bar chart for nominal variable distributions. From 1f353a5dfa8b99c68ccc9e53130279e211fcbc5d Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 04/15] fix(charts): escape labels in chart tooltips ECharts renders tooltip formatter output as HTML. Escape dataset, series, range and title labels in the grouped bar, box plot and SVM tooltips. --- .../visualisations/charts/renderers/box-plot-chart.ts | 3 ++- .../visualisations/charts/renderers/grouped-bar-chart.ts | 5 +++-- .../visualisations/charts/renderers/svm-chart.ts | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) 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/grouped-bar-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/grouped-bar-chart.ts index 3a82e266..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,5 +1,6 @@ import { EChartsOption } from 'echarts'; 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. @@ -106,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/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} `; }, From 6c0674fe8a5fe377aee453eccf61825fa8636aef Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 05/15] feat(charts): redraw the heatmaps and k-means centres for compare columns - Confusion matrix: reads both Exaflow shapes through readConfusionMatrix, drops classes absent on both sides, shades by share of the actual class and names a binary matrix after its positive class when given. - Pearson: one lower-triangle correlation heatmap; p-values and the 95% CI move into the tooltip instead of three more heatmaps. - PCA: component weights with variables as rows on the diverging scale, and a variance-explained chart with the cumulative share. - k-means: two variables as bubbles sized by cluster, three or more as a centres heatmap shaded around the size-weighted mean (replaces the 3D scatter and parallel coordinates); elbow curve marks the selected k. All four share DIVERGING_COLORS and cellLabelColor and fit a 360px compare column. --- .../charts/chart-builder.service.spec.ts | 22 +- .../chart-renderers-regressions.spec.ts | 99 ++++++- .../renderers/confusion-matrix-chart.ts | 170 +++++------ .../charts/renderers/k-means-chart.ts | 273 ++++++++++-------- .../charts/renderers/matrix-chart.ts | 203 ++++++------- .../charts/renderers/pca-heatmap-chart.ts | 175 ++++++----- 6 files changed, 524 insertions(+), 418 deletions(-) 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/renderers/chart-renderers-regressions.spec.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/chart-renderers-regressions.spec.ts index dacf8b39..be6f6027 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,4 +1,4 @@ -import { buildConfusionMatrixChart } from './confusion-matrix-chart'; +import { buildConfusionMatrixChart, readConfusionMatrix } from './confusion-matrix-chart'; import { buildCVMetricsChart } from './cv-metrics-chart'; import { buildTTestChart } from './t-test-chart'; import { buildKMeansChart } from './k-means-chart'; @@ -86,12 +86,12 @@ 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', () => { @@ -139,21 +139,92 @@ describe('Chart renderers regressions', () => { expect(charts.length).toBe(1); }); - it('falls back to parallel coordinates for high-dimensional kmeans clusters', () => { + 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']); + }); + + 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('draws three or more kmeans variables as a centres heatmap shaded around the size-weighted mean', () => { const charts = buildKMeansChart({ - variables: ['v1', 'v2', 'v3', 'v4'], + variables: ['v1', 'v2', 'v3'], 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: '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).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 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('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; + + const html = chart.tooltip.formatter({ value: [0, 0, 0, 1] }); + expect(html).not.toContain(' { + 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(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('sizes two-variable kmeans bubbles by the midpoint of each size interval', () => { + const charts = buildKMeansChart({ + variables: ['Age', 'Score'], + clusters: [ + { 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, + }); + + 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', () => { 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/k-means-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/k-means-chart.ts index 45ef2561..07493614 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,8 +1,12 @@ import { EChartsOption } from 'echarts'; +import { formatInteger, formatSummaryNumber } 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 []; @@ -10,11 +14,18 @@ export function buildKMeansChart(output: any): EChartsOption[] { const names: string[] = clusters.map((c: any) => c.label); const centers: number[][] = clusters.map((c: any) => variables.map((v: string) => 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, number][], names, sizedNames, variables, sizes, sizeTexts) + : buildKMeansCentresHeatmap(centers, sizedNames, variables, sizes); if (output.elbow) { charts.push(buildKMeansElbowChart(output.elbow)); @@ -23,155 +34,178 @@ export function buildKMeansChart(output: any): EChartsOption[] { return charts; } +/** 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; +} + +/** Two variables: one bubble per centre, its area proportional to the cluster's size. */ function buildKMeans2DChart( centers: [number, number][], names: string[], + legendNames: 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, - }, - ]; -} - -function buildKMeans3DChart( - centers: [number, number, number][], - 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', + data: [[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( +/** + * 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[][], 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); + const totalWeight = weights.reduce((sum, w) => sum + w, 0); + const deviations = variables.map((_, v) => { + const mean = centers.reduce((sum, center, c) => sum + weights[c] * Number(center[v]), 0) / totalWeight; + const raw = centers.map((center) => Number(center[v]) - mean); + const largest = Math.max(...raw.map(Math.abs)); + return raw.map((value) => (largest > 0 ? value / largest : 0)); + }); + + const data = centers.flatMap((center, c) => + variables.map((_, v) => { + const shade = deviations[v][c]; + return { + value: [v, c, shade, Number(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; + 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) => 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 +213,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..01f7d1cc 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,109 @@ 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[] }; - const pickMatrix = (...keys: string[]): any => { +/** + * 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 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); - } - - // 2. P-Values Matrix + if (!correlations) return []; // 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']); - } - - // 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); - } - return charts; + const allCols: string[] = correlations.variables ?? []; + const allRows = Object.keys(correlations).filter((key) => key !== 'variables'); + if (!allCols.length || !allRows.length) return []; + + const square = allRows.length === allCols.length && allRows.every((row) => allCols.includes(row)); + const rows = square ? allCols.slice(1) : allRows; + const cols = square ? allCols.slice(0, -1) : allCols; + + const cell = (matrix: Matrix | null, row: string, col: string): number | null => { + const values = matrix?.[row]; + const index = (matrix?.variables ?? allCols).indexOf(col); + const value = Array.isArray(values) && index >= 0 ? Number(values[index]) : NaN; + return Number.isFinite(value) ? value : null; + }; + + 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 [{ + 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/pca-heatmap-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/pca-heatmap-chart.ts index e2fc9786..dbe22484 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,17 @@ 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. */ +function varianceShares(eigenvalues: unknown): number[] { + if (!Array.isArray(eigenvalues)) return []; + const values = eigenvalues.map((value) => Number(value)).filter((value) => Number.isFinite(value)); + const total = values.reduce((sum, value) => sum + value, 0); + return total > 0 ? values.map((value) => (value / total) * 100) : []; +} export function buildPCAHeatmapChart(result: any): EChartsOption[] { const matrix = result?.eigenvectors; @@ -29,140 +42,144 @@ 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] === undefined ? 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] === undefined ? 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.length) { + let running = 0; + const cumulative = shares.map((share) => (running += share)); + const round1 = (value: number) => Math.round(value * 10) / 10; 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(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: componentNames.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(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; From 8b788b91542e5ba30da83337d371224785d52e13 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 06/15] =?UTF-8?q?feat(charts):=20t-test=20interval,=20ANOV?= =?UTF-8?q?A=20=C2=B11=20SD=20whiskers,=20naive=20Bayes=20profiles=20and?= =?UTF-8?q?=20ROC=20chance=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mean-plot-chart: one interval plot shared by the one-way ANOVA group means (whiskers labelled ±1 SD, which is what ci_info carries) and a new independent t-test mean-difference chart with a zero line when the CI reaches it. - naive Bayes: horizontal prior bars labelled with share and row count, and a Gaussian class-profile chart in pooled within-class SDs. - ROC: no smoothing, a dashed chance diagonal, short fold names with the AUC in the legend, and a tooltip listing every fold. --- .../chart-renderers-regressions.spec.ts | 34 ++++- .../charts/renderers/mean-plot-chart.ts | 117 +++++++++++------ .../renderers/naive-bayes-priors-chart.ts | 118 +++++++++++++++--- .../charts/renderers/roc-curve-chart.ts | 43 +++++-- 4 files changed, 242 insertions(+), 70 deletions(-) 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 be6f6027..09798ee6 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 @@ -2,11 +2,28 @@ import { buildConfusionMatrixChart, readConfusionMatrix } from './confusion-matr import { buildCVMetricsChart } from './cv-metrics-chart'; import { buildTTestChart } from './t-test-chart'; import { buildKMeansChart } from './k-means-chart'; -import { buildNaiveBayesPriorsChart } from './naive-bayes-priors-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({ @@ -255,4 +272,19 @@ 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]]); + }); }); 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/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, }, From 8679012c632743fef7ee45025b2b04202bcb7783 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 07/15] feat(results): label k-means, outlier, SMD and mixed-model results Map variable codes to their labels in k-means variables, centre keys and profile lines, the outlier report, Gaussian naive Bayes feature names and a mixed model's grouping variable sent as a list, and map standardized mean difference groups through the x variable's enumerations. --- .../core/algorithm-result-enum-mapper.spec.ts | 51 +++++++++++++++++++ src/app/core/algorithm-result-enum-mapper.ts | 48 +++++++++++++++++ 2 files changed, 99 insertions(+) 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': From a5c23edfb9b6b4c7b6af7e2581014caa7b32609b Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 08/15] feat(results): readable result tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - One p-value style in every table (formatTableP: "< 0.001", else three decimals); Tukey p marked as the clipped bounds it is. - Large numbers stay whole instead of 1.066e+6; on screen thousands are grouped with a thin space and negatives get a typographic minus, while CSV keeps plain numbers. - Name and prose columns align left, with a gap before every column and no mid-word breaks. - Naive Bayes CV: per-class precision/recall/F1 as mean ± SD with support, accuracy per fold, and unused classes dropped. Categorical naive Bayes: counts with class-conditional shares instead of logs. - PCA: variance explained, cumulative share and a component-weights table. Pearson: a correlation-pairs table sorted by strength. - Outlier report: one rule and one kept-range column. Mann-Whitney: P(A > B) next to p. k-means: drop the interpretation column. - Logistic regression CV: "Training rows" and readable fold names. --- .../algorithm-table-registry.spec.ts | 40 ++- .../auto-renderer/algorithm-table-registry.ts | 327 +++++++++++------- .../auto-renderer/auto-renderer.component.css | 17 +- .../auto-renderer.component.html | 4 +- .../auto-renderer.component.spec.ts | 10 + .../auto-renderer/auto-renderer.component.ts | 42 +-- 6 files changed, 271 insertions(+), 169 deletions(-) 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 1cc81137..e28fcc2c 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 @@ -170,7 +170,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 +181,10 @@ 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(tables[0].rows[0]).toEqual(['0', '50', '49.7%']); }); it('renders pca summary and keeps pca_with_transformation alias', () => { @@ -224,12 +227,20 @@ 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(['p-value', '< 0.001']); expect(tables[0].rows).toContainEqual(['z-score', '4.454']); + expect(tables[0].rows).toContainEqual(['P(A > B)', '0.14']); expect(tables[0].rows).toContainEqual(['Group A sample size', '15']); expect(tables[0].rows).toContainEqual(['Group B sample size', '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(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', () => { const tables = AlgorithmTableRegistry['standardized_mean_difference']({ comparisons: [ @@ -306,10 +317,13 @@ 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('filters frontend metadata keys from ttest results', () => { @@ -353,7 +367,7 @@ describe('AlgorithmTableRegistry', () => { 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(['p-value', '< 0.001']); expect(tables[0].rows).toContainEqual(['Mean Difference', '0.068']); }); @@ -614,6 +628,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 +637,15 @@ 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(anova[0].rows[0][5]).toBe('< 0.001'); + expect(anova[1].rows[0][7]).toBe('≤ 0.001'); + }); }); 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 49c88fbd..c216e86a 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,6 +11,8 @@ 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'; export interface TableSpec { title?: string; @@ -21,20 +23,19 @@ export interface TableSpec { type TableBuilder = (result: any) => TableSpec[]; -function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1); -} - 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,10 +61,11 @@ 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): string { + const numeric = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN; + return Number.isFinite(numeric) ? formatPValue(numeric) : ''; } function formatTTestKey(key: string): string { @@ -111,13 +113,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 +135,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,13 +243,19 @@ 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): string { const numeric = Number(value); - if (!Number.isFinite(numeric)) return '—'; - if (numeric < 0.001) return '<0.001'; + if (!Number.isFinite(numeric)) return ''; + if (numeric <= 0.001) return '≤ 0.001'; + if (numeric >= 0.9) return '≥ 0.900'; return numeric.toFixed(3); } +function formatCoxPValue(value: number | undefined): string { + return formatTableP(value) || '—'; +} + function formatCoxHazardRatioCi(lower: number | undefined, upper: number | undefined): string { const low = Number(lower); const high = Number(upper); @@ -404,9 +413,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[][] = []; @@ -452,7 +462,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]), ]); @@ -734,54 +744,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))]), }); } @@ -809,7 +818,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]), ]); @@ -846,10 +855,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]), @@ -860,7 +871,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 }]; }, @@ -882,66 +894,52 @@ 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 share = (log: unknown) => { + const value = Math.exp(Number(log)); + return Number.isFinite(value) ? `${(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]), + 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 rows = categories + .map((category, c) => ({ category, c })) + .filter(({ c }) => rawClasses.some((_cls, i) => Number(counts[i]?.[c]) > 0)) + .map(({ category, c }) => [ + getEnumLabel(feature, String(category)), + ...rawClasses.map((_cls, i) => { + const count = formatInteger(Number(counts[i]?.[c])); + const probability = logProbs ? share(logProbs[i]?.[c]) : ''; + return probability ? `${count} · ${probability}` : count; + }), + ]); tables.push({ - title: `Category Counts: ${featureLabel}`, - columns: colHeaders, - rows: countRows, - layout: 'full' + title: `${getLabel(feature)} by class`, + columns: ['Category', ...classLabels], + rows, + layout: 'full', }); - - if (logProbs) { - tables.push({ - title: `Category Log Probabilities: ${featureLabel}`, - columns: colHeaders, - rows: logProbRows, - layout: 'full' - }); - } } return tables; @@ -956,12 +954,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])), + ]), }); } @@ -1055,7 +1074,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), '', ''] ] }; @@ -1070,7 +1089,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) ]) }); } @@ -1100,7 +1119,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 [{ @@ -1111,9 +1130,44 @@ 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 raw = result as any; + const pick = (...keys: string[]): any => keys.map((key) => raw?.[key]).find((m) => m && typeof m === 'object' && !Array.isArray(m)) ?? null; + const correlations = pick('correlations'); + // Backwards compatibility: saved Pearson results may use historical p-value and CI aliases. + const pValues = pick('p_values', 'p-values', 'pvalues'); + const ciLower = pick('ci_lo', 'low_confidence_intervals'); + const ciUpper = pick('ci_hi', 'high_confidence_intervals'); + + if (correlations) { + const cols: string[] = correlations.variables ?? []; + const rows = Object.keys(correlations).filter((key) => key !== 'variables'); + const square = rows.length === cols.length && rows.every((row) => cols.includes(row)); + const cell = (matrix: any, 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; + }; + // 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)), p === null ? '' : formatPValue(p)]; + }), + }); + } + } + if (typeof result.n_obs === 'number') { tables.push({ title: 'Number of Observations', @@ -1212,40 +1266,39 @@ 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)); + 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 ?? {}; + 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 [ row?.variable ?? '', row?.dataset ?? '', - data.strategy ?? 'Unavailable', - data.tail ?? 'Unavailable', - formatNullableOutlierValue(data.fold), - formatNullableOutlierValue(data.lower_bound), - formatNullableOutlierValue(data.upper_bound), + `${strategy}${fold}${tail}`, + `${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', }]; }, @@ -1253,13 +1306,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)', (u / (n1 * n2)).toFixed(2)]] : []), ['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..827e23b8 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)); 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..7a1f4338 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 @@ -13,7 +13,7 @@ @for (col of table.columns; track col) { - {{ col }} + {{ col }} } @@ -24,7 +24,7 @@ + [class.ar-cell-text]="isTextColumn(table.columns[colIndex])"> {{ formatValue(cell) }} } 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..3edc34e5 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 @@ -214,4 +214,14 @@ 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'); + }); }); 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..82d40d80 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 @@ -2,7 +2,10 @@ import { ChangeDetectionStrategy, Component, effect, inject, input, signal } fro import { CsvExportService } from '../../../../services/csv-export.service'; import { getAlgorithmTableBuilder, TableSpec } 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']); @Component({ selector: 'app-auto-renderer', @@ -104,8 +107,14 @@ 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')); + } + + /** 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,29 +130,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; } exportToCSV(table: TableSpec) { this.csvExport.exportTableCsv( - { ...table, rows: (table.rows ?? []).map((row) => row.map((cell) => this.formatValue(cell))) }, + { ...table, rows: (table.rows ?? []).map((row) => row.map((cell) => this.formatValue(cell, false))) }, this.algorithm() || 'export', ); } From 1364289375a5073a79b191e746ccaa0b9599a05a Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 09/15] feat(results): leave out tables the figures or charts above already show A TableTrim names a table by title and, optionally, the columns (by header) or rows (by first cell) a summary above already shows. The auto renderer drops those on screen through trimTable, renders nothing when no table is left, and takes an optional section heading drawn only when a table remains. Exports still read the untrimmed registry. --- .../algorithm-table-registry.spec.ts | 12 +++++++++- .../auto-renderer/algorithm-table-registry.ts | 23 ++++++++++++++++++ .../auto-renderer/auto-renderer.component.css | 8 +++++++ .../auto-renderer.component.html | 5 +++- .../auto-renderer.component.spec.ts | 24 +++++++++++++++++++ .../auto-renderer/auto-renderer.component.ts | 15 ++++++++++-- 6 files changed, 83 insertions(+), 4 deletions(-) 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 e28fcc2c..19570eec 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,4 @@ -import { AlgorithmTableRegistry } from './algorithm-table-registry'; +import { trimTable, AlgorithmTableRegistry } from './algorithm-table-registry'; describe('AlgorithmTableRegistry', () => { it('supports linear_regression_cv metrics provided as mean/std objects', () => { @@ -648,4 +648,14 @@ describe('AlgorithmTableRegistry', () => { expect(anova[0].rows[0][5]).toBe('< 0.001'); expect(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 c216e86a..ecb59a61 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 @@ -21,6 +21,29 @@ export interface TableSpec { layout?: 'compact' | 'full'; } +/** + * 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(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 { 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 827e23b8..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 @@ -179,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 7a1f4338..6f354858 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) {
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 3edc34e5..0463d721 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 @@ -124,6 +124,30 @@ describe('AutoRendererComponent', () => { expect(tables?.[1].title).toBe('Error metrics'); }); + it('leaves out tables the page already shows above, before titling the rest', () => { + 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', 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 82d40d80..e295a414 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,6 +1,6 @@ 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 { getAlgorithmTableBuilder, TableSpec, TableTrim, trimTable } from './algorithm-table-registry'; import { EnumMaps } from '../../../../core/algorithm-result-enum-mapper'; import { groupThousands } from '../result-format'; @@ -23,6 +23,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); @@ -41,6 +46,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); @@ -63,6 +69,7 @@ export class AutoRendererComponent { yVar, xVar, fallbackTitle, + covered, }); if (key === this.lastKey && this.tableSpec()) return; @@ -70,7 +77,11 @@ 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 spec = builder(enrichedValue).flatMap((table) => { + const trim = covered.find((entry) => entry.title === table.title); + const kept = trim ? trimTable(table, trim) : table; + return kept ? [kept] : []; + }); const explicitTitle = this.getResultTitle(enrichedValue); const resultTitle = explicitTitle ?? this.getFallbackTitle(); this.tableSpec.set(this.applyResultTitle(spec, resultTitle, !!explicitTitle)); From 4f3a03fd1d92808d46dfd3b43be849056f9c3906 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 10/15] feat(results): headline figures for hypothesis tests and cross-validated models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KeyFiguresComponent draws one strip of label/value/note cells that wraps on its container. buildSummaryFigures returns them per algorithm: - t-tests: mean difference with CI and the compared groups, p with t and df, Cohen's d with its size. - chi-squared: p, Cramér's V, n from the expected counts; Fisher: p and odds ratio. - ANOVA: eta squared (one-way) and partial eta squared per term (two-way). - SMD: each comparison against the 0.1 balance threshold. - CV classifiers: accuracy against the majority baseline, balanced accuracy, macro F1, mean ROC AUC. Linear regression CV: RMSE, MAE, out-of-fold R² and fold sizes. TABLES_SHOWN_AS_FIGURES lists the table parts each set of figures covers. --- .../summary/key-figures.component.css | 49 +++ .../summary/key-figures.component.ts | 30 ++ .../summary/summary-figures.spec.ts | 91 +++++ .../visualisations/summary/summary-figures.ts | 362 ++++++++++++++++++ 4 files changed, 532 insertions(+) create mode 100644 src/app/pages/experiment-studio/visualisations/summary/key-figures.component.css create mode 100644 src/app/pages/experiment-studio/visualisations/summary/key-figures.component.ts create mode 100644 src/app/pages/experiment-studio/visualisations/summary/summary-figures.spec.ts create mode 100644 src/app/pages/experiment-studio/visualisations/summary/summary-figures.ts 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..5cd0698c --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/summary/summary-figures.spec.ts @@ -0,0 +1,91 @@ +import { buildSummaryFigures } 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'); + }); +}); 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..4192ceed --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/summary/summary-figures.ts @@ -0,0 +1,362 @@ +import { formatInteger, formatPValue, formatRatio, formatSignedNumber, formatSummaryNumber, toFiniteNumber } from '../result-format'; +import { readConfusionMatrix } from '../charts/renderers/confusion-matrix-chart'; +import { 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, + }; +} + +/** + * 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); + + // The share of the largest actual class: what always guessing that class would score. + const baseline = (() => { + if (!confusion) return null; + const totals = confusion.matrix.map((row) => row.reduce((sum, value) => sum + value, 0)); + const total = totals.reduce((sum, value) => sum + value, 0); + return total > 0 ? Math.max(...totals) / total : 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 total = rowTotals.reduce((sum, value) => sum + value, 0); + const supported = labels.map((_, i) => i).filter((i) => rowTotals[i] > 0); + + if (total > 0) { + const majority = supported.reduce((best, i) => (rowTotals[i] > rowTotals[best] ? i : best), supported[0]); + figures.push({ + label: 'Majority baseline', + value: percent(rowTotals[majority] / total), + note: `Accuracy of always predicting ${labels[majority]}`, + }); + } + 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.', +}; + +/** + * What the figures show of each result's tables, so the result view does not repeat it on screen + * (exports keep every table): a title alone covers the whole table, columns or rows only those. + */ +export const TABLES_SHOWN_AS_FIGURES: Record = { + ttest_independent: [{ title: 'Independent T-Test' }], + ttest_paired: [{ title: 'Paired T-Test' }], + ttest_onesample: [{ title: 'One-Sample T-Test' }], + chi_squared: [{ title: 'Chi-Squared Test Summary' }], + fisher_exact: [{ title: "Fisher's Exact Test Summary" }], + standardized_mean_difference: [{ title: 'Standardized Mean Difference' }], + linear_regression_cv: [{ title: 'Error metrics' }], + // F, its degrees of freedom and p are in the figures; the sums and mean squares stay. + anova_oneway: [{ title: 'ANOVA Summary', columns: ['df', 'F', 'Prob>F'] }], + anova_twoway: [{ title: 'Two-Way ANOVA', columns: ['df', 'F', 'Prob>F'] }], + // Fold means and SDs are in the figures; the per-fold rows stay. + naive_bayes_gaussian_cv: [{ title: 'Accuracy per fold', rows: ['Mean ± SD'] }], + naive_bayes_categorical_cv: [{ title: 'Accuracy per fold', rows: ['Mean ± SD'] }], +}; + +/** 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; +} From 93a6a96bf8bc6ce6036e70d362980ce407c11aaa Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 11/15] feat(results): coefficient view for regression, mixed and Cox models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildRegressionView is a pure builder for the key figures, coefficient rows and model-fit details of linear and logistic regression, LMM, binary and ordinal GLMMs and Cox regression; RegressionResultComponent renders it. - Ratio models report odds or hazard ratios with their log-scale coefficient, intervals drawn against 1, and enough digits that a tight CI does not print as "1.04 to 1.04". - Logistic regression adds the model LR test from ll and ll0; mixed models lead with non-convergence and the ICC; Cox leads with events and events per covariate. - Dummy terms read as variable and level ("Sex · Male"), created-column codes as words, and the F-test keeps Exaflow's display text when the statistic is not finite. - Column widths follow the shown columns, and the card narrows by its container for 360px compare columns. --- .../regression-result.component.css | 334 +++++++++++ .../regression-result.component.html | 103 ++++ .../regression/regression-result.component.ts | 98 ++++ .../regression/regression-view.spec.ts | 246 ++++++++ .../regression/regression-view.ts | 545 ++++++++++++++++++ 5 files changed, 1326 insertions(+) create mode 100644 src/app/pages/experiment-studio/visualisations/regression/regression-result.component.css create mode 100644 src/app/pages/experiment-studio/visualisations/regression/regression-result.component.html create mode 100644 src/app/pages/experiment-studio/visualisations/regression/regression-result.component.ts create mode 100644 src/app/pages/experiment-studio/visualisations/regression/regression-view.spec.ts create mode 100644 src/app/pages/experiment-studio/visualisations/regression/regression-view.ts 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..e98da299 --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.css @@ -0,0 +1,334 @@ +: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; + outline: none; +} + +.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; + 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; +} + +/* 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.ts b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.ts new file mode 100644 index 00000000..86559a50 --- /dev/null +++ b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.ts @@ -0,0 +1,98 @@ +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]]); + } + + /** The shown 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][] = this.columns().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; +} From 981933ae6c53cba2008d05e36ccbc45aad978cf7 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 12/15] feat(results): lead each result with its coefficient view, figures or charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AlgorithmResultComponent picks one layout: the regression view when buildRegressionView returns one, otherwise key figures, then the registry charts under their section title and note, then the tables without what those already show. "Show all values as tables" brings the covered tables back. - Chart registry entries carry a section title, an optional note and the tables they replace; the linear/logistic regression, CV metrics, t-test (paired/one-sample) and Cox charts are no longer registered, and unknown algorithms get no charts instead of a default entry. - ChartBuilderService no longer loads echarts-gl (no 3D chart is left) and keeps a saved run's own PCA variable names. - The studio, experiment detail and compare views pass the run's parameters, so two-group tests name their groups and binary models their positive class. - e2e: the coefficient row reads "Sex · Male". --- e2e/experiment-studio.flows.spec.ts | 2 +- .../algorithm-result.component.css | 420 ++---------------- .../algorithm-result.component.html | 139 +----- .../algorithm-result.component.spec.ts | 52 ++- .../algorithm-result.component.ts | 305 +++---------- .../execution-panel.component.html | 2 +- .../execution-panel.component.spec.ts | 2 +- .../charts/chart-builder.service.ts | 29 +- .../visualisations/charts/chart-registry.ts | 154 ++----- .../experiment-detail.component.html | 2 +- .../experiment-detail.component.ts | 2 + .../experiments-compare.component.html | 3 +- .../experiments-compare.component.ts | 3 + 13 files changed, 207 insertions(+), 908 deletions(-) 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/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..825f7042 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,16 +31,33 @@ letter-spacing: 0.02em; } -.results-section-lead { - margin: 0.35rem 0 0; +.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; - line-height: 1.45; - color: #64748b; - max-width: 52rem; + font-weight: 600; + cursor: pointer; } -.results-detail-section--with-chart { - padding-top: 0.25rem; +.tables-toggle:hover, +.tables-toggle:focus-visible { + background: #eef0fd; + outline: none; +} + +/* 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 */ @@ -70,8 +83,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 +173,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..f4d09f95 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..51ecf854 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,59 @@ 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'); + toggle.click(); + fixture.detectChanges(); + expect(html.querySelectorAll('.ar-card').length).toBe(1); + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + }); }); 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..c6cf1043 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 } 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, TABLES_SHOWN_AS_FIGURES } 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,22 @@ 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. */ + readonly coveredTables = computed(() => [ + ...(this.summaryFigures() ? TABLES_SHOWN_AS_FIGURES[this.algorithm()] ?? [] : []), + ...(this.renderedCharts().length ? AlgorithmChartRegistry[this.algorithm()]?.replaces ?? [] : []), + ]); + + 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/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-registry.ts b/src/app/pages/experiment-studio/visualisations/charts/chart-registry.ts index ac8212ed..e7f6f680 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,22 +7,21 @@ 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'; interface AlgorithmChartConfig { build: (input: any) => EChartsOption[]; - inputPath: string; + /** Heading of the chart section on the result page. */ + title: string; + /** What these charts already show of the result tables; left out on screen when the charts rendered. */ + replaces?: TableTrim[]; + /** How to read the charts, in plain language, shown under them. */ + note?: string; } function composeCharts(...builders: ((result: any) => EChartsOption[])[]): (result: any) => EChartsOption[] { @@ -29,121 +29,29 @@ function composeCharts(...builders: ((result: any) => EChartsOption[])[]): (resu } 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: '', - }, - - + kmeans: { build: buildKMeansChart, title: 'Cluster centres', replaces: [{ title: 'Cluster Centers' }] }, 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: '', - }, - - naive_bayes_categorical: { - build: buildNaiveBayesPriorsChart, - inputPath: '', - }, - - naive_bayes_gaussian_cv: { - build: buildNaiveBayesConfusionChart, - inputPath: '', - }, - - 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: '', - }, - - cox_regression_stacked: { - build: buildCoxHazardRatioForestChart, - inputPath: '', - }, - - default: { - build: () => [], - inputPath: '', - }, + build: composeCharts(buildNaiveBayesPriorsChart, buildNaiveBayesProfileChart), + title: 'Classes', + replaces: [{ title: 'Class Summary' }], + 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, title: 'Class priors', replaces: [{ title: 'Class Summary' }] }, + naive_bayes_gaussian_cv: { build: buildNaiveBayesConfusionChart, title: 'Classification performance', replaces: [{ title: 'Confusion Matrix (Combined)' }] }, + naive_bayes_categorical_cv: { build: buildNaiveBayesConfusionChart, title: 'Classification performance', replaces: [{ title: 'Confusion Matrix (Combined)' }] }, + // 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: [{ title: 'PCA Summary' }, { title: 'Component weights' }] }, + pca_with_transformation: { build: buildPCAHeatmapChart, title: 'Principal components', replaces: [{ title: 'PCA Summary' }, { title: 'Component weights' }] }, + 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' }, }; 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.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.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, }, })); }, From a1a13ec580ec6e7bdd461c0db922aefa77c73b2c Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 13/15] refactor(charts): remove the renderers the result views replaced The regression line, CV metrics, t-test and Cox forest charts are no longer registered: the coefficient view and key figures show those values. Their specs go with them. --- .../chart-renderers-regressions.spec.ts | 47 ---- .../cox-hazard-ratio-forest-chart.spec.ts | 53 ---- .../cox-hazard-ratio-forest-chart.ts | 241 ------------------ .../charts/renderers/cv-metrics-chart.ts | 80 ------ .../charts/renderers/regression-line-chart.ts | 136 ---------- .../charts/renderers/t-test-chart.ts | 108 -------- 6 files changed, 665 deletions(-) delete mode 100644 src/app/pages/experiment-studio/visualisations/charts/renderers/cox-hazard-ratio-forest-chart.spec.ts delete mode 100644 src/app/pages/experiment-studio/visualisations/charts/renderers/cox-hazard-ratio-forest-chart.ts delete mode 100644 src/app/pages/experiment-studio/visualisations/charts/renderers/cv-metrics-chart.ts delete mode 100644 src/app/pages/experiment-studio/visualisations/charts/renderers/regression-line-chart.ts delete mode 100644 src/app/pages/experiment-studio/visualisations/charts/renderers/t-test-chart.ts 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 09798ee6..f56e6340 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,6 +1,4 @@ import { buildConfusionMatrixChart, readConfusionMatrix } from './confusion-matrix-chart'; -import { buildCVMetricsChart } from './cv-metrics-chart'; -import { buildTTestChart } from './t-test-chart'; import { buildKMeansChart } from './k-means-chart'; import { buildTTestIntervalChart } from './mean-plot-chart'; import { buildNaiveBayesPriorsChart, buildNaiveBayesProfileChart } from './naive-bayes-priors-chart'; @@ -111,51 +109,6 @@ describe('Chart renderers regressions', () => { 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 }, - }); - - 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('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], - }); - - 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]); - }); - - it('charts RMSE from the legacy mean_sq_error field', () => { - const charts = buildCVMetricsChart({ n_obs: [100, 95], mean_sq_error: [1.2, 0.1] }); - - expect((charts[0] as any).xAxis.data).toEqual(['RMSE']); - }); - - it('accepts numeric strings in t-test CI bounds', () => { - const charts = buildTTestChart({ - mean_diff: 0.5, - ci_lower: '-0.1', - ci_upper: '1.2', - p: 0.03, - }); - - expect(charts.length).toBe(1); - }); - 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']); 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/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/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]; -} From fb5c4abf709a1f5367e0a022cd2b0a04d543a4ce Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 14:50:34 +0300 Subject: [PATCH 14/15] docs: chart house style and the result view layout DESIGN.md: the eight-slot palette, the shared heatmap scale and label colour, the house style ChartRendererComponent applies, and the 360px compare-column and no-smoothing rules. module-index.md: the regression and summary views, result-format.ts, and the rule that a table is never repeated under a summary that already shows it. --- DESIGN.md | 9 +++++++-- docs/context/module-index.md | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) 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/docs/context/module-index.md b/docs/context/module-index.md index bf2d629f..83a0128f 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: `TABLES_SHOWN_AS_FIGURES` (summary-figures) and each chart entry's `replaces` are `TableTrim`s — a title alone covers the whole table, `columns`/`rows` only those — and `trimTable` applies them on screen only when that summary rendered. 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. 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. From 803a000e59339c9aba05c799d3dcd32f854c4f33 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 15:20:03 +0300 Subject: [PATCH 15/15] fix(results): address review findings on result views - Exports carry exact values: p-values, priors, P(A > B) and Tukey p are DisplayCells (rounded text on screen, exact value exported); the naive Bayes category tables and the outlier report export one exact value per column. CSV, the ZIP and the PDF all go through exportTable. - Covering is worked out from the result: the figures hide only the rows or columns they print. Linear regression CV keeps the F diagnostic and undefined metrics; t-test, chi-squared, Fisher, ANOVA, SMD and naive Bayes CV are covered the same way. - A chart replaces a table only when that chart rendered (byChart): the k-means centres table stays under the two-variable bubble chart, the naive Bayes class summary stays without the priors chart. - The result title goes on the builder's first table before trimming. - Row labels and name columns are no longer digit-grouped. - Visible focus-visible outline on the tables toggle, the coefficients CSV link and the model-fit summary; the toggle drops aria-pressed and keeps its changing label; the key-figures wrapper gets a class. - The logistic coefficients CSV has every column, odds ratio and CI too. - PCA variance shares keep their component index; missing k-means centre values draw as missing instead of NaN; one Pearson reader and one majority baseline; stray note removed. - Remove the unused echarts-gl dependency (package.json, lockfile, angular.json, the geo3D test-log filter) and the 3D chart mentions in the visualisation guides. - Specs: exact exports, coefficients CSV, run parameters in the experiment detail and compare views, data-dependent covering and chart replaces, PCA and k-means gaps. --- angular.json | 1 - docs/context/module-index.md | 2 +- package-lock.json | 19 -- package.json | 1 - .../models/algorithm-visualization-guide.md | 5 +- src/app/models/visualization-guide.md | 5 +- .../algorithm-result.component.css | 13 +- .../algorithm-result.component.html | 4 +- .../algorithm-result.component.spec.ts | 70 +++++++- .../algorithm-result.component.ts | 11 +- .../algorithm-table-registry.spec.ts | 83 +++++++-- .../auto-renderer/algorithm-table-registry.ts | 163 ++++++++++++------ .../auto-renderer.component.html | 4 +- .../auto-renderer.component.spec.ts | 35 +++- .../auto-renderer/auto-renderer.component.ts | 33 ++-- .../visualisations/charts/chart-palette.ts | 1 - .../charts/chart-registry.spec.ts | 14 +- .../visualisations/charts/chart-registry.ts | 57 +++++- .../chart-renderers-regressions.spec.ts | 31 ++++ .../charts/renderers/k-means-chart.ts | 31 ++-- .../charts/renderers/matrix-chart.ts | 66 ++++--- .../charts/renderers/pca-heatmap-chart.ts | 29 ++-- .../regression-result.component.css | 14 +- .../regression-result.component.spec.ts | 47 +++++ .../regression/regression-result.component.ts | 7 +- .../summary/summary-figures.spec.ts | 34 +++- .../visualisations/summary/summary-figures.ts | 150 +++++++++++++--- .../experiment-detail.component.spec.ts | 68 ++++++++ .../experiments-compare.component.spec.ts | 30 +++- src/app/services/csv-export.service.spec.ts | 21 +++ src/app/services/csv-export.service.ts | 16 +- src/app/services/pdf-export.service.ts | 8 +- src/test-setup.ts | 1 - 33 files changed, 852 insertions(+), 222 deletions(-) create mode 100644 src/app/pages/experiment-studio/visualisations/regression/regression-result.component.spec.ts create mode 100644 src/app/pages/experiments-dashboard/experiment-detail/experiment-detail.component.spec.ts 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 83a0128f..3e025bd5 100644 --- a/docs/context/module-index.md +++ b/docs/context/module-index.md @@ -52,7 +52,7 @@ Notes: Browser validation often requires a backend with data models, algorithms, Purpose: Chart/table rendering for algorithm outputs, histograms, metadata browsers, and auto-rendered results. 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. `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: `TABLES_SHOWN_AS_FIGURES` (summary-figures) and each chart entry's `replaces` are `TableTrim`s — a title alone covers the whole table, `columns`/`rows` only those — and `trimTable` applies them on screen only when that summary rendered. 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. 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. +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/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/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 825f7042..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 @@ -31,6 +31,12 @@ letter-spacing: 0.02em; } +/* 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; } @@ -52,7 +58,12 @@ .tables-toggle:hover, .tables-toggle:focus-visible { background: #eef0fd; - outline: none; +} + +/* 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. */ 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 f4d09f95..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 @@ -11,7 +11,7 @@ } @else { @if (summaryFigures(); as figures) { -
+
@if (figureNote(); as note) {

{{ note }}

@@ -40,7 +40,7 @@

{{ chartTitle() } } @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 51ecf854..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 @@ -129,9 +129,77 @@ describe('AlgorithmResultComponent linear-regression review layout', () => { 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.getAttribute('aria-pressed')).toBe('true'); + 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 c6cf1043..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,14 +3,14 @@ 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 } from '../../visualisations/charts/chart-registry'; +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 { 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, TABLES_SHOWN_AS_FIGURES } from '../../visualisations/summary/summary-figures'; +import { buildSummaryFigures, FIGURE_NOTES, tablesShownAsFigures } from '../../visualisations/summary/summary-figures'; @Component({ selector: 'app-algorithm-result', @@ -104,10 +104,11 @@ export class AlgorithmResultComponent { 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. */ + /** 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() ? TABLES_SHOWN_AS_FIGURES[this.algorithm()] ?? [] : []), - ...(this.renderedCharts().length ? AlgorithmChartRegistry[this.algorithm()]?.replaces ?? [] : []), + ...(this.summaryFigures() ? tablesShownAsFigures(this.algorithm(), this.mappedResult()) : []), + ...tablesReplacedByCharts(this.algorithm(), this.renderedCharts()), ]); readonly hasSummaryAbove = computed(() => !!this.summaryFigures() || this.renderedCharts().length > 0); 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 19570eec..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 { trimTable, 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', () => { @@ -184,7 +187,24 @@ describe('AlgorithmTableRegistry', () => { 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(tables[0].rows[0]).toEqual(['0', '50', '49.7%']); + 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', () => { @@ -226,18 +246,23 @@ describe('AlgorithmTableRegistry', () => { }); expect(tables.length).toBe(1); - expect(tables[0].rows).toContainEqual(['U statistic', '163.5']); - expect(tables[0].rows).toContainEqual(['p-value', '< 0.001']); - expect(tables[0].rows).toContainEqual(['z-score', '4.454']); - expect(tables[0].rows).toContainEqual(['P(A > B)', '0.14']); - 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(tables[0].rows).toContainEqual(['p-value', '< 0.001']); + expect(shown(tables[0].rows)).toContainEqual(['p-value', '< 0.001']); expect(tables[0].rows.map((row) => row[0])).not.toContain('P(A > B)'); }); @@ -326,6 +351,27 @@ describe('AlgorithmTableRegistry', () => { 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', () => { const tables = AlgorithmTableRegistry['ttest_independent']({ t_stat: 3.888283695725533, @@ -365,10 +411,12 @@ 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.001']); - 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', () => { @@ -408,7 +456,8 @@ 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(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']); @@ -645,8 +694,10 @@ describe('AlgorithmTableRegistry', () => { min_max_per_group: { categories: [], min: [], max: [] }, } as any); - expect(anova[0].rows[0][5]).toBe('< 0.001'); - expect(anova[1].rows[0][7]).toBe('≤ 0.001'); + 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', () => { 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 ecb59a61..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 @@ -13,12 +13,51 @@ import { getFeaturewiseDescribeRows } from '../../../../core/describe-result.uti 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[][] }; +} + +/** 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 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))), + }; } /** @@ -38,7 +77,7 @@ export function trimTable(table: TableSpec, trim: TableTrim): TableSpec | null { 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(row?.[0] ?? ''))) + .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 }; @@ -86,12 +125,13 @@ function formatNullableOutlierValue(value: any): string { /** 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): string { +function formatTableP(value: unknown): DisplayCell | '' { const numeric = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN; - return Number.isFinite(numeric) ? formatPValue(numeric) : ''; + 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', @@ -267,15 +307,15 @@ function formatCoxHazardRatio(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): string { +function formatTukeyP(value: unknown): DisplayCell | '' { const numeric = Number(value); if (!Number.isFinite(numeric)) return ''; - if (numeric <= 0.001) return '≤ 0.001'; - if (numeric >= 0.9) return '≥ 0.900'; - return numeric.toFixed(3); + 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): string { +function formatCoxPValue(value: number | undefined): DisplayCell | string { return formatTableP(value) || '—'; } @@ -736,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', }); @@ -918,9 +958,13 @@ export const AlgorithmTableRegistry: Record = { 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 share = (log: unknown) => { + const probability = (log: unknown): number | null => { const value = Math.exp(Number(log)); - return Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : ''; + return Number.isFinite(value) ? value : null; + }; + const share = (log: unknown) => { + const value = probability(log); + return value === null ? '' : `${(value * 100).toFixed(1)}%`; }; // Class summary @@ -931,7 +975,7 @@ export const AlgorithmTableRegistry: Record = { rows: rawClasses.map((_cls, i) => [ classLabels[i], formatDecimal(result.class_count[i]), - share(result.class_log_prior[i]), + exactCell(probability(result.class_log_prior[i]), () => share(result.class_log_prior[i])), ]), layout: 'full', }); @@ -945,23 +989,37 @@ export const AlgorithmTableRegistry: Record = { const categories = result.categories[feature]; if (!counts || !categories) continue; - const rows = categories - .map((category, c) => ({ category, c })) - .filter(({ c }) => rawClasses.some((_cls, i) => Number(counts[i]?.[c]) > 0)) - .map(({ category, c }) => [ - getEnumLabel(feature, String(category)), - ...rawClasses.map((_cls, i) => { - const count = formatInteger(Number(counts[i]?.[c])); - const probability = logProbs ? share(logProbs[i]?.[c]) : ''; - return probability ? `${count} · ${probability}` : count; - }), - ]); + 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: `${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])] : []), + ]), + ]), + }, }); } @@ -1154,24 +1212,10 @@ export const AlgorithmTableRegistry: Record = { pearson_correlation: (result: PearsonResult) => { const tables: TableSpec[] = []; - const raw = result as any; - const pick = (...keys: string[]): any => keys.map((key) => raw?.[key]).find((m) => m && typeof m === 'object' && !Array.isArray(m)) ?? null; - const correlations = pick('correlations'); - // Backwards compatibility: saved Pearson results may use historical p-value and CI aliases. - const pValues = pick('p_values', 'p-values', 'pvalues'); - const ciLower = pick('ci_lo', 'low_confidence_intervals'); - const ciUpper = pick('ci_hi', 'high_confidence_intervals'); - - if (correlations) { - const cols: string[] = correlations.variables ?? []; - const rows = Object.keys(correlations).filter((key) => key !== 'variables'); - const square = rows.length === cols.length && rows.every((row) => cols.includes(row)); - const cell = (matrix: any, 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; - }; + 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)) @@ -1185,7 +1229,7 @@ export const AlgorithmTableRegistry: Record = { 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)), p === null ? '' : formatPValue(p)]; + return [row, col, formatDecimal(r), formatDecimal(cell(ciLower, row, col)), formatDecimal(cell(ciUpper, row, col)), formatTableP(p)]; }), }); } @@ -1302,18 +1346,23 @@ export const AlgorithmTableRegistry: Record = { 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', 'Rule', 'Kept range', 'Below', 'Above', 'Outliers', 'Share'], rows: rows.map((row: any) => { const data = row?.data ?? {}; - 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 [ row?.variable ?? '', row?.dataset ?? '', - `${strategy}${fold}${tail}`, + rule(data), `${bound(data.lower_bound)} to ${bound(data.upper_bound)}`, formatNullableOutlierValue(data.lower_outlier_count), formatNullableOutlierValue(data.upper_outlier_count), @@ -1322,6 +1371,24 @@ export const AlgorithmTableRegistry: Record = { share(data.total_outlier_percentage), ]; }), + // 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), + ]; + }), + }, }]; }, @@ -1341,7 +1408,7 @@ export const AlgorithmTableRegistry: Record = { ['U statistic', formatDecimal(result.u_stat)], ['p-value', formatTableP(result.p_value)], ['z-score', formatDecimal(result.z_score)], - ...(u !== null && n1 && n2 ? [['P(A > B)', (u / (n1 * n2)).toFixed(2)]] : []), + ...(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.html b/src/app/pages/experiment-studio/visualisations/auto-renderer/auto-renderer.component.html index 6f354858..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 @@ -23,12 +23,12 @@

{{ heading() }}

@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) }} + {{ 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 0463d721..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,7 +125,18 @@ describe('AutoRendererComponent', () => { expect(tables?.[1].title).toBe('Error metrics'); }); - it('leaves out tables the page already shows above, before titling the rest', () => { + 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' }], @@ -248,4 +260,25 @@ describe('AutoRendererComponent', () => { 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 e295a414..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,11 +1,13 @@ import { ChangeDetectionStrategy, Component, effect, inject, input, signal } from '@angular/core'; import { CsvExportService } from '../../../../services/csv-export.service'; -import { getAlgorithmTableBuilder, TableSpec, TableTrim, trimTable } 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', @@ -77,14 +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).flatMap((table) => { - const trim = covered.find((entry) => entry.title === table.title); + const tables = builder(enrichedValue); + const explicitTitle = this.getResultTitle(enrichedValue); + const resultTitle = explicitTitle ?? this.getFallbackTitle(); + // 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] : []; }); - const explicitTitle = this.getResultTitle(enrichedValue); - const resultTitle = explicitTitle ?? this.getFallbackTitle(); - this.tableSpec.set(this.applyResultTitle(spec, resultTitle, !!explicitTitle)); + this.tableSpec.set(spec); this.error.set(null); this.lastKey = key; } catch (err) { @@ -123,6 +129,15 @@ export class AutoRendererComponent { 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 { @@ -154,11 +169,9 @@ export class AutoRendererComponent { 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, false))) }, - 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-palette.ts b/src/app/pages/experiment-studio/visualisations/charts/chart-palette.ts index 04620ff4..8a0a367e 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/chart-palette.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/chart-palette.ts @@ -25,6 +25,5 @@ export const DIVERGING_COLORS = ['#e34948', '#f1f5f9', '#2b33e9']; * only takes over on the deep blue end, where it beats ink. */ export function cellLabelColor(scalePosition: number): string { - // ponytail: both colours dip to ~4.2:1 right at 0.7; a darker blue stop would close that gap. 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 e7f6f680..2ecbab17 100644 --- a/src/app/pages/experiment-studio/visualisations/charts/chart-registry.ts +++ b/src/app/pages/experiment-studio/visualisations/charts/chart-registry.ts @@ -14,22 +14,35 @@ import { buildSVMChart } from './renderers/svm-chart'; import { buildHistogramChart } from './renderers/histogram-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[]; /** Heading of the chart section on the result page. */ title: string; - /** What these charts already show of the result tables; left out on screen when the charts rendered. */ - replaces?: TableTrim[]; + /** 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, title: 'Cluster centres', replaces: [{ title: 'Cluster Centers' }] }, + // 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), title: 'Classification performance', @@ -37,21 +50,47 @@ export const AlgorithmChartRegistry: Record = { naive_bayes_gaussian: { build: composeCharts(buildNaiveBayesPriorsChart, buildNaiveBayesProfileChart), title: 'Classes', - replaces: [{ title: 'Class Summary' }], + 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, title: 'Class priors', replaces: [{ title: 'Class Summary' }] }, - naive_bayes_gaussian_cv: { build: buildNaiveBayesConfusionChart, title: 'Classification performance', replaces: [{ title: 'Confusion Matrix (Combined)' }] }, - naive_bayes_categorical_cv: { build: buildNaiveBayesConfusionChart, title: 'Classification performance', replaces: [{ title: 'Confusion Matrix (Combined)' }] }, + naive_bayes_categorical: { + build: buildNaiveBayesPriorsChart, + title: 'Class priors', + replaces: [{ title: 'Class Summary', byChart: 'Class Prior Probabilities' }], + }, + naive_bayes_gaussian_cv: { + build: buildNaiveBayesConfusionChart, + title: 'Classification performance', + replaces: [{ title: 'Confusion Matrix (Combined)', byChart: 'Confusion Matrix' }], + }, + naive_bayes_categorical_cv: { + build: buildNaiveBayesConfusionChart, + 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: [{ title: 'PCA Summary' }, { title: 'Component weights' }] }, - pca_with_transformation: { build: buildPCAHeatmapChart, title: 'Principal components', replaces: [{ title: 'PCA Summary' }, { title: 'Component weights' }] }, + 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' }, }; + +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; +} + +/** 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/renderers/chart-renderers-regressions.spec.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/chart-renderers-regressions.spec.ts index f56e6340..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,5 +1,6 @@ import { buildConfusionMatrixChart, readConfusionMatrix } from './confusion-matrix-chart'; import { buildKMeansChart } from './k-means-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'; @@ -240,4 +241,34 @@ describe('Chart renderers regressions', () => { 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/k-means-chart.ts b/src/app/pages/experiment-studio/visualisations/charts/renderers/k-means-chart.ts index 07493614..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,5 +1,5 @@ import { EChartsOption } from 'echarts'; -import { formatInteger, formatSummaryNumber } from '../../result-format'; +import { formatInteger, formatSummaryNumber, toFiniteNumber } from '../../result-format'; import { DIVERGING_COLORS, cellLabelColor } from '../chart-palette'; import { escapeHtml } from '../../../../../core/html.utils'; @@ -12,7 +12,8 @@ export function buildKMeansChart(output: any): EChartsOption[] { 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. @@ -24,7 +25,7 @@ export function buildKMeansChart(output: any): EChartsOption[] { // 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, sizedNames, variables, sizes, sizeTexts) + ? buildKMeans2DChart(centers as [number | null, number | null][], names, sizedNames, variables, sizes, sizeTexts) : buildKMeansCentresHeatmap(centers, sizedNames, variables, sizes); if (output.elbow) { @@ -47,7 +48,7 @@ function intervalMidpoint(interval: unknown): number | null { /** Two variables: one bubble per centre, its area proportional to the cluster's size. */ function buildKMeans2DChart( - centers: [number, number][], + centers: [number | null, number | null][], names: string[], legendNames: string[], variables: string[], @@ -88,7 +89,8 @@ function buildKMeans2DChart( series: centers.map(([x, y], i) => ({ name: legendNames[i], type: 'scatter', - data: [[x, y]], + // 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] }, @@ -104,7 +106,7 @@ function buildKMeans2DChart( * variable's overall mean (centres weighted by cluster size), scaled per column because units differ. */ function buildKMeansCentresHeatmap( - centers: number[][], + centers: (number | null)[][], names: string[], variables: string[], sizes: (number | null)[] @@ -114,19 +116,21 @@ function buildKMeansCentresHeatmap( // 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); - const totalWeight = weights.reduce((sum, w) => sum + w, 0); + // Each variable's mean over the clusters that report it; a missing centre shades neutral. const deviations = variables.map((_, v) => { - const mean = centers.reduce((sum, center, c) => sum + weights[c] * Number(center[v]), 0) / totalWeight; - const raw = centers.map((center) => Number(center[v]) - mean); - const largest = Math.max(...raw.map(Math.abs)); - return raw.map((value) => (largest > 0 ? value / largest : 0)); + 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, Number(center[v])], + value: [v, c, shade, center[v]], label: { color: cellLabelColor(shade) }, }; }) @@ -141,6 +145,7 @@ function buildKMeansCentresHeatmap( 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`; }, @@ -183,7 +188,7 @@ function buildKMeansCentresHeatmap( type: 'heatmap', data, itemStyle: { borderColor: '#ffffff', borderWidth: 2 }, - label: { show: true, fontSize: 12, formatter: (p: any) => formatSummaryNumber(p.value[3]) }, + label: { show: true, fontSize: 12, formatter: (p: any) => (p.value[3] === null ? '—' : formatSummaryNumber(p.value[3])) }, }, ], } as EChartsOption, 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 01f7d1cc..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 @@ -7,12 +7,23 @@ const AXIS_COLOR = '#475569'; type Matrix = Record & { variables?: string[] }; -/** - * 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[] { +/** 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; +} + +export function readCorrelations(result: any): CorrelationMatrices | null { const pickMatrix = (...keys: string[]): Matrix | null => { for (const key of keys) { const candidate = result?.[key]; @@ -22,26 +33,39 @@ export function buildMatrixChart(result: any): EChartsOption[] { }; const correlations = pickMatrix('correlations'); - if (!correlations) return []; - // Backwards compatibility: saved Pearson results may use historical p-value and CI aliases. - const pValues = pickMatrix('p_values', 'p-values', 'pvalues'); - const ciLower = pickMatrix('ci_lo', 'low_confidence_intervals'); - const ciUpper = pickMatrix('ci_hi', 'high_confidence_intervals'); - - const allCols: string[] = correlations.variables ?? []; - const allRows = Object.keys(correlations).filter((key) => key !== 'variables'); - if (!allCols.length || !allRows.length) return []; - - const square = allRows.length === allCols.length && allRows.every((row) => allCols.includes(row)); - const rows = square ? allCols.slice(1) : allRows; - const cols = square ? allCols.slice(0, -1) : allCols; - + 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 ?? allCols).indexOf(col); + 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, + }; +} + +/** + * 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; const data = rows.flatMap((row, i) => cols.flatMap((col, j) => { 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 dbe22484..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 @@ -5,12 +5,13 @@ import { escapeHtml } from '../../../../../core/html.utils'; const AXIS_COLOR = '#475569'; const TEXT_COLOR = '#0f172a'; -/** Share of total variance per component, in percent. */ -function varianceShares(eigenvalues: unknown): number[] { +/** 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) => Number(value)).filter((value) => Number.isFinite(value)); - const total = values.reduce((sum, value) => sum + value, 0); - return total > 0 ? values.map((value) => (value / total) * 100) : []; + 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[] { @@ -46,13 +47,13 @@ export function buildPCAHeatmapChart(result: any): EChartsOption[] { const shares = varianceShares(result?.eigenvalues); const componentNames = Array.from({ length: nComponents }, (_, i) => `PC${i + 1}`); const componentLabels = componentNames.map((name, i) => - shares[i] === undefined ? name : `${name} · ${shares[i].toFixed(1)}%` + shares[i] == null ? name : `${name} · ${shares[i]!.toFixed(1)}%` ); // 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] === undefined ? name : `${name}\n${shares[i].toFixed(1)}%` + shares[i] == null ? name : `${name}\n${shares[i]!.toFixed(1)}%` ); const heatmapData = matrix.flatMap((row: number[], component: number) => row.map((value, variable) => ({ @@ -127,10 +128,12 @@ export function buildPCAHeatmapChart(result: any): EChartsOption[] { ], } as EChartsOption); - if (shares.length) { + if (shares.some((share) => share !== null)) { let running = 0; - const cumulative = shares.map((share) => (running += share)); - const round1 = (value: number) => Math.round(value * 10) / 10; + 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: { @@ -141,13 +144,13 @@ export function buildPCAHeatmapChart(result: any): EChartsOption[] { tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, - valueFormatter: (value: any) => `${Number(value).toFixed(1)}%`, + 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', // Eigenvalue under each component, so the PCA summary table is not needed on screen. - data: componentNames.map((name, i) => { + data: varianceComponents.map((name, i) => { const eigenvalue = Number(result.eigenvalues[i]); return Number.isFinite(eigenvalue) ? `${name}\nλ ${eigenvalue.toFixed(2)}` : name; }), @@ -167,7 +170,7 @@ export function buildPCAHeatmapChart(result: any): EChartsOption[] { 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(p.value).toFixed(1)}%` }, + 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: 'Cumulative', 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 index e98da299..85186b83 100644 --- a/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.css +++ b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.css @@ -75,7 +75,12 @@ .csv-link:hover, .csv-link:focus-visible { background: #eef0fd; - outline: none; +} + +/* 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 { @@ -245,7 +250,12 @@ .model-fit-summary:hover, .model-fit-summary:focus-visible { background: #f8fafc; - outline: none; +} + +/* 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 { 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 index 86559a50..d19651b5 100644 --- a/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.ts +++ b/src/app/pages/experiment-studio/visualisations/regression/regression-result.component.ts @@ -71,12 +71,13 @@ export class RegressionResultComponent { return String(row[COLUMN_TEXT[column]]); } - /** The shown 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. */ + /** 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][] = this.columns().flatMap((column) => { + 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]]; 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 index 5cd0698c..114b5ae4 100644 --- a/src/app/pages/experiment-studio/visualisations/summary/summary-figures.spec.ts +++ b/src/app/pages/experiment-studio/visualisations/summary/summary-figures.spec.ts @@ -1,4 +1,4 @@ -import { buildSummaryFigures } from './summary-figures'; +import { buildSummaryFigures, tablesShownAsFigures } from './summary-figures'; const byLabel = (figures: { label: string; value: string; note?: string }[]) => Object.fromEntries(figures.map((figure) => [figure.label, figure])); @@ -89,3 +89,35 @@ describe('buildSummaryFigures', () => { 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 index 4192ceed..2c37b53c 100644 --- a/src/app/pages/experiment-studio/visualisations/summary/summary-figures.ts +++ b/src/app/pages/experiment-studio/visualisations/summary/summary-figures.ts @@ -1,6 +1,6 @@ import { formatInteger, formatPValue, formatRatio, formatSignedNumber, formatSummaryNumber, toFiniteNumber } from '../result-format'; import { readConfusionMatrix } from '../charts/renderers/confusion-matrix-chart'; -import { TableTrim } from '../auto-renderer/algorithm-table-registry'; +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. */ @@ -206,6 +206,15 @@ function foldAccuracy(result: any): { mean: number; sd: number | null; folds: nu }; } +/** 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 @@ -216,13 +225,8 @@ function classificationCvFigures(result: any): KeyFigure[] { const accuracy = foldAccuracy(result); const confusion = readConfusionMatrix(result?.confusion_matrix); - // The share of the largest actual class: what always guessing that class would score. - const baseline = (() => { - if (!confusion) return null; - const totals = confusion.matrix.map((row) => row.reduce((sum, value) => sum + value, 0)); - const total = totals.reduce((sum, value) => sum + value, 0); - return total > 0 ? Math.max(...totals) / total : null; - })(); + const majority = confusion ? majorityClass(confusion) : null; + const baseline = majority?.share ?? null; if (accuracy) { const spread = accuracy.sd === null ? '' : `± ${(accuracy.sd * 100).toFixed(1)} pts`; @@ -239,15 +243,13 @@ function classificationCvFigures(result: any): KeyFigure[] { 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 total = rowTotals.reduce((sum, value) => sum + value, 0); const supported = labels.map((_, i) => i).filter((i) => rowTotals[i] > 0); - if (total > 0) { - const majority = supported.reduce((best, i) => (rowTotals[i] > rowTotals[best] ? i : best), supported[0]); + if (majority) { figures.push({ label: 'Majority baseline', - value: percent(rowTotals[majority] / total), - note: `Accuracy of always predicting ${labels[majority]}`, + value: percent(majority.share), + note: `Accuracy of always predicting ${majority.label}`, }); } if (supported.length > 1) { @@ -333,26 +335,120 @@ export const FIGURE_NOTES: Record = { '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): a title alone covers the whole table, columns or rows only those. + * (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. */ -export const TABLES_SHOWN_AS_FIGURES: Record = { - ttest_independent: [{ title: 'Independent T-Test' }], - ttest_paired: [{ title: 'Paired T-Test' }], - ttest_onesample: [{ title: 'One-Sample T-Test' }], - chi_squared: [{ title: 'Chi-Squared Test Summary' }], - fisher_exact: [{ title: "Fisher's Exact Test Summary" }], - standardized_mean_difference: [{ title: 'Standardized Mean Difference' }], - linear_regression_cv: [{ title: 'Error metrics' }], +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: [{ title: 'ANOVA Summary', columns: ['df', 'F', 'Prob>F'] }], - anova_twoway: [{ title: 'Two-Way ANOVA', columns: ['df', 'F', 'Prob>F'] }], - // Fold means and SDs are in the figures; the per-fold rows stay. - naive_bayes_gaussian_cv: [{ title: 'Accuracy per fold', rows: ['Mean ± SD'] }], - naive_bayes_categorical_cv: [{ title: 'Accuracy per fold', rows: ['Mean ± SD'] }], + 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; 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/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/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 3b88b05d..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,11 +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 ?? []], + head: [exported.columns ?? []], // jsPDF's built-in Helvetica has no narrow no-break space (the on-screen thousands separator). - body: (spec.rows ?? []).map((row) => row.map((cell) => (cell === null || cell === undefined ? '' : String(cell).replace(/\u202f/g, ' ')))), + 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:/,