Typescript로 구현해 보는 KMeans (3) KMeans++ with Typescript

2024. 1. 17. 18:00@dev.formegusto

jeongli kkeut

저에게 3이라는 숫자는 도전적으로 느껴지는 숫자인데요. 승부 이즈 삼세판, 작심삼일 등과 같은 경쟁과 다짐의 맥락을 통해 만나볼 수 있어서 그런 것 같아요. 혼자만 간직하던 스타일을 외부에 꺼내보는 저의 블로그도 이런 의미를 가지는 것 같은데요. 꾸준히 작성해서 기록이 미래에 넘겨주는 행복을 어느 순간에 있는 저를 위해 많이 남겨놓고 싶네요.

🌞 Learning Point

  • KMeans++ with Typescript
  • KMeans++ with Iteration Protocol

저번 챕터에서는 KMeans의 기본적인 개념과 발생가능한 문제점을 고려하여 보완된 KMeans++를 소개해 드렸었는데요. 이 중에서 구현적인 면에서 조금 더 다양한 시도를 할 수 있는 KMeans++를 작성해 보도록 하겠습니다. 그리고 끝에는 Iteration Protocol을 적용해 보기도 하고요. 저번 챕터에서 은근슬쩍 파이썬으로 구현해 봤기 때문에 어렵지 않을 거예요. 🥳🥳


🌝 KMeans++ with Typescript

파이썬을 사용할 때는 numpy와 scikit-learn 라이브러리의 도움을 많이 받았었는데요. Typescript에서의 KMeans 시스템은 이 라이브러리들이 도움을 준 부분을 생각해 보며 필요한 모듈들과 내부 기능을 정리해 보도록 하겠습니다.

🌽 module structure

depth 1 depth 2 depth 3
utils random generateRandomDataset
  similarity euclideanDistance
  file exportOutput
    importOutput
models kmeans initCenters
    calcDistances
    setLabels
    moveCenters
    calcInertia
    fit

분포된 데이터 포인트 집합의 생성과 유클리디안 거리 계산 모듈이 numpy와 scikit-learn의 빈자리를 책임져줄 것 입니다. KMeans 클래스의 메서드 구성은 프로세스를 기반으로 설계해 보았습니다.

🌖 utils

interface GenerateRandomDatasetParams {
  // [row, column]
  shape: [number, number];
  max?: number;
}
type GenerateRandomDatasetResult = number[][];

function generateRandomDataset({
  shape,
  max,
}: GenerateRandomDatasetParams): GenerateRandomDatasetResult {
  // ...
}

generateRandomDataset 은 랜덤한 값으로 데이터 집합을 생성해 주는 모듈입니다. 여기서 말하는 데이터 집합은 여러 개의 특징값 데이터로 이루어진 2차원 배열로 표현될 수 있겠는데요. 이와 관련된 shape 매개변수를 받아 프로세스를 진행해 주도록 작성합니다.

function exportOutput(filename: string, data: any) {
  const filepath = path.join(OUTPUT_PATH, filename);
  fs.writeFileSync(filepath, JSON.stringify(data, null, "\t"), {
    encoding: "utf-8",
  });
  console.log(`export file ${filepath} success :)`);
  return filepath;
}

function importOutput(filename: string) {
  const filepath = path.join(OUTPUT_PATH, filename);
  const data = JSON.parse(fs.readFileSync(filepath, { encoding: "utf-8" }));
  console.log(`import file ${filepath} success :)`);
  return data;
}

exportOutput importOutput은 생성한 데이터 집합을 고정적으로 사용할 수 있도록 하고, 웹을 사용하지 않는 현재 상황에서 시각화의 진행이 원활하게 이어질 수 있도록 결과물을 내보내기 위한 용도로 활용합니다.

function euclideanDistance(A: number[], B: number[]) {
  return Math.sqrt(A.reduce((acc, A_i, i) => acc + (B[i] - A_i) ** 2, 0));
}

