nanye commited on
Commit
98fef72
·
1 Parent(s): 6ed536f

lighter color for basis decision boundary, pyodide status bar, tooltip error and weighted error

Browse files
Files changed (5) hide show
  1. README.md +1 -1
  2. app.js +20 -22
  3. app.test.js +36 -4
  4. index.html +5 -4
  5. style.css +5 -0
README.md CHANGED
@@ -26,7 +26,7 @@ Use `{}` or an empty box for sklearn defaults. Use JSON `true`, `false`, and `nu
26
 
27
  Each Add trains one real sklearn estimator. Python implements the incremental ensemble orchestration so removal restores the retained ensemble exactly, and additions remain possible after a perfect learner. Bagging trains on bootstrap samples and combines majority votes. Multiclass AdaBoost (SAMME) trains with sample weights, computes weighted votes, and rejects learners that cannot beat chance. Perfect learners receive a finite capped vote weight. Some classifiers require both classes or enough samples in each bootstrap sample; sklearn reports a training error if a sample does not meet those requirements.
28
 
29
- Dot area represents the sample weight for the next boosting step. Hover a point for its coordinates and weight. Hover or keyboard-focus a model chip to preview its boundary over the ensemble. Grid predictions are computed in Python on a 160 × 160 mesh; training accuracy and sample weights use exact training predictions. Arrow keys and Space/Enter add points on the focused canvas.
30
 
31
  Editing data or configuration clears the ensemble. Results from an in-progress fit are discarded if the data or settings change. Limits: 1,500 points and 100 learners. Accuracy is training accuracy, not held-out accuracy.
32
 
 
26
 
27
  Each Add trains one real sklearn estimator. Python implements the incremental ensemble orchestration so removal restores the retained ensemble exactly, and additions remain possible after a perfect learner. Bagging trains on bootstrap samples and combines majority votes. Multiclass AdaBoost (SAMME) trains with sample weights, computes weighted votes, and rejects learners that cannot beat chance. Perfect learners receive a finite capped vote weight. Some classifiers require both classes or enough samples in each bootstrap sample; sklearn reports a training error if a sample does not meet those requirements.
28
 
29
+ Dot area represents the sample weight for the next boosting step. Hover a point for its coordinates and weight. Hover or keyboard-focus a model chip to temporarily replace the ensemble boundary with that learner’s filled decision regions, using the same colors. Moving away restores the ensemble. Grid predictions are computed in Python on a 160 × 160 mesh; training accuracy and sample weights use exact training predictions. Arrow keys and Space/Enter add points on the focused canvas.
30
 
31
  Editing data or configuration clears the ensemble. Results from an in-progress fit are discarded if the data or settings change. Limits: 1,500 points and 100 learners. Accuracy is training accuracy, not held-out accuracy.
32
 
app.js CHANGED
@@ -2,6 +2,7 @@ const $=id=>document.getElementById(id);
2
  let points=[],models=[],label=-1,drawing=false,last=null,keyboard={x:.5,y:.5},showCursor=false;
3
  const colors={'-1':'#557cd4','1':'#e39a57','2':'#42a58b'};
4
  const classNames={'-1':'A','1':'B','2':'C'},regionColors={'-1':'#e1e9fb','1':'#fae8d8','2':'#dbf1e9'};
 
5
  let hoveredModel=null,backendReady=false,busy=false,revision=0,classifiers=[];
6
  let backend;
7
  function sampleWeights(){return $('method').value==='boosting'&&points.length?Ensemble.weightsFor(points,models):null;}
@@ -38,29 +39,13 @@ function paint(canvas,boundary=false) {
38
  const ctx=canvas.getContext('2d');
39
  ctx.setTransform(bufferWidth/w,0,0,bufferHeight/h,0,0);
40
  ctx.clearRect(0,0,w,h);
 
41
  if(boundary&&models.length) for(let x=0;x<w;x+=6) for(let y=0;y<h;y+=6) {
42
- ctx.fillStyle=regionColors[Ensemble.vote(models,{x:x/w,y:1-y/h})];ctx.fillRect(x,y,6,6);
 
43
  }
44
  ctx.strokeStyle='#e9edf4';ctx.lineWidth=1;
45
  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();}
46
- if(hoveredModel!==null&&models[hoveredModel]){
47
- const model=models[hoveredModel].tree,step=3;
48
- // Trace class transitions, keeping the ensemble's filled regions visible underneath.
49
- ctx.strokeStyle='#873fc5';ctx.lineWidth=2;ctx.beginPath();
50
- for(let x=0;x<w;x+=step) for(let y=0;y<h;y+=step){
51
- const prediction=Ensemble.predict(model,{x:(x+step/2)/w,y:1-(y+step/2)/h});
52
- if(x+step<w&&prediction!==Ensemble.predict(model,{x:(x+1.5*step)/w,y:1-(y+step/2)/h})){
53
- ctx.moveTo(x+step,y);ctx.lineTo(x+step,Math.min(h,y+step));
54
- }
55
- if(y+step<h&&prediction!==Ensemble.predict(model,{x:(x+step/2)/w,y:1-(y+1.5*step)/h})){
56
- ctx.moveTo(x,y+step);ctx.lineTo(Math.min(w,x+step),y+step);
57
- }
58
- }
59
- ctx.stroke();
60
- ctx.fillStyle='#ffffffeb';ctx.fillRect(8,8,220,28);
61
- ctx.fillStyle='#873fc5';ctx.font='12px system-ui';
62
- ctx.fillText(`Model ${hoveredModel+1} boundary${model.grid.every(v=>v===model.grid[0])?' · constant prediction':''}`,16,26);
63
- }
64
  const weights=sampleWeights();
65
  points.forEach((p,i)=>{
66
  // Circle area is proportional to sample weight; uniform weights keep the default size.
@@ -78,10 +63,13 @@ function render(message) {
78
  $('class-count').textContent=[-1,1,2].map(c=>`${classNames[c]}: ${points.filter(p=>p.label===c).length}`).join(' · ');
79
  $('remove').disabled=busy||!models.length;$('undo').disabled=!points.length;$('clear').disabled=!points.length;
80
  $('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);
81
- $('method-badge').textContent={boosting:'Boosting',bagging:'Bagging',random_forest:'Random Forest',extra_trees:'Extra Trees'}[$('method').value];
82
  $('models').replaceChildren(...models.map((m,i)=>{
83
  const el=document.createElement('span');el.className='model-chip';el.tabIndex=0;
84
- el.textContent=`${m.forest?'Tree':'Model'} ${i+1}`;el.title=`Vote weight: ${m.alpha.toFixed(3)}. Hover or focus to preview boundary.`;
 
 
 
85
  el.onmouseenter=el.onfocus=()=>previewModel(i);
86
  el.onmouseleave=el.onblur=()=>previewModel(null);
87
  return el;
@@ -146,9 +134,19 @@ if(typeof ResizeObserver!=='undefined') {
146
  new ResizeObserver(()=>paint($('boundary'),true)).observe($('boundary'));
147
  }
148
 
 
 
 
 
 
149
  backend=new PythonBackend(message=>{if(!backendReady)$('status').textContent=message;});
150
  $('status').textContent='Loading Python and scikit-learn… You can draw while they load.';
151
  backend.ready.then(available=>{
152
  classifiers=available;backendReady=true;$('base').disabled=false;updateClassifiers();
 
153
  render('scikit-learn is ready. Draw at least two classes and add a model.');
154
- }).catch(error=>render(`Could not load Python: ${error.message} Serve this app over HTTP and reload to retry.`));
 
 
 
 
 
2
  let points=[],models=[],label=-1,drawing=false,last=null,keyboard={x:.5,y:.5},showCursor=false;
3
  const colors={'-1':'#557cd4','1':'#e39a57','2':'#42a58b'};
4
  const classNames={'-1':'A','1':'B','2':'C'},regionColors={'-1':'#e1e9fb','1':'#fae8d8','2':'#dbf1e9'};
5
+ const modelRegionColors={'-1':'#eaf0fc','1':'#fcefe4','2':'#e6f5f0'};
6
  let hoveredModel=null,backendReady=false,busy=false,revision=0,classifiers=[];
7
  let backend;
8
  function sampleWeights(){return $('method').value==='boosting'&&points.length?Ensemble.weightsFor(points,models):null;}
 
39
  const ctx=canvas.getContext('2d');
40
  ctx.setTransform(bufferWidth/w,0,0,bufferHeight/h,0,0);
41
  ctx.clearRect(0,0,w,h);
42
+ const preview=hoveredModel===null?null:models[hoveredModel];
43
  if(boundary&&models.length) for(let x=0;x<w;x+=6) for(let y=0;y<h;y+=6) {
44
+ const point={x:x/w,y:1-y/h};
45
+ ctx.fillStyle=preview?modelRegionColors[Ensemble.predict(preview.tree,point)]:regionColors[Ensemble.vote(models,point)];ctx.fillRect(x,y,6,6);
46
  }
47
  ctx.strokeStyle='#e9edf4';ctx.lineWidth=1;
48
  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();}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  const weights=sampleWeights();
50
  points.forEach((p,i)=>{
51
  // Circle area is proportional to sample weight; uniform weights keep the default size.
 
63
  $('class-count').textContent=[-1,1,2].map(c=>`${classNames[c]}: ${points.filter(p=>p.label===c).length}`).join(' · ');
64
  $('remove').disabled=busy||!models.length;$('undo').disabled=!points.length;$('clear').disabled=!points.length;
65
  $('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);
66
+ $('method-badge').textContent={boosting:'AdaBoost',bagging:'Bagging',random_forest:'Random Forest',extra_trees:'Extra Trees'}[$('method').value];
67
  $('models').replaceChildren(...models.map((m,i)=>{
68
  const el=document.createElement('span');el.className='model-chip';el.tabIndex=0;
69
+ const errorRate=100*points.filter((p,j)=>m.predictions[j]!==p.label).length/points.length;
70
+ const weights=$('method').value==='boosting'?Ensemble.weightsFor(points,models.slice(0,i)):null;
71
+ const weightedError=weights?100*points.reduce((sum,p,j)=>sum+(m.predictions[j]!==p.label?weights[j]:0),0):errorRate;
72
+ 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)}`;
73
  el.onmouseenter=el.onfocus=()=>previewModel(i);
