바이올린 플롯

Violin plot

박스플롯의 다섯 숫자 요약 대신, 커널 밀도 추정 곡선을 좌우로 대칭시켜 분포의 실제 모양을 바이올린처럼 보여주는 차트.

다른 이름: Kernel density plot (mirrored)
···
html
<div class="viz">
  <svg id="svg"></svg>
</div>
css
:root{--viz-4:#e8a23a;--viz-5:#4098d7}
body{display:block}
.viz{position:relative;width:100%;height:100%}
svg{display:block;width:100%;height:100%;overflow:visible}
js
var svg = document.getElementById('svg');
var NS = 'http://www.w3.org/2000/svg';
function se(t){ return document.createElementNS(NS,t); }
function sa(e,o){ for (var k in o) e.setAttribute(k,o[k]); }
function R(a,b){ return a + Math.random()*(b-a); }

var CATS = [
  { name:'iOS', color:'var(--accent)' },
  { name:'Android', color:'var(--viz-4)' },
  { name:'Web', color:'var(--accent-3)' },
  { name:'Desktop', color:'var(--viz-5)' }
];
var NSAMP=50, MGRID=32;
var grid=[]; for (var g=0; g<=MGRID; g++) grid.push(g*100/MGRID);
var cur = CATS.map(function(){ return { density: grid.map(function(){ return 0.1; }), median:50 }; });
var W=300, H=200;

function measure(){
  var r = svg.getBoundingClientRect();
  W = Math.max(r.width,10); H = Math.max(r.height,10);
  sa(svg,{ viewBox: '0 0 ' + W + ' ' + H });
}

function samples(mean,spread){
  var out=[];
  for (var i=0;i<NSAMP;i++){
    var s=0; for (var k=0;k<4;k++) s+=Math.random();
    out.push(Math.max(1,Math.min(99, mean+(s/4-0.5)*2*spread)));
  }
  return out;
}

function kde(xs, g, h){
  return g.map(function(gv){
    var s=0;
    xs.forEach(function(x){ var u=(gv-x)/h; s += Math.exp(-0.5*u*u); });
    return s/(xs.length*h*Math.sqrt(2*Math.PI));
  });
}

function regen(){
  return CATS.map(function(){
    var pm = { mean:R(30,72), spread:R(9,22) };
    var xs = samples(pm.mean,pm.spread);
    var h = Math.max(4, pm.spread*0.5);
    var d = kde(xs, grid, h);
    var maxd = Math.max.apply(null, d.concat([0.001]));
    var norm = d.map(function(v){ return v/maxd; });
    xs.sort(function(a,b){ return a-b; });
    var median = xs[Math.floor(xs.length/2)];
    return { density:norm, median:median };
  });
}

function draw(){
  svg.innerHTML = '';
  var padL=14,padR=14,padT=10,padB=18;
  var pw=W-padL-padR, ph=H-padT-padB;
  var n=CATS.length;
  var colW = pw/n;
  var halfMax = colW*0.36;
  var fs = Math.max(8,Math.min(10,W/48));
  CATS.forEach(function(cat,ci){
    var cx = padL+colW*(ci+0.5);
    var dens = cur[ci].density;
    var leftPts=[], rightPts=[];
    dens.forEach(function(v,gi){
      var y = padT+ph-(grid[gi]/100)*ph;
      leftPts.push({ x:cx-v*halfMax, y:y });
      rightPts.push({ x:cx+v*halfMax, y:y });
    });
    var d = 'M'+leftPts[0].x.toFixed(1)+','+leftPts[0].y.toFixed(1);
    leftPts.forEach(function(p){ d += ' L'+p.x.toFixed(1)+','+p.y.toFixed(1); });
    for (var k=rightPts.length-1;k>=0;k--){ d += ' L'+rightPts[k].x.toFixed(1)+','+rightPts[k].y.toFixed(1); }
    d += ' Z';
    var shape = se('path');
    sa(shape,{ d:d, fill:cat.color, 'fill-opacity':0.5, stroke:cat.color, 'stroke-width':1.4 });
    svg.appendChild(shape);
    var my = padT+ph-(cur[ci].median/100)*ph;
    var mtick = se('line');
    sa(mtick,{ x1:cx-halfMax*0.55, y1:my, x2:cx+halfMax*0.55, y2:my, stroke:'var(--fg)', 'stroke-width':2 });
    svg.appendChild(mtick);
    var lab = se('text');
    sa(lab,{ x:cx, y:padT+ph+13, 'text-anchor':'middle', fill:'var(--muted)', 'font-size':fs, 'font-weight':600 });
    lab.textContent = cat.name;
    svg.appendChild(lab);
  });
}

function animate(target){
  var from = cur.map(function(c){ return { density:c.density.slice(), median:c.median }; });
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/850);
    var e = 1-Math.pow(1-p,3);
    cur = from.map(function(c,i){
      return {
        density: c.density.map(function(v,gi){ return v+(target[i].density[gi]-v)*e; }),
        median: c.median+(target[i].median-c.median)*e
      };
    });
    draw();
    if (p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

new ResizeObserver(function(){ measure(); draw(); }).observe(svg);
measure();
draw();
animate(regen());
setInterval(function(){ animate(regen()); }, 4000);

박스플롯과 같은 데이터(그룹별 분포 비교)를 다루지만, 박스플롯이 최소·1사분위·중앙값·3사분위·최대값 다섯 숫자로 요약하는 반면 바이올린 플롯은 그 분포를 부드러운 곡선(커널 밀도 추정, KDE)으로 그린 다음 좌우로 대칭 복사해 폭으로 보여줍니다. 그래서 봉우리가 하나가 아니라 둘 이상인 분포(다봉분포)를 박스플롯은 평평한 사각형 하나로 감춰버리지만, 바이올린은 그 두 봉우리를 그대로 드러냅니다.

KDE는 표본 하나하나를 종 모양(가우시안) 커널로 흐릿하게 펴 바른 다음 전부 더해서 곡선을 만듭니다. 이때 "얼마나 흐릿하게 펴는가"를 정하는 대역폭(bandwidth)이 결과를 크게 바꿉니다 — 대역폭이 너무 좁으면 표본 하나하나가 뾰족한 혹으로 남아 노이즈를 실제 구조처럼 보여주고, 너무 넓으면 진짜 다봉분포조차 뭉개져 매끈한 언덕 하나로 보입니다. 즉 바이올린의 모양은 데이터만이 아니라 대역폭이라는 선택에도 좌우됩니다.

곡선의 부드러운 윤곽만 보고 사분위수나 표본 수 같은 구체적인 숫자를 짐작할 수는 없다는 게 흔히 지나치는 약점입니다 — 그래서 이 데모처럼 중앙값 눈금을 하나 얹거나, 실무에서는 얇은 박스플롯을 바이올린 안에 겹쳐 그려 구체적인 숫자를 함께 보여주는 조합이 자주 쓰입니다.

언제 쓰나

그룹별 분포를 비교하면서 다봉분포나 치우침 같은 모양의 차이까지 보고 싶을 때. 표본이 매우 적으면(20개 미만) 곡선이 노이즈를 구조처럼 보여줄 수 있으니 비스웜이 낫습니다.