euclideanDistance 수식은 여러개의 특징을 가지는 A와 B의 각 특징들의 오차제곱합을 구해주고 이를 제곱근 한다는 특징을 가집니다. 배열의 반복적인 연산을 수행하는 데에 유용한 reduce와 Math 모듈의 sqrt를 사용하여 표현합니다.

🌖 get [Min/Max] Idx

KMeans++ 프로세스에는 배열 내에 최대 거리 및 최소 거리와 관련된 요소의 위치를 찾아내야 하는 단계가 포함되어 있는데요. 이를 미리 배열에 알아보기 쉽게 정의해 놓으면 좋을 것 같아요.

function setPrototype() {
  Array.prototype.getMinIdx = function () {
    const minValue = Math.min.apply(null, this);
    const minIdx = this.indexOf(minValue);

    return minIdx;
  };

  Array.prototype.getMaxIdx = function () {
    const maxValue = Math.max.apply(null, this);
    const maxIdx = this.indexOf(maxValue);

    return maxIdx;
  };
}

정의한 setPrototype 함수는 프로그램 EntryPoint 최상위에 작성하여 프로그램 스코프의 배열 객체가 getMinIdx, getMaxIdx 메서드를 사용할 수 있도록 해줍니다.

🌗 @types. kmeans

interface IKMeansSetting {
  K: number;
}

KMeans 모델의 하이퍼 파라미터인 K 변수를 포함하는 설정값 IKMeansSetting 인터페이스 구조를 위와 같이 작성해 줍니다.

interface IKMeansMethodParams {
  dataset?: number[][];
  centers?: number[][];
  distances?: number[][];
  labels?: number[];
}

사전에 설계한 6개의 메서드는 데이터 집합(dataset), 현재 라운드의 중심점(centers), 거리 정보(distances) 그리고 군집 정보(labels)  중에서 하나 혹은 여러 개의 매개변수를 받아야 연산을 수행할 수 있는데요. 이를 고려하여 매개변수 구조인 IKMeansMethodParams 인터페이스를 Optional 한 구조로 정의해 줍니다.

interface IKMeansResult extends IKMeansMethodParams {
  inertia: number;
}

이렇게 작성된 매개변수 인터페이스를 활용하여 KMeans 모델의 전체적인 프로세스를 진행하는 기능을 포함하는 fit 메서드가 반환할 IKMeansResult를 작성해 주도록 할게요. 이는 KMeans의 최종 결과를 나타내는 객체로, 초기 중심점 설정으로부터 계속 변화하는 매개변수 타임라인에서의 종료 시점의 값에 평가 결과(inertia)를 추가로 더한 형태를 가집니다.

type KMeansMethod<R = number[][]> = (params: IKMeansMethodParams) => R;
interface IKMeans extends IKMeansSetting {
  initCenters: KMeansMethod;
  calcDistances: KMeansMethod;
  setLabels: KMeansMethod<number[]>;
  moveCenters: KMeansMethod;
  calcInertia: KMeansMethod<number>;
  fit: KMeansMethod<IKMeansResult>;
}

지금까지의 메서드 인터페이스 구조 정의의 의미들을 합산하여 여러 개의 메서드를 KMeansMethod 타입으로 일반화시켜 주고, 여기에 설정값 인터페이스 구조 정의를 더하여 KMeans 클래스의 인터페이스인 IKMeans를 완성해 보도록 합니다. 

🌗 models.kmeans

class KMeans implements IKMeans {
  constructor(public K: number) {}
}

이제 본격적으로 클래스 내부를 작성해 볼 건데요. 이때의 생성자는 사용자가 전달한 설정값 매개변수를 객체의 적용하기 위하여 Classes  Parameter Properties 문법을 적용합니다.