74
  el.onmouseleave=el.onblur=()=>previewModel(null);
75
  return el;
 
134
  new ResizeObserver(()=>paint($('boundary'),true)).observe($('boundary'));
135
  }
136
 
137
+ function setRuntimeStatus(state,text){
138
+ $('runtime-status').setAttribute('data-state',state);
139
+ $('runtime-status-text').textContent=text;
140
+ }
141
+ setRuntimeStatus('loading','Loading Pythong runtime...');
142
  backend=new PythonBackend(message=>{if(!backendReady)$('status').textContent=message;});
143
  $('status').textContent='Loading Python and scikit-learn… You can draw while they load.';
144
  backend.ready.then(available=>{
145
  classifiers=available;backendReady=true;$('base').disabled=false;updateClassifiers();
146
+ setRuntimeStatus('ready','Python runtime ready');
147
  render('scikit-learn is ready. Draw at least two classes and add a model.');
148
+ }).catch(error=>{
149
+ backendReady=false;
150
+ setRuntimeStatus('error','Python runtime failed to load');
151
+ render(`Could not load Python: ${error.message} Serve this app over HTTP and reload to retry.`);
152
+ });
app.test.js CHANGED
@@ -6,25 +6,36 @@ const Ensemble=require('./ensemble.js');
6
 
