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{ // 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.`); });