0%

经验误差与过拟合

学习器的实际预测输出与样本的真实输出之间的差异称为“误差”,学习器在训练集上的错误称为训练误差(training error)或经验误差(empirical error),在新样本上的误差称为泛化误差(generalization error)

我们实际希望的,是在新样本上能表现得很好的学习器。当学习器把训练集样本学得“太好”了的时候,很可能把训练集样本自身的一些特点当作了所有潜在样本都具有的一般性质,这样就会导致泛化性能下降,这种现象称为过拟合

评估方法

使用一个“测试集(testing set)”来测试学习器对新样本的判别能力,然后测试集上的“测试误差”(testing error)作为泛化误差的近似,测试集应该尽可能与训练集互斥,即测试样本尽量不在训练集中出现、未在训练过程中使用过

验证数据集和测试数据集

验证数据集和测试数据集是两个比较容易混淆的概念。

验证数据集一般是从训练数据中取出一部分作为验证训练效果和调整超参数。

测试数据集是最终对模型进行benchmark测试的数据集,不能用于训练和超参调整。例如学术论文中展示的模型在某某测试数据集上的准确率表现。

留出法

“留出法” (hold-out)直接将数据集 D 划分为两个互斥的集合,其中一个集合作为训练集S,另一个作为测试集T, 即$D = S \bigcup T$,$S \bigcap T = \emptyset$ .在S上训练出模型后,用 T 来评估其测试误差,作为对泛化误差的估计。
训练/测试集的划分要尽可能保持数据分布的一致性,如果从采样 (sampling)的角度来看待数据集的划分过程,则保留类别比例的采样方式通常称为”分层采样” (stratified sampling)。例如通过对D进行分层而获得含70%样本的训练集S和含30%样本的测试集T,若D包含500个正例,500个反例,则分层采样S应包含350个正例,350个反例,T则包含150个正例,150个反例。

单次使用留出法得到的估计结果往往不够稳定可靠,在使用留出法时,一般要采用若干次随机划分、重复进行实验评估后取平均值作 为留出法的评估结果。
常见做法是将大约 $\frac{2}{3}$ ~ $\frac{4}{5}$ 的 样本用于训练,剩余样本用于测试。

K-交叉验证法

“交叉验证法” (cross validation)先将数据集D划分为k个大小相似的互斥子集,即${D = D1 \bigcup D2 \bigcup \dots \bigcup Dk},{Di\bigcap Dj = \emptyset}$ ($i\neq j$). 每个子集Di都尽可能保持数据分布的一致性,即从 D 中通过分层采样得到. 然后,每次用 k-1 个子集的并集作为训练集?余下的那个子集作为测试集;这样就可获得 k 组训练/测试集,从而可进行 k 次训练和测试,最终返回的是这 k 个测试结果的均值。交叉验证法评估结果的稳定性和保真性在很大程度上取决于 k 的取值。

与留出法相似,将数据集 D 划分为 k 个子集同样存在多种划分方式。为减小因样本划分不同而引入的差别,k折交叉验证通常要随机使用不同的划分重复p次,最终的评估结果是这 p次 k折交叉验证结果的均值。
院假定数据集 D 中包含 m 个样本,若令 k=m,则得到了交叉验证法的一个特例:留一法(Leave-One-Out比,简称 LOO)。

自助法

它直接以自助采样法 (bootstrap sampling)为基础。 给定包含 m 个样本的数据集 D ,我们对它进行采样产生数据集 D’: 每次随机从 D 中挑选一个样本,将其拷贝放入 D’ 然后再将该样本放回初始数据集 D 中,使得该样本在下次采样时仍有可能被采到;这个过程重复执行 m 次后,我们就得到了包含 m 个样本的数据集 D’,这就是自助采样的结果。

调参与最终模型

大多数学习算法都有些参数 (parameter)需要设定,参数配置不同,学得模 型的性能往往有显著差别.
对每种参数配置都训练出模型,然后把对应最好模型的参数作为结果。
学习算法的很多参数是在实数范围内取值,因此,对每种参数配置都训练出模型来是不可行的。现实中常用的做法,是对每个参数选定一个范围和变化步长,例如在 [0,0.2] 范围内以 0.05 为步长,则实际要评估的候选参数值有 5 个,最终是从这 5 个候选值中产生选定值。

给定包含 m 个样本的数据集 D ,在模型评估与选择过程中由于需要留出 一部分数据进行评估测试,事实上我们只使用了一部分数据训练模型。因此,在模型选择完成后,学习算法和参数配置己选定,此时应该用数据集 D 重新训练模型.这个模型在训练过程中使用了所有 m 个样本,这才是我们最终提交给用户的模型.

性能度量

错误率与精度

查准率、查全率与Fl

ROC 与 AUC

代价敏感错误率与代价曲线

神经元模型

神经网络中最基本的成分是神经元模型。

M-P神经元模型

在这个模型中,神经元接收来自n个其他神经元传递过来的输入信号,这些输入信号通过权重的连接进行传递,神经元接收到的总输入值将与神经元的阈值进行比较,然后通过“激活函数”处理以产生神经元的输出。

理想中的激活函数是阶跃函数,它将输入值映射为输出值为0,1,其中0对应神经元抑制,1对应神经元兴奋。然而,阶跃函数具有不连续、不光滑等不好的性质,因此实际常用Sigmoid函数作为激活函数,典型的Sigmoid函数就是对数几率函数。

神经元激活函数

把许多个这样的神经元按一定的层次结构连接起来,就得到了神经网络。

感知机与多层网络

感知机由两层神经元组成,输入层接收外界输入信号后传递给输出层(输入层只负责接收信号,不负责处理),输出层是M-P神经元,亦称“阈值逻辑单元”。感知机能实现逻辑与、或、非运算。例如对于只有两个输入、一个输出的感知机,令$w_1 = w_2 = 1$,$\theta = 1$,则$y = 1·x_1 + 1·x_2 - 1$则当$x_1 = x_2 = 1$时,$y=1$。

给定训练集数据,权重$wi(i=1,2,\cdots,n)$以及阈值$\theta$可通过学习算法得到,阈值$\theta$可看作一个固定输入为$-1$的结点,所对应的连接权重为$w{n+1}$(即$x{n+1} = -1, w{n+1} = \theta$),这样权重和阈值的学习就可以统一为权重的学习。感知机学习规则非常简单,对训练样例$(x,y)$,若当前感知机的输出为$\hat{y}$,则感知机权重将这样调整:

其中$\eta \in (0,1)$为学习率。

解释:假设$w_i > 0$,$x_i > 0$,预测结果$\hat{y} > y$时,说明需要使得$x_i$分类占的比例变小,此时$\Delta w_i < 0$,$w_i$减小。其他情况同理。

