Skip to content

Commit 0ab747f

Browse files
Overhaul the flavor map: UMAP, 2D zoom/pan + rotatable 3D, legend + key (#93)
- build_flavor_map.py now uses UMAP with the Jaccard metric (the right similarity for binary Morgan fingerprints — same family as Tanimoto) and embeds to BOTH 2D and 3D. - /api/map serves normalized 2D (x,y) + 3D (x3,y3,z3) coordinates with names. - Map UI: a 2D/3D toggle. 2D scatter with scroll-to-zoom + drag-to-pan; 3D is a drag-to-rotate, depth-shaded point cloud. Richer hover (name + colored taste + SMILES), a color legend with counts, a Reset view button, and a 'how to read it' key explaining that position = structural similarity, color = dominant taste, axes are unitless. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent 9e99eb8 commit 0ab747f

3 files changed

Lines changed: 122 additions & 54 deletions

File tree

training/app.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -389,18 +389,25 @@ def api_top(category: str = "", limit: int = 24):
389389

390390

391391
def _load_flavor_map():
392-
"""The 2D flavor-space embedding (flavor_map.parquet, built by build_flavor_map.py),
393-
coordinates normalized to 0..1 with names attached — served as an interactive scatter."""
392+
"""The flavor-space embedding (flavor_map.parquet, built by build_flavor_map.py): 2D (x,y)
393+
+ 3D (x3,y3,z3) coordinates normalized to 0..1 with names an interactive scatter / cloud."""
394394
try:
395395
import pandas as pd
396396
df = pd.read_parquet("flavor_map.parquet")
397-
xs, ys = df["x"], df["y"]
398-
x0, xr = xs.min(), (xs.max() - xs.min()) or 1.0
399-
y0, yr = ys.min(), (ys.max() - ys.min()) or 1.0
397+
398+
def norm(col):
399+
v = df[col]
400+
lo, rng = v.min(), (v.max() - v.min()) or 1.0
401+
return ((v - lo) / rng).round(4)
402+
403+
cols = {c: norm(c).tolist() for c in ("x", "y", "x3", "y3", "z3") if c in df.columns}
404+
smis, labs = df["smiles"].tolist(), df["label"].tolist()
400405
pts = []
401-
for smi, lab, x, y in zip(df["smiles"], df["label"], xs, ys):
402-
pts.append({"x": round((x - x0) / xr, 4), "y": round((y - y0) / yr, 4),
403-
"label": lab, "smiles": smi, "name": _table_name(smi) or ""})
406+
for i in range(len(smis)):
407+
p = {"label": labs[i], "smiles": smis[i], "name": _table_name(smis[i]) or ""}
408+
for c, v in cols.items():
409+
p[c] = v[i]
410+
pts.append(p)
404411
return pts
405412
except Exception: # noqa: BLE001 — no map built yet
406413
return []

training/build_flavor_map.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
"""
2-
build_flavor_map.py — a 2D "flavor-space" embedding of the labeled molecules.
2+
build_flavor_map.py — a flavor-space embedding of the labeled molecules (2D + 3D).
33
4-
Projects each molecule's 2048-bit Morgan fingerprint to 2D (PCA -> t-SNE) and labels it by
5-
its dominant known taste, so structurally similar molecules land near each other and the taste
6-
classes separate visually. Output flavor_map.parquet (smiles, label, x, y); the app normalizes
7-
+ serves it as an interactive scatter (hover = name, click = full read).
4+
Projects each molecule's 2048-bit Morgan fingerprint into 2D and 3D with UMAP (Jaccard metric,
5+
which is the right similarity for binary fingerprints — same family as Tanimoto), and labels it
6+
by its dominant known taste. Structurally similar molecules land near each other and the taste
7+
classes separate visually. Output flavor_map.parquet (smiles, label, x, y, x3, y3, z3); the app
8+
normalizes + serves it as an interactive scatter (2D zoom/pan, or a rotatable 3D point cloud).
9+
10+
Needs `pip install umap-learn` (a build-time dependency only — the server just reads the
11+
resulting parquet, so UMAP is not required to run the demo).
812
913
Usage: python build_flavor_map.py # taste_master.parquet -> flavor_map.parquet
1014
"""
@@ -13,22 +17,26 @@
1317

1418
import numpy as np
1519
import pandas as pd
20+
import umap
1621
from rdkit import Chem
1722
from rdkit.Chem import DataStructs, rdFingerprintGenerator
18-
from sklearn.decomposition import PCA
19-
from sklearn.manifold import TSNE
2023

2124
TASTES = ["sweet", "bitter", "umami", "sour", "salty"]
2225
_MORGAN = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)
2326

