## 朴素贝叶斯算法

我们继续为大家介绍解决分类任务的算法，本章介绍一种概率模型贝叶斯分类器。贝叶斯分类器是一类分类算法的总称，这类算法均以贝叶斯定理为基础，因而统称为贝叶斯分类器。在介绍贝叶斯定理之前，我们先讲一个故事：从 2015 年到 2020 年期间，某位李姓女士凭借自己对航班是否会延误的分析，购买了大约 900 次飞机延误险并获得延误赔偿，累计获得理赔金高达 300 多万元，真可谓“航班延误，发家致富”。当然，这套骚操作本身不是我们探讨的重点，我们的问题是：李女士是怎么决定要不要购买延误险的呢？航班延误最主要的原因就是天气（包括起飞地和降落地的天气）、机场（起飞机场和降落机场）和航司，由于李女士有过航空服务类工作的经历，有获得机场和航司相关数据的途径（天气数据相对更容易获取），集齐相关的数据再利用贝叶斯定理，她可以能够计算出当前航班延误的概率并决定是否购买延误险。接下来，李女士通过虚构不同身份购票并大量投保（每个身份购买 30 到 40 份延误险），这样一旦航班延误，她就可以向保险公司进行索赔。那么，我们要探讨的就是贝叶斯定理是如何利用现有数据计算出航班延误的概率。

### 贝叶斯定理

贝叶斯定理是概率论中的一个重要定理，它描述了如何从主观经验或已知事实出发，通过收集到的样本数据（证据）来更新对事件发生概率的认知（信念）。贝叶斯定理的数学表达式为：

$$
P(A \vert B) = \frac{P(B \vert A)}{P(B)} \cdot P(A)
$$

其中， $\small{P(A)}$ 是事件 $\small{A}$ 发生的**先验概率**，我们可以理解为已知事实或主观经验（**主观概率**）； $\small{P(B \vert A)}$ 是在事件 $\small{A}$ 发生的条件下事件 $\small{B}$ 发生的 条件概率，通常也称之为**似然性**（likelihood）， $\small{P(B)}$ 是事件 $\small{B}$ 发生的（全）概率，这两个概率可以通过我们收集到的样本数据（证据）获得； $\small{P(A \vert B)}$ 是在事件 $\small{B}$ 发生的条件下事件 $\small{A}$ 发生的条件概率，即收集到样本数据后对事件 $\small{A}$ 发生概率的重新认知，称之为**后验概率**。贝叶斯定理告诉我们一个重要的事实：可以从已知的事实或主观经验出发，通过收集到的证据来更新我们对某个事件发生概率的认知，简单的说就是**可以通过已知的事实和收集的证据来推断出未知的真相**。

回到上面李女士购买飞机延误险的例子，假设本次航班是从成都双流国际机场飞往北京首都国际机场，执飞的航空公司是四川航空，起飞地天气为雨天（小雨），温度为8°C，东北风2级，降落地天气为晴天，温度4°C，西北风2级。为了更简单的让大家理解贝叶斯定理，我们对这里的条件稍作简化，只保留天气中的降水信息，暂不考虑温度和风速等其他因素，对应到上面的贝叶斯定理有：

$$
P(延误 \vert 起飞机场=双流,到达机场=首都,起飞天气=小雨,降落天气=晴天,执飞航司=川航) = \\\\
\frac{P(起飞机场=双流,到达机场=首都,起飞天气=小雨,降落天气=晴天,执飞航司=川航 \vert 延误)}{P(起飞机场=双流,到达机场=首都,起飞天气=小雨,降落天气=晴天,执飞航司=川航)} \cdot P(延误)
$$

上面公式等号左边就是李女士想知道的当前航班延误的概率，等号右边的部分其实就是历史数据和当前信息，计算这个概率的关键在于计算出似然性，即 $\small{P(起飞机场=双流,到达机场=首都,起飞天气=小雨,降落天气=晴天,执飞航司=川航 \vert 延误)}$ 到底是多少，那么这个条件概率又该如何计算呢？

### 朴素贝叶斯

朴素贝叶斯算法是基于贝叶斯定理和特征条件独立性假设的分类算法，因其简单高效而受到广泛应用。朴素贝叶斯算法的关键在于“朴素”二字，就是刚才我们说到特征条件独立性假设，条件独立性假设是说用于分类的特征在类确定的条件下都是独立的，该假设使得朴素贝叶斯的学习成为可能。

