Skip to content

Commit 322ea86

Browse files
committed
fix: per-row normalize class heatmap so log transform stays visible
Global max-abs scaling let a single high-variance row flatten all others to white on the log transform (where most features are clipped near log(epsilon)). Switch to per-row normalization: each row is centered then divided by its own max-abs, so every feature uses the full divergent colorscale independently of scale.
1 parent 76db6ff commit 322ea86

1 file changed

Lines changed: 19 additions & 22 deletions

File tree

frontend/src/views/DataTab.vue

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,39 +1332,36 @@ async function renderClassHeatmapChart() {
13321332
if (!classHeatmapChartEl.value || !classHeatmapData.value || classHeatmapData.value.matrix.length === 0) return
13331333
const d = classHeatmapData.value
13341334
1335-
// Row-center: subtract per-feature mean so we show how each class deviates
1336-
// from that feature's overall average. This makes the visualization meaningful
1337-
// for all transforms (raw, log, zscore) — it reveals which class is higher/lower
1338-
// for each feature, regardless of absolute scale.
1339-
const rowCentered = d.matrix.map(row => {
1335+
// Row-normalize: center on per-feature mean, then scale by per-row max-abs so
1336+
// each row uses the full colorscale independently. Without per-row scaling,
1337+
// a single high-variance feature (e.g. on the log scale where most features
1338+
// are clipped near log(epsilon)) can swamp the global range and flatten all
1339+
// other rows to white. Per-row scaling guarantees every row is visible.
1340+
const rowNormalized = d.matrix.map(row => {
13401341
const valid = row.filter(v => Number.isFinite(v))
1341-
if (valid.length === 0) return row
1342+
if (valid.length === 0) return row.map(() => 0)
13421343
const mean = valid.reduce((a, b) => a + b, 0) / valid.length
1343-
return row.map(v => Number.isFinite(v) ? v - mean : 0)
1344+
const centered = row.map(v => Number.isFinite(v) ? v - mean : 0)
1345+
let rowMax = 0
1346+
for (const v of centered) if (Math.abs(v) > rowMax) rowMax = Math.abs(v)
1347+
if (rowMax === 0) return centered
1348+
return centered.map(v => v / rowMax)
13441349
})
1345-
1346-
// Symmetric scale around 0
1347-
let maxAbs = 0
1348-
for (const row of rowCentered) {
1349-
for (const v of row) {
1350-
if (Number.isFinite(v) && Math.abs(v) > maxAbs) maxAbs = Math.abs(v)
1351-
}
1352-
}
1353-
if (maxAbs === 0) maxAbs = 1
1350+
const maxAbs = 1
13541351
13551352
// Divergent colorscale: blue (below mean) → white (mean) → red (above mean)
13561353
const colorscale = [
13571354
[0, '#0000b8'], [0.25, '#4472ff'], [0.5, '#f8f8f8'],
13581355
[0.75, '#ff6060'], [1, '#b80000'],
13591356
]
13601357
1361-
// Hover: show original value + deviation
1358+
// Hover: show original value (in transform units) alongside the row-normalized display value
13621359
const customdata = d.matrix.map((row, i) =>
1363-
row.map((v, j) => [v, rowCentered[i][j]])
1360+
row.map((v, j) => [v, rowNormalized[i][j]])
13641361
)
13651362
13661363
const trace = {
1367-
z: rowCentered,
1364+
z: rowNormalized,
13681365
x: d.class_labels.map(cls => `Class ${cls}`),
13691366
y: d.feature_names.map(featureLabel),
13701367
type: 'heatmap',
@@ -1373,12 +1370,12 @@ async function renderClassHeatmapChart() {
13731370
zmax: maxAbs,
13741371
zmid: 0,
13751372
customdata,
1376-
hovertemplate: 'Feature: %{y}<br>Class: %{x}<br>Value (' + d.transform + '): %{customdata[0]:.4f}<br>Δ from row mean: %{customdata[1]:.4f}<extra></extra>',
1377-
colorbar: { title: { text: 'Δ ' + d.transform }, thickness: 12 },
1373+
hovertemplate: 'Feature: %{y}<br>Class: %{x}<br>Value (' + d.transform + '): %{customdata[0]:.4g}<br>Row-normalized: %{customdata[1]:.2f}<extra></extra>',
1374+
colorbar: { title: { text: 'Row-norm' }, thickness: 12, tickvals: [-1, 0, 1], ticktext: ['low', 'mean', 'high'] },
13781375
}
13791376
13801377
const layout = chartLayout({
1381-
title: `Class mean heatmap (${d.transform}, row-centered)`,
1378+
title: `Class mean heatmap (${d.transform}, row-normalized)`,
13821379
xaxis: { title: 'Class', side: 'bottom' },
13831380
yaxis: { title: 'Feature', autorange: 'reversed', tickfont: { size: 10 } },
13841381
margin: { l: 220, r: 40, t: 50, b: 60 },

0 commit comments

Comments
 (0)