2427

2528
def _fp(m):
2629
bv = _MORGAN.GetFingerprint(m)
27-
a = np.zeros((2048,), dtype=np.int8)
30+
a = np.zeros((2048,), dtype=np.float32) # float for UMAP's jaccard metric
2831
DataStructs.ConvertToNumpyArray(bv, a)
2932
return a
3033

3134

35+
def _umap(X, n):
36+
return umap.UMAP(n_components=n, n_neighbors=15, min_dist=0.15,
37+
metric="jaccard", random_state=42).fit_transform(X)
38+
39+
3240
if __name__ == "__main__":
3341
src = "taste_master.parquet"
3442
if not Path(src).exists():
@@ -44,12 +52,14 @@ def _fp(m):
4452
labels.append(next((t for t in TASTES if r.get(t) == 1), "other"))
4553
feats.append(_fp(m))
4654
X = np.array(feats)
47-
print(f"embedding {len(X)} molecules (PCA-50 -> t-SNE 2D)...", flush=True)
48-
x50 = PCA(n_components=50, random_state=42).fit_transform(X)
49-
xy = TSNE(n_components=2, random_state=42, perplexity=30, init="pca").fit_transform(x50)
55+
print(f"embedding {len(X)} molecules with UMAP (Jaccard) — 2D then 3D...", flush=True)
56+
xy = _umap(X, 2)
57+
xyz = _umap(X, 3)
5058
out = pd.DataFrame({"smiles": smiles, "label": labels,
51-
"x": xy[:, 0].astype(float), "y": xy[:, 1].astype(float)})
59+
"x": xy[:, 0].astype(float), "y": xy[:, 1].astype(float),
60+
"x3": xyz[:, 0].astype(float), "y3": xyz[:, 1].astype(float),
61+
"z3": xyz[:, 2].astype(float)})
5262
out.to_parquet("flavor_map.parquet")
5363
print(f"flavor_map.parquet: {len(out)} points "
54-
f"({', '.join(f'{t}={int((out.label==t).sum())}' for t in TASTES)}, "
55-
f"other={int((out.label=='other').sum())})")
64+
f"({', '.join(f'{t}={int((out.label == t).sum())}' for t in TASTES)}, "
65+
f"other={int((out.label == 'other').sum())})")

training/workbench.html