假设我们有一个特征集合 $\small{X = \{x_1, x_2, \ldots, x_n\}}$ 和一个类别 $\small{C}$ ，朴素贝叶斯算法假设：

$$
P(X \vert C) = P(x_1 \vert C) \cdot P(x_2 \vert C) \cdot \ldots \cdot P(x_n \vert C)
$$

这个假设大大简化了计算复杂性，使得我们可以只计算每个特征在给定类别下的概率，而不需要考虑特征之间的相互作用，对应到上面购买飞机延误险的例子，我们可以用下面的方式来计算似然性：

$$
P(起飞机场=双流,到达机场=首都,起飞天气=小雨,降落天气=晴天,执飞航司=川航 \vert 延误) = \\\\
P(起飞机场=双流 \vert 延误) \times P(到达机场=首都 \vert 延误) \times P(起飞天气=小雨 \vert 延误) \times P(降落天气=晴天 \vert 延误) \times P(执飞航司=川航 \vert 延误)
$$

### 算法原理

#### 训练阶段

在训练阶段，朴素贝叶斯算法首先需要计算每个类别的先验概率和每个特征在各个类别下的条件概率。

1. **计算先验概率**：

$$
P(C_{i}) = \frac{n_{i}}{n}
$$

其中， $\small{C_{i}}$ 表示类别， $\small{n_{i}}$ 是类别 $\small{C_{i}}$ 的样本数量， $\small{n}$ 是总的样本容量。

2. **计算条件概率**：

$$
P(x_{j} \vert C_{i}) = \frac{n_{i,j}}{n_{i}}
$$

其中， $\small{n_{i,j}}$ 是在类别 $\small{C_{i}}$ 中，特征 $\small{x_{j}}$ 出现的次数。

#### 预测阶段

在预测阶段，给定一个待分类样本 $\small{X}$ ，朴素贝叶斯算法通过以下步骤来计算其属于每个类别的后验概率：

$$
P(C_{i} \vert X) = \frac{P(X \vert C_{i})}{P(X)} \cdot P(C_{i})
$$

上面的公式中， $\small{P(X)}$ 对应到每个类别都是一个常量，可以忽略掉它，再结合独立性假设有：

$$
P(C_{i} \vert X) \propto P(C_{i}) \cdot P(x_1 \vert C_{i}) \cdot P(x_2 \vert C_{i}) \cdot \ldots \cdot P(x_n \vert C_{i})
$$

这样，我们可以选择具有最高后验概率的类别作为预测结果。

#### 代码实现

我们还是以鸢尾花数据集为例，按照上面的讲解的原理，用 NumPy 来实现一个朴素贝叶斯分类器，我们还是从加载数据开始，代码如下所示。

```python
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split

iris = load_iris()
X, y = iris.data, iris.target
X_train, X_test, y_train, y_test = train_test_split(X, y, train_size=0.8, random_state=3)
```

训练阶段我们要获得类别标签和对应的先验概率，此外还要计算出似然性，似然性的计算用到了上面提到的“朴素”假设，我们对鸢尾花连续的特征值进行了简单的离散化处理（大家先不考虑这种处理方式是否合理），代码如下所示。

```python
import numpy as np
import pandas as pd


def naive_bayes_fit(X, y):
    """
    :param X: 样本特征
    :param Y: 样本标签
    :returns: 二元组 - (先验概率, 似然性)
    """
    # 计算先验概率
    clazz_labels, clazz_counts = np.unique(y, return_counts=True)
    prior_probs = pd.Series({k: v / y.size for k, v in zip(clazz_labels, clazz_counts)})
    # 拷贝数组创建副本
    X = np.copy(X)
    # 保存似然性计算结果的字典
    likelihoods = {}
    for j in range(X.shape[1]):  # 对特征的循环
        # 对特征进行等宽分箱（离散化处理）
        X[:, j] = pd.cut(X[:, j], bins=5, labels=np.arange(1, 6))
        for i in prior_probs.index:
            # 按标签类别拆分数据并统计每个特征值出现的频次
            x_prime = X[y == i, j]
            x_values, x_counts = np.unique(x_prime, return_counts=True)
            for k, value in enumerate(x_values):  # 对不同特征值的循环
                # 计算似然性并保存在字典中（字典的键是一个三元组 - (标签, 特征序号, 特征值)）
                likelihoods[(i, j, value)] = x_counts[k] / x_prime.size
    return prior_probs, likelihoods
```