initCenters({ dataset }: IKMeansMethodParams): number[][] {
  // 1. 첫 번째 중심점을 무작위로 설정
  const centers = [dataset[Math.floor(Math.random() * dataset.length)]];

  // 4. 2~3의 과정을 설정된 K 변수 만큼의 중심점이 설정될 때 까지 반복
  while (centers.length < this.K) {
    // 2. 설정된 중심점과 데이터 간의 거리 계산
    const distances = this.calcDistances({ dataset, centers });

    // 3. 거리의 총합이 최대인 데이터 포인트를 다음 중심으로 설정
    const totalDistances = distances.map((distance) =>
      distance.reduce((acc, cur) => acc + cur, 0)
    );
    const nextCenterIdx = totalDistances.getMaxIdx();
    centers.push(dataset[nextCenterIdx]);
  }

  return centers;
}

initCenters 메서드에는 KMeans++의 초기 중심점 설정 내용을 작성해 줍니다. [0~(dataset.length - 1)] 범위의 랜덤 인덱스를 설정하여 첫 번째 중심점을 설정해 주고 거리 및 총합 계산을 통하여 최대 거리를 가지는 데이터를 다음 중심점을 구하는 프로세스를 반복합니다.

calcDistances({ dataset, centers }: IKMeansMethodParams): number[][] {
  const distances = [];
  for (let data of dataset) {
    const distance = centers.map((center) => euclideanDistance(center, data));
    distances.push(distance);
  }

  return distances;
}

calcDistances 메서드는 데이터 집합을 순회하며 데이터와 중심점 집합 간 유클리디안 거리 연산의 결과를 기록하여 거리 정보 변수 distances를 생성하여 반환합니다.

for (let i = 0; i < 4; i++) {
  const dataset = generateRandomDataset({ shape: [100, 2] }) as number[][];
  const K = 3;
  const kmeans = new KMeans(K);
  const centers = kmeans.initCenters({ dataset });
  exportOutput(`process_1-2_test_${i}.json`, { dataset, centers });
}

지금까지 작성한 메서드의 반환값을 Export 하여 잠시 파이썬으로 가져가서 시각화하면 초기 중심점들 간의 거리가 멀어지도록 구성하고 있는 것을 볼 수 있는데요. initCenters 메서드는 calcDistances를 내부에서 사용하고 있기 때문에 이와 같은 과정을 통해 2개의 메서드의 정상 동작을 확인할 수 있습니다.

setLabels({ distances }: IKMeansMethodParams): number[] {
  return distances.map((distance) => distance.getMinIdx());
}

setLabels는 거리 정보 변수 distances를 순회하며 최소 거리를 가지는 군집 번호와 동일한 의미를 가지는 인덱스를 파싱 하여 군집 정보 변수 labels를 생성하여 반환합니다.

moveCenters({ dataset, centers, labels }: IKMeansMethodParams): number[][] | null {
  // 1. 변수 정의
  // 2. 다음 중심점 위치 계산
  // 3. 다음 라운드 진행 여부 확인
}

moveCenters는 3개의 변수를 받는데요. 해당 메서드는 다음 중심점 변수를 내부에서 다룰 수 있기 때문에 현재 중심점의 변화 여부를 확인하기 편리하기 때문에 다음 라운드 진행 여부 확인 기능도 포함할 건데요. 반환값 으로 다음 중심점이 반환되면 진행 그리고 null값이 반환되면 정지의 의미를 담아 반환 타입을 (number [][] | null)로 작성해 주었습니다.

const colSize = dataset[0].length;
const labelCount = Array(this.K).fill(0);
const labelTotal = Array.from({ length: this.K }, () =>
  Array(colSize).fill(0)
);

해당 메서드는 2개의 주요 변수를 정의해 주는데요. 파이썬에서는 군집 별 데이터 집합을 파싱하고 numpy의 Broadcasting 기능으로 편리하게 다음 중심점 값을 구할 수 있었지만 자바스크립트에는 그런 게 없으니 평균 계산에 필요한 개수 변수 labelCount와 데이터 요소의 합계 변수 labelTotal을 K개만큼 정의해 줍니다.