Lines changed: 83 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@
9999
.map-legend{display:flex;flex-wrap:wrap;gap:10px;margin:10px 0 6px;font-size:12px}
100100
.map-legend span{display:inline-flex;align-items:center;gap:5px}
101101
.map-legend i{width:10px;height:10px;border-radius:50%;display:inline-block}
102+
.map-controls{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin:10px 0 8px}
103+
.map-toggle3d{display:inline-flex}
104+
.map-toggle3d .st-tab{padding:3px 12px}
105+
.map-reset{font:inherit;font-size:12px;padding:3px 10px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--muted);cursor:pointer;margin-left:auto}
106+
.map-reset:hover{border-color:var(--aroma)}
107+
.map-key{font-size:12px;color:var(--muted);line-height:1.55;margin-top:8px;max-width:900px}
108+
.map-key b{color:var(--ink)}
102109
.map-canvas-wrap{position:relative;width:100%;overflow-x:auto}
103110
#flavorMap{max-width:100%;border:1px solid var(--line);border-radius:8px;background:#fff;cursor:crosshair}
104111
.map-tip{position:absolute;pointer-events:none;background:#1c2620;color:#fff;font-size:11px;padding:3px 7px;border-radius:5px;white-space:nowrap;transform:translate(-50%,-140%)}
@@ -211,11 +218,20 @@ <h1>Flavormancer</h1>
211218

212219
<div class="map-section">
213220
<div class="map-head" id="mapToggle"><span id="mapCaret"></span> Flavor-space map
214-
<span class="mapsub">every labeled molecule placed by structural similarity; taste classes separate. Hover a point, click to read.</span></div>
221+
<span class="mapsub">molecules placed by structural similarity; taste classes cluster</span></div>
215222
<div id="mapWrap" style="display:none">
216-
<div id="mapLegend" class="map-legend"></div>
217-
<div class="map-canvas-wrap"><canvas id="flavorMap" width="920" height="520"></canvas>
223+
<div class="map-controls">
224+
<span class="map-toggle3d"><button type="button" id="map2d" class="st-tab on">2D</button><button type="button" id="map3d" class="st-tab">3D</button></span>
225+
<span id="mapLegend" class="map-legend"></span>
226+
<button type="button" id="mapReset" class="map-reset">Reset view</button>
227+
</div>
228+
<div class="map-canvas-wrap"><canvas id="flavorMap" width="960" height="560"></canvas>
218229
<div id="mapTip" class="map-tip" style="display:none"></div></div>
230+
<div class="map-key">Each dot is a molecule; <b>nearby dots are structurally similar</b>
231+
(UMAP over Morgan fingerprints, Jaccard/Tanimoto). Color = <b>dominant taste</b>; tight
232+
groups are structural families. Axes have no units — only <i>relative</i> position matters.
233+
<b>2D:</b> scroll to zoom, drag to pan. <b>3D:</b> drag to rotate. Hover for the molecule,
234+
click to open its read.</div>
219235
</div>
220236
</div>
221237

@@ -624,43 +640,78 @@ <h2>Aroma</h2>
624640
}
625641
initBrowse();
626642