调用上面的函数，我们可以得到一个二元组，解包之后分别是类别标签 $\small{C_{i}}$ 对应的先验概率和在类别 $\small{C_{i}}$ 中，第 $\small{j}$ 个特征取到某个值`value`（上面的代码中，我们用 pandas 的`cut`函数对特征值分箱，`value`的取值为`1` 到`5`）的似然性，前者是一个`Series`对象，后者是一个`dict`对象，如下所示：

```python
p_ci, p_x_ci = naive_bayes_fit(X_train, y_train)
print('先验概率: ', p_ci, sep='\n')
print('似然性: ', p_x_ci, sep='\n')
```

输出：

```
先验概率: 
0    0.333333
1    0.333333
2    0.333333
dtype: float64
似然性: 
{(0, 0, 1.0): 0.525, (0, 0, 2.0): 0.45, (0, 0, 3.0): 0.025, (1, 0, 1.0): 0.05, (1, 0, 2.0): 0.375, (1, 0, 3.0): 0.425, (1, 0, 4.0): 0.15, (2, 0, 1.0): 0.025, (2, 0, 2.0): 0.025, (2, 0, 3.0): 0.45, (2, 0, 4.0): 0.3, (2, 0, 5.0): 0.2, (0, 1, 1.0): 0.025, (0, 1, 3.0): 0.325, (0, 1, 4.0): 0.45, (0, 1, 5.0): 0.2, (1, 1, 1.0): 0.175, (1, 1, 2.0): 0.325, (1, 1, 3.0): 0.475, (1, 1, 4.0): 0.025, (2, 1, 1.0): 0.025, (2, 1, 2.0): 0.35, (2, 1, 3.0): 0.525, (2, 1, 4.0): 0.05, (2, 1, 5.0): 0.05, (0, 2, 1.0): 1.0, (1, 2, 2.0): 0.025, (1, 2, 3.0): 0.525, (1, 2, 4.0): 0.45, (2, 2, 4.0): 0.525, (2, 2, 5.0): 0.475, (0, 3, 1.0): 0.975, (0, 3, 2.0): 0.025, (1, 3, 2.0): 0.125, (1, 3, 3.0): 0.75, (1, 3, 4.0): 0.125, (2, 3, 3.0): 0.05, (2, 3, 4.0): 0.525, (2, 3, 5.0): 0.425}
```

> **说明**：字典中的第一个元素`(0, 0, 1.0): 0.525`表示标签为`0`，第`0`个特征（花萼长度）取值为`1.0`的似然性为`0.525`；最后一个元素`(2, 3, 5.0): 0.425`表示标签为`2`，第`3`个特征（花瓣宽度）取值为`5.0`的似然性为`0.425`。

预测阶段我们利用上面函数得到的先验概率和似然性计算后验概率，然后根据后验概率的最大值为样本赋予预测的类别标签。

```python
def naive_bayes_predict(X, p_ci, p_x_ci):
    """
    朴素贝叶斯分类器预测
    :param X: 样本特征
    :param p_ci: 先验概率
    :param p_x_ci: 似然性
    :return: 预测的标签
    """
    # 对特征进行等宽分箱（离散化处理）
    X = np.copy(X)
    for j in range(X.shape[1]):
        X[:, j] = pd.cut(X[:, j], bins=5, labels=np.arange(1, 6))
    # 保存每个样本对应每个类别后验概率的二维数组
    results = np.zeros((X.shape[0], p_ci.size))
    clazz_labels = p_ci.index.values
    for k in range(X.shape[0]):
        for i, label in enumerate(clazz_labels):
            # 获得先验概率（训练的结果）
            prob = p_ci.loc[label]
            # 计算获得特征数据后的后验概率
            for j in range(X.shape[1]):
                # 如果没有对应的似然性就取值为0
                prob *= p_x_ci.get((i, j, X[k, j]), 0)
            results[k, i] = prob
    # 根据每个样本对应类别最大的概率选择预测标签
    return clazz_labels[results.argmax(axis=1)]
```

将上面的函数作用于测试集进行预测，比较预测值和真实值，如下所示。

```python
y_pred = naive_bayes_predict(X_test, p_ci, p_x_ci)
y_pred == y_test
```

输出：

```
array([ True,  True,  True,  True,  True,  True,  True,  True,  True,
        True,  True,  True,  True,  True,  True,  True,  True,  True,
       False,  True,  True,  True,  True,  True,  True,  True, False,
        True,  True,  True])
```