for (let i = 0; i < dataset.length; i++) {
  const label = labels[i];
  const data = dataset[i];
  labelCount[label]++;
  labelTotal[label] = labelTotal[label].map((v, vi) => v + data[vi]);
}
const nextCenters = labelCount.map((count, label) =>
  labelTotal[label].map((total) => total / count)
);

이제 데이터 집합을 순회하면서 동일한 길이와 요소 의미를 가진 형태로 구성되어 있는 군집 정보 변수 labels도 같이 파싱을 진행하여 주고 현재 라운드의 데이터가 속한 군집 번호의 개수 및 합계의 증가를 진행하고 연산된 정보를 토대로 다음 중심점 위치 변수 nextCenters를 설정해 줍니다.

const prev = centers.flat();
const next = nextCenters.flat();
for (let i = 0; i < prev.length; i++) {
  if (prev[i] !== next[i]) return nextCenters;
}
return null;

마지막으로 중심점의 변화를 확인하기 위하여 현재 중심점 변수 centers와 다음 중심점 변수 nextCenters를 flat 하게 만들어주고 순회를 진행하며 변화의 흔적이 있다면 즉시 다음 중심점 변수를, 모든 중심점에 변화가 없다면 null 값을 반환해 줍니다.

// 1. K개의 초기 중심점 설정
let centers = kmeans.initCenters({ dataset });
while (true) {
  // 2. 중심점과 데이터 간의 거리 계산
  const distances = kmeans.calcDistances({ dataset, centers });
  
  // 3. 최소 거리 중심점의 군집 번호를 데이터에 부여
  const labels = kmeans.setLabels({ distances });
  steps.push({
    dataset,
    centers,
    labels,
  });
  
  // 4. 군집 별 평균값을 계산하여 중심점에 반영
  const nextCenters = kmeans.moveCenters({ dataset, centers, labels });
  
  // 5. 2~4의 과정을 중심점에 변화가 없을 때까지 반복
  if (!nextCenters) break;
  centers = nextCenters;
}
exportOutput(`process_3-4_test.json`, steps);

여기까지 작성했다면 KMeans 프로세스에 필요한 모든 메서드는 작성이 되었다는 것을 알 수 있는데요. 위는 각 메서드들을 단계별 위치에 배치해 주고 Export 하는 Typescript 코드와 파이썬에서 정상동작을 확인한 시각화입니다. 

calcInertia 메서드의 작성에 앞서 군집별 중심점과 데이터 간의 오차제곱합(SSE, Sum of Squared Error)의 총합을 구하는 수식은 위와 같이 구성됩니다. 

calcInertia({ dataset, centers, labels }: IKMeansMethodParams): number {
  let inertia = 0;
  for (let i = 0; i < dataset.length; i++) {
    const a = dataset[i];
    const b = centers[labels[i]];
    const sse = a.reduce((acc, cur, j) => acc + (cur - b[j]) ** 2, 0);
    inertia += sse;
  }
  return inertia;
}

이에 따라 작성된 코드는 데이터 집합을 순회하며 각 데이터가 속해있는 군집의 중심점을 파싱 하여 오차제곱합을 계산하여 inertia 변수에 지속적으로 더해주는 특징을 가집니다.

while (true) {
  // ...
  // 3. 최소 거리 중심점의 군집 번호를 데이터에 부여
  const labels = kmeans.setLabels({ distances });
  
  // *. 평가
  const inertia = kmeans.calcInertia({ dataset, centers, labels });
  console.log(inertia)
  
  // 4. 군집 별 평균값을 계산하여 중심점에 반영
  // ...
}
  cluster_centers labels inertia
