1. 为什么需要从零实现机器学习算法?

在机器学习领域,调用现成的库(如scikit-learn)固然方便,但真正理解算法本质的唯一途径就是亲手实现它们。我仍然记得第一次用Python实现线性回归时的顿悟时刻——那些在教科书上看不懂的数学公式,在代码中突然变得清晰可见。

从零实现机器学习算法能带来三个核心价值:

  1. 深入理解算法内部的数学原理和工作机制
  2. 获得定制和优化算法的能力,不再受限于现有库的功能
  3. 培养解决实际问题的工程化思维,而不仅仅是调参技巧

本文将以Python为例,带你实现5个最基础的机器学习算法:线性回归、逻辑回归、KNN、朴素贝叶斯和决策树。每个实现都将控制在100行代码以内,但会包含完整的训练和预测流程。

2. 准备工作与环境搭建

2.1 最小化工具链配置

我们只需要最基础的Python科学计算环境:

pip install numpy matplotlib

注意:刻意不使用任何机器学习专用库,甚至连scipy都不需要。这是为了确保我们真的从最底层理解每个算法的实现。

2.2 测试数据生成

为了验证算法效果,我们先实现一个简单的数据生成器:

import numpy as np

def make_regression_data(n_samples=100, noise=0.1):
    X = np.random.rand(n_samples, 1) * 2 - 1
    y = 5 * X + 3 + np.random.normal(0, noise, (n_samples, 1))
    return X, y

def make_classification_data(n_samples=100):
    X = np.random.randn(n_samples, 2)
    y = (X[:, 0] > 0).astype(int)
    return X, y

3. 线性回归实现

3.1 数学原理回顾

线性回归的核心是最小二乘法,目标是最小化损失函数: [ J(\theta) = \frac{1}{2m}\sum_{i=1}^m(h_\theta(x^{(i)}) - y^{(i)})^2 ] 其中 ( h_\theta(x) = \theta^Tx )

3.2 梯度下降实现

class LinearRegression:
    def __init__(self, lr=0.01, n_iters=1000):
        self.lr = lr
        self.n_iters = n_iters
        self.weights = None
        self.bias = None
    
    def fit(self, X, y):
        n_samples, n_features = X.shape
        self.weights = np.zeros(n_features)
        self.bias = 0
        
        for _ in range(self.n_iters):
            y_pred = np.dot(X, self.weights) + self.bias
            dw = (1/n_samples) * np.dot(X.T, (y_pred - y))
            db = (1/n_samples) * np.sum(y_pred - y)
            
            self.weights -= self.lr * dw
            self.bias -= self.lr * db
    
    def predict(self, X):
        return np.dot(X, self.weights) + self.bias

实操技巧:学习率(lr)通常从0.01开始尝试,如果损失震荡不收敛就调小,如果下降太慢就调大。

4. 逻辑回归实现

4.1 Sigmoid函数与决策边界

逻辑回归使用sigmoid函数将线性输出映射到(0,1)区间: [ \sigma(z) = \frac{1}{1+e^{-z}} ]

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

class LogisticRegression:
    def __init__(self, lr=0.01, n_iters=1000):
        self.lr = lr
        self.n_iters = n_iters
        self.weights = None
        self.bias = None
    
    def fit(self, X, y):
        n_samples, n_features = X.shape
        self.weights = np.zeros(n_features)
        self.bias = 0
        
        for _ in range(self.n_iters):
            linear = np.dot(X, self.weights) + self.bias
            y_pred = sigmoid(linear)
            
            dw = (1/n_samples) * np.dot(X.T, (y_pred - y))
            db = (1/n_samples) * np.sum(y_pred - y)
            
            self.weights -= self.lr * dw
            self.bias -= self.lr * db
    
    def predict(self, X):
        linear = np.dot(X, self.weights) + self.bias
        y_pred = sigmoid(linear)
        return [1 if i > 0.5 else 0 for i in y_pred]

4.2 多分类扩展技巧

通过One-vs-Rest策略可以实现多分类:

class MulticlassLogisticRegression:
    def __init__(self, n_classes, lr=0.01, n_iters=1000):
        self.n_classes = n_classes
        self.classifiers = [LogisticRegression(lr, n_iters) 
                           for _ in range(n_classes)]
    
    def fit(self, X, y):
        for i in range(self.n_classes):
            y_binary = (y == i).astype(int)
            self.classifiers[i].fit(X, y_binary)
    
    def predict(self, X):
        probs = np.array([clf.predict_proba(X) 
                         for clf in self.classifiers])
        return np.argmax(probs, axis=0)

