Nearest Centroid Classification for Numeric Data From Scratch Using Python

Nearest centroid classification is arguably the simplest machine learning technique to predict a discrete value. I created a demo, using the Python language, from scratch. My demo predicts the species (Adelie, Chinstrap, or Gentoo) of a male penguin based on bill length, bill width, flipper length, and body mass.

Briefly, in nearest centroid classification, if there are n classes, you compute the n centroids of the data. Centroids are also called vector means or vector averages. Then, to classify a data item, you compute the Euclidean distance from the item to each of the n centroids, and predict the class/label of the centroid that’s closest to the item being classified.

A few weeks ago, I put together a demo using the C# language. One rainy Saturday morning, I figured I’d refactor that demo to Python.

Nearest centroid classification (NCC) is simple, works well with small datasets, works without modification for both binary and multi-class classification, and is highly interpretable. But, and this is a big but, NCC is not very powerful because it doesn’t take into account the interactions between predictor variables. That said, for some datasets, NCC works very well.

My demo uses the Penguin Dataset. The raw training data looks like:

2, 50.0, 16.3, 230.0, 5700.0
0, 39.1, 18.7, 181.0, 3750.0
1, 38.8, 17.2, 180.0, 3800.0
2, 39.3, 20.6, 190.0, 3650.0
0, 39.2, 19.6, 195.0, 4675.0
2, 50.0, 15.2, 218.0, 5700.0
1, 50.0, 19.5, 196.0, 3900.0
. . .

The fields are species (0 = Adelie, 1 = Chinstrap, 2 = Gentoo), bill length, bill width, flipper length, body mass. The full Penguin Dataset has 345 items and has addition columns such as sex and island where measured. My demo data uses just 30 items for training and 10 items for testing. I didn’t use categorical predictor variables because NCC doesn’t work directly with categorical data. I used only male penguins.

Nearest centroid classification requires you to normalize the predictor variables so that variables with large magnitudes, such as body mass, don’t overwhelm variables with small magnitudes when computing Euclidean distance.

My demo loads the training data and applies min-max normalization like so:

  import numpy as np

  train_file = ".\\Data\\penguin_train_30.txt"
  test_file = ".\\Data\\penguin_test_10.txt"

  train_X = np.loadtxt(train_file, usecols=[1,2,3,4],
    delimiter =",", comments="#", dtype=np.float64)
  mins_maxs = mat_mins_maxs(train_X)
  train_X = normalize_using(train_X, mins_maxs)
  train_y = np.loadtxt(train_file, usecols=0,
    delimiter =",", comments="#", dtype=np.int64)
. . .

I implemented a from-scratch normalize_using() function rather than take a dependency on an external library function such as the scikit-learn sklearn.preprocessing.MinMaxScaler module. The normalized data looks like:

First four normalized predictors:
[[0.8508 0.2571 1.0000 0.9412]
 [0.2486 0.6000 0.0200 0.1765]
 [0.2320 0.3857 0.0000 0.1961]
 [0.2597 0.8714 0.2000 0.1373]]

The demo creates and trains a nearest centroid classifier using these statements:

  ncc = NearestCentroidClassifier(3)
  ncc.train(train_X, train_y)
  print("Done ")
  print(ncc.centroids)
. . .

The program-defined NearestCentroidClassifier train() method just computes the 3 centroids of the training data, which are:

Model centroids:
[[0.2700 0.7786 0.2025 0.3174]
 [0.8432 0.6418 0.2969 0.1931]
 [0.7103 0.2095 0.6956 0.7996]]

The demo uses the trained model to predict the species of a new, previously unseen penguin:

  x = np.array([46.5, 17.9, 192, 3500], dtype=np.float64)
  x = normalize_using(x, mins_maxs)
  print("Normalized x = ")
  print(x)
  pred_probs = ncc.predict_probs(x)
  print("Prediction probs: ")
  print(pred_probs)

  distances = np.zeros(ncc.num_classes)
  for i in range(len(distances)):
    distances[i] = np.linalg.norm(x - ncc.centroids[i])
  print("\nDistances to centroids: ")
  print(distances)
. . .

The output is:

Predicting species for 46.5, 17.9, 192, 3500