1 [ 81, 81], [ 2, 12], [ 86, 1] [0, 2, 0, ... , 0, 0, 0] 123,678
2 [ 60, 71], [ 26, 28], [ 74, 18] [0, 2, 0, ... , 2, 0, 2] 62,759
3 [ 59, 73], [ 28, 27], [ 76, 21] [0, 2, 0, ... , 2, 0, 2] 62,282
4 [ 59, 73], [ 28, 27], [ 75, 22] [0, 2, 0, ... , 2, 0, 2] 62,230

해당 메서드의 적절한 위치는 데이터 집합에 군집 정보 변수인 labels가 생성된 이후입니다. 3번과 4번 단계 사이에 배치를 해준 이후에 콘솔창에 이를 찍어보면 inertia 값이 내려가고 있다는 것을 알 수 있는데요. 이는 각 군집의 데이터 구성이 응집도가 내려가도록 진행되고 있다는 것을 나타냅니다. 이를 통해 Typescript 환경에서도 정상 동작 여부를 확인할 수 있죠. 👏🏻👏🏻

fit({ dataset }: IKMeansMethodParams): IKMeansResult {
  // 1. K개의 초기 중심점 설정
  while (true) {
    // 2. 중심점과 데이터 간의 거리 계산
    // 3. 최소 거리 중심점의 군집 번호를 데이터에 부여
    // *. 평가
    // 4. 군집 별 평균값을 계산하여 중심점에 반영
    // 5. 2~4의 과정을 중심점에 변화가 없을 때까지 반복
    if (!nextCenters) return { centers, labels, inertia };
    // ...
  }
}

fit 메서드는 지금까지 작성한 내용들을 적절한 프로세스 단계에 배치해 주면 됩니다. 이와 같은 구성은 기존 테스트 코드와는 크게 차이가 없고 학습 종료 시에 해당 시점에서의 결과만을 반환하도록 해줬어요.

const results = [];
for (let i = 0; i < 8; i++) {
  const dataset = generateRandomDataset({ shape: [100, 2] }) as number[][];
  const K = 3 + Math.floor(Math.random() * 3);
  const kmeans = new KMeans(K);
  const result = kmeans.fit({ dataset });
  results.push({
    dataset,
    result,
  });
}
exportOutput(`process_fit_test.json`, results);

KMeans를 위한 모든 메서드가 작성이 되었어요. 랜덤 하게 생성한 모든 데이터 집합에서 군집을 잘 이루도록 동작하고 있네요. 위에 보여드린 예시와 같이 각자 입맛에 맞게 테스트해 보시고 다음 섹션에서 작성된 클래스에 Iteration Protocol을 적용해 보도록 하겠습니다.


🌝 KMeans++ With Iteration Protocol

// Using Iterable
const arr = new Array([1, 2, 3, 4]);
// Using Iterator
for (let elem of arr);

Standard Built-In Objects에서 Iteration Protocol 이 적용된 객체들은 사용자 인터페이스 관점에서 보았을 때 Iterable 은 사용자가 직접 작성하는 코드에서 확인해 볼 수 있고, Iterator는 이에 관련된 문법에서 사용되어 내부적으로 Symbol.iterator를 호출하도록 구성이 되어 있는 것으로 유추해 볼 수 있는데요.

🌻 class structure for iteration protocol

type name method
iterable KMeans fit
    Symbol.iterator
iterator KMeansIterator initCenters
    calcDistances
    setLabels
    moveCenters
    calcInertia
    next

Iterable 객체의 생성 코드는 사용자로부터 작성되고 Iterator는 Iterable 내부에 Symbol.iterator 에 작성이 된다는 점에 따라 위와 같이 KMeans 사용자가 필요로 하는 메서드는 Iterable 객체에 위치시켜 주고 그 외 KMeans의 내부동작과 관련된 메서드는 Iterator 객체에 위치시켜 주는 형태로 클래스를 설계할 수 있습니다.

interface IKMeans extends IKMeansSetting, Iterable<IKMeansResult> {
  fit: KMeansMethod<IKMeansResult | undefined>;
}
interface IKMeansIterator extends IKMeansSetting, IterableIterator<IKMeansResult> {
  initCenters: KMeansMethod;
  calcDistances: KMeansMethod;
  setLabels: KMeansMethod<number[]>;
  moveCenters: KMeansMethod<number[][] | null>;
  calcInertia: KMeansMethod<number>;
}

