Skip to content

Commit e5a5595

Browse files
committed
feat: add Exploration tab with k×fit density heatmap and animation
Backend: - Worker samples up to 100 (k, fit) pairs per generation for visualization - Stores k_fit_sample, fit_mean, fit_std in generation_tracking Frontend: - New 'Exploration' sub-tab with 2D density heatmap (Plotly histogram2d) - Scatter overlay showing individual models colored by generation - Green star trajectory showing best model path - Generation slider to scrub through iterations - Animate button plays through generations at 200ms/frame - Stats bar: total models explored, unique sampled, generations - Works for GA, ACO, Beam — any algorithm with generation tracking Refs #56
1 parent 588b947 commit e5a5595

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

‎backend/app/services/worker.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,23 @@ def main():
511511
test_auc = _evaluate_one_generation(gen_best, test_ctx)
512512
if test_auc is not None:
513513
entry["best_auc_test"] = test_auc
514+
515+
# Sample k×fit pairs for exploration visualization (max 100 per generation)
516+
pop_size = len(gen_pop)
517+
sample_size = min(100, pop_size)
518+
step = max(1, pop_size // sample_size)
519+
k_fit_sample = []
520+
fit_values = []
521+
for idx in range(0, pop_size, step):
522+
ind = gen_pop.get_individual(idx)
523+
m = dict(ind.get_metrics())
524+
k_fit_sample.append([m["k"], round(m["fit"], 4)])
525+
fit_values.append(m["fit"])
526+
entry["k_fit_sample"] = k_fit_sample[:100]
527+
if fit_values:
528+
entry["fit_mean"] = round(float(np.mean(fit_values)), 4)
529+
entry["fit_std"] = round(float(np.std(fit_values)), 4)
530+
514531
generation_tracking.append(entry)
515532

516533
gen_dur = _time.monotonic() - t_gen

‎frontend/src/views/ResultsTab.vue‎

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
<button :class="{ active: subTab === 'ecosystem' }" @click="subTab = 'ecosystem'">{{ $t('results.ecosystem') }}</button>
145145
<button v-if="population.length > 1" :class="{ active: subTab === 'stability' }" @click="subTab = 'stability'">{{ $t('results.stability') }}</button>
146146
<button v-if="pheromoneData && pheromoneData.length > 0" :class="{ active: subTab === 'pheromone' }" @click="subTab = 'pheromone'">Pheromone</button>
147+
<button v-if="hasExplorationData" :class="{ active: subTab === 'exploration' }" @click="subTab = 'exploration'">Exploration</button>
147148
<button :class="{ active: subTab === 'basket' }" @click="subTab = 'basket'">{{ $t('results.basket') }}<span v-if="basketCount > 0" class="basket-badge">{{ basketCount }}</span></button>
148149
<button :class="{ active: subTab === 'console' }" @click="subTab = 'console'; loadConsoleLog()">{{ $t('results.console') }}</button>
149150
<div class="export-dropdown-wrap" v-if="detail">
@@ -998,6 +999,33 @@
998999
/>
9991000
</div>
10001001

1002+
<!-- ============================================================ -->
1003+
<!-- EXPLORATION SUB-TAB -->
1004+
<!-- ============================================================ -->
1005+
<div v-if="detail && subTab === 'exploration' && hasExplorationData" class="sub-content">
1006+
<section class="section">
1007+
<div class="section-title">Search Space Exploration — k × fit density</div>
1008+
<p class="info-text" style="margin-bottom: 12px;">
1009+
Each dot represents a model explored by the algorithm. Density shows where the search concentrates over iterations.
1010+
Use the slider to animate through generations or view all at once.
1011+
</p>
1012+
<div class="exploration-controls" style="display:flex; gap:16px; align-items:center; margin-bottom:12px;">
1013+
<label>Generation:
1014+
<input type="range" v-model.number="explorationGen" :min="-1" :max="generationTracking.length - 1" style="width:200px" />
1015+
<span style="min-width:60px; display:inline-block">{{ explorationGen === -1 ? 'All' : explorationGen }}</span>
1016+
</label>
1017+
<button class="btn-sm btn-outline" @click="animateExploration">▶ Animate</button>
1018+
<div style="flex:1"></div>
1019+
<div class="exploration-stats" v-if="explorationStats">
1020+
<span><strong>Total models:</strong> {{ explorationStats.totalModels.toLocaleString() }}</span>
1021+
<span style="margin-left:16px"><strong>Unique (sampled):</strong> {{ explorationStats.uniqueSampled.toLocaleString() }}</span>
1022+
<span style="margin-left:16px"><strong>Generations:</strong> {{ explorationStats.nGens }}</span>
1023+
</div>
1024+
</div>
1025+
<div ref="explorationChartEl" class="plotly-chart" style="min-height: 500px;"></div>
1026+
</section>
1027+
</div>
1028+
10011029
<!-- ============================================================ -->
10021030
<!-- PHEROMONE SUB-TAB (ACO only) -->
10031031
<!-- ============================================================ -->
@@ -1258,6 +1286,11 @@ const importanceData = ref(null)
12581286
const pheromoneData = ref(null)
12591287
const pheromoneTopN = ref(30)
12601288
const pheromoneChartEl = ref(null)
1289+
const explorationChartEl = ref(null)
1290+
const explorationGen = ref(-1) // -1 = all generations
1291+
const hasExplorationData = computed(() => {
1292+
return generationTracking.value.some(g => g.k_fit_sample && g.k_fit_sample.length > 0)
1293+
})
12611294
12621295
// Population filter state (P2.2 + P2.3)
12631296
const selectedLanguages = ref([])
@@ -4059,6 +4092,118 @@ function renderBasketCharts() {
40594092
renderBasketHeatmap()
40604093
}
40614094
4095+
const explorationStats = computed(() => {
4096+
if (!generationTracking.value.length) return null
4097+
const totalModels = generationTracking.value.reduce((s, g) => s + (g.population_size || 0), 0)
4098+
const allSamples = generationTracking.value.flatMap(g => g.k_fit_sample || [])
4099+
const uniqueSet = new Set(allSamples.map(([k, f]) => `${k}_${f}`))
4100+
return { totalModels, uniqueSampled: uniqueSet.size, nGens: generationTracking.value.length }
4101+
})
4102+
4103+
function renderExplorationChart() {
4104+
if (!explorationChartEl.value || !generationTracking.value.length) return
4105+
const Plotly = window.Plotly
4106+
if (!Plotly) return
4107+
4108+
const isDark = document.documentElement.classList.contains('dark')
4109+
const textColor = isDark ? '#ccc' : '#333'
4110+
const bgColor = 'rgba(0,0,0,0)'
4111+
4112+
// Collect data points
4113+
let ks = [], fits = [], colors = []
4114+
const nGens = generationTracking.value.length
4115+
4116+
if (explorationGen.value === -1) {
4117+
// All generations — color by generation
4118+
for (let g = 0; g < nGens; g++) {
4119+
const samples = generationTracking.value[g].k_fit_sample || []
4120+
for (const [k, f] of samples) {
4121+
ks.push(k)
4122+
fits.push(f)
4123+
colors.push(g)
4124+
}
4125+
}
4126+
} else {
4127+
// Single generation
4128+
const samples = generationTracking.value[explorationGen.value]?.k_fit_sample || []
4129+
for (const [k, f] of samples) {
4130+
ks.push(k)
4131+
fits.push(f)
4132+
colors.push(explorationGen.value)
4133+
}
4134+
}
4135+
4136+
if (ks.length === 0) return
4137+
4138+
const traces = [{
4139+
type: 'histogram2d',
4140+
x: ks,
4141+
y: fits,
4142+
colorscale: isDark ? 'Hot' : 'YlOrRd',
4143+
reversescale: true,
4144+
nbinsx: 40,
4145+
nbinsy: 40,
4146+
colorbar: { title: { text: 'Density', font: { color: textColor } }, tickfont: { color: textColor } },
4147+
}, {
4148+
type: 'scatter',
4149+
mode: 'markers',
4150+
x: ks,
4151+
y: fits,
4152+
marker: {
4153+
size: 3,
4154+
color: colors,
4155+
colorscale: 'Viridis',
4156+
opacity: 0.3,
4157+
},
4158+
hovertemplate: 'k=%{x}, fit=%{y:.4f}<extra></extra>',
4159+
showlegend: false,
4160+
}]
4161+
4162+
// Add best trajectory
4163+
const bestKs = generationTracking.value.map(g => g.best_k)
4164+
const bestFits = generationTracking.value.map(g => g.best_fit || g.best_auc)
4165+
traces.push({
4166+
type: 'scatter',
4167+
mode: 'lines+markers',
4168+
x: bestKs,
4169+
y: bestFits,
4170+
name: 'Best model trajectory',
4171+
marker: { size: 6, color: '#00ff88', symbol: 'star' },
4172+
line: { color: '#00ff88', width: 2 },
4173+
hovertemplate: 'Gen %{customdata}: k=%{x}, fit=%{y:.4f}<extra>Best</extra>',
4174+
customdata: generationTracking.value.map((_, i) => i),
4175+
})
4176+
4177+
const genLabel = explorationGen.value === -1 ? 'All generations' : `Generation ${explorationGen.value}`
4178+
const layout = {
4179+
title: { text: `Search Space Exploration — ${genLabel} (${ks.length} models)`, font: { color: textColor, size: 14 } },
4180+
xaxis: { title: { text: 'k (model complexity)', font: { color: textColor } }, tickfont: { color: textColor } },
4181+
yaxis: { title: { text: 'Penalized fitness', font: { color: textColor } }, tickfont: { color: textColor } },
4182+
height: 500,
4183+
margin: { l: 60, r: 30, t: 40, b: 50 },
4184+
paper_bgcolor: bgColor,
4185+
plot_bgcolor: bgColor,
4186+
legend: { font: { color: textColor }, x: 0.01, y: 0.99 },
4187+
}
4188+
4189+
Plotly.react(explorationChartEl.value, traces, layout, { responsive: true })
4190+
}
4191+
4192+
let _animTimer = null
4193+
function animateExploration() {
4194+
if (_animTimer) { clearInterval(_animTimer); _animTimer = null; return }
4195+
explorationGen.value = 0
4196+
_animTimer = setInterval(() => {
4197+
if (explorationGen.value >= generationTracking.value.length - 1) {
4198+
clearInterval(_animTimer)
4199+
_animTimer = null
4200+
explorationGen.value = -1 // show all at end
4201+
return
4202+
}
4203+
explorationGen.value++
4204+
}, 200)
4205+
}
4206+
40624207
function renderPheromoneChart() {
40634208
if (!pheromoneData.value || !pheromoneChartEl.value) return
40644209
const Plotly = window.Plotly
@@ -4130,6 +4275,7 @@ async function renderActiveTab() {
41304275
else if (subTab.value === 'jury') renderJuryCharts()
41314276
else if (subTab.value === 'comparative') renderComparativeCharts()
41324277
else if (subTab.value === 'copresence') renderCoPresenceCharts()
4278+
else if (subTab.value === 'exploration') renderExplorationChart()
41334279
else if (subTab.value === 'pheromone') renderPheromoneChart()
41344280
else if (subTab.value === 'basket') renderBasketCharts()
41354281
}
@@ -4156,6 +4302,11 @@ watch(basketItems, () => {
41564302
if (subTab.value === 'basket') nextTick(() => renderBasketCharts())
41574303
}, { deep: true })
41584304
4305+
// Re-render exploration chart when generation slider changes
4306+
watch(explorationGen, () => {
4307+
if (subTab.value === 'exploration') nextTick(() => renderExplorationChart())
4308+
})
4309+
41594310
// Re-render pheromone chart when topN changes
41604311
watch(pheromoneTopN, () => {
41614312
if (subTab.value === 'pheromone') nextTick(() => renderPheromoneChart())

0 commit comments

Comments
 (0)