感知机只有输出层神经元进行激活函数处理,即只拥有一层功能神经元,其学习能力非常有限。可以证明,若学习样例是线性可分的,即存在一个超平面能将它们分开,则感知机的学习过程一定会收敛而求得适当的权重向量;否则感知机学习过程将会发生振荡,权重向量难以稳定下来,不能求得合适解,例如感知机甚至不能解决异或这样简单的非线性可分问题

异或问题

要解决非线性可分问题,需考虑使用多层功能神经元。输出层与输入层之间的一层神经元,被称为隐层或隐含层,隐含层和输出层神经元都是拥有激活函数的功能神经元。

更一般的,常见的神经网络是如下图的层级结构,每层神经元与下一层神经元互连,神经元之间不存在同层连接,也不存在跨层连接。这样的神经网络结构通常称为多层前馈神经网络

前馈网络

注意,前馈并不意味着网络中信号不能向后传,而指网络拓扑结构上不存在环或回路。输入层神经元仅是接收输入,不进行函数处理,隐含层与输出层包含功能神经元。

神经网络的学习过程,就是根据训练集数据来调整神经元之间的连接权以及每个功能神经元的阈值。

误差逆传播(BP)算法

欲训练多层网络,简单的感知机学习规则显然不够了,需要更强大的学习算法。误差逆传播(Error BackPropagation, 简称BP)算法就是其中的最杰出的代表。BP算法不仅可用于多层前馈神经网络,还可用于其他类型的神经网络,例如训练递归神经网络,但通常说BP网络时,一般是指用BP算法训练的多层前馈神经网络

标准BP算法推导

对于给定的训练集$D = {(xi, y_i)}{i=1}^{m}$,其中$x_i \in R^d, y_i \in R^l$,即输入示例由$d$个属性描述,输出$l$维实值向量。首先定义一下变量:

下图是相关变量在神经网络中的表示:

BP网络及算法中的变量符号

假设隐层和输出层神经元都使用对数几率函数(以下公式的推导都是在这个前提下进行的,当实际运用中不是使用这个函数时需要自己另行推导公式),即$f(x) = \frac{1}{1 + e^{-x}}$,对训练例$(x_k, y_k)$,假定神经网络的输出为$\hat{y}_k = (\hat{y}_1^k,\hat{y}_2^k, \cdots,\hat{y}_l^k)$,即

则网络在$(x_k, y_k)$上的均方误差为

对于上面的公式,总过用$dq + lq + q + l$个参数需要确定。BP是一个迭代算法,在迭代的每一轮中采用广义的感知机学习规则对参数进行更新估计,对于任意参数$v$的更新估计式为

BP算法基于梯度下降策略,以隐层到输出层的连接权$w_{hj}$为例子,以目标的负梯度方向对参数进行调整。对均方误差$E_k$,给定学习率$\eta$,有

由于$\betaj = \sum{h=1}^{q} w_{hj}b_h$,则有

对于对数几率函数,有一个很好的性质:

则:

令$gj = - \frac{\partial E_k}{\partial \hat{y}_j^k} \frac{\partial \hat{y}_j^k}{\partial \beta_j} = \hat{y}_j^k(1-\hat{y}_j^k)(y_j^k - \hat{y}_j^k)$,则可得到关于$w{hj}$的更新公式

同理可得到

有时为了做精细调节,计算$\Delta w{hj}、\Delta \theta_j$使用的$\eta$可以和计算$\Delta v{ih}、\Delta \gamma_h$使用的$\eta$可以不同。

标准BP算法流程

对每个训练样例,BP算法执行以下操作:先将输入示例提供给输入层神经元,然后逐层将信号前传,直到产生输出层的结果;然后计算输出层的误差,再将误差逆向传播至隐层神经元,最后根据隐层神经云的误差来对连接权和阈值进行调整。该迭代过程一直进行,直到达到某个停止条件为止,例如训练误差已达到一个很小的值。

累计BP算法

BP算法的目标是要最小化训练集D上的累积误差,即:

标准BP算法每次仅对一个训练样例更新连接权和阈值,更新的规则也是基于单个 $E_k$ 推导的,如果类似地推导出基于累积误差最小化的更新规则,就得到了累积误差逆传播算法。累积BP算法与标准BP算法都很常用。

一般来说,标准BP算法每次更新都值针对单个样例,参数更新得非常频繁,而且对不同样例进行更新得效果可能出现抵消现象,因此,为了达到同样的累积误差极小点,标准BP算法往往需进行更多次数的迭代。

累积BP算法直接针对累积误差最小化,它在读取整个训练集D一遍后才对参数进行更新。其参数更新的频率低得多,但在很多任务中,累积误差下降到一定程度之后,进一步下降会非常缓慢,这时标准BP往往会更快获得较好的解,尤其是在训练集D非常大时会更明显。

防止过拟合

已证明,只需一个包含足够多神经元的隐层,多层前馈神经网络就能以任意精度逼近任意复杂度的连续函数。然而如何设置隐层神经元的个数仍是个未决问题,实际应用中通常靠“试错法”调整。

正是由于神经网络强大的表示能力,BP神经网络经常遭遇过拟合。有两种策略常用来缓解BP网络的过拟合。

第一种策略是早停:将数据分为训练集和验证集,训练集用于计算梯度、更新连接权和阈值,验证集用来估计误差,若训练集误差降低但验证集误差升高,则停止训练。

第二种策略是“正则化”,其基本思想是在误差目标函数中增加一个用于描述网络复杂度的部分,例如连接权和阈值的平方和(增加连接权和阈值平方和这一项后,训练过程将会偏好比较小的连接权和阈值,使网络输出更加“光滑”,从而对过拟合有所缓解),令$E_k$表示第k个训练集上的误差,$w_i$表示连接权和阈值,则误差目标函数改变为

其中$\lambda$用于对经验误差与网络复杂度这两项进行折中,通常通过交叉验证法(见模型评估方法那一节)来估计。

全局最小与局部最小

由于上面的神经网络算法是基于梯度下降方法的,所以在寻找最优参数的时候很有可能最后得到的结果是局部最小值而不是全局最小值,我们在参数寻优的过程中是希望找到全局最小。

为了避免陷入局部最小,在现实任务中,人们常采用以下策略来试图“跳出”局部最小,从而进一步接近全局最小。

  • 以多种不同参数初始化多个神经网络,按标准方法训练后,取其中误差最小的解作为最终方案。这相当于从多个不同的初始开始搜索,这样就可能陷入不同的局部最小,从中进行选择有可能更接近全局最小的结果。
  • 使用“模拟退火”技术,模拟退火在每一步都以一定的概率接受比当前接更差的结果,从而有助于“跳出”局部最小,在每步迭代过程中,接受“次优解”的概率要随着时间的推移而逐渐降低,从而保证算法的稳定性。
  • 使用随机梯度下降。使用随机梯度下降,由于每次迭代都是随机选择的部分数据,所以当陷入了局部最小,它计算出来的梯度仍可能不为零,这样就有机会跳出局部最小继续搜索。