실제 코드에서는 기존에 사용했던  IKMeans 인터페이스를 분리하여 IKMeansResult 인터페이스를 Iterator의 결괏값으로 사용하는 IKMeans와 IKMeansIterator 인터페이스를 작성해 주면 됩니다.

interface IKMeansSetting {
  K: number;
  dataset?: number[][];
  centers?: number[][];
}

기존에 작성한 KMeans 객체의 경우에는 모든 메서드를 사용자가 자유롭게 통제할 수 있도록 dataset과 centers를 내부적으로 저장하지 않는 구조로 멤버 변수를 구성했었는데요. Iteration Protocol을 적용하게 되면 문법적으로 호출이 되는 Symbol.iterator 메서드 그리고 next 메서드로 인해 매개변수를 자유롭게 전달할 수 없게 됩니다. 이에 따라 인터페이스 IKMeansSetting는 내부적으로 프로세스를 진행함에 있어서 각 라운드마다 저장해야 하는 변수들을 추가한 형태로 재구성을 합니다.

class KMeansIterator implements IKMeansIterator {
  centers: number[][];
  constructor(public K: number, public dataset: number[][]) {
    this.centers = this.initCenters({ dataset });
  }
  
  initCenters({ dataset }: IKMeansMethodParams): number[][] {...}
  calcDistances({ dataset, centers }: IKMeansMethodParams): number[][] {...}
  setLabels({ distances }: IKMeansMethodParams): number[] {...}
  moveCenters({ dataset, centers, labels }: IKMeansMethodParams): number[][] | null {...}
  calcInertia({ dataset, centers, labels }: IKMeansMethodParams): number {...}
}

KMeansIterator부터 위와 같이 작성해 보도록 하겠습니다. Iterator 객체는 Iterable 객체의 반환요소 중 하나이기 때문에 먼저 작성하게 되는데요. 설정값 변수가 바뀌었다고 해도 수정할 메서드는 없습니다. 대신에 지속적으로 호출될 next 메서드가 프로세스를 반복 진행할 수 있도록 내부적으로 필요로 하는 변수들에 대한 저장을 생성 시점에서 진행해 줍니다.

next(): IteratorResult<IKMeansResult> {
  if (!this.centers) return { value: undefined, done: true };
  /* 2~4 process */
  const result: IteratorResult<IKMeansResult> = {
    value: {
      centers: this.centers,
      labels,
      inertia,
    },
    done: false,
  };
  // 5. 2~4의 과정을 중심점에 변화가 없을 때까지 반복
  if (!nextCenters) {
    this.centers = undefined;
    return result;
  }
  this.centers = nextCenters;
  return result;
}

next 메서드는 기존에 작성한 KMeans.fit 메서드가 한 라운드씩 진행하는 모습을 떠올리면 2~4번의 메서드를 작성해 주시면 되는데요. 현재 중심점에 대한 군집 정보와 평가 결과를 포함한 Iterator Result 객체를 구성하면 되겠습니다. 그 후 5번의 과정에서 다음 라운드 여부를 확인하여 반환하는데, KMeans의 종료시점은 중심점에 변화가 없을 때입니다. 이 말은 종료시점에서 진행한 결과는 보내주어야 한다는 것인데요. iterator 문법은 done이 true가 되는 순간 종료되기 때문에 KMeans 프로세스가 종료시점이 아닌 이다음 시점에 종료되도록 첫 번째 줄에 작성된 코드와 상호작용 하도록 작성을 해 줍니다.

[Symbol.iterator](): IterableIterator<IKMeansResult> {
  return this;
}

마지막으로 iterator 이면서 iterable 인 객체로 동작하게 하기 위하여 Symbol.iterator 메서드를 위와 같이 작성을 합니다.