7
  test('async training ignores stale results, accepts parameters and preserves hover/removal',async()=>{
8
  const elements=new Map(),calls=[];
9
- const ctx=new Proxy({}, {get:()=>()=>{}});
 
10
  const document={
11
  getElementById(id){
12
  if(!elements.has(id)) elements.set(id,{
13
  width:6,height:6,value:id==='method'?'boosting':id==='params'?'{}':'DecisionTreeClassifier',
14
  getContext:()=>ctx,addEventListener(){},replaceChildren(...children){this.children=children;},
15
- setAttribute(){},removeAttribute(){},style:{},offsetWidth:240,offsetHeight:60,
16
  });
17
  return elements.get(id);
18
  },querySelectorAll:()=>[],createElement:()=>({}),
19
  };
 
20
  class PythonBackend{
21
- ready=Promise.resolve([{name:'DecisionTreeClassifier',weighted:true}]);
22
  add(request){return new Promise(resolve=>calls.push({request,resolve}));}
23
  }
24
  const sandbox=vm.createContext({document,Ensemble,PythonBackend,window:{innerWidth:1000,innerHeight:800}});
25
  vm.runInContext(fs.readFileSync(require.resolve('./app.js'),'utf8'),sandbox);
26
- await Promise.resolve();
27
  const get=id=>document.getElementById(id);
 
 
 
 
 
 
 
 
 
 
28
  vm.runInContext('points=[{x:.2,y:.2,label:-1},{x:.8,y:.8,label:1}]; reset();',sandbox);
29
  const model={tree:{size:2,grid:[-1,-1,1,1]},predictions:[-1,1],alpha:1,perfect:true};
30
  let pending=get('add').onclick();
@@ -37,6 +48,7 @@ test('async training ignores stale results, accepts parameters and preserves hov
37
  calls[i].resolve(model);await pending;
38
  assert.equal(get('count').textContent,i);assert.equal(get('add').disabled,false);
39
  }
 
40
  get('models').children[0].onmouseenter();assert.equal(vm.runInContext('hoveredModel',sandbox),0);
41
  get('models').children[0].onmouseleave();assert.equal(vm.runInContext('hoveredModel',sandbox),null);
42
  get('remove').onclick();assert.equal(get('count').textContent,1);
@@ -45,4 +57,24 @@ test('async training ignores stale results, accepts parameters and preserves hov
45
  assert.match(get('point-tooltip').textContent,/Sample weight: 0.5000/);
46
  get('params').value='[]';await get('add').onclick();
47
  assert.match(get('status').textContent,/Invalid arguments/);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  });
 
6
 
7
  test('async training ignores stale results, accepts parameters and preserves hover/removal',async()=>{
8
  const elements=new Map(),calls=[];
9
+ const fills=[];
10
+ const ctx=new Proxy({}, {get:(target,key)=>key==='fillRect'?()=>fills.push(target.fillStyle):(key in target?target[key]:()=>{})});
11
  const document={
12
  getElementById(id){
13
  if(!elements.has(id)) elements.set(id,{
14
  width:6,height:6,value:id==='method'?'boosting':id==='params'?'{}':'DecisionTreeClassifier',
15
  getContext:()=>ctx,addEventListener(){},replaceChildren(...children){this.children=children;},
16
+ setAttribute(key,value){this[key]=value;},removeAttribute(){},style:{},offsetWidth:240,offsetHeight:60,
17
  });
18
  return elements.get(id);
19
  },querySelectorAll:()=>[],createElement:()=>({}),
20
  };
21
+ let resolveReady;
22
  class PythonBackend{
23
+ ready=new Promise(resolve=>{resolveReady=resolve;});
24
  add(request){return new Promise(resolve=>calls.push({request,resolve}));}
25
  }
26
  const sandbox=vm.createContext({document,Ensemble,PythonBackend,window:{innerWidth:1000,innerHeight:800}});
27
  vm.runInContext(fs.readFileSync(require.resolve('./app.js'),'utf8'),sandbox);
 
28
  const get=id=>document.getElementById(id);
29
+ assert.equal(get('runtime-status')['data-state'],'loading');
30
+ assert.equal(get('runtime-status-text').textContent,'Loading Pythong runtime...');
31
+ get('example').onclick();
32
+ assert.equal(get('add').disabled,true);
33
+ resolveReady([{name:'DecisionTreeClassifier',weighted:true}]);
34
+ await Promise.resolve();
35
+ assert.equal(get('runtime-status')['data-state'],'ready');
36
+ assert.equal(get('runtime-status-text').textContent,'Python runtime ready');
37
+ assert.equal(get('add').disabled,false);
38
+ assert.equal(get('method-badge').textContent,'AdaBoost');
39
  vm.runInContext('points=[{x:.2,y:.2,label:-1},{x:.8,y:.8,label:1}]; reset();',sandbox);
40
  const model={tree:{size:2,grid:[-1,-1,1,1]},predictions:[-1,1],alpha:1,perfect:true};
41
  let pending=get('add').onclick();
 
48
  calls[i].resolve(model);await pending;
49
  assert.equal(get('count').textContent,i);assert.equal(get('add').disabled,false);
50
  }
51
+ assert.match(get('models').children[0].title,/Training error: 0\.0%/);
52
  get('models').children[0].onmouseenter();assert.equal(vm.runInContext('hoveredModel',sandbox),0);
53
  get('models').children[0].onmouseleave();assert.equal(vm.runInContext('hoveredModel',sandbox),null);
54
  get('remove').onclick();assert.equal(get('count').textContent,1);
 
57
  assert.match(get('point-tooltip').textContent,/Sample weight: 0.5000/);
58
  get('params').value='[]';await get('add').onclick();
59
  assert.match(get('status').textContent,/Invalid arguments/);
60
+ // The hovered learner disagrees with the ensemble across the entire plot.
61
+ vm.runInContext('models=[{classes:[-1,1,2],alpha:1,predictions:[2,2],tree:{size:1,grid:[2]}},{classes:[-1,1,2],alpha:2,predictions:[1,1],tree:{size:1,grid:[1]}}]; render();',sandbox);
62
+ assert.equal(fills.at(-1),'#fae8d8');
63
+ assert.match(get('models').children[0].title,/Training error: 100\.0%/);
64
+ assert.match(get('models').children[1].title,/Training error: 50\.0%/);
65
+ fills.length=0;get('models').children[0].onmouseenter();
66
+ assert.ok(fills.length>0);assert.ok(fills.every(color=>color==='#e6f5f0'));
67
+ fills.length=0;get('models').children[0].onmouseleave();
68
+ assert.ok(fills.every(color=>color==='#fae8d8'));
69
+ fills.length=0;get('models').children[0].onfocus();
70
+ assert.ok(fills.every(color=>color==='#e6f5f0'));
71
+ get('models').children[0].onblur();
72
+ // Each model's weighted error uses only the learners preceding it.
73
+ vm.runInContext('models=[{alpha:Math.log(3),predictions:[1,1],tree:{size:1,grid:[1]}},{alpha:2,predictions:[1,1],tree:{size:1,grid:[1]}}]; render();',sandbox);
74
+ assert.equal(get('models').children[0].title,'Training error: 50.0%\nWeighted error: 50.0%\nVote weight: 1.099');
75
+ assert.equal(get('models').children[1].title,'Training error: 50.0%\nWeighted error: 75.0%\nVote weight: 2.000');
76
+ get('method').value='bagging';
77
+ vm.runInContext('render();',sandbox);
78
+ assert.equal(get('models').children[1].title,'Training error: 50.0%\nWeighted error: 50.0%\nVote weight: 2.000');
79
+
80
  });