5. K近邻算法实现

5.1 距离度量选择

KNN的核心是距离计算,常用欧式距离: [ d(x, y) = \sqrt{\sum_{i=1}^n(x_i - y_i)^2} ]

class KNN:
    def __init__(self, k=3):
        self.k = k
    
    def fit(self, X, y):
        self.X_train = X
        self.y_train = y
    
    def predict(self, X):
        y_pred = [self._predict(x) for x in X]
        return np.array(y_pred)
    
    def _predict(self, x):
        distances = [np.sqrt(np.sum((x - x_train)**2)) 
                    for x_train in self.X_train]
        k_indices = np.argsort(distances)[:self.k]
        k_labels = [self.y_train[i] for i in k_indices]
        return max(set(k_labels), key=k_labels.count)

5.2 效率优化技巧

原始KNN的时间复杂度是O(n),可以通过KD树优化到O(log n):

from collections import Counter

class KNN_KD:
    def __init__(self, k=3):
        self.k = k
        self.tree = None
    
    def fit(self, X, y):
        self.tree = KDTree(X)
        self.y_train = y
    
    def predict(self, X):
        dist, ind = self.tree.query(X, k=self.k)
        k_labels = [self.y_train[i] for i in ind]
        return [Counter(labels).most_common(1)[0][0] 
               for labels in k_labels]

6. 朴素贝叶斯实现

6.1 概率计算原理

基于贝叶斯定理: [ P(y|x) = \frac{P(x|y)P(y)}{P(x)} ] "朴素"假设特征条件独立: [ P(x|y) = \prod_{i=1}^n P(x_i|y) ]

class NaiveBayes:
    def fit(self, X, y):
        n_samples, n_features = X.shape
        self.classes = np.unique(y)
        n_classes = len(self.classes)
        
        self.mean = np.zeros((n_classes, n_features))
        self.var = np.zeros((n_classes, n_features))
        self.priors = np.zeros(n_classes)
        
        for c in self.classes:
            X_c = X[y == c]
            self.mean[c] = X_c.mean(axis=0)
            self.var[c] = X_c.var(axis=0)
            self.priors[c] = X_c.shape[0] / n_samples
    
    def predict(self, X):
        y_pred = [self._predict(x) for x in X]
        return np.array(y_pred)
    
    def _predict(self, x):
        posteriors = []
        for c in self.classes:
            prior = np.log(self.priors[c])
            likelihood = np.sum(np.log(self._pdf(c, x)))
            posterior = prior + likelihood
            posteriors.append(posterior)
        return self.classes[np.argmax(posteriors)]
    
    def _pdf(self, class_idx, x):
        mean = self.mean[class_idx]
        var = self.var[class_idx]
        numerator = np.exp(-(x - mean)**2 / (2 * var))
        denominator = np.sqrt(2 * np.pi * var)
        return numerator / denominator

注意:实际计算中使用对数概率避免下溢问题,同时高斯分布假设可能不适合所有特征。

7. 决策树实现

7.1 信息增益计算

使用信息熵作为划分标准: [ \text{Entropy} = -\sum_{i=1}^c p_i \log_2 p_i ] 信息增益: [ \text{IG} = \text{Entropy(parent)} - \sum \frac{N_v}{N} \text{Entropy(child}_v) ]