const K = 3;
const dataset = generateRandomDataset({ shape: [100, 2] }) as number[][];
const iterator = new KMeansIterator(K, dataset);
const steps = [...iterator];
exportOutput(`process_iterator_test.json`, { dataset, steps });

여기까지 작성하면 바로 iteration protocol이 적용되어 얻을 수 있는 것들을 진행할 수 있어요. 저는 작성이 끝나자마자 스프레드 문법을 사용하여 테스트해 보았는데요. 육안상으로도 확인한 중심점의 움직임과 inertia의 감소를 보아하니 아주 잘 동작하고 있네요. 🍻🥂🍾

class KMeans implements IKMeans {
  constructor(public K: number, public dataset?: number[][]) {}

  [Symbol.iterator](): Iterator<IKMeansResult> {
    return new KMeansIterator(
      this.K,
      this.dataset ?? (generateRandomDataset({ shape: [100, 2] }) as number[][])
    );
  }

  fit({ dataset }: IKMeansMethodParams) {
    if (!dataset) throw Errors.EmptyRequiredParameters("dataset");
    const iterator = new KMeansIterator(this.K, dataset);
    let result;
    for (result of iterator);
    return result;
  }
}

KMeansIterable 클래스는 Iterator 테스트까지 해보셨다면 아주 빠르게 완성하실 수 있습니다. 저는 기존에 K 매개변수만 받던 형식을 유지하고 싶었고 이에 따른 Symbol.iterator 메서드의 KMeansIterator 생성 형태에 이어서 fit 메서드에는 for-of 문법에 활용까지 적용한 형식으로 작성해 보았습니다.

const results = [];
for (let i = 0; i < 8; i++) {
  const K = 3 + Math.floor(Math.random() * 3);
  const kmeans = new KMeans(K);
  let result;
  for (result of kmeans);
  results.push(result);
}
exportOutput(`process_iterable_test.json`, results);

fit 메서드의 검증은 포함된 for-of 문에 대한 테스트 코드를 포함한 Iterable 객체의 정상 동작을 확인하면 진행할 수 있기 때문에 저는 위와 같이 테스트 코드를 작성하여 확인해 보았습니다.

steps({ dataset }: IKMeansMethodParams): IKMeansResult[] {
  const iterator = new KMeansIterator(this.K, dataset);
  return [...iterator];
}

추가적으로 기존에는 단순히 KMeans 반복 프로세스에 for-of 문을 적용하고자 Iteration Protocol을 채택했었는데요. Iterator 객체 테스트에서 작성한 코드와 같이 단계별 중심점과 군집 구성의 변화를 첨부하려다 보니 위와 같이 스프레드 문법을 사용하면 반복 프로세스의 각 라운드별 결과를 확인할 수 있다는 장점도 살펴볼 수 있었어요.

이렇게 최종 목표인 Typescript로 구현해 보는 KMeans가 완성되었습니다. 현재까지 작성한 내용의 Github Repository도 함께 공유해 드릴게요. 테스트를 위한 무작위 데이터 집합의 생성과 KMeans 실행을 포함시켰는데 사용법은 README를 참고바랄게요. 🤙🏻🤙🏻


현재는 공부의 방향성이 다양한 지식에 있어서 라이브러리의 사용을 더 적극적으로 하는 성향이지만 과거에는 깊은 지식을 추구하여 습관적으로 직접 구현하는 방식을 즐겨 했던 것 같아요. 시간적 비용이 많이 든다는 단점이 있었지만, 필요에 따라 코드보다는 원초적인 내부 프로세스에 관심을 가지는 집중력을 키워주었습니다. 언젠가 또 이런 순수함에서 나오는 노력을 맞이할 수 있는 계기가 찾아오면 좋겠네요. 그럼 오늘 하루도 수고 많으셨고 다음에 다시 찾아 뵙겠습니다. 🥰🥰