此外,遗传算法也常用来训练神经网络以更好逼近全局最小。需注意的是,上述用于跳出局部最小的技术大多是启发式,理论上尚缺乏保障

深度学习

理论上来说,参数越多的模型复杂度越高,这意味着它能完成更复杂的学习任务,但一般情形下,复杂模型的训练效率低,易陷入过拟合。而随着云计算、大数据时代的到来,计算能力的大幅提高可缓解训练低效性,训练数据的大幅增加则可降低过拟合风险。因此,以深度学习为代表的复杂模型开始受到人们的关注。(大型深度学习模型中甚至有上百亿个参数)

典型的深度学习就是很深层的神经网络。典型的深度学习模型就是很深层的神经网络。单隐层的多层前馈网络已具有很强大的学习能力;模型复杂度的增加可通过单纯增加隐层神经元的数目来实现,但从增加模型复杂度的角度来看,增加隐层的数目比增加隐层神经元的数目更有效。然而,多隐层神经网络难以直接用经典算法(例如标准BP算法)进行训练,因为误差在多隐层内逆传播时,往往会“发散”而不能收敛到稳定状态。

深度学习训练方法

无监督逐层训练是多隐层网络训练的有效手段,其基本思想是每次训练一层隐结点,训练时将上一层隐节点的输出作为输入,而本层隐节点的输出作为下一层隐结点的输入,这成为“预训练”;在预训练全部完成后,再对整个网络进行“微调”训练。

“预训练+微调”的做法可视为将大量参数分组,对每组先找到局部看来比较好的设置,然后再基于这些局部较优的结果联合起来进行全局寻优。这样就在利用了模型大量参数所提供的自由度的同时,有效地节省了训练开销。

另一种节省训练开销的策略是“权共享”,即让一组神经元使用相同的连接权,这个策略在卷积神经网络中发挥了重要作用。

深度学习总结

对于深度学习,可看作是对输入信号进行逐层加工,从而把初始的、与输出目标之间联系不大密切的输入表示、转化成与输出目标联系更密切的表示,使得原来仅基于最后一层输出映射难以完成的任务成为可能。换言之,通过多层处理,逐渐将初始“低层”特征表示转化为“高层”特征表示后,用“简单模型”即可完成复杂的分类等学习任务,由此可将深度学习理解为进行“特征学习”或“表示学习”。

以往在机器学习用于现实任务时,描述样本的特征通常需要由人类专家来设计,这成为”特征工程“。特征的好坏对泛化性能有至关重要的影响,人类专家设计出好特征也并非易事;特征学习则通过机器学习技术自身来产生好特征,这使机器学习向“全自动数据分析”又前进了一步。

解决过拟合的一些方法

为了减少过拟合,一般有两种方法:

1、减少一些特征。其手段也分为手动选择要丢掉的特征和使用一些算法来丢掉部分特征。但是减少特征就意味着丢失信息,所以要谨慎丢弃特征。

2、在模型中加入正则化项。使用正则化项时不用删除特征。

正则化项的作用

正则化项的使用其思想是让某些特征的参数尽可能小(这也叫做对某些参数的惩罚),从而让这个特征所占的重要性变低。

例如某个模型为$f(x) = w1 x + w_2 x^2 + w_3 x^3 + w_4 x^4$ + b,其对应的目标函数是$argmin{(w, b)} \sum{i=1}^{m} [f(x_i) - y_i]^2$,那么可以对目标函数加入正则化项得:$argmin{(w,b)}\sum_{i=1}^{m}[f(x_i) - y_i]^2 + 1000w_3^3 + 1000w_4^4$,来使训练后的模型中$w_3$和$w_4$尽可能小,从而使得特征$x^3$和$x^4$在模型中的作用减小。

但在实际操作中,通常不知道需要让哪些参数尽可能小,所以我们让所有的参数都尽可能小。

通常我们使用两种正则化项:L1正则化和L2正则化

L1正则化

以线性模型为例,加入L1正则化的损失函数形式为: $\sum{i=1}^{m} [wx_i+b-y_i]^2 + \lambda\sum{i=1}^{d}|w_i|$。

使用L1正则化通常会导致模型会将一部分特征的权重值清零,而只保留少部分特征的权重值,这种特性也被叫做特征选择。

L2正则化

同样以线性模型为例,加入L2正则化的损失函数形式为:$\sum{i=1}^{m}[wx_i + b - y_i]^2 + \lambda \sum{i=1}^{d}w_i^2$。

如果是使用标准方程法求解的话,其解的形式为:

而且在加入正则化项后,标准方程法中的括号部分的矩阵一定可逆。

使用L2正则化通常会使特征的权重值分配得比较均匀,即使在特征很多的情况下。

使用正则化项的一些注意

1、$\lambda$不能设置得过大。$\lambda$越大,$w$的自由度就越小,$\lambda$越小,$w$的自由度就越高

2、一般不惩罚常数参数$b$

构建决策树

决策树是一种常见的分类机器学习方法,顾名思义,决策树就是基于树形结构来进行决策,这恰是人类在面临决策问题时一种很自然的处理机制。

决策树例子

一棵判断西瓜是否是好瓜的决策树例子

在构建一棵决策树时,其决策的过程就是对某个属性的“测试”,每个测试的结果或是导出最终的分类结果,或是导出下一步的判定问题,每一次需要决策的样本是上次的决策结果之后划分出来的样本。

一般,一棵决策树包含一个根结点、若干个内部结点和若干个叶结点。叶结点对应于决策结果,其他每个结点则对应一个属性测试,每个结点包含的样本集合根据属性测试的结果被划分到子结点中,根结点包含样本全集。

决策树学习的目的是为了产生一棵泛化能力强,即处理未见示例能力强的决策树。

构建一棵决策树的伪代码为:

输入:训练集$D={(xi, y_i)}{i=1}^{m}$

​ 属性集$A = {ai}{i=1}^{d}$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void GenerateDTree(D, A) {
生成根结点 root;
if (D中样本全属于同一类别C) {//当前结点包含的样本全属于同一类别,即yi都相同
将root标记为C类叶结点;
return;
}
if (A == NULL || D中样本在属性A上取值相同) {//没有属性可以选择或者当前所有样本在剩下的属性上取值相同
将root标记为叶结点,其类别标记为D中样本最多的类;
return;
}
从属性集A中选择最优划分属性a*;
for (对于属性a*每一个可能的属性取值v) {
为root生成一个分支;令D_v表示D中在属性a*上取值为v的样本子集;
if (D_v 为空集) {
将分支结点标记为叶结点,其类别标记为D中(注意是D)中样本最多的类;
return;
} else {
GenerateDTree(D_v, A - a*);
}
}
}

决策树生成的算法是一个递归算法,注意该递归算法的三个返回条件

