Skip to content

Implement K-Nearest Neighbors (KNN)

From-scratch implementation with variations and optimizations


Problem Statement

Implement the K-Nearest Neighbors algorithm for classification. Given training data X_train with labels y_train, predict the label for a query point x_query based on the k nearest training points.

KNN Query with K Nearest Neighbors


Clarifying Questions to Ask

  1. Classification or regression? (Start with classification, extend to regression)
  2. Distance metric? (Euclidean is default, mention Manhattan, cosine as alternatives)
  3. How to handle ties? (When k neighbors have equal votes)
  4. Libraries allowed? (NumPy yes, sklearn no for implementation questions)
  5. k validation? (What if k > n_samples?)

Solution: Basic KNN Classifier

python
from typing import List
from collections import Counter
import heapq
import math

def knn_classify(X_train: List[List[float]], y_train: List[int], x_query: List[float], k: int = 3) -> int:
    """
    K-Nearest Neighbors classification.

    Args:
        X_train: Training features, list of feature vectors
        y_train: Training labels
        x_query: Query point
        k: Number of neighbors

    Returns:
        Predicted class label
    """
    # Step 1: Validate inputs
    if k <= 0:
        raise ValueError("k must be positive")
    if k > len(X_train):
        raise ValueError(f"k={k} exceeds training set size={len(X_train)}")

    # Step 2: Compute distances from query to all training points
    distances: List[tuple] = []
    for i, x in enumerate(X_train):
        dist = math.sqrt(sum((a - b) ** 2 for a, b in zip(x, x_query)))
        distances.append((dist, i))

    # Step 3: Find indices of k nearest neighbors using heap
    k_nearest = heapq.nsmallest(k, distances)
    k_nearest_indices = [idx for _, idx in k_nearest]

    # Step 4: Get labels of k nearest neighbors
    k_nearest_labels = [y_train[i] for i in k_nearest_indices]

    # Step 5: Majority vote
    label_counts = Counter(k_nearest_labels)
    prediction = label_counts.most_common(1)[0][0]

    return prediction


def knn_classify_batch(X_train: List[List[float]], y_train: List[int],
                       X_query: List[List[float]], k: int = 3) -> List[int]:
    """
    Classify multiple query points.
    """
    predictions = []
    for x_query in X_query:
        pred = knn_classify(X_train, y_train, x_query, k)
        predictions.append(pred)
    return predictions
python
import numpy as np
from collections import Counter

def knn_classify(X_train, y_train, x_query, k=3):
    """
    K-Nearest Neighbors classification.

    Args:
        X_train: Training features, shape (n_samples, n_features)
        y_train: Training labels, shape (n_samples,)
        x_query: Query point, shape (n_features,)
        k: Number of neighbors

    Returns:
        Predicted class label
    """
    # Step 1: Validate inputs
    if k <= 0:
        raise ValueError("k must be positive")
    if k > len(X_train):
        raise ValueError(f"k={k} exceeds training set size={len(X_train)}")

    # Step 2: Compute distances from query to all training points
    # Using Euclidean distance: sqrt(sum((a - b)^2))
    distances = np.sqrt(np.sum((X_train - x_query) ** 2, axis=1))

    # Step 3: Find indices of k nearest neighbors
    k_nearest_indices = np.argsort(distances)[:k]

    # Step 4: Get labels of k nearest neighbors
    k_nearest_labels = y_train[k_nearest_indices]

    # Step 5: Majority vote
    label_counts = Counter(k_nearest_labels)
    prediction = label_counts.most_common(1)[0][0]

    return prediction


def knn_classify_batch(X_train, y_train, X_query, k=3):
    """Classify multiple query points."""
    predictions = []
    for x_query in X_query:
        pred = knn_classify(X_train, y_train, x_query, k)
        predictions.append(pred)
    return np.array(predictions)
python
import torch
from collections import Counter

def knn_classify(X_train: torch.Tensor, y_train: torch.Tensor,
                 x_query: torch.Tensor, k: int = 3) -> int:
    """
    K-Nearest Neighbors classification.
    """
    # Validate inputs
    if k <= 0:
        raise ValueError("k must be positive")
    if k > len(X_train):
        raise ValueError(f"k={k} exceeds training set size={len(X_train)}")

    # Compute distances
    distances = torch.sqrt(torch.sum((X_train - x_query) ** 2, dim=1))

    # Find k nearest neighbors
    _, k_nearest_indices = torch.topk(distances, k, largest=False)

    # Majority vote
    k_nearest_labels = y_train[k_nearest_indices].tolist()
    label_counts = Counter(k_nearest_labels)
    prediction = label_counts.most_common(1)[0][0]

    return prediction