index.html CHANGED
@@ -2,13 +2,14 @@
2
  <html lang="en">
3
  <head>
4
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
5
- <title>Ensemble playground</title><link rel="stylesheet" href="style.css?v=multiclass-1">
6
  </head>
7
  <body>
8
  <main>
9
  <div class="intro"><h1>Ensemble playground</h1><p>Draw a dataset. Build an ensemble. Watch the boundary evolve.</p></div>
 
10
  <div class="layout">
11
- <section class="card result"><div class="card-heading"><div><h2>Decision boundary</h2></div><span id="method-badge" class="badge">Boosting</span></div>
12
  <div class="plot"><canvas id="boundary" width="600" height="600" tabindex="0" aria-label="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."></canvas><span class="axis x">Feature 1 →</span><span class="axis y">Feature 2 →</span><div id="empty">Choose a class on the right, then click or drag here to draw points</div></div>
13
  <div class="stats"><div><span>BASE MODELS</span><strong id="count">0</strong></div><div><span>TRAINING ACCURACY</span><strong id="accuracy">—</strong></div><div><span>DATA POINTS</span><strong id="points">0</strong></div></div>
14
  <div id="models" class="models" aria-label="Added base models"></div>
@@ -19,7 +20,7 @@
19
  <div class="canvas-caption">Draw on the decision-boundary plot · <span id="class-count">A: 0 · B: 0 · C: 0</span></div>