疑问:
1、 如果在选择当前样本数量最多的类作为类别标记时,有多个样本数量最多的类别时怎么办?随便选一个?
2、for循环中,对于属性a*每一个可能的属性取值v,这里的可能取值是相对于最开始的数据集来说还是相对于当前结点中的数据集来说?因为当前结点中的数据集在属性a*上的取值可能比最开始的数据集要少一些

划分选择

在上面的生成决策树的伪代码中,我们要选择最优的划分属性,那么应该怎么选择最后的划分属性呢?一般而言,随着划分过程不断进行,我们希望决策树的分支结点所包含的样本尽可能属于同一类别,即结点的“纯度”越来越高。所以可以选择使得划分后结点纯度更高的属性作为划分属性,一般有以下几种度量某种属性这种能力的指标:信息增益、增益率、基尼指数。

信息增益

在查看定义信息增益之前,需要先查看信息熵的定义。信息熵是度量样本集合纯度最常用的一种指标。假定当前样本集合$D$中第$k$类样本所占的比例为$p_k \space (k = 1,2, \cdots, |Y|, |Y|表示当前样本中类别的总数)$,则信息熵的定义为:

$Ent(D)$越小,则数据集合$D$的纯度越高

有了信息熵的定义之后,再来看信息增益的定义。假设某个离散属性$a$有$V$个可能的取值${a_1, a_2, \cdots, a_V}$,若使用属性a来对样本集D来划分,则会产生$V$个分支结点。其中第$i$个分支结点包含了D中所有在属性$a$上取值为$a_i$的样本,记为$D_i$。这样可以计算出$D_i$的信息熵,在考虑在不同的分支结点包含的样本数不同,给分支结点赋予权重$|\frac{D_i}{D}|$,即样本数多的分支结点的影响越大。最后可以得到使用属性$a$对样本集D进行划分所获得的“信息增益”:

一般而言,信息增益越大,则意味着使用属性a来进行划分所获得的“纯度提示”越大。若在一次划分中,有多个属性均取得了最大的信息增益,则任选其中之一作为划分属性。

增益率

信息增益准则的特点是对可取值数目较多的属性性有所偏好,例如某种属性有1000个取值,那么这个属性的信息增益率一般就会很大,但是选择这个属性作为划分属性后,每个划分出来的结点可能会因为已经没有多少剩余的样本而很快地被标记为叶结点。那么使用这个有1000个取值的属性划分得到的决策树泛化能力一般不会太好。而增益率则将每个属性的可能取值个数考虑了进去,其定义为:

$IV(a)$称为属性a的“固有值”,属性a的可能取值数目越多(即$V$越大),则$IV(a)$的取值通常会越大。

需要注意的是,增益率准则对可取值数目较少的属性有所偏好,因此,有些算法并不是直接选择增益率最大的候选划分属性,而是先从候选划分属性中找出信息增益率高于平均水平的属性,再从中选择增益率最高的。

基尼指数

查看基尼指数的定义前,先看一下基尼值的定义。基尼值也是用来描述数据集D的纯度的指标。其具体定义为:

直观来说,$Gini(D)$反映了从数据集D中随机抽取两个样本,其类别标记不一致的概率,因此,$Gini(D)$越小,则数据集$D$的纯度越高

有了基尼值的定义,再来看属性a的基尼指数的定义

我们在候选属性集合$A$中,选择那个使得划分后基尼指数最小(注意是最小,信息增益和增益率中是选最大的那个)的属性作为划分属性。

剪枝处理

剪枝处理是决策树学习算法处理过拟合的主要手段。在决策树学习中,为了尽可能正确分类训练样本,结点划分过程不断重复,有时会造成决策树分支过多,这时就可能因训练样本学习得“太好”了,以至于把训练集自身的一些特点也当做所有数据都具有的一般性质而导致过拟合。因此,可通过主动去掉一些分支来降低过拟合的风险。

决策树剪枝的基本策略有预剪枝后剪枝

预剪枝

预剪枝是指在决策树生成过程中,对每个结点在划分前先进进行估计,若当前结点的划分不能带来决策树泛化性能提升,则停止划分并将当前结点标记为叶结点。那么如何判断泛化性能是否提升呢?可以使用验证集来判断,看剪枝后的决策树在验证集上的表现是否比未剪枝的决策树好。

预剪枝使得决策树的很多分支结点都没有展开,这不仅降低了过拟合的风险,还显著减少了决策树的训练时间开销和测试时间开销。但另一方面,有些分支的当前划分虽不能提升,甚至可能导致泛化性能暂时下降,但在其基础上进行的后续划分却有可能导致性能显著提高,这给预剪枝决策树带来了欠拟合的风险。

后剪枝

后剪枝则是先从训练集中生成一棵完整的决策树,然后自底向上地对每个非叶结点进行判断,若将该非叶结点对应的子树替换为叶结点能带来决策树泛化能力的提升(同样也可使用验证集来判定泛化性能是否提升),则该非叶结点标记叶结点。

后剪枝决策树通常比预剪枝决策树保留了更多的分支,一般情况下后剪枝决策树的欠拟合风险很小,泛化性能往往优于预剪枝决策树。但后剪枝过程是在生成完全决策树之后进行的,并且要自底向上地对树中的所有非叶结点进行逐一考察,因此其训练时间开销比未剪枝决策树和预剪枝决策树都要大得多。

连续值处理

在决策树中,对于某些取值为连续值的属性,不能直接根据连续属性的可取值来对结点进行划分,此时要将连续值转换为离散值,最简单的策略是采用二分法对连续属性进行处理。

给定样本集$D$和连续属性$a$,假设$a$在$D$上出现了n各不同的取值,将这些值从小到大进行排列,记为${a_1, a_2, \cdots, a_n}$。选定划分点$t$为某两个相邻取值的中点,则数据集可以被划分为两个子集。因此,对于n个不同

取值,共有$n-1$个候选划分点,这些划分点构成集合:

然后,可以像离散属性值一样来考察这些划分点,选取最优的划分点进行样本集的划分。例如可用改造后的信息增益来判断划分点的优劣:

$Gain(D, a, t)$是样本集在连续属性$a$上基于划分点$t$二分后的信息增益。于是,我们就可选择使$G(D, a, t)$

最大化的划分点。

需要注意的是,与离散值属性不同,若当前结点划分属性为连续属性,该属性还可作为其后代结点的划分属性。例如当前结点选择的划分属性为连续属性$a$,并且划分标准为$a \leq 0.5$和$a> 0.5$,则对于使用$a \leq 0.5$划分出来的结点,仍然可以使用属性a最后划分属性,这时可能选择划分标准为$a \leq 0.25$和$0.25 < a \leq 0.5$。

缺失值处理

现实任务中常会遇到不完整的样本,尤其是在属性数目较多的情况下,往往会有大量样本出现缺失值,如果简单地放弃不完整样本,仅使用无缺失值的样本来进行学习,显然是对数据信息的极大浪费。所以要想办法处理缺失值的情况。