Normalized x =
[0.6575 0.4857 0.2400 0.0784]

Prediction probs:
[0.2791 0.5522 0.1686]

Distances to centroids:
[0.5426 0.2743 0.8981]

The NearestCentroidClassifier definition has a predict() method and a predict_probs() method. The predict() method returns the predicted class/label/species — 0, 1, or 2. The predict_probs() method returns pseudo-probabilities that sum to 1, and so the predicted class is the index of the pseudo-probability associated with the largest probability, in this case [1] which is associated with pp = 0.5522.

The three distances from the item-to-classify to the three centroids are computed using Euclidean distance via the not-at-all-obvious np.linalg.norm(a-b) function. I could have implemented a program-defined euclidean_dist() function but because I already had a dependency on NumPy, I just used the built-in norm() function. The predicted class is the index of the smallest distance — again [1], associated with dist = 0.2743.

My demo concludes by evaluating the model accuracy. The statements are:

  test_X = np.loadtxt(test_file, usecols=[1,2,3,4],
    delimiter =",", comments="#", dtype=np.float64)
  test_X = normalize_using(test_X, mins_maxs)
  test_y = np.loadtxt(test_file, usecols=0,
    delimiter =",", comments="#", dtype=np.int64)

  acc_train = ncc.accuracy(train_X, train_y)
  print("Accuracy on training data = %0.4f " % acc_train)
  acc_test = ncc.accuracy(test_X, test_y)
  print("Accuracy on test data = %0.4f " % acc_test)

  cm = ncc.confusion_matrix(train_X, train_y)
  print("\nConfusion matrix for training data: ")
  ncc.show_confusion(cm)
. . .

And the outputs are:

Accuracy on training data = 0.9333
Accuracy on test data = 1.0000

Confusion matrix for training data:
actual 0:    8   0   0 |    8  |  1.0000
actual 1:    1  12   0 |   13  |  0.9231
actual 2:    1   0   8 |    9  |  0.8889

The nearest centroid classification technique worked surprisingly well on the demo data. In fact, I had to change two of the class labels/species in the training data in order to get any incorrect predictions at all. If you look at the centroids, you can see that species 0 = Adelie is unambiguously determined by small bill length and large bill width. Species 1 = Chinstrap is determined by large bill length and small body mass. Species 2 = Gentoo is determined by large flipper length and large body mass.

Good fun on a Saturday morning.



An Internet search for “centroid” returned an image of the structure of vanadinite, an uncommon mineral. Vanadinite contains element #23 vanadium. Vanadium is added to steel, which makes the steel lighter and stronger. The first widespread industrial use of vanadium steel was in the chassis of the Ford Model T.


Demo code. Replace the “lt” (less than) with Boolean operator symbol (my blog editor chokes on symbols).

# nearest_centroid_classification.py

import numpy as np

class NearestCentroidClassifier:
  def __init__(self, num_classes):
    self.num_classes = num_classes
    self.centroids = None
    # consider adding self.distances here

  def train(self, train_X, train_y):
    # compute centroid of each class
    n = len(train_X)
    dim = len(train_X[0])
    self.centroids = np.zeros((self.num_classes,dim),
      dtype=np.float64)
    counts = np.zeros(self.num_classes)
 
    for i in range(n): 
      c = train_y[i]  # current class
      self.centroids[c] += train_X[i]
      counts[c] += 1
    for j in range(self.num_classes):
      self.centroids[j] /= counts[j]

  def predict_probs(self, x):
    # returns pseudo-probs
    distances = np.zeros(self.num_classes)
    for i in range(self.num_classes):
      distances[i] = \
      np.linalg.norm(x - self.centroids[i])
      if distances[i] "lt" 0.00000001:
        distances[i] = 0.00000001 # avoid div by 0
    inv_dists = 1 / distances
    probs = inv_dists / np.sum(inv_dists)
    return probs
 
  def predict(self, x):
    # returns predicted class 
    distances = np.zeros(self.num_classes)
    for i in range(self.num_classes):
      distances[i] = \
      np.linalg.norm(x - self.centroids[i])
    pred_class = np.argmin(distances)
    return pred_class

  def accuracy(self, data_X, data_y):
    n_correct = 0; n_wrong = 0
    for i in range(len(data_X)):
      x = data_X[i]
      trgt_y = data_y[i]
      pred_y = self.predict(x)
      if pred_y == trgt_y: n_correct += 1
      else: n_wrong += 1
    return n_correct / (n_correct + n_wrong)

  def confusion_matrix(self, data_X, data_y):
    nc = self.num_classes
    result = np.zeros((nc,nc), dtype=np.float64)
    for i in range(len(data_X)):
      x = data_X[i]
      act_y = data_y[i]
      pred_y = self.predict(x)
      result[act_y][pred_y] += 1
    return result

  def show_confusion(self, cm):
    nc = self.num_classes
    counts = np.zeros(nc, dtype=np.int64)
    accs = np.zeros(nc, dtype=np.float64)
    counts = np.sum(cm, axis=1)  # collapse cols, do rows
    for i in range(nc):
      accs[i] = cm[i][i] / counts[i] 
    for i in range(nc):
      print("actual " + str(i) + ": ", end="")
      for j in range(nc):
        print("%4.0f" % cm[i][j], end="")
      print(" | %4d " % counts[i], end="")
      print(" | %7.4f " % accs[i])

