Typescript로 구현해 보는 KMeans (4) KMeans With UI Interaction

2024. 2. 7. 18:00@dev.formegusto

chakchak point

문득 웹 분야를 경험하면서 설렘을 느꼈었던 순간들이 떠오르네요. 처음으로 마주한 텍스트가 아닌 그림 형태의 상호작용에서, SPA(Single Page Application)의 모던함에서, 웹 애니메이션 특유의 세련됨에서, 그리고 사용자에 대한 배려를 업으로 하고 계시는 UI/UX 디자이너님들의 접근법을 들었을 때, 이렇게 보니 저는 웹의 공학과 예술 그 어디쯤 자리 잡고 있는 면을 좋아하는 것 같네요. 앞으로도 잘하고 싶은 것과 좋아하는 것 사이에서 적절한 균형을 찾아가며 꾸준히 성장하는 제가 되고 싶어요. 😌😌

🗒️ Learning Point

  • Project: KMeans With UI Interaction Overview
  • Generate Dataset
  • Labeling
  • Move Centers
  • Prediction

머신러닝 분야에서 시각화를 통한 인사이트 제공은 매우 중요한 요소입니다. 그중에서도 클러스터링 알고리즘은 일반적으로 두 변수 간의 관계를 표현하는 Scatter Plot(산점도)을 이용하는데요. 색상 구분 및 중심 표시 등의 표현을 사용하여, 군집의 경향성과 밀도 그리고 분포 등을 시각적으로 파악하는 데에 도움을 줍니다. 이것과 관련해서 여러 가지 UI Interaction을 시도해 볼 수 있을 것 같았었요. 그래서 React 기반 개발 환경에 Typescript로 구현한 KMeans++ 모듈을 적용하여 KMeans With UI Interaction 프로젝트를 진행하였습니다.


🗂️ Project: KMeans With UI Interaction Overview

KMeans With UI Interaction은 클릭 혹은 터치 이벤트를 통해 생성된 포인트 형태의 데이터 집합을 사용하여 KMeans++ Clustering을 진행하는 일련의 과정을 경험해 볼 수 있는 웹 서비스 입니다. 사용자에게 KMeans 프로세스와의 직접적인 상호작용을 제공하여 군집화 알고리즘의 동작을 이해할 수 있도록 해줍니다.

프로젝트의 주요 관심사는 KMeans 프로세스 상에서 사용자의 경험이 군집화 지식으로 이어질 수 있는 잠재적인 UI Interaction 요소를 찾아내는 것이었으며 이에 따라 실행에 필요한 하이퍼파라미터인 dataset과 K 변수를 설정하는 과정과 라벨링 및 중심점 이동과 같은 각 단계에서의 결과를 시각화하는 것을 목표로 진행하였습니다.

개발이 완료된 화면에서는 ScatterArea, Floating Action Button 그리고 Toolbar 컴포넌트가 구성되었는데요. 이를 통하여 사용자는 KMeans 프로세스의 준비, 실행, 반복 그리고 예측에 대한 제어를 경험할 수 있습니다.

🎢 Component Detail

  • Scatter Area - 데이터 포인트를 추가하고 동적으로 변화하는 결과를 실시간으로 확인할 수 있어요.
  • Floating Action Button - 데이터 포인트의 생성과 KMeans 프로세스의 시작과 끝을 제어할 수 있어요.
  • Toolbar - 실행 중인 KMeans Iteration에 대한 정보를 확인하고 제어할 수 있어요.

전체적으로 클릭에 따른 컴포넌트 노출과 프로세스 진행으로 간단하게 구성되어 있는데요. 저는 이 중에서 Scatter Area에 결과의 형상 유지, 동적인 표현을 포함하기 위해 했던 고민과 작업을 소개해 드리고 싶어요. 이어지는 목차는 사용자 마우스 이벤트와의 상호작용을 다룬 Generate Dataset과 KMeans Iteration 결과를 동적으로 반영하는 Labeling, Move Centers 그리고 학습이 완료된 모델의 예측 프로세스를 담은 Prediction으로 구성하였습니다. 📸📸


📍 Generate Dataset

#root {
  width: 100vw;
  height: 100vh;
}

#scatter-area {
  width: 100%;
  height: 100%;
}