上面两个函数希望能帮助大家理解朴素贝叶斯的工作原理，实际工作中我们还是推荐大家使用 scikit-learn 库的`navie_bayes`模块封装的类来创建朴素贝叶斯模型，该模块下有五个朴素贝叶斯算法的变体，每种变体针对不同类型的数据和特征分布，对应的五种朴素贝叶斯分类器分别是：`BernoulliNB`、`CategoricalNB`、`ComplementNB`、`GaussianNB`和`MultinomialNB`，它们之间的差别如下表所示：

| 分类器          | 特征类型 | 主要假设                               |
| --------------- | -------- | ------------------------------------ |
| `BernoulliNB`   | 二元特征 | 特征服从 Bernoulli 分布                |
| `CategoricalNB` | 类别特征 | 特征服从多项式分布，常用于处理类别数据   | 
| `ComplementNB`  | 计数特征 | 利用补集概率，常用于处理不平衡数据集    |
| `GaussianNB`    | 连续特征 | 特征服从高斯分布                      |
| `MultinomialNB` | 计数特征 | 特征服从多项式分布，常用于文本分类      |

对于鸢尾花数据集，由于其特征是连续值，我们可以用`GaussianNB`来创建模型，代码如下所示。

```python
from sklearn.naive_bayes import GaussianNB

model = GaussianNB()
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
```

我们看看模型评估的结果。

```python
from sklearn.metrics import classification_report

print(classification_report(y_test, y_pred))
```

输出：

```
              precision    recall  f1-score   support

           0       1.00      1.00      1.00        10
           1       0.91      1.00      0.95        10
           2       1.00      0.90      0.95        10

    accuracy                           0.97        30
   macro avg       0.97      0.97      0.97        30
weighted avg       0.97      0.97      0.97        30
```

如果想看看朴素贝叶斯模型给每个样本对应到每个标签给出的概率值，可以使用下面的代码。

```python
model.predict_proba(X_test).round(2)
```

输出：

```
array([[1.  , 0.  , 0.  ],
       [1.  , 0.  , 0.  ],
       [1.  , 0.  , 0.  ],
       [1.  , 0.  , 0.  ],
       [1.  , 0.  , 0.  ],
       [0.  , 0.  , 1.  ],
       [0.  , 1.  , 0.  ],
       [1.  , 0.  , 0.  ],
       [0.  , 0.  , 1.  ],
       [0.  , 0.98, 0.02],
       [0.  , 1.  , 0.  ],
       [1.  , 0.  , 0.  ],
       [0.  , 1.  , 0.  ],
       [0.  , 1.  , 0.  ],
       [0.  , 0.  , 1.  ],
       [1.  , 0.  , 0.  ],
       [0.  , 0.93, 0.07],
       [0.  , 0.  , 1.  ],
       [0.  , 0.02, 0.98],
       [1.  , 0.  , 0.  ],
       [0.  , 0.22, 0.78],
       [0.  , 0.  , 1.  ],
       [0.  , 0.  , 1.  ],
       [0.  , 0.92, 0.08],
       [1.  , 0.  , 0.  ],
       [0.  , 0.  , 1.  ],
       [0.  , 0.54, 0.46],
       [0.  , 1.  , 0.  ],
       [0.  , 1.  , 0.  ],
       [0.  , 0.81, 0.19]])
```

### 算法优缺点

朴素贝叶斯算法的优缺包括：

1. **逻辑简单容易实现，适合大规模数据集**。
2. **运算开销较小**。预测需要用到的概率在训练阶段都已经准好了，当新数据来了之后，只需要获取对应的概率值并进行简单的运算就能获得预测的结果。
3. **受噪声和无关属性影响小**。

当然，由于做了“特征相互独立”这个假设，朴素贝叶斯算法的缺点也相当明显，因为在实际应用中，特征之间很难做到完全独立，尤其是维度很高的数据，如果特征之间的相关性较大，那么分类的效果就会变得很差。为了解决这个问题，在朴素贝叶斯算法的基础上又衍生出了一些新的方法，包括：半朴素贝叶斯（One Dependent Estimator）、AODE（Averaged One Dependent Estimator）、K 依赖朴素贝叶斯、朴素贝叶斯网络、高斯混合朴素贝叶斯等，有兴趣的读者可以自行了解。

### 总结

朴素贝叶斯算法在多个领域有广泛应用，以下是一些常见的应用场景：

- **文本分类**：如垃圾邮件检测、情感分析等。
- **推荐系统**：根据用户行为和喜好进行个性化推荐。
- **医药诊断**：根据症状预测疾病。
