[Feature] Show Multi Gen Eval + Batch Prediction (#267)

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#267
This commit is contained in:
2025-12-15 08:48:28 +13:00
parent 648ec150a9
commit d2d475b990
18 changed files with 1102 additions and 232 deletions

View File

@ -0,0 +1,10 @@
{% if job.is_result_downloadable %}
<li>
<a
class="button"
onclick="document.getElementById('download_job_result_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-floppy-save"></i> Download Result</a
>
</li>
{% endif %}

View File

@ -0,0 +1,168 @@
{% extends "framework_modern.html" %}
{% load static %}
{% block content %}
<div class="mx-auto w-full p-8">
<h1 class="h1 mb-4 text-3xl font-bold">Batch Predict Pathways</h1>
<form id="smiles-form" method="POST" action="{% url "jobs" %}">
{% csrf_token %}
<input type="hidden" name="substrates" id="substrates" />
<input type="hidden" name="job-name" value="batch-predict" />
<fieldset class="flex flex-col gap-4 md:flex-3/4">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>SMILES</th>
<th>Name</th>
</tr>
</thead>
<tbody id="smiles-table-body">
<tr>
<td>
<label>
<input
type="text"
class="input input-bordered w-full smiles-input"
placeholder="CN1C=NC2=C1C(=O)N(C(=O)N2C)C"
{% if meta.debug %}
value="CN1C=NC2=C1C(=O)N(C(=O)N2C)C"
{% endif %}
/>
</label>
</td>
<td>
<label>
<input
type="text"
class="input input-bordered w-full name-input"
placeholder="Caffeine"
{% if meta.debug %}
value="Caffeine"
{% endif %}
/>
</label>
</td>
</tr>
<tr>
<td>
<label>
<input
type="text"
class="input input-bordered w-full smiles-input"
placeholder="CC(C)CC1=CC=C(C=C1)C(C)C(=O)O"
{% if meta.debug %}
value="CC(C)CC1=CC=C(C=C1)C(C)C(=O)O"
{% endif %}
/>
</label>
</td>
<td>
<label>
<input
type="text"
class="input input-bordered w-full name-input"
placeholder="Ibuprofen"
{% if meta.debug %}
value="Ibuprofen"
{% endif %}
/>
</label>
</td>
</tr>
</tbody>
</table>
<label class="select mb-2 w-full">
<span class="label">Predictor</span>
<select id="prediction-setting" name="prediction-setting">
<option disabled>Select a Setting</option>
{% for s in meta.available_settings %}
<option
value="{{ s.url }}"
{% if s.id == meta.user.default_setting.id %}selected{% endif %}
>
{{ s.name }}{% if s.id == meta.user.default_setting.id %}
(User default)
{% endif %}
</option>
{% endfor %}
</select>
</label>
<label class="floating-label" for="num-tps">
<input
type="number"
name="num-tps"
value="50"
step="1"
min="1"
max="100"
id="num-tps"
class="input input-md w-full"
/>
<span>Max Transformation Products</span>
</label>
<div class="flex justify-end gap-2">
<button type="button" id="add-row-btn" class="btn btn-outline">
Add row
</button>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</fieldset>
</form>
</div>
<script>
const tableBody = document.getElementById("smiles-table-body");
const addRowBtn = document.getElementById("add-row-btn");
const form = document.getElementById("smiles-form");
const hiddenField = document.getElementById("substrates");
addRowBtn.addEventListener("click", () => {
const row = document.createElement("tr");
const tdSmiles = document.createElement("td");
const tdName = document.createElement("td");
const smilesInput = document.createElement("input");
smilesInput.type = "text";
smilesInput.className = "input input-bordered w-full smiles-input";
smilesInput.placeholder = "SMILES";
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.className = "input input-bordered w-full name-input";
nameInput.placeholder = "Name";
tdSmiles.appendChild(smilesInput);
tdName.appendChild(nameInput);
row.appendChild(tdSmiles);
row.appendChild(tdName);
tableBody.appendChild(row);
});
// Before submit, gather table data into the hidden field
form.addEventListener("submit", (e) => {
const smilesInputs = Array.from(
document.querySelectorAll(".smiles-input"),
);
const nameInputs = Array.from(document.querySelectorAll(".name-input"));
const lines = [];
for (let i = 0; i < smilesInputs.length; i++) {
const smiles = smilesInputs[i].value.trim();
const name = nameInputs[i]?.value.trim() ?? "";
// Skip emtpy rows
if (!smiles && !name) {
continue;
}
lines.push(`${smiles},${name}`);
}
// Value looks like:
// "CN1C=NC2=C1C(=O)N(C(=O)N2C)C,Caffeine\nCC(C)CC1=CC=C(C=C1)C(C)C(=O)O,Ibuprofen"
hiddenField.value = lines.join("\n");
});
</script>
{% endblock content %}

View File

@ -210,6 +210,27 @@
step="0.05"
/>
</div>
<div class="form-control mb-3">
<label
class="label"
for="model-based-prediction-setting-expansion-scheme"
>
<span class="label-text">Select Expansion Scheme</span>
</label>
<select
id="model-based-prediction-setting-expansion-scheme"
name="model-based-prediction-setting-expansion-scheme"
class="select select-bordered w-full"
>
<option value="" disabled selected>
Select the Expansion Scheme
</option>
<option value="BFS">Breadth First Search</option>
<option value="DFS">Depth First Search</option>
<option value="GREEDY">Greedy</option>
</select>
</div>
</div>
<div class="form-control">

View File

@ -0,0 +1,66 @@
{% load static %}
<dialog
id="download_job_result_modal"
class="modal"
x-data="modalForm()"
@close="reset()"
>
<div class="modal-box">
<!-- Header -->
<h3 class="font-bold text-lg">Download Job Result</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<p>By clicking on Download the Result of this Job will be saved.</p>
<form
id="download-job-result-modal-form"
accept-charset="UTF-8"
action="{{ job.url }}"
method="GET"
>
<input type="hidden" name="download" value="true" />
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Close
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('download-job-result-modal-form'); $el.closest('dialog').close();"
:disabled="isSubmitting"
>
<span x-show="!isSubmitting">Download</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -3,7 +3,9 @@
{% block content %}
{% block action_modals %}
{# {% include "modals/objects/refresh_job_log.html" %}#}
{% if job.is_result_downloadable %}
{% include "modals/objects/download_job_result_modal.html" %}
{% endif %}
{% endblock action_modals %}
<div class="space-y-2 p-4">
@ -49,22 +51,20 @@
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Description</div>
<div class="collapse-content">
Status page for Task {{ job.job_name }}
</div>
<div class="collapse-content">Status page for Job {{ job.job_name }}</div>
</div>
<!-- Job Status -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Task Status</div>
<div class="collapse-title text-xl font-medium">Job Status</div>
<div class="collapse-content">{{ job.status }}</div>
</div>
<!-- Job ID -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Task ID</div>
<div class="collapse-title text-xl font-medium">Job ID</div>
<div class="collapse-content">{{ job.task_id }}</div>
</div>
@ -72,7 +72,7 @@
{% if job.is_in_terminal_state %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Task Result</div>
<div class="collapse-title text-xl font-medium">Job Result</div>
<div class="collapse-content">
{% if job.job_name == 'engineer_pathways' %}
<div class="card bg-base-100">
@ -103,6 +103,68 @@
</ul>
</div>
</div>
{% elif job.job_name == 'batch_predict' %}
<div
id="table-container"
class="overflow-x-auto overflow-y-auto max-h-96 border rounded-lg"
></div>
<script>
const input = `{{ job.task_result }}`;
function renderCsvTable(str) {
const lines = str
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const [headerLine, ...rows] = lines;
const headers = headerLine.split(",").map((h) => h.trim());
const table = document.createElement("table");
table.className = "table table-zebra w-full";
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
headers.forEach((h) => {
const th = document.createElement("th");
th.textContent = h;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
const tbody = document.createElement("tbody");
rows.forEach((rowStr) => {
console.log(rowStr.split(","));
console.log(headers);
const row = document.createElement("tr");
const cells = rowStr.split(",").map((c) => c.trim());
headers.forEach((_, i) => {
const td = document.createElement("td");
const value = cells[i] || "";
td.textContent = value;
row.appendChild(td);
});
console.log(row);
tbody.appendChild(row);
});
table.appendChild(thead);
table.appendChild(tbody);
return table;
}
document
.getElementById("table-container")
.appendChild(renderCsvTable(input));
</script>
{% else %}
{{ job.parsed_result }}
{% endif %}

View File

@ -73,13 +73,29 @@
</ul>
</div>
</div>
<!-- Reaction Packages -->
{% endif %}
<!-- Reaction Packages -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Reaction Packages</div>
<div class="collapse-content">
<ul class="menu bg-base-100 rounded-box w-full">
{% for p in model.data_packages.all %}
<li>
<a href="{{ p.url }}" class="hover:bg-base-200">{{ p.name }}</a>
</li>
{% endfor %}
</ul>
</div>
</div>
{% if model.eval_packages.all|length > 0 %}
<!-- Eval Packages -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Reaction Packages</div>
<div class="collapse-title text-xl font-medium">Eval Packages</div>
<div class="collapse-content">
<ul class="menu bg-base-100 rounded-box w-full">
{% for p in model.data_packages.all %}
{% for p in model.eval_packages.all %}
<li>
<a href="{{ p.url }}" class="hover:bg-base-200">{{ p.name }}</a>
</li>
@ -87,31 +103,13 @@
</ul>
</div>
</div>
{% if model.eval_packages.all|length > 0 %}
<!-- Eval Packages -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Eval Packages</div>
<div class="collapse-content">
<ul class="menu bg-base-100 rounded-box w-full">
{% for p in model.eval_packages.all %}
<li>
<a href="{{ p.url }}" class="hover:bg-base-200"
>{{ p.name }}</a
>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<!-- Model Status -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Model Status</div>
<div class="collapse-content">{{ model.status }}</div>
</div>
{% endif %}
<!-- Model Status -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Model Status</div>
<div class="collapse-content">{{ model.status }}</div>
</div>
{% if model.ready_for_prediction %}
<!-- Predict Panel -->
@ -174,7 +172,6 @@
</div>
</div>
{% endif %}
{% if model.model_status == 'FINISHED' %}
<!-- Single Gen Curve Panel -->
<div class="collapse-arrow bg-base-200 collapse">
@ -188,6 +185,19 @@
</div>
</div>
</div>
{% if model.multigen_eval %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
Multi Gen Precision Recall Curve
</div>
<div class="collapse-content">
<div class="flex justify-center">
<div id="mg-chart"></div>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
@ -244,6 +254,105 @@
}
}
function makeChart(selector, data) {
const x = ['Recall'];
const y = ['Precision'];
const thres = ['threshold'];
function compare(a, b) {
if (a.threshold < b.threshold)
return -1;
else if (a.threshold > b.threshold)
return 1;
else
return 0;
}
function getIndexForValue(data, val, val_name) {
for (const idx in data) {
if (data[idx][val_name] == val) {
return idx;
}
}
return -1;
}
if (!data || data.length === 0) {
console.warn('PR curve data is empty');
return;
}
const dataLength = data.length;
data.sort(compare);
for (const idx in data) {
const d = data[idx];
x.push(d.recall);
y.push(d.precision);
thres.push(d.threshold);
}
const chart = c3.generate({
bindto: selector,
data: {
onclick: function (d, e) {
const idx = d.index;
const thresh = data[dataLength - idx - 1].threshold;
},
x: 'Recall',
y: 'Precision',
columns: [
x,
y,
]
},
size: {
height: 400,
width: 480
},
axis: {
x: {
max: 1,
min: 0,
label: 'Recall',
padding: 0,
tick: {
fit: true,
values: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
}
},
y: {
max: 1,
min: 0,
label: 'Precision',
padding: 0,
tick: {
fit: true,
values: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
}
}
},
point: {
r: 4
},
tooltip: {
format: {
title: function (recall) {
const idx = getIndexForValue(data, recall, "recall");
if (idx != -1) {
return "Threshold: " + data[idx].threshold;
}
return "";
},
value: function (precision, ratio, id) {
return undefined;
}
}
},
zoom: {
enabled: true
}
});
}
function makeLoadingGif(selector, gifPath) {
const element = document.querySelector(selector);
if (element) {
@ -260,107 +369,12 @@
}
{% if model.model_status == 'FINISHED' %}
// Precision Recall Curve
const sgChart = document.getElementById('sg-chart');
if (sgChart) {
const x = ['Recall'];
const y = ['Precision'];
const thres = ['threshold'];
function compare(a, b) {
if (a.threshold < b.threshold)
return -1;
else if (a.threshold > b.threshold)
return 1;
else
return 0;
}
function getIndexForValue(data, val, val_name) {
for (const idx in data) {
if (data[idx][val_name] == val) {
return idx;
}
}
return -1;
}
var data = {{ model.pr_curve|safe }};
if (!data || data.length === 0) {
console.warn('PR curve data is empty');
return;
}
const dataLength = data.length;
data.sort(compare);
for (const idx in data) {
const d = data[idx];
x.push(d.recall);
y.push(d.precision);
thres.push(d.threshold);
}
const chart = c3.generate({
bindto: '#sg-chart',
data: {
onclick: function (d, e) {
const idx = d.index;
const thresh = data[dataLength - idx - 1].threshold;
},
x: 'Recall',
y: 'Precision',
columns: [
x,
y,
]
},
size: {
height: 400,
width: 480
},
axis: {
x: {
max: 1,
min: 0,
label: 'Recall',
padding: 0,
tick: {
fit: true,
values: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
}
},
y: {
max: 1,
min: 0,
label: 'Precision',
padding: 0,
tick: {
fit: true,
values: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
}
}
},
point: {
r: 4
},
tooltip: {
format: {
title: function (recall) {
const idx = getIndexForValue(data, recall, "recall");
if (idx != -1) {
return "Threshold: " + data[idx].threshold;
}
return "";
},
value: function (precision, ratio, id) {
return undefined;
}
}
},
zoom: {
enabled: true
}
});
}
// Precision Recall Curve
makeChart('#sg-chart', {{ model.pr_curve|safe }});
{% if model.multigen_eval %}
// Multi Gen Precision Recall Curve
makeChart('#mg-chart', {{ model.mg_pr_curve|safe }});
{% endif %}
{% endif %}
// Predict button handler

View File

@ -393,7 +393,9 @@
<tbody>
<tr>
<td>Threshold</td>
<td>{{ pathway.setting.model_threshold }}</td>
<td>
{{ pathway.setting_with_overrides.model_threshold }}
</td>
</tr>
</tbody>
</table>
@ -420,11 +422,15 @@
{% endif %}
<tr>
<td>Max Nodes</td>
<td>{{ pathway.setting.max_nodes }}</td>
<td>{{ pathway.setting_with_overrides.max_nodes }}</td>
</tr>
<tr>
<td>Max Depth</td>
<td>{{ pathway.setting.max_depth }}</td>
<td>{{ pathway.setting_with_overrides.max_depth }}</td>
</tr>
<tr>
<td>Expansion Scheme</td>
<td>{{ user.default_setting.expansion_scheme }}</td>
</tr>
</tbody>
</table>

View File

@ -150,6 +150,10 @@
<td>Max Depth</td>
<td>{{ user.default_setting.max_depth }}</td>
</tr>
<tr>
<td>Expansion Scheme</td>
<td>{{ user.default_setting.expansion_scheme }}</td>
</tr>
</tbody>
</table>
</div>