ScatterArea 컴포넌트는 브라우저 크기의 고정값을 가지는 Root 하위에서 전체를 채우는 크기로 구성되어 있어요. 이는 사용자의 시각에 편의성을 고려하는 동시에 다양한 디바이스에 유연하게 대응하기 위함입니다. 브라우저 크기를 기준으로 비율을 적용함으로써 크기에 구애받지 않고 결과의 형상 유지와 사용자의 클릭 및 터치 이벤트 처리를 일관된 방식으로 유지할 수 있답니다.

결과의 형상을 유지한다는 것은 브라우저의 크기 변화에도 Scatter Plot 내부 데이터 포인트의 위치가 변함 없어야 함을 의미합니다. 웹 개발의 환경을 고려하면 Resize 이벤트에 따른 데이터 포인트 값 변경 혹은 비율을 사용한 정규화 표현을 사용하는 등의 방식이 있을 텐데요.

/* ScatterArea.tsx */
points.map(([x, y], i) => (
  <circle
    cx={`${x}%`}
    cy={`${y}%`}
    // ...
  />
))

렌더링 성능의 안정화를 위하여 비율을 통한 정규화 표현을 택했습니다. 하나의 데이터 포인트에 대한 x와 y축을 0부터 100 사이의 값으로 구성시켜 circle 태그의 중심 좌표로 적용하는 방식을 사용했는데요. 이를 통해 내부적으로 일관된 데이터 포인트 표현을 유지하면서 유동적인 화면 변화에 대응하였습니다.