如何在属性缺失的情况下进行划分属性选择

给定数据集$D$和属性$a$,令$\overline{D}$表示D中在属性a上没有缺失值的样本子集,显然我们可根据$\overline{D}$来判断属性a的优劣。假设属性a由$V$个可取值${a_1, a_2, \cdots, a_V}$,令$\overline{D_i}$表示$\overline{D}$中在属性a上取值为$a_i$的样本子集。$\overline{D_k}$表示$\overline{D}$中属于第$k$类$(k = {1, 2, \cdots, |Y|}, \space|Y|为\overline{D}中类别总数)$的样本子集。则有:

假定为$D$中的每个样本$x$赋予一个权值$w_x$,并定义:

直观上来看,对属性$a$,$\rho$表示无缺失值样本所占的比例,$\overline{pk}$表示无缺失值样本中第$k$类样本所占的比例,$\overline{r_i}$则表示无缺失值样本中在属性a上取值为$a_i$的样本占的比例。显然有:$\sum{k=1}^{|Y|} \overline{pk} = 1$,$\sum{i=1}^{V} \overline{r_i} = 1$。

基于上述定义,可以将信息增益的计算推广为:

同理也可得到增益率和基尼指数的推广。

给定划分属性,若样本在该属性上的值缺失,如果对样本进行划分

若确定了划分属性为$a$,若样本$x$在划分属性$a$上的取值未知,则将$x$同时划入所有子结点,且样本权值在与属性值$a_i$对应的子结点调整为$\overline{r_i} · w_x$。直观上来看,这就是让同一个样本以不同的概率划入到不同的子结点中去。

多变量决策树

若把每个属性视为坐标空间中的一个坐标轴,则$d$个属性描述的样本就对应了$d$维空间中的一个数据点,对样本分类则意味着在这个坐标空间中寻找不同类样本之间的分类边界。决策树所形成的分类边界有一个明显的特点: 轴平行​,即它的分界边界由若干个和坐标轴平行的分段组成。

显然,分类边界与坐标轴平行时,这样的分类边界使得学习结果具有较好的可解释性。但在学习任务的真实边界比较复杂时,必须使用很多段划分才能获得较好的近似,此时的决策树会相当复杂,由于要进行大量的属性测试,预测时间开销会很大。

若能使用斜的划分边界,则决策树的模型将能得到很大的简化,多变量决策时就是能实现斜划分甚至更复杂划分的决策树。在此类决策树中,非叶结点不再仅对莫个属性,而是对属性的线性组合进行测试;换言之,每个非叶结点是一个形如$\sum_{i=1}^d w_i a_i = t$的线性分类器,其中$w_i$是属性$a_i$的权重。在多变量决策树的学习过程中,不是为每个非叶结点寻找一个最有划分属性,而是试图建立一个合适的线性分类器。

其他

在决策时中,常使用信息增益、增益率、基尼指数作为划分属性选择的标准,除这些意外,人们还设计了许多其他的准则用于决策树划分选择。这些准则虽然对决策树的尺寸有较大影响,但对泛化性能的影响很有限。而使用剪枝的方法和程度对决策树泛化性能的影响相当显著,有实验研究表明,在数据带有噪声时通过剪枝甚至可将决策树的泛化性能提高25%。

有算法试图在决策树中的叶结点嵌入神经网络,例如感知机树在决策树的每个叶结点上训练一个感知机,有的直接在叶结点嵌入多层神经网络。

有一些决策树算法可进行增量学习,即在接收到新样本后对已学得的模型进行调整,而不完全重新学习。主要机制是通过调整分支路径上的划分次序来对树进行部分重构。增量学习可有效降低每次接受到新样本后的训练时间开销,但多步增量学习后的模型与基于全部数据训练而得的模型有较大差别。

什么是类别不同平衡问题

类别不平衡就是指分类任务中不同类别的训练样例数目差别很大的情况。例如在二分类任务中有998个正例,2个反例,那么学习方法只需要返回一个永远将新样本预测为反例的学习器,就能达到99.8%的精度,然而这个学习器往往没有价值,因为它不能预测出任何正例。还有,在通过拆分法解决多分类问题时,即使原始问题中不同类别的训练样例数目相当,在使用OvR、MvM策略后产生的二分类任务仍可能出现类别不平衡现象。

处理类别不平衡问题

以logistic回归处理二分类任务为例,分类任务的输出一般都是判断某个样例为正例的概率。假如输出$y$表示样例为正例的概率,则$1-y$就表示样例为反例的概率。

在训练集中,正、反例的数目不同时,令$m^+$表示正例数目,$m^-$表示反例数目。于是,只要分类器的预测概率比例高于$\frac{m^+}{m^-}$就应该判为正例。即:

还有一种形式为将$\frac{m^+}{m^-}$替换为$\frac{cost^+}{cost^-}$,其中$cost^+$表示将正例误分为反例的代价,$cost^-$是将反例误分为正例的代价,这其实就是代价敏感学习的思想。

但是使用这种方法一般要求“训练集时真是样本总体的无偏采样”,而这个要求一般很难实现。除了上面的这个方法还有其他两种方法:“欠采样”和“过采样”。

欠采样即去除一些多的样例,使得正、反样例数目接近,然后再进行学习。欠采样若丢弃样例后,可能丢失一些重要的信息。欠采样的代表算法EasyEnsemble则是利用集成学习机制,将样例多的类别中的样例划分为若干个集合供不同的学习器使用,这样对每个学习器来看都进行了欠采样,但在全局来看却不会丢失重要信息

过采样即赠加一些样例数较少的类别的样例,使得正、反例数目接近,然后再学习。但是过采样不能简单对初始的样本进行重复采样,否则将会导致严重的过拟合。过采样的代表算法SMOTE是通过对训练集中的样例进行插值来表示额外的样例。

切比雪夫大数定律

假设${X_n}$是相互独立的随机变量,如果方差$DX_i$存在,且有一致有上界,则

伯努利大数定律

假设$u_n$是n重伯努利实验中事件A发生的次数,在每次实验中事件A发生的概率为$p$,则

辛钦大数定律

假设${X_n}$是独立同分布的的随机变量序列,如果$EX_i=u$存在,则

中心极限定理

假设${X_n}$是独立同分布的随机变量序列,如果$EX_i=u$,$DX_i = \sigma^2$存在,则${X_n}$服从中心极限定理,有

从二分类到多分类

在遇到多分类任务时,有些二分类学习方法可直接推广到多分类,例如线性判别分析LDA,但在更多情形下,我们是基于一些基本策略,利用二分类学习器来解决多分类问题。

一般的,对于N个类别$C_1, c_2, \cdots,C_N$,多分类学习器的基本思想是拆解法,即将多分类任务拆为若干个二分类任务求解。在测试时,对这些二分类器的预测结果进行集成以获得最终的多分类结果,比如说可以选择概率最大的那个结果。

拆分策略

OvO策略