# -----------------------------------------------------------

def mat_mins_maxs(data_X):
  n_cols = len(data_X[0])
  mins_maxs = np.zeros((2,n_cols), dtype=np.float64)
  mins = data_X.min(axis=0)  # collapse rows == process cols
  maxs = data_X.max(axis=0)
  mins_maxs[0] = mins; mins_maxs[1] = maxs
  return mins_maxs

# -----------------------------------------------------------

def minmax_normalize(data_X):
  n_rows = len(data_X); n_cols = len(data_X[0])
  result = np.zeros((n_rows,n_cols), dtype=np.float64)
  mins_maxs = np.zeros((2,n_cols), dtype=np.float64)
  mins = data_X.min(axis=0)  # collapse rows == process cols
  maxs = data_X.max(axis=0)
  mins_maxs[0] = mins; mins_maxs[1] = maxs
  result = (data_X - mins) / (maxs - mins)
  return (result, mins_maxs)

# -----------------------------------------------------------

def normalize_using(x, mins_maxs):
  # x can be either a data matrix or single vector
  return (x - mins_maxs[0]) / (mins_maxs[1] - mins_maxs[0])

# -----------------------------------------------------------

def main():
  print("\nBegin nearest centroid classification demo ")
  print("\nPredict penguin species from bill length, bill ")
  print("  width, flipper length, body mass ")
  print("Species: 0 = Adelie, 1 = Chinstrap, 2 = Gentoo ")

  train_file = ".\\Data\\penguin_train_30.txt"
  test_file = ".\\Data\\penguin_test_10.txt"

  train_X = np.loadtxt(train_file, usecols=[1,2,3,4],
    delimiter =",", comments="#", dtype=np.float64)
  np.set_printoptions(precision=1, suppress=True,
    floatmode='fixed')
  print("\nFirst four raw training data predictors: ")
  print(train_X[0:4,:])

  print("\nApplying min-max normalization to raw data ")
  mins_maxs = mat_mins_maxs(train_X)
  train_X = normalize_using(train_X, mins_maxs)
  # train_X, mins_maxs = minmax_normalize(train_X)
  np.set_printoptions(precision=4, suppress=True,
    floatmode='fixed')
  print("\nFirst four normalized predictors: ")
  print(train_X[0:4,:])

  train_y = np.loadtxt(train_file, usecols=0,
    delimiter =",", comments="#", dtype=np.int64)
  print("\nTraining data target values: ")
  print(train_y)

  print("\nTraining nearest centroid classifier ")
  ncc = NearestCentroidClassifier(3)
  ncc.train(train_X, train_y)
  print("Done ")

  print("\nModel centroids: ")
  np.set_printoptions(precision=4, suppress=True,
    floatmode='fixed')
  print(ncc.centroids)

  print("\nPredicting species for 46.5, 17.9, 192, 3500")
  x = np.array([46.5, 17.9, 192, 3500], dtype=np.float64)
  x = normalize_using(x, mins_maxs)
  print("\nNormalized x = ")
  print(x)
  pred_probs = ncc.predict_probs(x)
  print("\nPrediction probs: ")
  print(pred_probs)

  distances = np.zeros(ncc.num_classes)
  for i in range(len(distances)):
    distances[i] = np.linalg.norm(x - ncc.centroids[i])
  print("\nDistances to centroids: ")
  print(distances)

  print("\nEvaluating model accuracy ")
  test_X = np.loadtxt(test_file, usecols=[1,2,3,4],
    delimiter =",", comments="#", dtype=np.float64)
  test_X = normalize_using(test_X, mins_maxs)
  test_y = np.loadtxt(test_file, usecols=0,
    delimiter =",", comments="#", dtype=np.int64)

  acc_train = ncc.accuracy(train_X, train_y)
  print("Accuracy on training data = %0.4f " % acc_train)
  acc_test = ncc.accuracy(test_X, test_y)
  print("Accuracy on test data = %0.4f " % acc_test)

  cm = ncc.confusion_matrix(train_X, train_y)
  print("\nConfusion matrix for training data: ")
  ncc.show_confusion(cm)

  print("\nEnd demo ")