20
  <div class="dataset-actions"><button id="example">Load example</button><button id="undo">Undo point</button><button id="clear">Clear</button></div>
21
  <div class="divider"></div><h3><span class="step">2</span> Configure the ensemble</h3>
22
- <label for="method">Ensemble method</label><select id="method"><option value="boosting">Boosting (AdaBoost)</option><option value="bagging">Bagging</option><option value="random_forest">Random Forest</option><option value="extra_trees">Extra Trees</option></select>
23
  <label for="base">scikit-learn classifier</label><select id="base" disabled>
24
  <option value="DecisionTreeClassifier">DecisionTreeClassifier</option>
25
  </select>
@@ -32,5 +33,5 @@
32
  <p id="status" role="status" aria-live="polite"></p>
33
  </aside>
34
  </div>
35
- </main><div id="point-tooltip" role="tooltip" hidden></div><script src="ensemble.js?v=multiclass-1"></script><script src="backend-client.js?v=multiclass-1"></script><script src="app.js?v=multiclass-1"></script>
36
  </body></html>
 
2
  <html lang="en">
3
  <head>
4
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <title>Ensemble playground</title><link rel="stylesheet" href="style.css?v=runtime-status-1">
6
  </head>
7
  <body>
8
  <main>
9
  <div class="intro"><h1>Ensemble playground</h1><p>Draw a dataset. Build an ensemble. Watch the boundary evolve.</p></div>