class DecisionTree:
    def __init__(self, max_depth=5):
        self.max_depth = max_depth
    
    def fit(self, X, y):
        self.n_classes = len(np.unique(y))
        self.n_features = X.shape[1]
        self.tree = self._grow_tree(X, y)
    
    def _grow_tree(self, X, y, depth=0):
        n_samples, n_features = X.shape
        n_labels = len(np.unique(y))
        
        if (depth >= self.max_depth or n_labels == 1):
            leaf_value = self._most_common_label(y)
            return {'value': leaf_value}
        
        feat_idxs = np.random.choice(n_features, 
                                   int(np.sqrt(n_features)), 
                                   replace=False)
        
        best_feat, best_thresh = self._best_split(X, y, feat_idxs)
        left_idxs, right_idxs = self._split(X[:, best_feat], best_thresh)
        
        left = self._grow_tree(X[left_idxs, :], y[left_idxs], depth+1)
        right = self._grow_tree(X[right_idxs, :], y[right_idxs], depth+1)
        return {'feature': best_feat, 
               'threshold': best_thresh,
               'left': left, 
               'right': right}
    
    def _best_split(self, X, y, feat_idxs):
        best_gain = -1
        split_idx, split_thresh = None, None
        
        for feat_idx in feat_idxs:
            X_column = X[:, feat_idx]
            thresholds = np.unique(X_column)
            
            for thresh in thresholds:
                gain = self._information_gain(y, X_column, thresh)
                if gain > best_gain:
                    best_gain = gain
                    split_idx = feat_idx
                    split_thresh = thresh
        return split_idx, split_thresh
    
    def _information_gain(self, y, X_column, threshold):
        parent_entropy = self._entropy(y)
        left_idxs, right_idxs = self._split(X_column, threshold)
        
        if len(left_idxs) == 0 or len(right_idxs) == 0:
            return 0
        
        n = len(y)
        n_l, n_r = len(left_idxs), len(right_idxs)
        e_l, e_r = self._entropy(y[left_idxs]), self._entropy(y[right_idxs])
        child_entropy = (n_l/n) * e_l + (n_r/n) * e_r
        return parent_entropy - child_entropy
    
    def _split(self, X_column, threshold):
        left_idxs = np.argwhere(X_column <= threshold).flatten()
        right_idxs = np.argwhere(X_column > threshold).flatten()
        return left_idxs, right_idxs
    
    def _entropy(self, y):
        hist = np.bincount(y)
        ps = hist / len(y)
        return -np.sum([p * np.log2(p) for p in ps if p > 0])
    
    def _most_common_label(self, y):
        return np.argmax(np.bincount(y))
    
    def predict(self, X):
        return np.array([self._traverse_tree(x, self.tree) for x in X])
    
    def _traverse_tree(self, x, node):
        if 'value' in node:
            return node['value']
        
        if x[node['feature']] <= node['threshold']:
            return self._traverse_tree(x, node['left'])
        return self._traverse_tree(x, node['right'])

7.2 预剪枝与特征采样

实现中已经包含两个关键优化:

  1. 最大深度限制(max_depth)
  2. 特征随机采样(np.sqrt(n_features))

8. 算法测试与比较

8.1 回归任务测试

X_reg, y_reg = make_regression_data()
lr = LinearRegression()
lr.fit(X_reg, y_reg)
predictions = lr.predict(X_reg)

plt.scatter(X_reg, y_reg)
plt.plot(X_reg, predictions, color='red')
plt.show()

8.2 分类任务测试

X_clf, y_clf = make_classification_data()
models = {
    "Logistic Regression": LogisticRegression(),
    "KNN": KNN(k=3),
    "Naive Bayes": NaiveBayes(),
    "Decision Tree": DecisionTree(max_depth=5)
}

for name, model in models.items():
    model.fit(X_clf, y_clf)
    accuracy = np.mean(model.predict(X_clf) == y_clf)
    print(f"{name} Accuracy: {accuracy:.2f}")

9. 工程实践中的注意事项

  1. 数值稳定性

    • 概率计算使用对数空间
    • 添加极小值(epsilon)防止除零错误
    • 特征缩放(标准化)提升收敛速度
  2. 效率优化

    • 使用向量化操作替代循环
    • 对KNN等算法使用近似最近邻搜索
    • 对决策树进行后剪枝
  3. 扩展性考虑

    • 添加稀疏矩阵支持
    • 实现增量学习(partial_fit)
    • 添加早停机制
  4. 生产环境准备

    • 添加模型序列化方法
    • 实现scikit-learn兼容API
    • 添加详细的文档字符串

10. 从简单实现到工业级应用的演进路径

当这些基础实现需要投入生产环境时,通常需要考虑:

  1. 性能优化

    • 使用Cython或Numba加速数值计算
    • 实现多线程/多进程并行
    • 支持GPU加速(CUDA)
  2. 功能扩展

    • 添加正则化项(L1/L2)
    • 实现更复杂的分裂准则(Gini, χ²)
    • 支持缺失值处理
  3. 鲁棒性增强

    • 添加输入数据验证
    • 实现更完善的异常处理
    • 添加类型提示和单元测试
  4. 生态集成

    • 支持scikit-learn的Pipeline
    • 添加MLflow等实验跟踪
    • 实现ONNX格式导出

实现这些算法只是机器学习工程化的起点。在我的实践中发现,从零实现的过程比调用现成库能学到更多设计决策背后的深层原因。比如为什么逻辑回归默认使用L2正则化?为什么决策树要进行剪枝?这些问题的答案只有在亲手实现时才会真正理解。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