给定数据集$D={(xi y_i)}{i=1}^{m}$,$y_i \in {C_1, C_2, \cdots, C_N }$,OvO将这N个类别两两配对,从而产生$\frac{N(n-1)}{2}$

个二分类任务。在一个二分类任务中,把一种类别当做正例(随便选定两者中的一个),另一种类别当做负例。

在测试阶段 ,新样本将同时提交给所有分类器,将会得到$\frac{N(N-1)}{2}$个分类结果,最终结果结果可通过投票产生:即把被预测得最多的类别作为最终结果

OvR策略

对于OvR,则是每次将一个类别的样例作为正例,所有其他类的样例作为反例来训练N个分类器。在测试时,若仅有一个分类器预测为正类,则对应的类别标记作为最终分类结果;若有多个分类器预测为正类,则通常考虑各分类器的预测置信度,选择置信度最大的类别作为分类结果。

OvO于OvR策略比较

OvR相比于OvO只需要训练N个分类器,而OvO需训练$\frac{N(N-1)}{2}$个分类器,因此,OvO的存储开销和测试时间开销通常比OvR更大。但在训练时,OvR的每个分类器均使用全部训练样例,而OvO的每个分类器仅用两个类的样例,因此,在类别很多时,OvO的训练时间开销通常比OvR更小。至于预测性能,则取决于具体的数据分布,在多数情况下两种策略差不多

MvM

MvM是每次将若干个类作为正类,若干个其他类作为反类。MvM的正、反类构造必须有特殊的设计,不能随意选取。有一种常用的MvM技术:纠错输出码(Error Correcting Output Codes, ECOC)

纠错输出码ECOC

ECOC工作过程主要分为两步:

  • 编码:对N个类别分别做M次划分,每次划分将一部分类别划分为正类,一部分划分为反类,从而形成一个二分类训练集;这样一共产生M个训练集,可以训练出M个分类器。
  • 解码:M个分类器分别对测试样本进行预测,这些预测标记组成一个编码。将这个编码与每个类别各自的编码进行比较,返回其中距离(海明距离或者欧式距离)最小的类别作为最终预测结果

类别划分通过编码矩阵指定,编码矩阵有多种形式,常见的主要有二元码三元码。二元码将每个类别分别指定正类和反类;三元码在正、反类之外,还可指定停用类。

ECOC

在编码矩阵中,使用“+1”、“-1”分别表示正、反例,0表示不使用该类样本即停用类。在上图的例子中,(a)图中的测试用例最后分类结果应归为$C_3$类,(b)图中的测试用例最后分类结果应归为$C_2$类。

为什么叫做“纠错输出码”呢?原因是在测试阶段ECOC编码对分类器的错误有一定的容错和纠正能力。例如,图(a)中对测试用例的正确预测编码是$(-1,+1,+1,-1,+1)$,假设在预测时某个分类器出错了,假设$f_2$分类器出错了,从而导致了错误的编码$(-1,-1,+1,-1,+1)$,但基于这个编码仍能产生正确的最终分类结果$C_3$。一般来说,对同一个学习任务,ECOC编码越长,纠错能力越强。然而,编码越长意味着所需训练的分类器越多,计算、存储开销都会增大;另一方面,对有限类别数,可能的组合数目是有限的,码长超过一定范围后就失去了意义。

对于等长度的编码,理论上来说,任意两个类别之间的编码距离越远,则纠错能力越强。因此,在码长较小时可根据这个原则计算出理论最优编码。然而一般很难求的最优的编码,选择一个较优的编码就足够了。

思路

线性判别分析(Linear Discriminant Analysis, LDA)是一种经典的线性分类学习方法。LDA的思想非常朴素:给定训练集样例,设法将样例投影到一条直线上,使得同类样例的投影点尽可能接近,异类样例的投影点尽可能远离;在对新样本进行分类时,将其投影到同样的这条直线上,再根据投影点的位置来确定新样本的类别。

线性判别分析

模型求解

模型推广到多分类任务

总结

什么是logistic回归

logistic回归又叫做对数几率回归,它是基于线性模型的一种分类模型。那么如何用线性回归得到的输出进行分类任务呢?只需找一个单调可微的函数将分类任务的真实标记y与线性回归模型的预测值联系起来。单调可微的目的是为了方便模型的求解。

例如对于二分类任务,其输出标记$y \in {0, 1}$,而线性回归模型产生的预测值$z = wx + b$是实值,于是,我们需要将实值转换为0/1值。最理想的是单位阶跃函数:

即若预测值$z$大于0就判为正例,小于0则判为反例,预测值为临界值0则可任意判别

但是,单位阶跃函数不连续,所以直接使用单位阶跃函数的话,该分类模型则将很难求解。

由于不能使用阶跃函数,所有我们希望找到能在一定程度上近似单位阶跃函数的“替代函数”,并希望它单调可微。对数几率函数(logistic function)正是这样一个常用的替代函数:

阶跃函数和对数几率函数的图像为:

logistic

从上图可以看出,对数几率函数是一种”Sigmoid函数”(即形似S的函数),对率函数是Sigmoid函数中最重要的代表。

将线性模型和对数几率函数结合得到最后的logistic回归的模型:

logistic模型求解

logistic模型求解就是求出最优的$w$和$b$。然而这里不能直接使用求解线性回归的方法进行求解,即不能定义目标函数为$argmin{(w,b)}\sum{i=1}^{m}(wx_i+b - y_i)^2$。因为在这里,数据集中的y只能取0和1(对于二分类来说),如果仍然使用均方误差来作为损失函数的话,那么在这里均方误差函数将不再是一个凸函数,所以我们要寻求另外的目标函数。

logistic回归中的对率函数的实际输出其意义为:样本为正例(即$y=1$)的概率。例如对于某个样例$x$,在参数为$w,b$的条件下的输出为0.7,则表示该样例有0.7的概率为正例。相反如果输出为0.3,则表示该样例为正例的概率为0.3,即为反例的概率有0.7。

则由此即有:

于是,可通过极大似然法来估计$w$和$b$。给定数据集${(xi, y_i)}{i=1}^{m}$,其中$y_i \in {0,1}$,则可得极大似然函数为:

其中$p(y_i|x_i,w,b)$可改写为$y_ip(y=1|x_i,w,b) + (1-y_i)p(y=0 | x_i,w,b)$,则似然函数可写为:

由于似然函数法的思想就是求出使得得出当前结果的概率最大的参数,$l(w,b)$的意义其实就是在参数为$w$和$b$的条件下,取值为当前数据集的y的概率再取对数。由于对数函数和原函数具有相同的单调性,当对数函数取最大值时,原函数也去最大值。所以最后我们的目标函数即为:

注意是argmax

最大化(1)式,等价于:

公式(2)是最常用的形式。这个公式相当于在说当$y_i = 1$时,损失函数为$ln\frac{1}{1 + e^{-(wx+b)}}$,当$y_i = 0$时,损失函数为$1-ln\frac{1}{1 + e^{-(wx+b)}}$。接下里就是利用梯度下降法或者其他方法求解最优解即可了。

利用极大似然估计法寻找目标函数也是常用的一种方法

总结

logistic回归虽然它的名字是“回归”,但实际上却是一个分类学习方法。这种方法有很多特点,它是基于线性回归模型的,具有很好的解释性。同时它是直接对分类可能性进行建模,无需要事先假设数据分布,这样就避免了假设分布不准确所带来的问题。

它不仅是预测出类别,而是可得到近似概率预测。此外,对率函数是任意阶可导的凸函数。

离散型

0-1 分布B(1, p)

$E(x) = p$,$D(x) = p(1-p)$

二项分布B(n, p)

$E(x) = np$,$D(x) = np(1-p)$

泊松分布$P(\lambda)$

$E(x) = \lambda$,$D(x) = \lambda$

几何分布a(p)

$E(x) = \frac{1}{p}$,$D(x) = \frac{1 - p}{p^2}$

几何分布的意义表示一个事件首次发生所需要的实验次数

超几何分布H(n, N, M)

$E(x) = \frac{nM}{N}$,超几何分布的意义为:在N件产品中有M件不合格,在产品中随机取出n件检查,发现有k件不合格的概率。

连续型

均匀分布U(a,b)

$E(x) = \frac{a+b}{2}$,$D(x) = \frac{(b-a)^2}{12}$

指数分布$E(\lambda)$

$E(x) = \frac{1}{\lambda}$,$D(x) = \frac{1}{\lambda^2}$

正态分布$N(\mu, \sigma^2)$

$E(x) = \mu$,$D(x) = \sigma^2$

常微分方程

方程的解为相若$y=f(x)$的,只有一个自变量的微分方程,称为常微分方程,如果有多个自变量,则称为偏微分方程。

可分离变量型

形如$y’ = g(x)$的微分方程,为可分离变量型的常微分方程。

其解法为:

可转化为可分离变量型

形如$y’ = g(ay+bx+c)$的微分方程,可以转化为可分离变量型的微分方程

解:

​ 令

​ 则

​ 则原式变为:

​ 即转化为了可分离变量型

齐次常微分方程

形如$\frac{dy}{dx} = g(\frac{y}{x})$或$\frac{dx}{dy} = g(\frac{x}{y})$的微分方程称为0次齐次常微分方程,其解法为:

解:

​ 令

​ 则:

​ 也转换为了可分离变量型的微分方程了。

一次线性常微分方程

1、$y’ + p(x)y = q(x)$

其解的公式为:

2、$y’ + p(x)y = q(x)y^n$

解:

​ 原式两边同除$y^n$

​ 令

​ 则有

​ 则原式转换为:

​ 再直接使用公式(1)即可。

二次微分方程

可降次二次微分方程

1、$y’’ = g(y’, x)$

解:

​ 令

​ 则

​ 则原式转换为:

2、$y’’ = g(y’, y)$

解:

​ 令

​ 则

​ 则原式转换为:

二次常系数微分方程

形如$y’’+py’ + qy = g(x)$,其中$p,q$为常数的微分方程称为二次常系数微分方程。

二次常系数齐次微分方程

如果二次常系数微分方程中g(x) = 0,则称该方程为二次常系数齐次微分方程。其通解取决于其对应的特征方程的解的个数。

若有$y’’+py’ + qy = 0$,其对应的特征方程为$\lambda^2 + p\lambda + q = 0$,则其通解为:

其中$C_1、C_2$为任意常数

二次常系数非齐次微分方程

二次常系数非齐次微分方程的解的结构为其对应的齐次微分方程的通解再加上非齐次微分方程对应的一个特解。

一般考察的非齐次方程有两种:

1、$g(x) = P_n(x)e^{ax}$,$P_n(x)$为x的n次多项式

其特解公式为:

$Q_n(x)$为x的n次多项式

2、$g(x) = e^{ax} [P_n(x)cosbx + Q_m(x)sinbx]$,$h_n$为x的n次多项式,$h_m$为x的m次多项式

其特解公式为:

$h_s(x)$为x的s次多项式,其中s = $max{m, n}$

要求出多项式的具体参数,就将特解带回原方程求解即可

欧拉方程

$x^2 \frac{d^2y}{dx^2} + px \frac{dy}{dx} + qy = f(x)$,其中$p、q$为常数,这种方程即为欧拉方程。

解:

​ 对于 x>0,令

​ 则

​ 则

​ 则原式转换为:

​ 即转化为了二次常系数微分方程。

​ 同理对于x < 0,也可以用同样的方法。

C++ 编译链接过程

1、预处理器:将.c 文件转化成 .i文件,使用的gcc命令是:gcc –E,对应于预处理命令cpp;

2、编译器:将.c/.h文件转换成.s文件,使用的gcc命令是:gcc –S,对应于编译命令 cc –S;

3、汇编器:将.s 文件转化成 .o文件,使用的gcc 命令是:gcc –c,对应于汇编命令是 as;

4、链接器:将.o文件转化成可执行程序,使用的gcc 命令是: gcc,对应于链接命令是 ld;

5、加载器:将可执行程序加载到内存并进行执行,loader和ld-linux.so。

由不同编译器创建的二进制模块很可能无法正确链接,在链接编译模块时,请确保所有对象文件或库都是由同一个编译器生成的(或同一标准的编译器)

编译器前端和后端

编译器粗略分为词法分析语法分析类型检查中间代码生成代码优化目标代码生成目标代码优化

把中间代码生成及之前阶段划分问编译器的前端,那么后端与前端是独立的。后端只需要一种中间代码表示,可以是三地址代码或四元式等,而这些都与前端生成的方式无关。

编译器的前端将不同的高级编程语言经过词法分析、语法分析转化为与前端语言无关的中间表示。有了与前端语言无关的中间表示,一个编译器就可以支持编译多种高级语言。

按照这个分类,自己动手编写编译器,可以不必从头开始了。使用LLVM,我们可以做一个前端,然后和LLVM后端对接。

大端(Big Endian/Big Endiayin)

数据的高字节部分放在内存中的低字节地址,数据的低字节部分放在内存中的高字节地址

例如:大端.png

小端(Little Endian)

数据的高字节部分放在内存中的高字节地址,数据的低字节部分放在内存中的低字节地址

例如:小端.png

类型转换

不同基本操作数进行运算会隐式的将内存占用较少的类型转换成占用较多的操作数类型

尽量不要使用隐式类型转换,即使是隐式的数据类型转换是安全的,因为隐式类型数据转换降低了程序的可读性。尽量使用强制(显示)类型转换

占用空间大的数据的指针强制转换成占用空间小的数据的指针,会发生内存截断,将占用空间小的数据的指针强制转换成占用空间大的数据的指针,会发生内存扩张。例如

