KDE로 데이터 분포를 부드럽게 시각화하기
Kernel Density Estimation 구현기

프로젝트에서 데이터의 분포를 시각화해야 하는 요구사항이 있었다. 단순한 히스토그램 대신 부드러운 곡선으로 데이터 분포를 표현하고 싶었고, 이를 위해 Kernel Density Estimation (KDE) 을 구현해봤다.
KDE란 무엇인가?
KDE는 데이터의 확률 밀도 함수(PDF) 를 추정하는 비모수적 방법이다. 쉽게 말해서 히스토그램의 부드러운 버전이라고 생각하면 된다.
확률 밀도 함수가 뭔가요?
확률 밀도 함수는 "이 범위에 데이터가 얼마나 몰려 있는가"를 나타내는 함수다.
예를 들어, 반 학생들의 시험 점수가 있다고 해보자. 70~80점대에 많은 학생이 몰려 있다면, 그 구간의 밀도가 높다. 90점 이상은 적다면 밀도가 낮다. 이런 "몰려 있는 정도"를 부드러운 곡선으로 표현한 것이 확률 밀도 함수다.
곡선이 높은 곳 = 데이터가 많은 곳, 낮은 곳 = 데이터가 적은 곳이라고 이해하면 된다.
히스토그램은 데이터를 구간(bin)으로 나눠서 각 구간에 몇 개의 데이터가 있는지 세는 방식이다. 하지만 이 방법은 구간의 크기와 시작점에 따라 결과가 크게 달라질 수 있다. 같은 데이터라도 구간을 어떻게 나누느냐에 따라 전혀 다른 모양의 차트가 나올 수 있다는 뜻이다.
KDE는 각 데이터 포인트에 커널 함수를 씌워서 이를 모두 합산하는 방식으로 부드러운 곡선을 만들어낸다.
커널 함수, 직관적으로 이해하기
커널 함수는 각 데이터 포인트를 중심으로 퍼지는 종 모양의 함수이다. 가장 흔히 사용되는 것이 가우시안(정규분포) 커널이다.
쉽게 비유하면 이렇다. 데이터 포인트 하나하나가 모래 한 줌이라고 상상해보자. 이 모래를 숫자 위에 올려놓으면, 정확히 그 숫자에만 쌓이는 게 아니라 주변으로 살짝 퍼지면서 작은 언덕을 만든다. 이게 바로 커널 함수가 하는 일이다.
데이터 포인트가 많이 모여 있는 곳에서는 모래 언덕들이 겹쳐서 큰 산이 되고(높은 밀도), 데이터가 적은 곳에서는 낮은 언덕만 있게 된다(낮은 밀도). 이 모든 언덕을 합치면 부드러운 곡선이 완성된다.
핵심 개념: Bandwidth
KDE에서 가장 중요한 파라미터가 바로 bandwidth(대역폭, h) 이다. 이는 커널 함수의 퍼짐 정도를 결정한다.
위의 모래 비유로 다시 설명하면, bandwidth는 모래를 얼마나 넓게 뿌릴 것인가를 결정하는 값이다.
- bandwidth가 너무 작으면: 모래가 좁게 퍼진다 → 곡선이 울퉁불퉁해져서 노이즈에 민감해진다 (과적합)
- bandwidth가 너무 크면: 모래가 넓게 퍼진다 → 곡선이 너무 평평해져서 데이터의 특성이 사라진다 (과소적합)
적절한 bandwidth를 찾는 것이 KDE의 핵심이다!
과적합과 과소적합
과적합(Overfitting) 은 데이터의 노이즈까지 반영해서 곡선이 지나치게 복잡해지는 현상이다. 새로운 데이터가 들어오면 잘 맞지 않는다.
과소적합(Underfitting) 은 데이터의 패턴을 충분히 반영하지 못해 곡선이 지나치게 단순해지는 현상이다. 데이터에 두 개의 봉우리가 있어도 하나로 뭉개져 보일 수 있다.
Silverman의 경험적 규칙
bandwidth를 매번 수동으로 조절하기는 어렵다. 그래서 데이터로부터 적절한 bandwidth를 자동으로 계산하는 방법이 있는데, 대표적인 것이 Silverman의 경험적 규칙(Rule of Thumb) 이다.
/**
* Silverman의 경험적 규칙을 사용해 최적의 bandwidth 계산
* h = 0.9 * min(σ, IQR / 1.34) * n^(-1/5)
*/
export const calculateBandwidth = (data: number[]): number => {
if (data.length < 2) return 1;
const n = data.length;
const stdDev = calculateStdDev(data);
const iqr = calculateIQR(data);
// 표준편차와 정규화된 IQR 중 더 작은 값을 사용해
// 이상치에 강건한 scale 값 계산
const scale = Math.min(stdDev, iqr / 1.34);
// 모든 값이 동일하여 scale이 0인 경우
if (scale === 0) {
const mean = data.reduce((sum, val) => sum + val, 0) / n;
return mean * 0.1 || 1;
}
// Silverman의 경험적 규칙 적용
return 0.9 * scale * Math.pow(n, -0.2);
};이 공식의 각 부분이 하는 역할을 하나씩 살펴보자.
표준편차(σ)와 IQR
표준편차(σ, Standard Deviation) 는 데이터가 평균에서 얼마나 퍼져 있는지를 나타내는 값이다. 값이 크면 데이터가 넓게 흩어져 있고, 작으면 평균 주변에 모여 있다는 뜻이다.
IQR(사분위 범위, Interquartile Range) 은 데이터를 크기 순으로 정렬했을 때, 하위 25%(Q1)부터 상위 75%(Q3)까지의 범위이다. 전체 데이터의 가운데 50%가 차지하는 범위라고 이해하면 된다.
왜 표준편차와 IQR 중 작은 것을 선택할까?
표준편차는 이상치(극단적으로 크거나 작은 값) 에 민감하다. 예를 들어, 대부분의 응답 시간이 100~200ms인데 하나가 10,000ms라면 표준편차가 크게 부풀려진다.
IQR은 가운데 50% 데이터만 보기 때문에 이상치에 영향을 받지 않는다. 그래서 min(σ, IQR / 1.34) 처럼 둘 중 작은 값을 택하면, 이상치가 있어도 bandwidth가 지나치게 커지지 않는다.
왜 0.9와 1.34인가요?
1.34는 정규분포에서 IQR과 표준편차의 관계에서 나온 값이다. 정규분포를 따르는 데이터에서 IQR ≈ 1.34 × σ이므로, IQR / 1.34는 IQR을 표준편차와 같은 단위로 변환하는 역할을 한다.
0.9는 Silverman이 정규분포 데이터에서 평균 적분 제곱 오차(MISE) 를 최소화하도록 경험적으로 도출한 상수이다. 대부분의 실제 데이터에서 합리적인 결과를 만들어낸다.
n^(-1/5) (= n^(-0.2))는 데이터가 많아질수록 bandwidth가 줄어들게 하는 항이다. 데이터가 많아지면 분포를 더 세밀하게 추정할 수 있으므로, 커널의 퍼짐을 줄여 더 정밀한 곡선을 만드는 것이다.
가우시안 커널 함수
KDE의 핵심인 커널 함수는 다음과 같이 구현한다.
/**
* Gaussian 커널 함수
* K(x) = (1 / √(2π)) * e^(-x² / 2)
*/
export const gaussianKernel = (x: number): number => {
return Math.exp(-0.5 * x * x) / Math.sqrt(2 * Math.PI);
};이 함수는 표준 정규분포의 확률 밀도 함수와 동일하다. 입력값이 0일 때 최댓값(약 0.399)을 가지고, 0에서 멀어질수록 값이 급격히 감소한다.
왜 가우시안 커널을 쓸까?
커널 함수는 가우시안 외에도 균일(Uniform), 삼각형(Triangular), Epanechnikov 등 여러 종류가 있다. 그런데 실무에서는 거의 대부분 가우시안을 쓴다. 이유는 다음과 같다.
- 수학적으로 다루기 편하다: 미분, 적분이 깔끔하게 떨어진다
- 결과 차이가 크지 않다: 어떤 커널을 써도 충분한 데이터가 있으면 결과가 비슷하다
- 무한 지지(support): 가우시안은 양쪽 꼬리가 0에 수렴하지만 완전히 0이 되지는 않아서, 곡선이 부드럽게 이어진다
쉽게 말해, 모래 알갱이를 동그란 것으로 쓰든 네모난 것으로 쓰든 최종 언덕 모양은 거의 비슷하다. 하지만 모래를 좁게 뿌리느냐 넓게 뿌리느냐(= bandwidth)에 따라 결과는 완전히 달라진다. 그래서 커널 모양은 가우시안으로 고정해두고, bandwidth를 잘 정하는 데 집중하는 것이 합리적이다.
KDE 계산 로직
이제 핵심인 KDE 계산 함수를 살펴보자.
export const calculateKDE = (
data: number[],
bandwidth?: number,
samplePoints: number = 100,
): KDEPoint[] => {
if (data.length === 0) return [];
const h = bandwidth ?? calculateBandwidth(data);
const n = data.length;
// x 축 범위 설정: 최소/최대값 기준으로 ± 3 * bandwidth 만큼 확장
const minVal = Math.min(...data);
const maxVal = Math.max(...data);
const padding = h * 3;
const xMin = Math.max(0, minVal - padding);
const xMax = maxVal + padding;
// 샘플 포인트 생성
const step = (xMax - xMin) / samplePoints;
const result: KDEPoint[] = [];
for (let i = 0; i <= samplePoints; i++) {
const x = xMin + i * step;
// 해당 x 지점에서의 밀도 계산
let density = 0;
for (const dataPoint of data) {
const u = (x - dataPoint) / h;
density += gaussianKernel(u);
}
density /= n * h;
result.push({ x, density });
}
return result;
};이 코드가 하는 일
- X축 범위 결정: 데이터의 최소/최대값에서 bandwidth의 3배만큼 여유를 둔다
- 샘플 포인트 생성: X축을 균등하게 나눈다
- 각 포인트에서 밀도 계산: 모든 실제 데이터와의 거리 기반으로 가우시안 커널 값을 합산한다
왜 ±3 × bandwidth만큼 확장하나요?
가우시안 분포에서 평균으로부터 ±3σ 범위 안에 데이터의 약 99.7% 가 포함된다(3-시그마 규칙). 커널의 bandwidth가 σ 역할을 하므로, 양쪽으로 3 × bandwidth만큼 확장하면 커널의 꼬리 부분까지 거의 빠짐없이 포함할 수 있다.
이 여유 공간이 없으면 양 끝 데이터 포인트의 커널이 잘려서, 곡선이 갑자기 뚝 떨어지는 부자연스러운 모양이 된다.
밀도 계산의 핵심: density /= n * h
이 한 줄이 KDE에서 가장 중요한 정규화(normalization) 과정이다.
n으로 나누는 이유: 데이터 개수에 관계없이 전체 곡선 아래 면적이 1이 되도록 맞추기 위해서다. 데이터가 100개든 1000개든 밀도의 스케일을 일정하게 유지한다.h로 나누는 이유: bandwidth가 커질수록 커널이 넓게 퍼지므로, 넓어진 만큼 높이를 낮춰서 면적 합이 1을 유지하게 한다.
샘플 포인트 수: 왜 'data.length'가 아니라 고정값 100인가?
여기서 100은 곡선을 그리기 위한 X축 눈금의 수이다. 실제 데이터 개수와는 전혀 다른 역할을 한다.
"그러면 data.length를 샘플 포인트 수로 쓰면 안 될까?" 라는 의문이 들 수 있다. 결론부터 말하면 좋지 않은 선택이다. 그 이유를 살펴보자.
계산 복잡도부터 보자. KDE의 시간 복잡도는 O(samplePoints × dataLength)이다. 샘플 포인트마다 모든 데이터를 순회하며 커널 값을 계산하기 때문이다.
예를 들어 데이터가 각각 10개, 100개, 1,000개, 10,000개일 때를 비교해보면:
- 데이터 10개: 고정 100 → 1,000번 연산 vs data.length → 100번 연산
- 데이터 100개: 고정 100 → 10,000번 연산 vs data.length → 10,000번 연산
- 데이터 1,000개: 고정 100 → 100,000번 연산 vs data.length → 1,000,000번 연산
- 데이터 10,000개: 고정 100 → 1,000,000번 연산 vs data.length → 100,000,000번 연산
데이터가 10,000개면 고정값은 100만 번, data.length는 1억 번 연산이다. 100배 차이다.
데이터가 적을 때도 문제다. 데이터가 5개뿐이면 X축에 5개 점만 찍히므로 곡선이 아니라 꺾은선 그래프처럼 각져 보인다. KDE의 장점인 "부드러운 곡선"이 사라지는 것이다.
샘플 포인트의 의미를 혼동하지 말자
- 샘플 포인트 (samplePoints): 곡선을 그리기 위해 X축을 몇 등분할 것인가. 시각적 해상도에 해당한다.
- 데이터 포인트 (data.length): 밀도를 계산하기 위해 사용하는 실제 관측값의 개수. 이것이 많을수록 추정이 정확해진다.
이 둘은 독립적인 개념이다. 100개의 샘플 포인트는 화면에 점 100개를 찍어 곡선을 그린다는 뜻이지, 데이터를 100개만 쓴다는 뜻이 아니다. 각 샘플 포인트에서 모든 데이터를 사용해 밀도를 계산한다.
그래서 고정값 100은 합리적인 기본값이다. 시각적으로 충분히 부드러우면서 어떤 크기의 데이터에서도 성능이 일정하다. 차트 너비가 넓어서 더 세밀한 곡선이 필요하다면 200이나 300 정도로 올릴 수 있지만, 그 이상은 눈으로 차이를 구분하기 어렵다.
사전 계산된 통계값 활용
실무에서는 통계값(평균, 표준편차, 사분위수 등)이 이미 계산되어 있는 경우가 많다. 예를 들어 API 응답에 통계 요약이 포함되어 오거나, 데이터베이스에서 집계 쿼리로 미리 구해놓은 경우다. 이런 상황에서는 원본 데이터를 다시 순회할 필요 없이, 통계값만으로 bandwidth를 바로 계산할 수 있다.
export const calculateBandwidthFromStats = (
stdDev: number,
p25: number,
p75: number,
n: number,
mean: number,
): number => {
if (n < 2) return 1;
const iqr = p75 - p25;
const scale = Math.min(stdDev, iqr / 1.34);
if (scale === 0) {
return mean * 0.1 || 1;
}
return 0.9 * scale * Math.pow(n, -0.2);
};calculateBandwidth와 로직은 동일하지만, 원본 데이터 배열 대신 미리 계산된 통계값을 인자로 받는다. 이렇게 하면 클라이언트에서 전체 데이터를 다시 순회하지 않아도 되므로 효율적이다.
차트 컴포넌트 구현
이제 이 KDE 로직을 활용해 차트를 그려보자. React와 recharts 라이브러리를 사용한 예시이다.
const DensityChart = ({ data, stats, height = 200 }: Props) => {
const { kdeData, isEmpty, hasInsufficientData } = useKDE(data, stats);
if (isEmpty) {
return <div>데이터가 없습니다.</div>;
}
if (hasInsufficientData) {
return <div>분포를 표시하기에 데이터가 부족합니다.</div>;
}
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={kdeData}>
<defs>
<linearGradient id="densityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#4f46e5" stopOpacity={0.4} />
<stop offset="100%" stopColor="#4f46e5" stopOpacity={0.05} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="density"
stroke="#4f46e5"
fill="url(#densityGradient)"
/>
</AreaChart>
</ResponsiveContainer>
);
};커스텀 훅으로 로직 분리
KDE 계산 로직은 커스텀 훅으로 분리하면 재사용성을 높일 수 있다.
const useKDE = (
data: number[],
precomputedStats?: Stats,
samplePoints: number = 100,
) => {
return useMemo(() => {
const validData = data.filter(
v => typeof v === 'number' && !isNaN(v) && v >= 0,
);
if (validData.length === 0) {
return { kdeData: [], isEmpty: true, hasInsufficientData: true };
}
// 사전 계산된 통계값이 있으면 활용
let bandwidth: number | undefined;
if (precomputedStats) {
bandwidth = calculateBandwidthFromStats(
precomputedStats.stdDev,
precomputedStats.p25,
precomputedStats.p75,
validData.length,
precomputedStats.mean,
);
}
const kdeData = calculateKDE(validData, bandwidth, samplePoints);
return {
kdeData,
isEmpty: false,
hasInsufficientData: validData.length < 5,
};
}, [data, precomputedStats, samplePoints]);
};useMemo를 사용해서 data나 precomputedStats가 변경될 때만 KDE를 다시 계산한다. KDE 계산은 데이터 크기에 따라 비용이 크기 때문에, 불필요한 재계산을 방지하는 것이 중요하다.
Box Plot과 함께 사용하기
KDE 차트만으로는 정확한 통계값을 파악하기 어렵다. 곡선의 모양은 알 수 있지만, 중앙값이 정확히 얼마인지, 데이터의 절반이 어디에 모여 있는지는 보이지 않는다.
그래서 Box Plot을 함께 표시하면 최소값, Q1, 중앙값, Q3, 최대값을 한눈에 볼 수 있다.
const MiniBoxPlot = ({ min, max, p25, p75, median }: Props) => {
const range = max - min;
const getPosition = (val: number) => ((val - min) / range) * 100;
return (
<div className="relative mx-4 h-8">
{/* 전체 범위 (Min ~ Max) */}
<div className="absolute top-1/2 h-1 w-full -translate-y-1/2 rounded bg-gray-200" />
{/* IQR 박스 (Q1 ~ Q3) */}
<div
className="absolute top-1/2 h-4 -translate-y-1/2 rounded bg-indigo-200"
style={{
left: `${getPosition(p25)}%`,
width: `${getPosition(p75) - getPosition(p25)}%`,
}}
/>
{/* 중앙값 마커 */}
<div
className="absolute top-1/2 h-5 w-0.5 -translate-y-1/2 rounded bg-indigo-700"
style={{ left: `${getPosition(median)}%` }}
/>
</div>
);
};이렇게 KDE 곡선 아래에 Box Plot을 배치하면 데이터 분포의 전체적인 모양과 구체적인 통계값을 동시에 파악할 수 있다. KDE가 "숲"을 보여준다면, Box Plot은 "나무"를 보여주는 셈이다.
결론
KDE를 직접 구현해보면서 데이터 시각화의 이면에 있는 수학적 원리를 이해할 수 있었다.
핵심 포인트를 정리하면 다음과 같다.
- KDE는 히스토그램보다 부드럽고 연속적인 분포를 표현할 수 있다
- Bandwidth는 곡선의 부드러움을 결정하는 핵심 파라미터이다
- Silverman의 경험적 규칙으로 적절한 bandwidth를 자동 계산할 수 있다
- 샘플 포인트 수는 데이터 개수와 독립적인 "시각적 해상도"이므로 고정값이 합리적이다
- Box Plot과 함께 사용하면 분포의 모양과 통계 요약을 동시에 전달할 수 있다
처음에는 복잡해 보였던 KDE가 막상 구현해보니 그렇게 어렵지 않았다(?) (사실 매우 어려움) 물론 수학적 배경을 완벽히 이해하려면 더 깊이 공부해야 하겠지만, 실무에서 사용하기에는 이 정도 이해만으로도 충분한 것 같다.
데이터 시각화를 할 때 단순히 라이브러리에 의존하지 않고 원리를 이해하고 직접 구현해보는 경험이 정말 값진 것 같다!