Skip to content

Commit 04d030c

Browse files
committed
feat: add class-mean heatmap viz with z-score transform by default
Backend: - compute_class_heatmap() computes features × class means with optional transform - compute_barcode_data() now accepts transform parameter (raw/log/zscore) - New /class-heatmap endpoint; /barcode-data accepts transform Frontend: - New 'Class heatmap' viz tab in DataTab - Features × classes heatmap with symmetric divergent colorscale for z-score - Barcode viz now has transform selector (raw/log/zscore) - i18n en + fr for classHeatmap label Class heatmap shows which features discriminate classes: red = higher than overall mean in that class, blue = lower (when transform=zscore).
1 parent f0233c5 commit 04d030c

5 files changed

Lines changed: 197 additions & 6 deletions

File tree

‎backend/app/routers/data_explore.py‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,16 +150,20 @@ async def get_barcode_data(
150150
project_id: str,
151151
features: str = "",
152152
max_samples: int = 500,
153+
transform: str = "raw",
153154
user: User = Depends(get_current_user),
154155
db: AsyncSession = Depends(get_db),
155156
):
156-
"""Get raw feature values per sample for barcode heatmap (viewer access).
157+
"""Get feature values per sample for barcode heatmap (viewer access).
157158
158159
Returns a matrix of shape (n_features, n_samples) with samples ordered by class.
159160
Pass feature names as comma-separated string, max 100.
161+
Transform: raw (default), log, or zscore.
160162
"""
161163
if not features:
162164
raise HTTPException(400, "No features specified")
165+
if transform not in data_analysis.VALID_TRANSFORMS:
166+
raise HTTPException(400, f"Invalid transform: {transform}")
163167

164168
feature_list = [f.strip() for f in features.split(",") if f.strip()][:100]
165169
if not feature_list:
@@ -169,11 +173,44 @@ async def get_barcode_data(
169173
x_path, y_path = _resolve_train_files(project)
170174

171175
barcode = data_analysis.compute_barcode_data(
172-
x_path, y_path, feature_list, max_samples=max_samples
176+
x_path, y_path, feature_list, max_samples=max_samples, transform=transform,
173177
)
174178
return barcode
175179

176180

181+
@router.get("/{project_id}/class-heatmap")
182+
async def get_class_heatmap(
183+
project_id: str,
184+
features: str = "",
185+
transform: str = "zscore",
186+
user: User = Depends(get_current_user),
187+
db: AsyncSession = Depends(get_db),
188+
):
189+
"""Compute features × class-mean heatmap (viewer access).
190+
191+
Returns a compact matrix of mean values per class for each feature.
192+
Defaults to z-score transform, showing how each feature deviates from
193+
the overall mean in each class — useful for spotting class-discriminative features.
194+
Pass feature names as comma-separated string, max 100.
195+
"""
196+
if not features:
197+
raise HTTPException(400, "No features specified")
198+
if transform not in data_analysis.VALID_TRANSFORMS:
199+
raise HTTPException(400, f"Invalid transform: {transform}")
200+
201+
feature_list = [f.strip() for f in features.split(",") if f.strip()][:100]
202+
if not feature_list:
203+
raise HTTPException(400, "No valid features specified")
204+
205+
project, _ = await get_project_with_access(project_id, user, db, require_role="viewer")
206+
x_path, y_path = _resolve_train_files(project)
207+
208+
result = data_analysis.compute_class_heatmap(
209+
x_path, y_path, feature_list, transform=transform,
210+
)
211+
return result
212+
213+
177214
@router.get("/{project_id}/pcoa")
178215
async def get_pcoa(
179216
project_id: str,

‎backend/app/services/data_analysis.py‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,11 @@ def compute_barcode_data(
232232
feature_names: list[str],
233233
features_in_rows: bool = True,
234234
max_samples: int = 500,
235+
transform: str = "raw",
235236
) -> dict[str, Any]:
236-
"""Compute barcode heatmap data: raw feature values per sample, ordered by class.
237+
"""Compute barcode heatmap data: feature values per sample, ordered by class.
237238
239+
Supports transforms: raw (default), log, zscore.
238240
Returns a matrix (features x samples) plus metadata for heatmap rendering.
239241
"""
240242
feature_names = feature_names[:100]
@@ -282,8 +284,11 @@ def compute_barcode_data(
282284
cumulative += int((y_sorted == cls).sum())
283285
class_boundaries.append(cumulative)
284286

285-
# Build matrix (features x samples)
286-
matrix = X_sorted[valid_features].T.values.tolist()
287+
# Build matrix (features x samples), applying transform if requested
288+
values = X_sorted[valid_features].values.astype(float) # shape (samples, features)
289+
if transform != "raw":
290+
values = apply_transform(values, transform=transform)
291+
matrix = values.T.tolist() # transpose to (features, samples)
287292

288293
return {
289294
"matrix": matrix,
@@ -292,6 +297,61 @@ def compute_barcode_data(
292297
"sample_classes": y_sorted.astype(int).tolist(),
293298
"class_labels": [str(int(c)) for c in class_labels],
294299
"class_boundaries": class_boundaries,
300+
"transform": transform,
301+
}
302+
303+
304+
def compute_class_heatmap(
305+
x_path: str,
306+
y_path: str,
307+
feature_names: list[str],
308+
features_in_rows: bool = True,
309+
transform: str = "zscore",
310+
) -> dict[str, Any]:
311+
"""Compute a class-mean heatmap: features × classes, showing mean per class
312+
(optionally z-score standardized first). Compact and readable for highlighting
313+
class-discriminative features.
314+
"""
315+
feature_names = feature_names[:100]
316+
317+
X = pd.read_csv(x_path, sep="\t", index_col=0)
318+
y = pd.read_csv(y_path, sep="\t", index_col=0)
319+
320+
if features_in_rows:
321+
X = X.T # rows=samples, cols=features
322+
323+
y_series = y.iloc[:, 0]
324+
common = X.index.intersection(y_series.index)
325+
X = X.loc[common]
326+
y_series = y_series.loc[common]
327+
328+
valid_features = [f for f in feature_names if f in X.columns]
329+
if not valid_features:
330+
return {"matrix": [], "feature_names": [], "class_labels": [], "transform": transform}
331+
332+
mat = X[valid_features].values.astype(float) # (samples, features)
333+
mat = np.nan_to_num(mat, nan=0.0)
334+
335+
# Apply transform (z-score is typical for this view, but raw/log also allowed)
336+
if transform != "raw":
337+
mat = apply_transform(mat, transform=transform)
338+
339+
class_labels = sorted(y_series.unique())
340+
# Compute per-class mean: (n_classes, n_features)
341+
means = np.zeros((len(class_labels), len(valid_features)))
342+
for ci, cls in enumerate(class_labels):
343+
mask = (y_series == cls).values
344+
if mask.any():
345+
means[ci] = np.nanmean(mat[mask], axis=0)
346+
347+
# Return as (features × classes) for display (rows=features)
348+
matrix = means.T.tolist()
349+
350+
return {
351+
"matrix": matrix,
352+
"feature_names": valid_features,
353+
"class_labels": [str(int(c)) for c in class_labels],
354+
"transform": transform,
295355
}
296356

297357

‎frontend/src/i18n/locales/en.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,7 @@
630630
"prevalence": "Prevalence",
631631
"abundance": "Abundance",
632632
"barcode": "Barcode",
633+
"classHeatmap": "Class heatmap",
633634
"volcano": "Volcano",
634635
"pcoa": "PCoA",
635636
"pcoaMetric": "Distance metric",

‎frontend/src/i18n/locales/fr.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,7 @@
630630
"prevalence": "Prévalence",
631631
"abundance": "Abondance",
632632
"barcode": "Code-barres",
633+
"classHeatmap": "Heatmap par classe",
633634
"volcano": "Volcano",
634635
"pcoa": "ACoP",
635636
"pcoaMetric": "Métrique de distance",

‎frontend/src/views/DataTab.vue‎

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@
175175
<button :class="{ active: vizTab === 'prevalence' }" @click="vizTab = 'prevalence'">{{ $t('data.prevalence') }}</button>
176176
<button :class="{ active: vizTab === 'abundance' }" @click="vizTab = 'abundance'">{{ $t('data.abundance') }}</button>
177177
<button :class="{ active: vizTab === 'barcode' }" @click="vizTab = 'barcode'">{{ $t('data.barcode') }}</button>
178+
<button :class="{ active: vizTab === 'classHeatmap' }" @click="vizTab = 'classHeatmap'">{{ $t('data.classHeatmap') }}</button>
178179
<button :class="{ active: vizTab === 'volcano' }" @click="vizTab = 'volcano'">{{ $t('data.volcano') }}</button>
179180
<button :class="{ active: vizTab === 'pcoa' }" @click="vizTab = 'pcoa'">{{ $t('data.pcoa') }}</button>
180181
</nav>
@@ -213,10 +214,36 @@
213214

214215
<!-- Barcode heatmap -->
215216
<section class="section viz-section" v-if="vizTab === 'barcode'">
217+
<div class="viz-controls">
218+
<label class="toggle-label">
219+
<span>{{ $t('data.transform') }}</span>
220+
<select v-model="dataTransform" @change="loadBarcodeData">
221+
<option value="raw">Raw</option>
222+
<option value="log">Log</option>
223+
<option value="zscore">Z-score</option>
224+
</select>
225+
</label>
226+
</div>
216227
<div ref="barcodeChartEl" class="plotly-chart plotly-chart-tall"></div>
217228
<p v-if="!featureStats" class="info-text">Upload datasets and run filtering to see barcode.</p>
218229
</section>
219230

231+
<!-- Class mean heatmap (features x classes) -->
232+
<section class="section viz-section" v-if="vizTab === 'classHeatmap'">
233+
<div class="viz-controls">
234+
<label class="toggle-label">
235+
<span>{{ $t('data.transform') }}</span>
236+
<select v-model="dataTransform" @change="loadClassHeatmap">
237+
<option value="raw">Raw</option>
238+
<option value="log">Log</option>
239+
<option value="zscore">Z-score</option>
240+
</select>
241+
</label>
242+
</div>
243+
<div ref="classHeatmapChartEl" class="plotly-chart plotly-chart-tall"></div>
244+
<p v-if="!featureStats" class="info-text">Upload datasets and run filtering to see class heatmap.</p>
245+
</section>
246+
220247
<!-- Volcano plot -->
221248
<section class="section viz-section" v-if="vizTab === 'volcano'">
222249
<div ref="volcanoChartEl" class="plotly-chart plotly-chart-tall"></div>
@@ -332,6 +359,7 @@ const summary = ref(null)
332359
const featureStats = ref(null)
333360
const abundanceData = ref([])
334361
const barcodeData = ref(null)
362+
const classHeatmapData = ref(null)
335363
const mspAnnotations = ref({})
336364
const vizTab = ref('prevalence')
337365
const pcoaData = ref(null)
@@ -374,6 +402,7 @@ const selectedFeatures = ref([])
374402
const prevalenceChartEl = ref(null)
375403
const abundanceChartEl = ref(null)
376404
const barcodeChartEl = ref(null)
405+
const classHeatmapChartEl = ref(null)
377406
const volcanoChartEl = ref(null)
378407
const pcoaChartEl = ref(null)
379408
@@ -663,14 +692,31 @@ async function loadBarcodeData() {
663692
if (feats.length === 0 || !hasTrainData.value) return
664693
try {
665694
const { data } = await axios.get(`/api/data-explore/${projectId.value}/barcode-data`, {
666-
params: { features: feats.join(',') },
695+
params: { features: feats.join(','), transform: dataTransform.value },
667696
})
668697
barcodeData.value = data
698+
await nextTick()
699+
renderBarcodeChart()
669700
} catch (e) {
670701
console.error('Failed to load barcode data:', e)
671702
}
672703
}
673704
705+
async function loadClassHeatmap() {
706+
const feats = plotFeatureNames.value
707+
if (feats.length === 0 || !hasTrainData.value) return
708+
try {
709+
const { data } = await axios.get(`/api/data-explore/${projectId.value}/class-heatmap`, {
710+
params: { features: feats.join(','), transform: dataTransform.value },
711+
})
712+
classHeatmapData.value = data
713+
await nextTick()
714+
renderClassHeatmapChart()
715+
} catch (e) {
716+
console.error('Failed to load class heatmap:', e)
717+
}
718+
}
719+
674720
async function loadPcoaJobs() {
675721
try {
676722
const { data } = await axios.get(`/api/analysis/${projectId.value}/jobs`)
@@ -1270,13 +1316,59 @@ function renderCurrentViz() {
12701316
if (vizTab.value === 'prevalence') renderPrevalenceChart()
12711317
else if (vizTab.value === 'abundance') renderAbundanceChart()
12721318
else if (vizTab.value === 'barcode') renderBarcodeChart()
1319+
else if (vizTab.value === 'classHeatmap') {
1320+
if (!classHeatmapData.value) loadClassHeatmap()
1321+
else renderClassHeatmapChart()
1322+
}
12731323
else if (vizTab.value === 'volcano') renderVolcanoChart()
12741324
else if (vizTab.value === 'pcoa') {
12751325
if (!pcoaData.value && !loadingPcoa.value) loadPcoa()
12761326
else renderPcoaChart()
12771327
}
12781328
}
12791329
1330+
async function renderClassHeatmapChart() {
1331+
await nextTick()
1332+
if (!classHeatmapChartEl.value || !classHeatmapData.value || classHeatmapData.value.matrix.length === 0) return
1333+
const c = chartColors()
1334+
const d = classHeatmapData.value
1335+
1336+
// Symmetric divergent colorscale centered at 0 (z-score) or around mean (raw/log)
1337+
const flat = d.matrix.flat().filter(v => Number.isFinite(v))
1338+
let maxAbs = 0
1339+
if (flat.length > 0) {
1340+
maxAbs = Math.max(Math.abs(Math.min(...flat)), Math.abs(Math.max(...flat)))
1341+
}
1342+
if (maxAbs === 0) maxAbs = 1
1343+
1344+
const isZscore = d.transform === 'zscore'
1345+
const colorscale = isZscore
1346+
? [[0, '#0000b8'], [0.25, '#4472ff'], [0.5, '#f8f8f8'], [0.75, '#ff6060'], [1, '#b80000']]
1347+
: [[0, '#f8f8f8'], [0.5, '#ffa500'], [1, '#b80000']]
1348+
1349+
const trace = {
1350+
z: d.matrix, // (features x classes)
1351+
x: d.class_labels,
1352+
y: d.feature_names.map(featureLabel),
1353+
type: 'heatmap',
1354+
colorscale,
1355+
zmin: isZscore ? -maxAbs : Math.min(...flat),
1356+
zmax: isZscore ? maxAbs : Math.max(...flat),
1357+
zmid: isZscore ? 0 : undefined,
1358+
hovertemplate: 'Feature: %{y}<br>Class: %{x}<br>Value: %{z:.3f}<extra></extra>',
1359+
colorbar: { title: { text: d.transform }, thickness: 12 },
1360+
}
1361+
1362+
const layout = chartLayout({
1363+
title: `Class mean heatmap (${d.transform})`,
1364+
xaxis: { title: 'Class', side: 'bottom' },
1365+
yaxis: { title: 'Feature', autorange: 'reversed', tickfont: { size: 10 } },
1366+
margin: { l: 220, r: 40, t: 50, b: 60 },
1367+
})
1368+
1369+
Plotly.newPlot(classHeatmapChartEl.value, [trace], layout, { responsive: true, displayModeBar: true })
1370+
}
1371+
12801372
// Watchers for chart rendering
12811373
watch(vizTab, async () => {
12821374
await nextTick()

0 commit comments

Comments
 (0)