Walkthrough Example

python
# Example data
X_train = np.array([
    [1, 1],   # Class 0
    [1, 2],   # Class 0
    [2, 1],   # Class 0
    [5, 5],   # Class 1
    [5, 6],   # Class 1
    [6, 5],   # Class 1
])
y_train = np.array([0, 0, 0, 1, 1, 1])

# Query point
x_query = np.array([3, 3])
k = 3

# Step-by-step trace:
# 1. Distances: [2.83, 2.24, 2.24, 2.83, 3.61, 3.61]
# 2. Sorted indices: [1, 2, 0, 3, 4, 5]
# 3. Top 3: [1, 2, 0]
# 4. Labels: [0, 0, 0]
# 5. Majority: 0

prediction = knn_classify(X_train, y_train, x_query, k=3)
print(f"Prediction: {prediction}")  # Output: 0

Effect of K on Decision Boundaries

KNN Decision Boundaries for Different K Values


Variations

Variation 1: KNN Regression

python
from typing import List
import math
import heapq

def knn_regress(X_train: List[List[float]], y_train: List[float],
                x_query: List[float], k: int = 3) -> float:
    """
    K-Nearest Neighbors regression (average of k nearest values).
    """
    # Compute distances
    distances: List[tuple] = []
    for i, x in enumerate(X_train):
        dist = math.sqrt(sum((a - b) ** 2 for a, b in zip(x, x_query)))
        distances.append((dist, i))

    # Find k nearest
    k_nearest = heapq.nsmallest(k, distances)
    k_nearest_indices = [idx for _, idx in k_nearest]
    k_nearest_values = [y_train[i] for i in k_nearest_indices]

    # Return mean (regression) instead of mode (classification)
    return sum(k_nearest_values) / len(k_nearest_values)
python
import numpy as np

def knn_regress(X_train, y_train, x_query, k=3):
    """
    K-Nearest Neighbors regression (average of k nearest values).
    """
    distances = np.sqrt(np.sum((X_train - x_query) ** 2, axis=1))
    k_nearest_indices = np.argsort(distances)[:k]
    k_nearest_values = y_train[k_nearest_indices]

    return np.mean(k_nearest_values)
python
import torch

def knn_regress(X_train: torch.Tensor, y_train: torch.Tensor,
                x_query: torch.Tensor, k: int = 3) -> float:
    """
    K-Nearest Neighbors regression (average of k nearest values).
    """
    distances = torch.sqrt(torch.sum((X_train - x_query) ** 2, dim=1))
    _, k_nearest_indices = torch.topk(distances, k, largest=False)
    k_nearest_values = y_train[k_nearest_indices]

    return torch.mean(k_nearest_values).item()

Variation 2: Weighted KNN

Weighted vs Uniform KNN Voting

python
from typing import List, Dict
import math
import heapq

def knn_classify_weighted(X_train: List[List[float]], y_train: List[int],
                          x_query: List[float], k: int = 3) -> int:
    """
    KNN with distance-based weighting (closer neighbors have more influence).
    """
    # Compute distances
    distances: List[tuple] = []
    for i, x in enumerate(X_train):
        dist = math.sqrt(sum((a - b) ** 2 for a, b in zip(x, x_query)))
        distances.append((dist, i))

    # Find k nearest
    k_nearest = heapq.nsmallest(k, distances)
    k_nearest_indices = [idx for _, idx in k_nearest]
    k_nearest_labels = [y_train[i] for i in k_nearest_indices]
    k_nearest_distances = [dist for dist, _ in k_nearest]

    # Weight = 1 / distance (add small epsilon to avoid division by zero)
    weights = [1.0 / (d + 1e-8) for d in k_nearest_distances]

    # Weighted vote
    unique_labels = set(y_train)
    weighted_votes: Dict[int, float] = {}

    for label in unique_labels:
        weighted_votes[label] = sum(
            w for l, w in zip(k_nearest_labels, weights) if l == label
        )

    return max(weighted_votes, key=weighted_votes.get)