627-
// --- flavor-space map (t-SNE of fingerprints, colored by taste) ---
643+
// --- flavor-space map: 2D (zoom/pan) + rotatable 3D point cloud, colored by taste ---
628644
const MAP_COLORS = {sweet:'#E0972E',bitter:'#6B4E9E',umami:'#C0563B',sour:'#9FB024',salty:'#4E84B0',other:'#C3C9C0'};
629-
let _mapPts=null, _mapDrawn=false;
645+
let _mapPts=null, _mapReady=false, _mapMode='2d', _mapProj=null;
646+
let _v2={s:1,ox:0,oy:0}, _v3={rx:-0.45,ry:0.6}, _drag=null, _dragged=false;
647+
630648
$('mapToggle').addEventListener('click', async ()=>{
631-
const w=$('mapWrap'); const open = w.style.display==='none';
632-
w.style.display = open?'block':'none'; $('mapCaret').textContent = open?'▾':'▸';
633-
if(open && !_mapDrawn){ await drawMap(); }
649+
const w=$('mapWrap'); const open=w.style.display==='none';
650+
w.style.display=open?'block':'none'; $('mapCaret').textContent=open?'▾':'▸';
651+
if(open && !_mapReady) await loadMap();
634652
});
635-
async function drawMap(){
636-
const cv=$('flavorMap'); const ctx=cv.getContext('2d');
637-
ctx.clearRect(0,0,cv.width,cv.height);
653+
$('map2d').addEventListener('click', ()=>setMapMode('2d'));
654+
$('map3d').addEventListener('click', ()=>setMapMode('3d'));
655+
$('mapReset').addEventListener('click', ()=>{ _v2={s:1,ox:0,oy:0}; _v3={rx:-0.45,ry:0.6}; drawMap(); });
656+
function setMapMode(m){ _mapMode=m; $('map2d').classList.toggle('on',m==='2d'); $('map3d').classList.toggle('on',m==='3d'); drawMap(); }
657+
658+
async function loadMap(){
659+
const cv=$('flavorMap'), ctx=cv.getContext('2d');
638660
ctx.fillStyle='#8a938c'; ctx.font='13px sans-serif'; ctx.fillText('Loading…',12,22);
639-
const d = await getJSON('/api/map'); _mapPts = d.points||[];
640-
if(!_mapPts.length){ ctx.clearRect(0,0,cv.width,cv.height); ctx.fillStyle='#8a938c'; ctx.fillText('Map not built yet (build_flavor_map.py).',12,22); return; }
641-
const pad=14, W=cv.width, H=cv.height;
642-
const px = p => pad + p.x*(W-2*pad), py = p => H-pad - p.y*(H-2*pad);
643-
ctx.clearRect(0,0,W,H);
644-
for(const p of _mapPts){ ctx.beginPath(); ctx.arc(px(p),py(p),2.6,0,7); ctx.fillStyle=MAP_COLORS[p.label]||MAP_COLORS.other; ctx.globalAlpha=.82; ctx.fill(); }
645-
ctx.globalAlpha=1;
646-
// legend
661+
const d=await getJSON('/api/map'); _mapPts=d.points||[];
662+
if(!_mapPts.length){ ctx.clearRect(0,0,cv.width,cv.height); ctx.fillText('Map not built yet (build_flavor_map.py).',12,22); return; }
647663
const counts={}; _mapPts.forEach(p=>counts[p.label]=(counts[p.label]||0)+1);
648-
$('mapLegend').innerHTML = Object.keys(MAP_COLORS).filter(l=>counts[l]).map(l=>
664+
$('mapLegend').innerHTML=Object.keys(MAP_COLORS).filter(l=>counts[l]).map(l=>
649665
`<span><i style="background:${MAP_COLORS[l]}"></i>${l} (${counts[l]})</span>`).join('');
650-
_mapDrawn=true;
651-
// interactivity: nearest point in canvas space
652-
const nearest = ev=>{
653-
const r=cv.getBoundingClientRect(), sx=cv.width/r.width, sy=cv.height/r.height;
654-
const mx=(ev.clientX-r.left)*sx, my=(ev.clientY-r.top)*sy;
655-
let best=null,bd=100;
656-
for(const p of _mapPts){ const dx=px(p)-mx, dy=py(p)-my, dd=dx*dx+dy*dy; if(dd<bd){bd=dd;best=p;} }
657-
return best;
658-
};
666+
_mapReady=true; attachMapEvents(cv); drawMap();
667+
}
668+
function drawMap(){ _mapMode==='3d'?draw3D():draw2D(); }
669+
670+
function draw2D(){
671+
const cv=$('flavorMap'), ctx=cv.getContext('2d'), W=cv.width, H=cv.height, pad=18;
672+
ctx.clearRect(0,0,W,H);
673+
const px=p=>(pad+p.x*(W-2*pad))*_v2.s+_v2.ox, py=p=>(H-pad-p.y*(H-2*pad))*_v2.s+_v2.oy;
674+
for(const p of _mapPts){ ctx.beginPath(); ctx.arc(px(p),py(p),2.6,0,7); ctx.fillStyle=MAP_COLORS[p.label]||MAP_COLORS.other; ctx.globalAlpha=.8; ctx.fill(); }
675+
ctx.globalAlpha=1; _mapProj={px,py};
676+
}
677+
function draw3D(){
678+
const cv=$('flavorMap'), ctx=cv.getContext('2d'), W=cv.width, H=cv.height;
679+
ctx.clearRect(0,0,W,H);
680+
const cx=W/2, cy=H/2, R=Math.min(W,H)*0.66, cY=Math.cos(_v3.ry),sY=Math.sin(_v3.ry),cX=Math.cos(_v3.rx),sX=Math.sin(_v3.rx);
681+
const proj=p=>{ const X=(p.x3||0)-.5,Y=(p.y3||0)-.5,Z=(p.z3||0)-.5;
682+
const x1=X*cY-Z*sY, z1=X*sY+Z*cY, y1=Y*cX-z1*sX, z2=Y*sX+z1*cX;
683+
return {sx:cx+x1*R, sy:cy-y1*R, d:z2}; };
684+
const all=_mapPts.map(p=>({p,pr:proj(p)})).sort((a,b)=>a.pr.d-b.pr.d); // far first
685+
for(const {p,pr} of all){ const t=pr.d+0.5; ctx.beginPath(); ctx.arc(pr.sx,pr.sy,1.7+2.1*t,0,7); ctx.fillStyle=MAP_COLORS[p.label]||MAP_COLORS.other; ctx.globalAlpha=Math.max(.22,Math.min(1,.35+.6*t)); ctx.fill(); }
686+
ctx.globalAlpha=1; _mapProj={px:p=>proj(p).sx, py:p=>proj(p).sy};
687+
}
688+
function nearestMapPt(ev){
689+
if(!_mapProj) return null;
690+
const cv=$('flavorMap'), r=cv.getBoundingClientRect(), sx=cv.width/r.width, sy=cv.height/r.height;
691+
const mx=(ev.clientX-r.left)*sx, my=(ev.clientY-r.top)*sy;
692+
let best=null,bd=130;
693+
for(const p of _mapPts){ const dx=_mapProj.px(p)-mx, dy=_mapProj.py(p)-my, dd=dx*dx+dy*dy; if(dd<bd){bd=dd;best=p;} }
694+
return best;
695+
}
696+
function attachMapEvents(cv){
659697
const tip=$('mapTip');
660-
cv.onmousemove = ev=>{ const p=nearest(ev); if(p){ const r=cv.getBoundingClientRect(); tip.style.display='block'; tip.style.left=(ev.clientX-r.left)+'px'; tip.style.top=(ev.clientY-r.top)+'px'; tip.textContent=(p.name||p.smiles)+' · '+p.label; } else tip.style.display='none'; };
661-
cv.onmouseleave = ()=> tip.style.display='none';
662-
cv.onclick = ev=>{ const p=nearest(ev); if(p){ q.value=p.smiles; window.scrollTo({top:0,behavior:'smooth'}); run(); } };
698+
cv.addEventListener('mousedown', ev=>{ _drag={x:ev.clientX,y:ev.clientY,v2:{..._v2},v3:{..._v3}}; _dragged=false; });
699+
window.addEventListener('mouseup', ()=>{ _drag=null; });
700+
cv.addEventListener('mousemove', ev=>{
701+
if(_drag){ _dragged=true; const dx=ev.clientX-_drag.x, dy=ev.clientY-_drag.y;
702+
if(_mapMode==='2d'){ _v2.ox=_drag.v2.ox+dx; _v2.oy=_drag.v2.oy+dy; }
703+
else { _v3.ry=_drag.v3.ry+dx*0.008; _v3.rx=_drag.v3.rx+dy*0.008; }
704+
drawMap(); tip.style.display='none'; return; }
705+
const p=nearestMapPt(ev); _mapLastHover=p;
706+
if(p){ const r=cv.getBoundingClientRect(); tip.style.display='block'; tip.style.left=(ev.clientX-r.left)+'px'; tip.style.top=(ev.clientY-r.top)+'px';
707+
tip.innerHTML=`<b>${p.name||p.smiles}</b><br><span style="color:${MAP_COLORS[p.label]}">●</span> ${p.label} · <span style="opacity:.8">${p.smiles}</span>`; }
708+
else tip.style.display='none';
709+
});
710+
cv.addEventListener('mouseleave', ()=>{ tip.style.display='none'; });
711+
cv.addEventListener('wheel', ev=>{ if(_mapMode!=='2d')return; ev.preventDefault(); _v2.s*=(ev.deltaY<0?1.12:0.89); drawMap(); }, {passive:false});
712+
cv.addEventListener('click', ()=>{ if(_dragged){ _dragged=false; return; } const p=_mapLastHover; if(p){ q.value=p.smiles; window.scrollTo({top:0,behavior:'smooth'}); run(); } });
663713
}
714+
let _mapLastHover=null;
664715

665716
// --- mixture mode ---
666717
function mixAddRow(val){

0 commit comments

Comments
 (0)