1
2
3
4
5
6
7
doule d5 = 100.0;

int *pInt = (int*)&d5;

int i4 = 100;

double pDbl = (double*)&i4;

内存截断:内存截断.png

内存扩张:内存扩张.png

发生内存扩张时,往扩张的内存写数据会发生运行时错误

标识符

标准C语言规定,编译器只取前31个字符作为有效的标示符,而标准C++取前255个字符作为有效的标识符

引用类型

引用变量在声明时必须被赋值,引用变量和被引用的变量的地址是相同的

1
2
3
4
int a = 1;
int &b = a;
printf("the address of a is %p and the address of b is %p", &a, &b);
//打印的结果地址相同

常量

对于指针类型和引用类型,将非const值赋值给const变量是合法的,但是反之则是非法的

1
2
3
4
5
6
7
int a = 10;

const int &b = a; //ok

int &c = b;//错误,const int &类型,不能直接赋给int &类型

int *d = &b;//错误,const int *类型,不能直接赋给int *类型

字面常量

字面常量:直接出现的数字、字符、字符串等,只存在基本数据类型的字面常量,字面常量只能引用,不能修改。除字符串外,你无法取一个字面常量的地址,例如:

1
int *p = &5;//语句错误

并且当你试图通过常量字符串的地址修改其中的字符时就会报告“只读错误”(此错误为运行时错误)例如:

1
2
3
char *pChar = "abcdef";

*(pChar + 2) = 'k';//错误,不能修改字面常量的内存单元

符号常量

符号常量分为用#define定义的宏常量和用const定义的常量。

使用#define宏定义的符号常量在进入编译阶段前就已经被替代为所代表的字面常量了,因此宏常量本质上是字面常量。

取const符号常量的地址或引用时,对于基本数据类型的const常量,编译器会重新在内存中创建一个拷贝,你通过其地址访问到的时这个拷贝而非原始的符号常量,例如:
1
2
3
4
5
6
7
const long lng = 10;

long *pl = (long *)&lng;

*pl = 11;

std::cout<<lng<<"-"<<*pl;//输出为10-11
对于构造类型的const常量,实际上是编译时不允许修改的变量,因此可以绕过编译器的静态类型安全检查机制,就可以在运行时修改其内存单元,例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Integer {
int m_lng
}


const Integer int_1;

//int_1.m_lng = 1000;//编译错误

Integer *pInt = (Integer *)&int_1;

pInt->m_lng = 1000;

std::cout << int_1.m_lng << "-" << pInt->m_lng;//输出为1000-1000
在C++程序中要尽量使用const来定义符号常量,包括字符串常量。 ### const char* p 和 char* const p, const char* const p 这三个的记忆方法为从右到左读: 1. const char* p读作:p is a pointer to const char(`const char* p等同于char const* p`)。对于const char* p,不能使用指针改变指针指向的内容,但是可以改变指针自身的值。 2. char* const p读作:p is a const pointer to char。对于char* const p,可以使用指针改变指针指向的内容,但是不能改变指针自身的值。 3. const char* const p 读作:p is a const pointer to const char。对于const char* const p,既不能使用指针改变指针指向的 # 函数 ## 函数参数传递规则 * 一般的,输出参数放在参数列表的前面,输入参数放在后面,并且不要交叉出现输入输出参数。 * 如果参数是指针,且仅做输入用,则应在类型前加const,以防止该指针指向的内存单元在函数体内无意中被修改。如果输入参数以值传递的方式传递对象,则宜改用"const &"方式来传递,因为引用的创建和销毁不会调用对象的构造和析构函数,从而可提高效率。 ## 返回值的规则 * 不要将正常值和错误标志混在一起返回。建议正常值用输出参数获得,而错误标志用return语句返回。 ## 函数内部实现的规则 * return语句不可返回指向“栈内存”的指针或者引用,因为该内存单元在函数体结束时会被自动释放。 ## 使用const提高函数的健壮性 如果参数用于输出,不论参数是什么数据类型,也不论是采用“指针传递”还是“引用传递”,都不能加const参数,否则该参数将失去输出功能。const只能修饰输入参数。 我个人认为不要将函数的返回值用const修饰,因为函数的作用应该和其他代码是弱耦合的,而且函数返回值应该怎么使用应该由函数外部的代码来决定。 `函数需要返回一个数组时,最好不要返回指向数组的指针,因为如果这么做的话,这个数组要么必须是new出来的,要么必须是static 类型的。最好将需要返回的数组作为函数参数传入。` 不管指针变量是全局的还是局部的、静态的还是非静态的,应当在声明它的同时初始化它,要么赋予它一个有效的地址,要么赋予它NULL。 # assert assert的宏体全部被条件编译命令\#ifdef _DEBUG和 \#endif所包含,因此assert只有在Debug版本才有效

指针运算

  • 指针自增(++),表示它指向了序列中的后一个元素

  • 指针自减(—),表示它指向了序列中的前一个元素

  • 指针加一个正整数i,表示它向后递进i个元素

  • 指针减一个正整数i,表示它向前递进i个元素

  • 两个同类型指针相减,表示计算它们之间的元素个数

指针加/减一个正整数i,其含义并不是在其值上直接加/减i,还要包含所指对象的字节数信息。

不能对void*类型指针使用“*”来取所指的变量

数组

任何数组,不论是静态声明的还是动态创建的,其所有元素在内存中都是连续字节存放的,也就是说保存在一大块连续的内存区中,std::vector的所有元素对象在内存中也是连续存放的。

创建动态二维数组的方法

1
2
3
4
5
6
7
int **p = new int*[n];

for(int i = 0; i < n; ++i) {

p[i] = new int[m];

}

一个很不符合逻辑的表达式

1
2
3
4
5
6
7
int a = 0;

int b = 1;

int c = 2;

a + b = c;//这句话会先执行a+b,将a+b的值放在一个临时对象中,然后将c的值赋予该临时对象。

最后一个表达式没有任何实际意义,它一般是错误将判断相等表达式“==”错误写成“=”的结果

endl

endl是一个函数模板,它实例化之后变成一个模板函数,其作用是插入换行符并刷新输出流。其中刷新输出流指的是将缓冲区的数据全部传递到输出设备并将输出缓冲区清空。

避免头文件循环引用

1
2
3
4
5
6
7
#ifndef HEADER_H

#define HEADER_H

//place include file content and your code here

#endif

  • #ifdef 只判断是否定义了某个宏

  • #if 不仅判断是否定义了宏,而且还判断宏是否为真

  • #undef 用于取消定义的宏

利用宏转字符串

1
#define TO_STR(a) #a

在宏定义汇总使用#表示将符号转换为对应的字符串,例如使用上面的宏TO_STR(test)的结果就是"test"

利用宏链接符号

1
#define Contact(a) a##_1

在宏定义中使用##表示连接,例如使用上面的宏Contact(test)的结果就是test_1