python
import numpy as np

def knn_classify_weighted(X_train, y_train, x_query, k=3):
    """
    KNN with distance-based weighting (closer neighbors have more influence).
    """
    distances = np.sqrt(np.sum((X_train - x_query) ** 2, axis=1))
    k_nearest_indices = np.argsort(distances)[:k]
    k_nearest_labels = y_train[k_nearest_indices]
    k_nearest_distances = distances[k_nearest_indices]

    # Weight = 1 / distance
    weights = 1.0 / (k_nearest_distances + 1e-8)

    # Weighted vote
    unique_labels = np.unique(y_train)
    weighted_votes = {}

    for label in unique_labels:
        mask = k_nearest_labels == label
        weighted_votes[label] = np.sum(weights[mask])

    return max(weighted_votes, key=weighted_votes.get)
python
import torch

def knn_classify_weighted(X_train: torch.Tensor, y_train: torch.Tensor,
                          x_query: torch.Tensor, k: int = 3) -> int:
    """
    KNN with distance-based weighting (closer neighbors have more influence).
    """
    distances = torch.sqrt(torch.sum((X_train - x_query) ** 2, dim=1))
    _, k_nearest_indices = torch.topk(distances, k, largest=False)
    k_nearest_labels = y_train[k_nearest_indices]
    k_nearest_distances = distances[k_nearest_indices]

    weights = 1.0 / (k_nearest_distances + 1e-8)

    unique_labels = torch.unique(y_train)
    weighted_votes = {}

    for label in unique_labels:
        mask = k_nearest_labels == label.item()
        weighted_votes[label.item()] = torch.sum(weights[mask]).item()

    return max(weighted_votes, key=weighted_votes.get)

Variation 3: Different Distance Metrics

Distance Metric Comparison: Euclidean vs Manhattan

python
from typing import List
from collections import Counter
import math
import heapq

def compute_distance(x1: List[float], x2: List[float], metric: str = 'euclidean') -> float:
    """Compute distance between two points using the specified metric."""
    if metric == 'euclidean':
        return math.sqrt(sum((a - b) ** 2 for a, b in zip(x1, x2)))
    elif metric == 'manhattan':
        return sum(abs(a - b) for a, b in zip(x1, x2))
    elif metric == 'cosine':
        dot = sum(a * b for a, b in zip(x1, x2))
        norm1 = math.sqrt(sum(a ** 2 for a in x1))
        norm2 = math.sqrt(sum(b ** 2 for b in x2))
        return 1 - dot / (norm1 * norm2 + 1e-8)
    raise ValueError(f"Unknown metric: {metric}")


def knn_classify_flexible(X_train: List[List[float]], y_train: List[int],
                          x_query: List[float], k: int = 3,
                          metric: str = 'euclidean') -> int:
    """KNN with configurable distance metric."""
    distances = []
    for i, x in enumerate(X_train):
        dist = compute_distance(x, x_query, metric)
        distances.append((dist, i))

    k_nearest = heapq.nsmallest(k, distances)
    k_nearest_labels = [y_train[idx] for _, idx in k_nearest]

    return Counter(k_nearest_labels).most_common(1)[0][0]
python
import numpy as np
from collections import Counter

def knn_classify_flexible(X_train, y_train, x_query, k=3, metric='euclidean'):
    """KNN with configurable distance metric."""
    if metric == 'euclidean':
        distances = np.sqrt(np.sum((X_train - x_query) ** 2, axis=1))
    elif metric == 'manhattan':
        distances = np.sum(np.abs(X_train - x_query), axis=1)
    elif metric == 'cosine':
        dot_products = np.dot(X_train, x_query)
        norms_train = np.linalg.norm(X_train, axis=1)
        norm_query = np.linalg.norm(x_query)
        similarities = dot_products / (norms_train * norm_query + 1e-8)
        distances = 1 - similarities
    else:
        raise ValueError(f"Unknown metric: {metric}")

    k_nearest_indices = np.argsort(distances)[:k]
    k_nearest_labels = y_train[k_nearest_indices]

    return Counter(k_nearest_labels).most_common(1)[0][0]
python
import torch
from collections import Counter