10
+ <div id="runtime-status" class="runtime-status" role="status" aria-live="polite" data-state="loading"><span class="runtime-dot" aria-hidden="true"></span><span id="runtime-status-text">Loading Pythong runtime...</span></div>
11
  <div class="layout">
12
+ <section class="card result"><div class="card-heading"><div><h2>Decision boundary</h2></div><span id="method-badge" class="badge">AdaBoost</span></div>
13
  <div class="plot"><canvas id="boundary" width="600" height="600" tabindex="0" aria-label="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."></canvas><span class="axis x">Feature 1 →</span><span class="axis y">Feature 2 →</span><div id="empty">Choose a class on the right, then click or drag here to draw points</div></div>
14
  <div class="stats"><div><span>BASE MODELS</span><strong id="count">0</strong></div><div><span>TRAINING ACCURACY</span><strong id="accuracy">—</strong></div><div><span>DATA POINTS</span><strong id="points">0</strong></div></div>
15
  <div id="models" class="models" aria-label="Added base models"></div>
 
20
  <div class="canvas-caption">Draw on the decision-boundary plot · <span id="class-count">A: 0 · B: 0 · C: 0</span></div>
21
  <div class="dataset-actions"><button id="example">Load example</button><button id="undo">Undo point</button><button id="clear">Clear</button></div>
22
  <div class="divider"></div><h3><span class="step">2</span> Configure the ensemble</h3>
23
+ <label for="method">Ensemble method</label><select id="method"><option value="boosting">AdaBoost</option><option value="bagging">Bagging</option><option value="random_forest">Random Forest</option><option value="extra_trees">Extra Trees</option></select>
24
  <label for="base">scikit-learn classifier</label><select id="base" disabled>
25
  <option value="DecisionTreeClassifier">DecisionTreeClassifier</option>
26
  </select>
 
33
  <p id="status" role="status" aria-live="polite"></p>
34
  </aside>
35
  </div>
36
+ </main><div id="point-tooltip" role="tooltip" hidden></div><script src="ensemble.js?v=multiclass-1"></script><script src="backend-client.js?v=multiclass-1"></script><script src="app.js?v=weighted-error-1"></script>
37
  </body></html>
style.css CHANGED
@@ -40,3 +40,8 @@ textarea[aria-invalid="true"]{border-color:#c24d4d}
40
  .green{background:#42a58b}
41
  .toolbar{gap:6px}
42
  .class-button{padding:9px 10px;gap:6px}
 
 
 
 
 
 
40
  .green{background:#42a58b}
41
  .toolbar{gap:6px}
42
  .class-button{padding:9px 10px;gap:6px}
43
+
44
+ .runtime-status{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-shrink:0;margin-bottom:7px;font-size:11px;color:#6c788a}
45
+ .runtime-dot{width:8px;height:8px;border-radius:50%;background:#e39a57;flex-shrink:0}
46
+ .runtime-status[data-state="ready"] .runtime-dot{background:#42a58b}
47
+ .runtime-status[data-state="error"] .runtime-dot{background:#c24d4d}