/* GenMouse.tsx */
const stampPoint = React.useCallback(
  (e: React.MouseEvent) => {
    const { clientX: x, clientY: y } = e;
    const { innerWidth: windowWidth, innerHeight: windowHeight } = window;
    const pointX = x / windowWidth;
    const pointY = y / windowHeight;

    if (mode === "gen") appendPoint([pointX, pointY]);
    // ...
windowWidth windowHeight x y pointX pointY
2560 1319 591 298 23.086 22.593
2560 1319 2125 1176 83.008 89.158
2560 1319 977 982 38.164 74.45

여기서 발생하는 마우스 이벤트의 좌푯값을 브라우저 크기의 관계와 비율식을 적용하면 앞서 설명한 표현으로 변환할 수 있으며, 이를 데이터 집합 상태에 추가해주면 사용자의 클릭 혹은 터치 위치에 데이터 포인트가 생성되게 됩니다.

const MAX_X = 200;
const MAX_Y = 200;

/* ScatterArea.tsx */
<circle
  cx={`${(x / MAX_X) * 100}%`}
  cy={`${(y / MAX_Y) * 100}%`}
  // ...
/>

/* GenMouse.tsx */
const pointX = (x / windowWidth) * MAX_X;
const pointY = (y / windowHeight) * MAX_Y;
windowWidth windowHeight x y pointX pointY
2560 1319 1241 622 96.953 94.314
2560 1319 2416 519 188.75 78.696
2560 1319 405 1079 31.641 163.609

또한 위와 같이 최댓값의 범위를 지정하는 변수를 사용해 주면 0부터 100 사이로 제한되어 표현된 값의 범위를 조정할 수도 있어요.


🖍️ Labeling

서비스 내의 KMeans 프로세스는 K를 설정하는 화면으로부터 시작됩니다. 아직 라벨링이 되지 않아 활성화되지 않은 데이터 포인트와 활성화된 초기 중심점을 포함하는 initCenters 상태로부터 Toolbar의 Next 혹은 Double Next 버튼을 통하여 KMeans Iteration을 제어하는 과정이 진행되는데요.

/* kmeans.context.tsx */
const start = React.useCallback(
    (k: number, dataset: IPoint[]) => {
      // ...
      const kmeans = new KMeans(k, dataset);
      const iterator = kmeans[Symbol.iterator]() as IKMeansIterator;
      setIterator(iterator);
      setResult({
        centers: iterator.centers!,
      });
      // ...
      
const next = React.useCallback(() => {
      if (iterator) {
        const iterResult = iterator.next();
        if (!iterResult.done) {
          const result = iterResult.value;
          setResult(result);
         // ...
      
const autoNext = React.useCallback(() => {
      if (iterator) {
        let result: IKMeansResult | null = null;
        for (result of iterator);
        if(result) {
          setResult(result);
          // ...

이는 KMeansContext 모듈의 관점에서 dataset과 K 매개변수를 받아 start 메서드를 실행시켜 초기 중심점을 가지는 iterator 객체를 생성하고 next 혹은 autoNext 메서드 실행에 따라 각 iteration round마다 발생하는 Iterator Result Object를 갱신하게 되어 있습니다.

/* ScatterArea.tsx */
{points &&
  points.map(([x, y], i) => (
    <circle
      fill={result ? IOSDefault[result.labels[i]] : IOSGrayLight[0]}
      // ...
    />
  ))}
{result &&
  result.centers.map(([x, y], i) => (
    <circle
      cx={`${(x / MAX_X) * 100}%`}
      cy={`${(y / MAX_Y) * 100}%`}
      // ...
    />
  ))}

컴포넌트의 관점에서는 위와 같이 결과 상태의 변화에 따라 데이터 집합의 라벨링 결과(labels)를 반영하는 Repaint와 중심점(centers)을 새로운 위치로 Reflow하는 작업을 통해 현재 iteration의 결과를 반영시킬 수 있어요.

그러나 단면적인 반영보다는 라벨링과 중심점의 변화를 점진적으로 진행하는 형태의 애니메이션을 추가하여 군집화의 흐름을 더욱 효과적으로 사용자가 이해할 수 있도록 제작하고 싶었어요.

cluster no flat members frame members
1 [56, 27, 32, 45, 97, 11] [[56, 27], [32, 45], [97, 11]
2 [6, 10, 90, 67] [[6], [10], [90, 67]]
3 [1, 20, 17, 22, 5] [[1], [20, 17], [22, 5]]

우선 라벨링의 경우에는 위 도표처럼, 기존에는 플랫하게 나타낼 수 있는 각 군집별 소속된 데이터 포인트의 정보를 Reshape 하여 프레임의 형태로 분리하는 작업이 요구되었습니다.

🔨 Labels Reshape Process

  • points, distances grouping
  • index sorting
  • generate labeling frames

해당 작업을 위한 프로세스는 points, distances grouping과 index sorting 그리고 generate labeling frames로 총 3개의 단계로 구성하였습니다.

/* ui.context.tsx */
const { distances, labels } = result;

const indexList: number[] = Array.from({ length: labels.length }, (_, i) => i);
const pointsGroup: number[][] = Array.from({ length: K }, () => []);
const distancesGroup: number[][] = Array.from({ length: K }, () => []);

for (let i = 0; i < labels.length; i++) {
  pointsGroup[labels[i]].push(indexList[i]);
  distancesGroup[labels[i]].push(distances[i][labels[i]]);
}

points, distances grouping 단계에서는 측정이 완료된 labels와 distances 변수를 참고하여 군집별로 포함하는 데이터 포인트의 인덱스와 거리를 기록합니다. 이는 기존의 결과를 군집별로 그룹화했다는 특징을 가집니다.

/* ui.context.tsx */
for (let g = 0; g < pointsGroup.length; g++) {
  pointsGroup[g].sort((a, b) => {
    const a_i = pointsGroup[g].indexOf(a);
    const b_i = pointsGroup[g].indexOf(b);
    return distancesGroup[g][a_i] - distancesGroup[g][b_i];
  });
}

index sorting 단계는 가까운 거리를 가지는 포인트부터 변화하도록 하여 후에 작성될 moveCenters과 자연스럽게 조화를 이룬 애니메이션 구현을 위하여 진행합니다. 군집별로 포함하는 데이터 포인트의 인덱스를 거리 기준으로 오름차순 정렬 시켜주었어요.

/* utils */
function itemSplit(item: any[], count: number): number[][] {
  const splits = [];
  let rate = item.length / count;
  if (rate === 0) rate = 1;
  for (let i = 0; i < item.length; i += rate) {
    if (i + rate < item.length) splits.push(item.slice(i, i + rate));
    else splits.push(item.slice(i));
  }
  return splits;
}

/* ui.context.tsx */
const labelFrames = pointsGroup.map((g) => itemSplit(g, frameCount));

generate labeling frames 단계에서는 각 군집별로 포함한 플랫 데이터를 frameCount에 따라 분할하여 프레임의 형태로 Reshape 진행합니다.

/* ScatterArea.tsx */
const paintPoints = React.useCallback(
  (frame: number[][], label: number, count: number) => {
    if (count === frame.length) return;
    const targetPoints = frame[count];
    for (let targetPoint of targetPoints) {
      const el = document.querySelector(`.point-${targetPoint}`);
      if (el) el.setAttribute("fill", IOSDefault[label]);
    }
    requestAnimationFrame(() => paintPoints(frame, label, count + 1));
  },[]);

이렇게 만들어진 labelFrames 변수를 브라우저 그래픽 업데이트에 다양한 이점을 가진 requestAnimationFrame을 활용한 형태의 함수에 사용하여 순차적으로 라벨링이 진행되는 Repaint 프로세스를 구현하였습니다.

📐 Move Centers

중심점 이동 애니메이션은 circle 태그의 cx, cy 속성에 변화를 주면 되는데요. 이들은 HTML의 요소이기 때문에 CSS Transition 및 Animation 적용에 제한이 따른다는 특징을 가지기 때문에 현재 중심점부터 다음 중심점까지의 과정을 직접 구현해야 합니다.

이에 따라 저는 두 점을 연결하는 직선을 사용하여 이들 사이의 값을 추정하는 방법인 선형 보간법(Linear Interpolation)을 채택하였는데요. 이를 적용하면 위 시각화와 같은 형태로 중심점을 일정하게 이동시킬 수 있게 되며, 사용자에게는 효과적인 시각적 경험과 데이터 변화의 전달을 기대해 볼 수 있기 때문이었습니다.

선형 보간법은 일반적으로 새로운 점(x, y)를 점 (x1, y1)과 (x2, y2)를 연결하는 직선상에 위치한 값으로 추정하려 할 때 x1과 x2 사이의 값으로 주어지는 x에 대한 y값을 계산하는 수식으로 나타납니다.

여기에 0과 1 사이의 값으로 구성되는 변수를 활용하면, t의 특정 값에 대응하는 (x,y)를 계산할 수 있는데요. 변수 t를 통하여 기존의 입력 변수 x 혹은 y를 x1과 x2 혹은 y1과 y2 사잇값으로 구성함으로써 두 점을 잇는 직선상에서 특정 비율에 위치한 점을 찾아낼 수 있습니다.

/* utils */
function linearInterpolation(p1: IPoint, p2: IPoint, t: number): IPoint {
  const [x1, y1] = p1;
  const [x2, y2] = p2;

  const x = x1 + t * (x2 - x1);
  const y = y1 + t * (y2 - y1);

  return [x, y];
}
/* ui.context.tsx */
const { centers, nextCenters } = result;
const centerInterpolations: IPoint[][] = [];
for (let i = 0; i < centers.length; i++) {
  const interpolation: IPoint[] = [];
  for (let t = 1 / frameCount; t <= 1; t += 1 / frameCount) {
    const estimated = linearInterpolation(centers[i], nextCenters![i], t);
    interpolation.push(estimated);
  }
  centerInterpolations.push(interpolation);
}
centers nextCenters centerInterpolations
[96, 167] [96.596, 146.051] [96.119, 162.81] ... [96.596, 146.051]
[184, 5] [149.133, 45.717] [177.027, 13.143] ... [149.133, 45.717]
[1, 25] [46.194, 44.081] [10.039, 28.816] ... [46.194, 44.081]

이를 활용하면 현재 중심점과 다음 중심점 간의 선형 직선상에 위치한 점들을 구할 수 있어요. 위 도표는 centers로부터 nextCenters까지의 직선 간격을 frameCount만큼 분할하여 각 경계점의 좌표를 calcInterpolation을 통해 구하여, 이로 구성된 각 군집별 중심점 이동 경로를 나타내는 centerInterpolations 변수의 예시를 나타냅니다.

/* ScatterArea.tsx */
const moveCenters = React.useCallback(
  (interpolation: IPoint[], label: number, count: number) => {
    if (count === interpolation.length) return;
    const [nx, ny] = interpolation[count];
    const el = document.querySelector(`.center-${label}`);
    if (el && roundEl) {
      el.setAttribute("cx", (nx / MAX_X) * 100 + "%");
      el.setAttribute("cy", (ny / MAX_Y) * 100 + "%");
      requestAnimationFrame(() => moveCenters(interpolation, label, count + 1));
    }
  },[]);

앞서 라벨링에서 사용한 requestAnimationFrame에 생성된 centerInterpolations를 활용하여 중심점을 표현하는 circle 태그의 중심좌표를 갱신시켜 주는 방식으로 부드럽게 이동하는 moveCenters 애니메이션을 구현할 수 있었습니다.


🔍 Prediction

KMeans Iteration이 종료되면 다음과 같이 학습에 사용된 데이터 포인트는 모두 투명해지게 되는데요. 이는 내부적으로 Prediction 모드로 소통되어지고 있습니다.

해당 모드에서는 Generate Dataset 과같이 데이터 포인트를 추가할 수 있는 기능이 자동으로 실행되는데요. 클릭 혹은 터치를 이벤트로 인하여 생성될 데이터 포인트에 대하여 가까운 중심점의 라벨 정보를 포함하여 화면에 반영합니다. 

/* kmeans.model.ts */
if (!nextCenters) {
  const centers = this.centers;
  const predict = ({ dataset }: IKMeansMethodParams): number[] => {
    if (!dataset) throw Errors.EmptyRequiredParameters("dataset");
    const distances = this.calcDistances({
      dataset,
      centers,
    });
    const labels = this.setLabels({ distances });
    return labels;
  };
  result.value.predict = predict;
  // ...

이는 사전에 주어진 학습 데이터를 K개의 군집으로 그룹화하여 각 군집을 대표하는 중심점 중에서 최소 거리를 가지는 군집으로 라벨을 할당하는 KMeans의 예측 프로세스를 표현하는데요. 해당에 필요한 predict 메서드를 KMeans Iteration 종료 시점에 Iteration Result Object 에 포함하여 반환하는 형태로 작성하였습니다.

/* models.d.ts */
declare interface IPrediction {
  point: IPoint;
  label: number;
}
/* kmeans.context.tsx */
const predict = React.useCallback(
  (dataset: IPoint[]) => {
    if (result && result.predict) {
      const labels = result.predict({ dataset });
      return labels;
    }
    return null;
  }, [result]);
/* GenMouse.tsx */
const stampPoint = React.useCallback(
  (e: React.MouseEvent) => {
    // ...
    if (mode === "gen") appendPoint([pointX, pointY]);
    else if (mode === "prediction") {
      const point: IPoint = [pointX, pointY];
      const labels = predict([point]);
      if (labels)
        appendPrediction({
          point,
          label: labels[0],
        });
    }
    // ...

작성된 predict 함수는 기존에 Generate Dataset을 진행하는 stampPoint 함수에서 생성이 진행될 데이터 포인트를 사용하여 해당 포인트의 라벨값을 추출하는 데에 사용합니다. 그리고 이들로 구성된 Prediction 객체를 추가하여 화면에 반영되도록 하였습니다.


이렇게 예측 프로세스 구현까지 KMeans With UI Interaction 서비스가 완성되었습니다. 알고리즘을 사용자 인터페이스 관점에서 고민하고 구현해 보니 색다른 느낌이 드네요. 창작의 자유도가 높은 하얀 도화지 덕분에 포함시키고 싶은 기능이 너무나도 많았지만, 최대한 KMeans 프로세스에 포커싱을 두고 진행했는데요. 생각보다 디자인 관점으로 시도해 볼 수 있는 요소들이 많아서 즐거운 개발이 되었습니다.

더불어 제가 계획한 "Typescript로 구현해 보는 KMeans" 시리즈가 모두 마무리 되었는데요. Javascript Iteration Protocol과 KMeans and KMeans++ Difference를 통하여 기초 개념을 익혔으며 KMeans++ with Typescript에서는 구현을 진행하였고 마지막으로 KMeans With UI Interaction 서비스로의 활용까지 진행했습니다. 여기에는 공부를 시작으로 프로젝트까지, 각 단계에 임하는 저의 자세와 접근법을 글 속에 담아보았어요. 이제 저는 새로운 주제를 준비해서 다시 찾아뵙겠습니다. 오늘 하루도 수고 많으셨어요. 😊😊