def knn_classify_flexible(X_train: torch.Tensor, y_train: torch.Tensor,
                          x_query: torch.Tensor, k: int = 3,
                          metric: str = 'euclidean') -> int:
    """KNN with configurable distance metric."""
    if metric == 'euclidean':
        distances = torch.sqrt(torch.sum((X_train - x_query) ** 2, dim=1))
    elif metric == 'manhattan':
        distances = torch.sum(torch.abs(X_train - x_query), dim=1)
    elif metric == 'cosine':
        dot_products = torch.mv(X_train, x_query)
        norms_train = torch.norm(X_train, dim=1)
        norm_query = torch.norm(x_query)
        similarities = dot_products / (norms_train * norm_query + 1e-8)
        distances = 1 - similarities
    else:
        raise ValueError(f"Unknown metric: {metric}")

    _, k_nearest_indices = torch.topk(distances, k, largest=False)
    k_nearest_labels = y_train[k_nearest_indices].tolist()

    return Counter(k_nearest_labels).most_common(1)[0][0]

Vectorized Batch Prediction

For efficiency when predicting many points:

python
from typing import List
from collections import Counter
import math

def knn_classify_batch_vectorized(X_train: List[List[float]], y_train: List[int],
                                   X_query: List[List[float]], k: int = 3) -> List[int]:
    """Batch KNN prediction with pairwise distance computation."""
    n_train = len(X_train)
    n_query = len(X_query)

    # Compute pairwise distance matrix: shape (n_query, n_train)
    distances = []
    for x_q in X_query:
        row = []
        for x_t in X_train:
            dist = math.sqrt(sum((a - b) ** 2 for a, b in zip(x_q, x_t)))
            row.append(dist)
        distances.append(row)

    # Predict for each query
    predictions = []
    for i in range(n_query):
        indexed_dists = [(distances[i][j], j) for j in range(n_train)]
        indexed_dists.sort()
        k_nearest_indices = [idx for _, idx in indexed_dists[:k]]
        k_labels = [y_train[idx] for idx in k_nearest_indices]
        pred = Counter(k_labels).most_common(1)[0][0]
        predictions.append(pred)

    return predictions
python
import numpy as np
from collections import Counter

def knn_classify_batch_vectorized(X_train, y_train, X_query, k=3):
    """
    Vectorized KNN for batch prediction.
    Uses pairwise distance matrix for efficiency.
    """
    n_query = len(X_query)

    # Compute pairwise distance matrix: ||a - b||^2 = ||a||^2 + ||b||^2 - 2*a.b
    X_train_sq = np.sum(X_train ** 2, axis=1).reshape(1, -1)
    X_query_sq = np.sum(X_query ** 2, axis=1).reshape(-1, 1)
    cross_term = np.dot(X_query, X_train.T)

    dist_sq = X_query_sq + X_train_sq - 2 * cross_term
    distances = np.sqrt(np.maximum(dist_sq, 0))

    # Find k nearest for each query
    k_nearest_indices = np.argpartition(distances, k, axis=1)[:, :k]

    # Predict for each query
    predictions = []
    for i in range(n_query):
        k_labels = y_train[k_nearest_indices[i]]
        pred = Counter(k_labels).most_common(1)[0][0]
        predictions.append(pred)

    return np.array(predictions)
python
import torch
from collections import Counter

def knn_classify_batch_vectorized(X_train: torch.Tensor, y_train: torch.Tensor,
                                   X_query: torch.Tensor, k: int = 3) -> torch.Tensor:
    """Vectorized KNN for batch prediction using PyTorch."""
    n_query = X_query.shape[0]

    # Compute pairwise distance matrix
    X_train_sq = torch.sum(X_train ** 2, dim=1).unsqueeze(0)
    X_query_sq = torch.sum(X_query ** 2, dim=1).unsqueeze(1)
    cross_term = torch.mm(X_query, X_train.T)

    dist_sq = X_query_sq + X_train_sq - 2 * cross_term
    distances = torch.sqrt(torch.clamp(dist_sq, min=0))

    # Find k nearest for each query
    _, k_nearest_indices = torch.topk(distances, k, dim=1, largest=False)

    # Predict for each query
    predictions = []
    for i in range(n_query):
        k_labels = y_train[k_nearest_indices[i]].tolist()
        pred = Counter(k_labels).most_common(1)[0][0]
        predictions.append(pred)

    return torch.tensor(predictions)