if __name__ == "__main__":
  main()

Training data:

# penguin_train_30.txt
# male only
# train mins = (34.60, 14.50, 180.00, 3300.00)
# train maxs = (52.70, 21.50, 230.00, 5850.00)
#
# 30 items
# actual: 0, 38.8, 17.2, 180.0, 3800.0
# replaced by: 1, 38.8, 17.2, 180.0, 3800.0
#
# actual: 0, 39.3, 20.6, 190.0, 3650.0
# replaced by 2, 39.3, 20.6, 190.0, 3650.0
# to generate at least one bad prediction
#
2, 50.0, 16.3, 230.0, 5700.0
0, 39.1, 18.7, 181.0, 3750.0
# 0, 38.8, 17.2, 180.0, 3800.0 # true data
1, 38.8, 17.2, 180.0, 3800.0
# 0, 39.3, 20.6, 190.0, 3650.0 # true data
2, 39.3, 20.6, 190.0, 3650.0
0, 39.2, 19.6, 195.0, 4675.0
2, 50.0, 15.2, 218.0, 5700.0
1, 50.0, 19.5, 196.0, 3900.0
1, 51.3, 19.2, 193.0, 3650.0
0, 38.6, 21.2, 191.0, 3800.0
1, 52.7, 19.8, 197.0, 3725.0
2, 47.6, 14.5, 215.0, 5400.0
1, 51.3, 18.2, 197.0, 3750.0
2, 46.7, 15.3, 219.0, 5200.0
1, 51.3, 19.9, 198.0, 3700.0
0, 34.6, 21.1, 198.0, 4400.0
1, 51.7, 20.3, 194.0, 3775.0
1, 52.0, 18.1, 201.0, 4050.0
2, 46.8, 15.4, 215.0, 5150.0
0, 42.5, 20.7, 197.0, 4500.0
1, 50.5, 19.6, 201.0, 4050.0
1, 50.3, 20.0, 197.0, 3300.0
2, 49.0, 16.1, 216.0, 5550.0
0, 46.0, 21.5, 194.0, 4200.0
1, 49.2, 18.2, 195.0, 4400.0
2, 48.4, 14.6, 213.0, 5850.0
2, 49.3, 15.7, 217.0, 5850.0
0, 37.7, 18.7, 180.0, 3600.0
0, 38.2, 18.1, 185.0, 3950.0
1, 48.5, 17.5, 191.0, 3400.0
1, 50.6, 19.4, 193.0, 3800.0

Test data:

# penguin_test_10.txt
# male only
# 10 items
0, 40.6, 18.6, 183.0, 3550.0
0, 40.5, 18.9, 180.0, 3950.0
0, 37.2, 18.1, 178.0, 3900.0
0, 40.9, 18.9, 184.0, 3900.0
1, 52.0, 19.0, 197.0, 4150.0
1, 49.5, 19.0, 200.0, 3800.0
1, 52.8, 20.0, 205.0, 4550.0
2, 49.2, 15.2, 221.0, 6300.0
2, 48.7, 15.1, 222.0, 5350.0
2, 50.2, 14.3, 218.0, 5700.0
This entry was posted in Machine Learning. Bookmark the permalink.