ensemble / app.js
nanye's picture
support model random seed
f13c9e5
Raw History Blame Contribute Delete
12.3 kB
const $=id=>document.getElementById(id);
let points=[],models=[],label=-1,drawing=false,last=null,keyboard={x:.5,y:.5},showCursor=false;
const colors={'-1':'#557cd4','1':'#e39a57','2':'#42a58b'};
const classNames={'-1':'A','1':'B','2':'C'},regionColors={'-1':'#e1e9fb','1':'#fae8d8','2':'#dbf1e9'};
const modelRegionColors={'-1':'#eaf0fc','1':'#fcefe4','2':'#e6f5f0'};
let hoveredModel=null,backendReady=false,busy=false,revision=0,classifiers=[];
let backend;
let customPoints=[],datasetType='custom';
function isCustomDataset(){return datasetType==='custom';}
function sampleWeights(){return $('method').value==='boosting'&&points.length?Ensemble.weightsFor(points,models):null;}
function pointRadius(weight){return 4.5*Math.sqrt(weight===undefined?1:weight*points.length);}
function hideTooltip(){ $('point-tooltip').hidden=true; }
function hoverPoint(e){
const weights=sampleWeights();
if(!weights){hideTooltip();return;}
const rect=$('boundary').getBoundingClientRect(),w=rect.width-2,h=rect.height-2;
const x=e.clientX-rect.left-1,y=e.clientY-rect.top-1;
let hit=-1,best=Infinity;
points.forEach((p,i)=>{
const distance=Math.hypot(p.x*w-x,(1-p.y)*h-y);
if(distance<=Math.max(6,pointRadius(weights[i]))&&distance<best){hit=i;best=distance;}
});
if(hit<0){hideTooltip();return;}
const p=points[hit],tip=$('point-tooltip');
tip.textContent=`Class ${classNames[p.label]} · x: ${p.x.toFixed(3)} · y: ${p.y.toFixed(3)}\nSample weight: ${weights[hit].toPrecision(4)} (${(weights[hit]*100).toFixed(2)}%)`;
tip.hidden=false;
tip.style.left=`${Math.max(8,Math.min(e.clientX+14,window.innerWidth-tip.offsetWidth-8))}px`;
tip.style.top=`${Math.max(8,Math.min(e.clientY+14,window.innerHeight-tip.offsetHeight-8))}px`;
}
function previewModel(index){hoveredModel=index;hideTooltip();paint($('boundary'),true);}
function paint(canvas,boundary=false) {
const rect=canvas.getBoundingClientRect?.();
// Draw in CSS pixels, with a separate high-resolution backing buffer.
// Account for the canvas's 1px border on each side.
const w=rect?rect.width-2:canvas.width,h=rect?rect.height-2:canvas.height;
if(w<=0||h<=0) return;
const ratio=globalThis.devicePixelRatio||1;
const bufferWidth=Math.max(1,Math.round(w*ratio)),bufferHeight=Math.max(1,Math.round(h*ratio));
if(canvas.width!==bufferWidth) canvas.width=bufferWidth;
if(canvas.height!==bufferHeight) canvas.height=bufferHeight;
const ctx=canvas.getContext('2d');
ctx.setTransform(bufferWidth/w,0,0,bufferHeight/h,0,0);
ctx.clearRect(0,0,w,h);
const preview=hoveredModel===null?null:models[hoveredModel];
if(boundary&&models.length) for(let x=0;x<w;x+=6) for(let y=0;y<h;y+=6) {
const point={x:x/w,y:1-y/h};
ctx.fillStyle=preview?modelRegionColors[Ensemble.predict(preview.tree,point)]:regionColors[Ensemble.vote(models,point)];ctx.fillRect(x,y,6,6);
}
ctx.strokeStyle='#e9edf4';ctx.lineWidth=1;
for(let i=1;i<10;i++){ctx.beginPath();ctx.moveTo(i*w/10,0);ctx.lineTo(i*w/10,h);ctx.moveTo(0,i*h/10);ctx.lineTo(w,i*h/10);ctx.stroke();}
const weights=sampleWeights();
points.forEach((p,i)=>{
// Circle area is proportional to sample weight; uniform weights keep the default size.
const radius=pointRadius(weights?.[i]);
ctx.beginPath();ctx.arc(p.x*w,(1-p.y)*h,radius,0,2*Math.PI);ctx.fillStyle=colors[p.label];ctx.fill();ctx.strokeStyle='white';ctx.lineWidth=Math.min(1.3,radius/3);ctx.stroke();
});
if(showCursor){ctx.strokeStyle='#26334c';ctx.strokeRect(keyboard.x*w-7,(1-keyboard.y)*h-7,14,14);}
}
function render(message) {
hoveredModel=null;hideTooltip();
paint($('boundary'),true);
$('count').textContent=models.length;$('points').textContent=points.length;
$('accuracy').textContent=models.length?`${Math.round(100*points.filter((p,i)=>Ensemble.trainingVote(models,i)===p.label).length/points.length)}%`:'—';
$('empty').hidden=!!models.length || !!points.length;
$('class-count').textContent=[-1,1,2].map(c=>`${classNames[c]}: ${points.filter(p=>p.label===c).length}`).join(' · ');
$('remove').disabled=busy||!models.length;$('undo').disabled=!isCustomDataset()||!points.length;$('clear').disabled=!isCustomDataset()||!points.length;
$('add').disabled=busy||!backendReady||new Set(points.map(p=>p.label)).size<2||models.length>=100||($('method').value==='boosting'&&!classifiers.find(c=>c.name===$('base').value)?.weighted);
$('method-badge').textContent={boosting:'AdaBoost',bagging:'Bagging',random_forest:'Random Forest',extra_trees:'Extra Trees'}[$('method').value];
$('models').replaceChildren(...models.map((m,i)=>{
const el=document.createElement('span');el.className='model-chip';el.tabIndex=0;
const errorRate=100*points.filter((p,j)=>m.predictions[j]!==p.label).length/points.length;
const weights=$('method').value==='boosting'?Ensemble.weightsFor(points,models.slice(0,i)):null;
const weightedError=weights?100*points.reduce((sum,p,j)=>sum+(m.predictions[j]!==p.label?weights[j]:0),0):errorRate;
el.textContent=`${m.forest?'Tree':'Model'} ${i+1}`;el.title=`Training error: ${errorRate.toFixed(1)}%\nWeighted error: ${weightedError.toFixed(1)}%\nVote weight: ${m.alpha.toFixed(3)}`;
el.onmouseenter=el.onfocus=()=>previewModel(i);
el.onmouseleave=el.onblur=()=>previewModel(null);
return el;
}));
if(message!==undefined) $('status').textContent=message;
}
function reset(){revision++;models=[];render('Ensemble reset. Add a model when your dataset is ready.');}
function addPoint(p){if(!isCustomDataset())return;if(points.length>=1500){render('Dataset limit reached (1,500 points).');return;}points.push({...p,label});revision++;models=[];render('Dataset updated. Add a model to start training.');}
document.querySelectorAll('[data-label]').forEach(button=>button.onclick=()=>{label=Number(button.dataset.label);document.querySelectorAll('[data-label]').forEach(b=>{b.classList.toggle('selected',b===button);b.setAttribute('aria-pressed',String(b===button));});});
function position(e){const r=$('boundary').getBoundingClientRect();return {x:Math.max(0,Math.min(1,(e.clientX-r.left-1)/(r.width-2))),y:Math.max(0,Math.min(1,1-(e.clientY-r.top-1)/(r.height-2)))};}
$('boundary').onpointerdown=e=>{if(e.button!==0||!isCustomDataset())return;drawing=true;showCursor=false;$('boundary').setPointerCapture(e.pointerId);last=position(e);addPoint(last);};
$('boundary').onpointermove=e=>{if(!drawing){hoverPoint(e);return;}const p=position(e);if(Math.hypot(p.x-last.x,p.y-last.y)>.025){last=p;addPoint(p);}};
$('boundary').onpointerleave=hideTooltip;
for(const event of ['pointerup','pointercancel','lostpointercapture']) $('boundary').addEventListener(event,()=>{drawing=false;last=null;});
$('boundary').onkeydown=e=>{if(!isCustomDataset())return;const moves={ArrowLeft:[-.02,0],ArrowRight:[.02,0],ArrowUp:[0,.02],ArrowDown:[0,-.02]};if(moves[e.key]){e.preventDefault();showCursor=true;keyboard.x=Math.max(0,Math.min(1,keyboard.x+moves[e.key][0]));keyboard.y=Math.max(0,Math.min(1,keyboard.y+moves[e.key][1]));paint($('boundary'),true);}else if(e.key===' '||e.key==='Enter'){e.preventDefault();addPoint(keyboard);}};
$('undo').onclick=()=>{if(!isCustomDataset())return;points.pop();reset();};$('clear').onclick=()=>{if(!isCustomDataset())return;points=[];reset();};
$('dataset-type').onchange=()=>{
if(isCustomDataset())customPoints=points.map(p=>({...p}));
datasetType=$('dataset-type').value;
points=isCustomDataset()?customPoints.map(p=>({...p})):Datasets.create(datasetType);
drawing=false;last=null;showCursor=false;
document.querySelectorAll('[data-label]').forEach(button=>{button.disabled=!isCustomDataset();});
$('boundary').style.cursor=isCustomDataset()?'crosshair':'default';
$('boundary').setAttribute('aria-label',isCustomDataset()?'Draw training points and view the ensemble decision boundary. Click or drag to add the selected class. Arrow keys move the keyboard cursor; Space or Enter adds a point.':'View the selected dataset and ensemble decision boundary.');
$('dataset-help').textContent=isCustomDataset()?'Draw on the decision-boundary plot':datasetType==='xor'?'For AdaBoost with decision trees, use {"max_depth": 2}.':'Select Custom to draw your own dataset.';
reset();
};
function updateClassifiers(){
const forest=['random_forest','extra_trees'].includes($('method').value);
$('base').disabled=forest||!backendReady;
$('params-help').textContent=forest?'Forest arguments, e.g. {"max_depth": 3, "max_features": "sqrt"}. Add/Remove sets the tree count.':'Use {} for defaults. Example: {"max_depth": 2}';
$('add').textContent=forest?'+ Add one tree':'+ Add one model';
$('remove').textContent=forest?'↶ Remove last tree':'↶ Remove last model';
const selected=forest?'DecisionTreeClassifier':$('base').value;
$('base').replaceChildren(...classifiers.filter(c=>!['ExtraTreeClassifier','ExtraTreesClassifier'].includes(c.name)).map(c=>{
const option=document.createElement('option');option.value=c.name;
option.textContent=c.name+(c.weighted?'':' (bagging only)');
option.disabled=$('method').value==='boosting'&&!c.weighted;
return option;
}));
$('base').value=selected;
if(!classifiers.some(c=>c.name===selected&&($('method').value!=='boosting'||c.weighted))){
$('base').value='DecisionTreeClassifier';$('params').value='{}';
}
}
$('method').onchange=()=>{if(['random_forest','extra_trees'].includes($('method').value)||$('base').disabled)$('params').value='{}';updateClassifiers();reset();};
$('base').onchange=()=>{$('params').value='{}';reset();};
$('params').oninput=reset;
$('seed').oninput=()=>{$('seed').removeAttribute('aria-invalid');reset();};
$('add').onclick=async()=>{
if($('add').disabled)return;
const seed=Number($('seed').value);
if(!$('seed').value.trim()||!Number.isInteger(seed)||seed<0||seed>4294967295){
$('seed').setAttribute('aria-invalid','true');
render('Invalid random seed: enter an integer from 0 to 4294967295.');return;
}
$('seed').removeAttribute('aria-invalid');
let params;
try{
params=JSON.parse($('params').value.trim()||'{}');
if(!params||Array.isArray(params)||typeof params!=='object')throw new Error('Enter a JSON object for classifier arguments.');
$('params').removeAttribute('aria-invalid');
}catch(error){$('params').setAttribute('aria-invalid','true');render(`Invalid arguments: ${error.message}`);return;}
const currentRevision=revision;
const request={points:points.map(p=>({...p})),count:models.length,method:$('method').value,classifier:$('base').value,params,seed};
busy=true;render('Training one scikit-learn model…');
try{
const model=await backend.add(request);
if(currentRevision!==revision)return;
models.push(model);
render(`Model ${models.length} added.${model.perfect?' Perfect fit; further models may leave the boundary unchanged.':''}${model.warning?' '+model.warning:''}`);
}catch(error){if(currentRevision===revision)render(`Training failed: ${error.message}`);}
finally{busy=false;render();}
};
$('remove').onclick=()=>{models.pop();render('Last model removed. The previous ensemble is restored.');};
render('Choose a class and draw points on the decision-boundary plot, or select a built-in dataset.');
// Match the drawing buffer to the responsive plot so points remain circular.
if(typeof ResizeObserver!=='undefined') {
new ResizeObserver(()=>paint($('boundary'),true)).observe($('boundary'));
}
function setRuntimeStatus(state,text){
$('runtime-status').setAttribute('data-state',state);
$('runtime-status-text').textContent=text;
}
setRuntimeStatus('loading','Loading Pythong runtime...');
backend=new PythonBackend(message=>{if(!backendReady)$('status').textContent=message;});
$('status').textContent='Loading Python and scikit-learn… You can draw while they load.';
backend.ready.then(available=>{
classifiers=available;backendReady=true;$('base').disabled=false;updateClassifiers();
setRuntimeStatus('ready','Python runtime ready');
render('scikit-learn is ready. Draw at least two classes and add a model.');
}).catch(error=>{
backendReady=false;
setRuntimeStatus('error','Python runtime failed to load');
render(`Could not load Python: ${error.message} Serve this app over HTTP and reload to retry.`);
});