Complexity Analysis

OperationTimeSpace
Single predictionO(nd + n log n)O(n)
Batch (naive)O(m(nd + n log n))O(n)
Batch (vectorized)O(mnd)O(mn)

n = training samples, m = query points, d = features


Optimizations

KD-Tree for O(log n) Queries

python
from scipy.spatial import KDTree
from collections import Counter

def knn_with_kdtree(X_train, y_train, X_query, k=3):
    """KNN using KD-Tree for O(log n) nearest neighbor queries."""
    tree = KDTree(X_train)
    distances, indices = tree.query(X_query, k=k)
    return np.array([Counter(y_train[idx]).most_common(1)[0][0] for idx in indices])

When to use: n > 10,000 and d < 20. Use Ball Tree for d > 20, Faiss/Annoy for approximate NN on large datasets.


Edge Cases to Handle

python
from typing import List
from collections import Counter
import math
import heapq

def knn_classify_robust(X_train: List[List[float]], y_train: List[int],
                        x_query: List[float], k: int = 3) -> int:
    """Robust KNN with edge case handling."""
    # Edge case 1: Empty training set
    if len(X_train) == 0:
        raise ValueError("Training set is empty")

    # Edge case 2: k larger than training set
    if k > len(X_train):
        print(f"Warning: k={k} > n_samples={len(X_train)}, using k={len(X_train)}")
        k = len(X_train)

    # Edge case 3: Mismatched dimensions
    if len(X_train[0]) != len(x_query):
        raise ValueError(f"Feature dimension mismatch: {len(X_train[0])} vs {len(x_query)}")

    distances = []
    for i, x in enumerate(X_train):
        dist = math.sqrt(sum((a - b) ** 2 for a, b in zip(x, x_query)))
        distances.append((dist, i))

    k_nearest = heapq.nsmallest(k, distances)
    k_nearest_labels = [y_train[idx] for _, idx in k_nearest]

    label_counts = Counter(k_nearest_labels)
    return label_counts.most_common(1)[0][0]
python
import numpy as np
from collections import Counter

def knn_classify_robust(X_train, y_train, x_query, k=3):
    """Robust KNN with edge case handling."""
    if len(X_train) == 0:
        raise ValueError("Training set is empty")

    if k > len(X_train):
        print(f"Warning: k={k} > n_samples={len(X_train)}, using k={len(X_train)}")
        k = len(X_train)

    if X_train.shape[1] != len(x_query):
        raise ValueError(f"Feature dimension mismatch: {X_train.shape[1]} vs {len(x_query)}")

    distances = np.sqrt(np.sum((X_train - x_query) ** 2, axis=1))
    k_nearest_indices = np.argsort(distances)[:k]
    k_nearest_labels = y_train[k_nearest_indices]

    label_counts = Counter(k_nearest_labels)
    return label_counts.most_common(1)[0][0]

Test Suite

python
def test_knn():
    """Test cases for KNN implementation."""

    # Test 1: Basic classification
    X_train = np.array([[0, 0], [1, 1], [2, 2], [10, 10], [11, 11], [12, 12]])
    y_train = np.array([0, 0, 0, 1, 1, 1])
    x_query = np.array([1.5, 1.5])
    assert knn_classify(X_train, y_train, x_query, k=3) == 0
    print("Test 1 passed: Basic classification")

    # Test 2: Query near class boundary
    x_query = np.array([5, 5])
    pred = knn_classify(X_train, y_train, x_query, k=3)
    print(f"Test 2: Boundary query predicted as class {pred}")

    # Test 3: k=1 (nearest neighbor)
    x_query = np.array([10.1, 10.1])
    assert knn_classify(X_train, y_train, x_query, k=1) == 1
    print("Test 3 passed: k=1 nearest neighbor")

    # Test 4: k equals dataset size
    pred = knn_classify(X_train, y_train, x_query, k=6)
    print(f"Test 4: k=n prediction: {pred}")

    print("\nAll tests passed!")

test_knn()

Interview Tips

  1. Clarify first - distance metric, classification vs regression, tie handling
  2. Write clean code - optimize later if time permits
  3. Trace an example - demonstrates understanding
  4. Know complexity - mention KD-tree for large n
  5. Handle edge cases - empty data, k > n, dimension mismatch