Repository: datawhalechina/intro-mathmodel
Branch: main
Commit: 39ca6c9a6e7e
Files: 19
Total size: 599.4 KB
Directory structure:
gitextract_hfcu50jj/
├── README.md
└── docs/
├── .nojekyll
├── CH0/
│ └── 绪论-走进数学建模的大门.md
├── CH1/
│ └── 第1章-解析方法与几何模型.md
├── CH10/
│ └── 第10章-图像、文本与信号数据.md
├── CH2/
│ └── 第2章-微分方程与动力系统.md
├── CH3/
│ └── 第三章-函数极值与规划模型.md
├── CH4/
│ └── 第4章-复杂网络与图论模型.md
├── CH5/
│ └── 第五章-进化计算与群体智能.md
├── CH6/
│ └── 第六章-数据处理与拟合模型.md
├── CH7/
│ ├── 第7章-权重生成与评价模型.md
│ └── 第7章-评价类模型.docx
├── CH8/
│ ├── 第8章-时间序列(1).docx
│ └── 第8章-时间序列.md
├── CH9/
│ ├── 副本第9章-统计与机器学习(1).docx
│ └── 第九章-机器学习与统计模型.md
├── README.md
├── _sidebar.md
└── index.html
================================================
FILE CONTENTS
================================================
================================================
FILE: README.md
================================================
# 数学建模导论【🧪 Beta公测版】
【🧪 Beta公测版本提示:教程主体已完成,正在优化细节,欢迎大家提Issue反馈问题或建议。】
项目教程地址:https://datawhalechina.github.io/intro-mathmodel/#/
- 项目背景:本项目基于我的课程《数学建模导论——基于Python语言》整理而来,是为了帮助学生更好的学习数学建模、数据科学知识所用。可用于大学生参加数学建模竞赛,也可用作工作中的平时学习。万物皆可建模,人工智能的本质也是数学模型,所以我们开放这门课程的教程。
- 项目内容目录:本项目一共包含十章内容,包括解析几何与方程模型、微分方程与动力系统模型、函数极值与规划模型、复杂网络与图论模型、进化计算与群体智能算法、数据处理与拟合模型、权重生成与评价模型、时间序列与投资模型、机器学习与统计模型、多模态数据处理模型等十个方面内容,旨在尽可能多地向大家展示数学建模中所用到的数学基础与算法知识,打造属于自己的数学建模宇宙。
---
## RoadMap
目前稿件基本已经完工
---
## 参与贡献
- 如果你想参与到项目中来欢迎查看项目的 [Issue](https://github.com/datawhalechina/repo-template/blob/main/docs) 查看没有被分配的任务。
- 如果你发现了一些问题,欢迎在 [Issue](https://github.com/datawhalechina/repo-template/blob/main/docs) 中进行反馈🐛。
- 如果你对本项目感兴趣想要参与进来可以通过 [Discussion](https://github.com/datawhalechina/repo-template/blob/main/docs) 进行交流💬。
如果你对 Datawhale 很感兴趣并想要发起一个新的项目,欢迎查看 [Datawhale 贡献指南](https://github.com/datawhalechina/DOPMC#为-datawhale-做出贡献)。
---
## 贡献者名单
> 项目负责人&主编:马世拓(若冰,马马老师,B站id:平成最强假面骑士)
>
> 编辑&校对:
>
> - 陈思州(小红花):第1章,第2章,第10章
> - 刘旭(卡拉比丘流形):第3章,第5章
> - 萌弟:第6章,第8章
> - 聂雄伟(牧小熊):第7章,第8章
> - 邢硕(Susan):第4章,第9章
>
> 排版&美工:何瑞杰(拟身怪乖宝宝),聂雄伟(牧小熊),陈思州(小红花),骆秀韬(Epsilon Luoo)
## 关注我们
扫描下方二维码关注公众号:Datawhale
[](https://raw.githubusercontent.com/datawhalechina/pumpkin-book/master/res/qrcode.jpeg)
## LICENSE
[](http://creativecommons.org/licenses/by-nc-sa/4.0/)
本作品采用[知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议](http://creativecommons.org/licenses/by-nc-sa/4.0/)进行许可。
# 目录
- 绪论:走进数学建模的大门
- 第1章:解析几何与方程模型
- 1.1 向量表示法与几何建模基本案例
- 1.2 Numpy与线性代数
- 1.3 平面几何模型的构建
- 1.4 立体几何模型的构建
- 1.5 使用Python解方程与方程组
- 第2章:微分方程与动力系统
- 2.1 微分方程的理论基础
- 2.2 使用Scipy与Sympy解微分方程
- 2.3 偏微分方程的数值求解
- 2.4 微分方程的应用案例
- 2.5 差分方程的应用案例
- 2.6 元胞自动机与仿真模拟
- 2.7 数值计算方法与微分方程求解
- 第3章:函数极值与规划模型
- 3.1 从线性代数到线性规划
- 3.2 使用Numpy进行矩阵计算
- 3.3 线性规划的算法原理
- 3.4 线性规划的建模案例
- 3.5 从线性规划到非线性规划
- 3.6 非线性规划的建模案例
- 3.7 整数规划与指派问题
- 3.8 使用scipy与cvxpy解决规划问题
- 3.9 动态规划与贪心算法
- 3.10 博弈论与排队论初步
- 3.11 多目标规划
- 3.12 蒙特卡洛模拟
- 第4章:复杂网络与图论模型
- 4.1 复杂网络概念与理论
- 4.2 图论算法:遍历,二分图与最小生成树
- 4.3 图论算法:最短路径与最大流问题
- 4.4 使用Networkx完成复杂网络建模
- 4.5 TSP问题与VRP问题
- 第5章:进化计算与群体智能
- 5.1 遗传算法理论与实现
- 5.2 粒子群算法理论与实现
- 5.3 蚁群算法理论与实现
- 5.4 模拟退火算法理论与实现
- 5.5 使用sci-opt实现群体智能算法
- 第6章:数据处理与拟合模型
- 6.1 什么是数据
- 6.2 数据的预处理
- 6.3 使用Pandas进行数据处理与分析
- 6.4 插值模型
- 6.5 回归与拟合模型
- 6.6 数据可视化与matplotlib、seaborn
- 第7章:权重生成与评价模型
- 7.1 层次分析法
- 7.2 熵权分析法
- 7.3 TOPSIS分析法
- 7.4 CRITIC法
- 7.5 模糊综合分析法
- 7.6 秩和比分析法
- 7.7 主成分分析法
- 7.8 因子分析法
- 7.9 数据包络分析法
- 7.10 评价模型总结
- 第8章:时间序列与投资模型
- 8.1 时间序列的基本概念
- 8.2 移动平均法与指数平滑法
- 8.3 ARIMA系列模型
- 8.4 GARCH系列模型
- 8.5 灰色系统模型
- 8.6 组合投资问题的一些策略
- 8.7 马尔可夫模型
- 第9章:机器学习与统计模型
- 9.1 统计分布与假设检验
- 9.2 回归不止用于预测
- 9.3 使用scipy与statsmodels完成统计建模
- 9.4 机器学习的研究范畴
- 9.5 使用scikit-learn完成机器学习任务
- 9.6 基于距离的KNN模型
- 9.7 基于优化的LDA和SVM模型
- 9.8 基于树形结构的模型
- 9.9 集成学习模型与GBDT
- 9.10 从神经网络到深度学习
- 9.11 使用PyTorch构造神经网络
- 9.12 聚类算法
- 9.13 关联关系挖掘模型
- 9.14 图数据与PageRank算法
- 9.15 朴素贝叶斯模型
- 第10章:多模数据与智能模型
- 10.1 数字图像处理与计算机视觉
- 10.2 计算语言学与自然语言处理
- 10.3 数字信号处理与智能感知
- 10.4 多模态数据与人工智能
================================================
FILE: docs/.nojekyll
================================================
================================================
FILE: docs/CH0/绪论-走进数学建模的大门.md
================================================
绪论 走进数学建模的大门
> 项目负责人&主编:马世拓(若冰,马马老师,B站id:平成最强假面骑士)
>
> 编辑&校对:
>
> - 陈思州(小红花):第1章,第2章,第10章
> - 刘旭(卡拉比丘流形):第3章,第5章
> - 萌弟:第6章,第8章
> - 聂雄伟(牧小熊):第7章,第8章
> - 邢硕(Susan):第4章,第9章
>
> 排版&美工:何瑞杰(拟身怪乖宝宝),陈思州(小红花),聂雄伟(牧小熊)
>
> 特别鸣谢:CSDN博主youcans_()和 洋洋菜鸟()的博客教程内容对本教程的支持与帮助!
何谓“数学建模”?我想这个问题其实并没有一个明确的定义。老宗师姜启源先生说,“数学建模就是建立数学模型解决实际问题”,我却总感觉这个定义还是抽象了些。“建立数学模型”,什么是“数学”的“模型”?怎样建这个“模型”?用什么去建这个“模型”?在我刚开始读本科的时候,我也对这个问题感到很疑惑,于是去拜读了很多人的作品,诸如姜启源、谢金星二位先生的《数学模型》、司守奎先生的《数学建模算法与应用》等很多经典教材,隐约是能够觉得,这个“数学”的“模型”本质上还是一些数学知识,而建这个“模型”则不能手算,须用计算机去设计程序。至少姜先生告诉了我数模的本质,司先生告诉了我数模的方法。

图0.1 人类认识世界的三种模型
可道理和工具都有了,应该如何建立这个“模型”呢?
这个问题我从20年就在思考,一直思考至今。朋友,请不要被“数学建模”这四个字劝退,它的本质还是解应用题,只是曾经的“小明买糖”变成了如今的“嫦娥探月”。可数学建模若是仅仅作为解题的手段而存在,那我便不会愿意花上四年时间去学它。数学建模作为一种思想,它是真真切切在工程问题中有所应用的。
我想,一些照本宣科的老师若是来上数学建模这门课,给你们讲的第一个例子大概率是船划水或包饺子。我不愿意照本宣科,也并不打算去介绍一些你们听着简单无聊可在实际工程中却没多大用处的例子。我便以身边最近的一个例子介绍吧:
2020年初,新冠病毒的到来打乱了我们的正常生活。在新冠肺炎的防治过程中就充满了数学建模的影子:如果读者翻到第三章,我会介绍SEIR模型,也就是传染病模型,我们把人群分为易感人群、密切接触者、感染者和康复者四类人群,这四类人群的新增、减少都遵循着动力系统的一些规律,我们可以列出一个微分方程组对其进行模拟:
$$
\begin{array}{c}
N\frac{{ds}}{{dt}} = - N\lambda si\\
N\frac{{de}}{{dt}} = N\lambda si - N\delta e\\
N\frac{{di}}{{dt}} = N\delta e - N\mu i\\
N\frac{{dr}}{{dt}} = N\mu i\\
s(t) + e(t) + i(t) + r(t) = 1
\end{array}
$$
读者现在可能并不知道这个方程组是什么意思,没有关系,在后续的学习中我们会一点点解释。而除了解微分方程组以外,我想很多读者还见过“一个教室里面的同学如果封锁了教室传染病会如何传染”这样的动画,而这样的动画可以通过元胞自动机去进行模拟。医生在研发新药、在患者身上进行试验的时候,会收集相应的实验数据,而对这些实验数据的分析、假设检验则又充满了统计学知识的应用……
所以,你看到了,这个过程充满了数学建模。
一个好的模型,它能够准确地反映问题,但又不失简洁性。我历来信奉一条最根本的道义,那就是“大道至简”。什么样的模型可以算得上一个好模型呢?我认为它需要遵循以下要点:
- 形式简洁:模型不至于太冗长,大道至简。
- 精度到位:求解精度符合工程实际的要求。
- 理论创新:在理论层面上进行一些创新。
- 排除干扰:能够排除一些无关紧要的干扰项。
- 可解释性:模型的结果有良好的可解释性。
- 求解方便:模型能够利用MATLAB等求解工具进行求解。
> 注意:好的模型需要不断进行调整,前人的一些好模型我们仍然可以进行改进。
数学建模是一门十分注重理论联系实际的课程,它有助于培养学生的数学思维与想象力;有助于培养学生的工程应用能力;有助于学生了解量化研究的一般方法。但在高等院校数学教学中,数学建模这门学科的学习是一门比较艰难的事情,是一个需要长期积累与总结的工作。对于这门学科,学生不好学、教师不好教的问题由来已久,其原因归根结底,仍然是由于缺乏对这门学科课程框架的统一共识与系统化的课程架构。数学建模课上教什么?怎么教?学生在下面准备数学建模竞赛备什么?怎么备?要达到一个怎样的标准才算是备好了?可能很多的学生和教师们都还有这样一个疑问。
数学模型不同于数学建模,二者是有区别的。而数学建模这门学科要是说想用一套统一的方法去学是很难。这也就是说,数学建模领域的研究方法很难形成一套绝对统一的研究范式。不同应用领域、不同数学知识相互交融,所使用的建模方法论也不可能做到一言以蔽之。诚然,数学建模没有一个完整的通用的体系可循,但就本科阶段的教学与竞赛需求而言,建模不同于模型案例的拼盘,它是有法可循的。
春秋时期著名的哲学家老子在《道德经》一书中说到:“人法地,地法天,天法道,道法自然”,这一思想是体现了“天人合一”的思想。在长期实践中,总结出以中国道家古典哲学为思想内核的“道与理”课程框架。以学生对数学建模课程认知过程为基础,将数学建模知识的教学划分为“道——法——术——器”四个阶段,对现有数学建模课程知识内容进行了整合与重构。

图0.2 数学建模的四个境界
纵观多年全国大学生数学建模竞赛赛题,问题类型大致可以分为“以模型为核心”的优化类、过程类问题,与“以数据为核心”的统计类、分析类问题。这为数学建模课程教学提供了一个很好的思路:以模型与数据为教学中的两条核心主线,将数学建模课程的知识进行串联、归纳与整合。为此,构建了“以数为本”和“以模为本”的数学建模知识体系架构,如图所示:

图0.3 数学建模导论教程的章节逻辑
在双主线的数学建模知识框架下,将学生需要掌握与学习的模型大体分为了十个章节。十章内容环环相扣,能够很系统地串联起数学建模教学中的主要知识体系。例如,从向量与几何谈起,讨论到几何图形中位置关系与数量关系的抽取与方程构建和求解;再从一般方程的求解到微分方程的求解,介绍函数与多元函数的微分和微分方程,从数学理论到代码实现,再到应用案例,并创新性地将元胞自动机融合于本章教学;随后从微分方程一章的数值方法出发引出函数的极值问题,从而引出规划模型的原理、求解与建模案例,以及博弈论、排队论等同样具有优化性质的模型;再介绍图论的基本概念与典型问题后,将重点聚焦于最短路径、TSP问题等的离散优化模型在图论中的应用,从而又引出新需求;智能计算与优化从TSP问题的解法谈起,引出遗传算法等智能优化方法。“以模为本”能够较好地串联起运筹学、微分方程、动力系统等知识内容。
而“以数为本”的主线则强调培养学生的“数据驱动”思维。随着人工智能与大数据技术的发展,数学建模的竞赛教学与研究得到了极大的推动,有越来越多的数学建模竞赛题开始融入“大数据分析”类的问题,而为了应对这一变化,也有越来越多的高校在数学建模课程中融入了数据分析的内容。将前面的模型应用于数据中于是有了最小二乘拟合的优化等数据处理与拟合方法;在数据处理的基础上可以对数据进行评价;将时间序列这一特殊形式的数据抽象为回归问题介绍预测处理方法,并介绍投资组合问题的优化形式;在数据处理和优化背景的基础上对数据进行挖掘建模,重点介绍传统数学课程中难以展开的机器学习方法;对于一些非常规数据,也创新性地借鉴了计算机视觉、自然语言处理领域的方式方法,对其进行有针对性选择融入到课程教学中。
这门课程起源于我在2022年最早的一版数学建模导论教程,在网络上的反馈还是很不错的。如今我把它放到datawhale的平台,希望能让更多的学习者参与学习。新的导论在以往内容的基础上新增了许多补充性内容,也参考了很多大师的作品。其中非常感谢CSDN博主洋洋菜鸟和youcans_二位的数学建模博客,有部分章节的案例参考过二位的作品,二位也对本教程表示非常支持。也非常感谢datawhale数学建模导论项目组团队成员以及开源项目保姆团队,让本教程顺利孵化。
现在,同学们,让我们一同走进数学建模的大门吧!
================================================
FILE: docs/CH1/第1章-解析方法与几何模型.md
================================================
第1章 解析方法与几何模型
> 内容:@若冰(马世拓)
>
> 审稿:@陈思州
>
> 排版&校对:@何瑞杰
读者朋友们,在这一章当中我们将会主要学习几何模型的相关知识。你们应该都还记得一些基本的几何原理知识,用具体的几何形状来抽丝剥茧为大家介绍数学建模这再合适不过了。而在这一章中,我们不仅会学习到如何分析问题、构造数学模型,更重要的是学会如何使用代码求解一些简单的模型。本章我们希望各位能够:
* **了解一些常见的几何模型构造的思想方法**
* **对常见立体几何模型与平面几何模型能够有自己的思考**
* **可以使用Python求解一些简单方程或方程组的解**
> 注意:本章内容不会太难,大家注意在简单案例中蕴含的数学思想即可。
## 1.1 向量表示法与几何建模基本案例
### 1.1.1 几何建模的思想
人类对于数学世界的探索起源于两样东西:一是计数,二是丈量。计数用以表示多少,要计算多了多少少了多少,于是有了数字的概念和四则运算,也就有了后来的代数学;丈量是测量土地的长宽面积、测量角度、分析几何关系等,也就有了后来的几何学。高中毕业以后,我们对于数学模型的理解其实还很浅薄,但几何模型对我们来讲是最直观的东西。有一张图,我们就可以直观地感受:哪两个平面平行,哪两条线垂直……通过一系列的几何定理还可以推算线段的长度等等。所以,我们就以几何模型作为了解数学建模的切入点。
但是大家可万万不要小看几何模型,虽然一些基本的数学公式我们在初中高中就学过了,这类问题还是大有讲究的。几何模型中充斥了不同的数学关系,根据关系的分类可以把它们分为位置关系和数量关系两种。位置关系就包括平行、垂直、异面、相交等,数量关系则是需要具体求解边长、角度、面积等。那么既然涉及到求解,就必然会涉及到等量关系也就是方程与函数。以往的这些方程与函数是可以手动计算的,但在一个复杂的几何框架中,量与量之间的关系,还会有那么容易计算吗?
分析几何问题,在现在这个阶段我们所掌握的方法大体上可以分为三种:
* **传统几何的演绎-证明体系**:这种体系下的方法都是基于已经被证明了的公理与定理体系(例如勾股定理、正弦定理、圆幂定理等),在解决问题的过程中更强调分析而非计算,往往是通过构造辅助线、辅助平面等利用严密的逻辑推理步步为营推导出最后的结果。这种方法往往分析起来会更加困难,但减少了计算量。
* **基于向量的计算化几何**:向量被引入几何当中最初的目的是为了表示有向线段,但后来大家发现基于向量的一些运算特性可以把一些数量问题统一化。几何图形中的边长、角度、面积可以转化为向量的模长、内积等求解,平行、垂直等可以转化为向量共线、内积为0等求解,就可以把所有几何问题都变成可计算的问题。这样的方法更加重视计算,并且除了传统的几何向量外,还可以构造直角坐标系从而获得坐标向量,运算更加方便。
* **基于极坐标与方程的解析几何**:这种方法其实可以回溯到当初学圆锥曲线的时期,把几何图形的相交、相切、相离抽象成方程解的问题。后来又学习过极坐标和参数方程,就会发现利用极坐标和参数方程去表示曲线实在是太方便了。这样的方法就可以把几何问题转化成一个代数问题来求解,大大提高了求解的效率。
同学们在中学阶段还记得都用过哪些几何方法去进行建模吗?我想,那真的太多了,但我们总能够总结出一些常见的公理与定理,例如:
* **三角形中的角度关系**:这是几何学中的基本概念之一,涉及到三角形内角和定理(三角形的三个内角之和等于$180\circ$)、直角三角形的角度关系(一个角为$90^{\circ}$,另外两个角之和为$90^{\circ}$)等。
* **勾股定理**:这是直角三角形中最著名的定理之一,表明直角三角形的两条直角边的平方和等于斜边的平方。即如果直角三角形的直角边长分别为$a$和$b$,斜边长为$c$,则有 $a^{2} + b^{2} = c^{2}$。
* **正弦定理**:这是解决三角形问题时非常有用的定理,它表明在任意三角形中,各边的长度与其对应角的正弦值之比相等。即如果三角形的边长分别为$a$、$b$、$c$,对应的角分别为$A$、$B$、$C$,则有 $\displaystyle \frac{a}{\sin A} = \frac{b}{\sin B} = \frac{c}{\sin C} = 2R$,其中$R$是三角形外接圆的半径。
* **余弦定理**:在任意三角形中,任意一边的平方等于其他两边的平方和减去两倍的其他两边长度乘以它们夹角的余弦值。即如果三角形的边长分别为$a$、$b$、$c$,对应的角分别为$A$、$B$、$C$,则有 $c^{2} = a^{2} + b^{2} - 2ab\cos C$。余弦定理在解决非直角三角形的问题中非常有用,尤其是在涉及边长和角度关系的问题中。
* **圆幂定理**:对于任意一点到圆的两条切线,它们的长度是相等的。对于任意一条经过该点的割线,割线的两段长度的乘积是一个常数,这个常数等于该点到圆心的距离的平方减去圆的半径的平方。圆幂定理在解决与圆有关的几何问题中非常有用。
* **切割线定理**(也称为割线-切线定理):如果一条直线从外部点P切割圆,形成一条切线段$PT$和一条割线段$PAB$(A和B是割线与圆的交点),那么$PT^{2} = PA \cdot PB$。这个定理在解决圆和直线关系的几何问题中很有帮助。
* **四点共圆**:四点共圆是一个判断四个点是否能在同一个圆上的几何概念。如果四个点$A$、$B$、$C$、$D$满足某种特定的关系,那么它们可以位于同一个圆上。这个概念在解决几何问题时非常重要,尤其是在证明四个点共圆的时候。常用的判断四点共圆的方法包括使用圆的方程、利用角的性质(如对顶角、圆周角等)等。
* **圆锥曲线的几何性质**:圆锥曲线(包括椭圆、双曲线和抛物线)拥有许多独特的几何性质。例如,椭圆的每一点到两个焦点的距离之和是一个常数;双曲线的每一点到两个焦点的距离之差的绝对值是一个常数;抛物线上的每一点到焦点的距离等于该点到准线的距离。
* **圆锥曲线的光学性质**:圆锥曲线的光学性质是指光线在圆锥曲线上的反射和折射特性。例如,椭圆上的任意一点反射到两个焦点的光线路径长度相等;抛物线上的任意一点反射到焦点的光线都平行于对称轴;双曲线上的任意一点反射到一个焦点的光线将经过另一个焦点。
* 圆锥曲线的离心率:圆锥曲线的离心率(eccentricity)是一个非负实数,它描述了圆锥曲线的形状。对于椭圆,离心率$e$满足$0\leqslant e<1$;对于双曲线,$e>1$;对于抛物线,$e=1$。离心率越小,椭圆越接近圆形;离心率越大,双曲线的两支越开。
当然,常见的其实远不止这么一点,这里罗列的是笔者觉得比较重要的几何定理,特地做一个总结。实际运用的时候有很多东西都是可以用的。
### 1.1.2 向量表示与坐标变换
我相信各位同学在高中肯定是知道向量这个概念的,但是你们最多认识到三维,并且还依赖于画图,中学阶段我们也仅仅是接触到了三维向量。现在如果我告诉你,一个向量可以有不止三个轴,可以有$5$维,有$10$维,有$10000$维,你现在还能依靠图理解向量吗?事实上向量的维数可以是很多维,从代数的意义上你可以认为向量是一个集合,从几何的意义上你又可以认为向量是一个n维欧几里得空间中的一个点:
$$
x = [x_{1}, x_{2}, \dots, x_{n}]^{\top}. \tag{1.1.1}
$$
和二维、三维空间中的向量一样,高维空间中的向量同样可以进行加减运算、数量乘运算和数乘运算。但毕竟这是一门应用数学课程,我们不打算把太多精力放在任何一本线代课本里面都能找到的公式上,使用 Python 的NumPy 库举例子恐怕会更加直观。从程序设计的角度来看,如果读者接触过C语言应该会了解数组的概念,而在 C++ 语言中 STL 里面已经包含了 `vector` 类型。在 Python 中我们可以使用 NumPy 库来创建和操作向量,例如:
```python
import numpy as np
x = np.array([1, 2, 3, 5, 8])
# array([1, 2, 3, 5, 8])
```
这样我们便创建了一个向量。随后便可以进行各种操作。
> 注意:分隔符也可以用分号或空格,但得到向量的形状是不同的。
引入向量的目的并不仅仅是为了在几何图形中更好地表示方向和距离,而是为了利用代数的方法来解决几何问题。向量提供了一种将几何概念转化为代数表达式的方式,从而使得几何问题的解决变得更加简单和直接。
例如,在解决物理问题时,力、速度和加速度等物理量都可以用向量表示。通过向量的加减和数乘运算,我们可以直接计算出合力、相对速度等结果,而不需要借助复杂的几何图形。在计算机图形学中,向量被广泛用于表示和处理图形和动画。通过向量运算,我们可以实现图形的旋转、缩放、平移等变换,以及计算光线的反射和折射等效果。
解析几何法的本质就是利用函数与方程来表示不同的几何曲线。在中学阶段我们都学习过圆锥曲线的方程形式,但在实际问题中我们面临的曲线会更加复杂。尤其是在三维空间中的曲线与曲面,可能会用到多元函数去进行表示,也可能用极坐标或参数方程更加方便,但不管怎么说,解析几何方法的本质就是把各种几何问题都转化为代数问题求解。解方程比起复杂的分析,更依靠计算,而这恰恰是程序所擅长的。
在数学中,坐标变换通常涉及到一系列的矩阵运算,这些矩阵描述了一个坐标系相对于另一个坐标系的位置和方向。旋转变换就是其中的一个典型例子。当我们说一个坐标系相对于另一个坐标系进行了旋转,我们通常是指它绕着一个轴或者点旋转了一定的角度。二维空间的旋转可以简化为点绕原点旋转,而三维空间则涉及到更复杂的轴向旋转。

图1 二维坐标系中的旋转
在二维空间中,如果我们要将坐标系绕原点旋转一个角度,就可以通过旋转矩阵来实现。旋转矩阵是一个非常简单而又强大的工具,它可以将原始坐标系中的点通过线性变换映射到新坐标系中。对于逆时针旋转,二维旋转矩阵的形式是
$$
\left[ \begin{matrix}
\cos \theta & -\sin \theta\\ \sin \theta & \cos \theta
\end{matrix} \right] . \tag{1.1.2}
$$
这里,$\theta$是旋转角度,当应用这个旋转矩阵于一个点$(x, y)$,它会给出新的坐标$(x', y')$,这表示了原始点在新坐标系中的位置。
使用NumPy进行这样的变换非常简单。首先,我们创建一个表示点坐标的NumPy数组,然后创建表示旋转矩阵的二维数组。通过对这两个数组进行点积运算(也就是矩阵乘法),我们就可以得到新的坐标,在Python中可以这样实现:
```python
import numpy as np
# 设定旋转角度,这里我们以30度为例
theta = np.radians(30) # 将30度转换为弧度
# 创建旋转矩阵
rotation_matrix = np.array([
[np.cos(theta), -np.sin(theta)],
[np.sin(theta), np.cos(theta)]
])
# 假设我们有一个点 (a, b)
point = np.array([a, b])
# 通过旋转矩阵变换这个点的坐标
rotated_point = rotation_matrix.dot(point)
print("原坐标为:", point)
print("旋转后的坐标为:", rotated_point)
# 原坐标为: [5 3]
# 旋转后的坐标为: [2.83012702 5.09807621]
```
此示例代码中,旋转角度是预设的,你可以根据实际情况调整。通过这种方式,我们能够将几何问题通过坐标变换转化为代数问题,使用编程方法来进行高效的计算。这不仅仅适用于理论数学问题,同样也适用于工程、物理学、计算机图形学以及机器人技术等多个领域中。

图2 三维坐标系中绕三个坐标轴的旋转
在三维空间中,物体的旋转可以围绕三个主轴进行:$\mathrm{x}$轴, $\mathrm{y}$轴和$\mathrm{z}$轴。这些轴旋转代表了不同方向的运动,并且可以通过旋转矩阵来数学描述。例如,一个点$P(x, y, z)$绕$\mathrm{z}$轴旋转角度$\alpha$可以表示为
$$
R_{\mathrm{z}}(\alpha) = \left[ \begin{matrix}
\cos\alpha & -\sin\alpha & 0\\sin\alpha & \cos\alpha & 0\\0 & 0 &1
\end{matrix} \right], \tag{1.1.3}
$$
这个旋转保持$\mathrm{z}$坐标不变,同时在$XY$平面上变换$\mathrm{x}$和$\mathrm{y}$坐标。相似地,点$P$绕$\mathrm{y}$轴旋转角度$\beta$的旋转矩阵为
$$
R_{\mathrm{y}}(\beta) = \left[ \begin{matrix}
\cos\beta & 0 & \sin\beta\\
0 & 1 & 0\\
-\sin\beta & 0 & \cos\beta
\end{matrix} \right], \tag{1.1.4}
$$
这个旋转保持$\mathrm{y}$坐标不变,同时在$XZ$平面上变换$\mathrm{x}$和$\mathrm{z}$坐标。而点$P$绕$\mathrm{x}$轴旋转角度$\gamma$的旋转矩阵为
$$
R_{\mathrm{x}}(\gamma) = \left[ \begin{matrix}
1 & 0 & 0\\
0 & \cos\gamma & -\sin\gamma\\
0 & \sin\gamma & \cos\gamma
\end{matrix} \right], \tag{1.1.5}
$$
这个旋转保持$\mathrm{x}$坐标不变,同时在$YZ$平面上变换$\mathrm{y}$和$\mathrm{z}$坐标。若点$P$需同时围绕三个轴旋转,则最终旋转矩阵$R$为这三个矩阵的乘积,即$R = R_{\mathrm{z}}(\alpha)R_{\mathrm{y}}(\beta)R_{\mathrm{x}}(\gamma)$。需要注意的是,由于矩阵乘法的非交换性,旋转的顺序会影响最终结果。
在实际应用中,如机器人学、航空航天和计算机图形学,旋转顺序对于模拟和预测物体如何移动至关重要。例如,飞机的姿态控制就极依赖于绕不同轴的旋转顺序,以精确地模拟和控制飞机的行动。在三维建模和动画制作中,这些旋转变换同样是创建动态、逼真场景的基础。
在Python中,利用NumPy库,我们可以使用如下代码片段来实现三维旋转变换:
```python
import numpy as np
# 定义旋转角度(以弧度为单位)
alpha = np.radians(30) # 绕 Z 轴旋转
beta = np.radians(45) # 绕 Y 轴旋转
gamma = np.radians(60) # 绕 X 轴旋转
# 定义旋转矩阵
R_z = np.array([[np.cos(alpha), -np.sin(alpha), 0],
[np.sin(alpha), np.cos(alpha), 0],
[0, 0, 1]])
R_y = np.array([[np.cos(beta), 0, np.sin(beta)],
[0, 1, 0],
[-np.sin(beta), 0, np.cos(beta)]])
R_x = np.array([[1, 0, 0],
[0, np.cos(gamma), -np.sin(gamma)],
[0, np.sin(gamma), np.cos(gamma)]])
# 总旋转矩阵
R = R_z @ R_y @ R_x
# 定义点P的坐标
P = np.array([1, 2, 3])
# 计算旋转后的坐标
P_rotated = R @ P
print("旋转后P点的坐标为:", P_rotated)
# 旋转后P点的坐标为: [3.39062937 0.11228132 1.57829826]
```
在该代码中,点$P$经过由$\alpha$,$\beta$,和$\gamma$定义的旋转后,其新坐标由$P_{\text{rotated}}$给出。该代码首先创建了绕$\mathrm{z}$,$\mathrm{y}$,和$\mathrm{x}$轴的三个旋转矩阵,然后将它们相乘得到一个总的旋转矩阵$R$,并应用这个矩阵来转换点$P$的坐标。
正是通过这些准确的数学变换和编程实现,我们能够在计算机模拟和实际应用中处理复杂的三维空间问题。无论是设计复杂的机械系统、创建逼真的三维动画,还是开发高级的虚拟现实环境,三维旋转都是不可或缺的基础。

图3 欧拉角图示
欧拉角是三维空间中用于表示一个物体相对于一个固定坐标系(通常是参考坐标系或世界坐标系)的方向的一组角。这种表示方法定义了三次旋转,将物体从其初始方向旋转到期望方向。欧拉角通常表示为三个角度:$\alpha$,$\beta$,和$\gamma$分别对应于绕$\mathrm{z}$轴,$\mathrm{x}$轴(或$\mathrm{y}$轴),以及再次$\mathrm{y}$轴(或$\mathrm{x}$轴)的旋转。
欧拉角旋转顺序的不同,定义了不同的旋转方式,最常见的是:
* **Z-Y-X(Roll-Pitch-Yaw)**:首先绕$\mathrm{z}$轴旋转$\alpha$(Yaw),然后绕*新位置的*$\mathrm{y}$轴旋转$\beta$(Pitch),最后绕*新位置的*$\mathrm{x}$轴旋转$\gamma$(Roll)。
* **Z-X-Y(Yaw-Pitch-Roll)**:首先绕$\mathrm{z}$轴旋转$\alpha$(Yaw),然后绕*新位置的*$\mathrm{x}$轴旋转$\beta$(Pitch),最后再次绕$\mathrm{y}$轴旋转$\gamma$(Roll)。
每次旋转都是围绕变换后的轴,而不是初始的固定轴。这些旋转通常通过旋转矩阵进行计算,并且可以合成为一个单一的矩阵,它描述了总的旋转。在实际应用中,如飞行动力学和计算机图形学,欧拉角非常重要。
这里是Python中计算欧拉角旋转的代码示例,假设采用$Z$-$Y$-$X$顺序:
```python
import numpy as np
# 定义欧拉角(以弧度为单位)
alpha = np.radians(30) # 绕 z 轴的 Yaw 角度
beta = np.radians(45) # 绕 y 轴的 Pitch 角度
gamma = np.radians(60) # 绕 x 轴的 Roll 角度
# 构建对应的旋转矩阵
R_z = np.array([[np.cos(alpha), -np.sin(alpha), 0],
[np.sin(alpha), np.cos(alpha), 0],
[0, 0, 1]])
R_y = np.array([[np.cos(beta), 0, np.sin(beta)],
[0, 1, 0],
[-np.sin(beta), 0, np.cos(beta)]])
R_x = np.array([[1, 0, 0],
[0, np.cos(gamma), -np.sin(gamma)],
[0, np.sin(gamma), np.cos(gamma)]])
# 总旋转矩阵,注意乘法的顺序
R = np.dot(R_x, np.dot(R_y, R_z))
print("组合旋转矩阵为:")
print(R)
# 组合旋转矩阵为:
# [[ 0.61237244 -0.35355339 0.70710678]
# [ 0.78033009 0.12682648 -0.61237244]
# [ 0.12682648 0.9267767 0.35355339]]
```
当你运行这段代码,它会打印出综合所有三次旋转的旋转矩阵R。
## 1.2 Numpy 与线性代数
在本节中,我们将探讨Python中最强大的科学计算库之一:NumPy。在深入学习之前,我们需要弄清楚线性代数在实际应用中的重要性。线性代数不仅是理解数据结构、解决数学问题的基础,也是计算机图形学、机器学习等高级领域不可或缺的工具。NumPy库在这里扮演了至关重要的角色,因为它为我们提供了一个高效、便捷的平台来处理数值计算和线性代数运算。接下来的内容,将通过实际的例子展示如何使用NumPy来执行一些基础但强大的线性代数操作。
### 1.2.1 Numpy向量与矩阵的操作
在科学计算的世界里,NumPy的数组对象是我们解决问题的得力助手。它能让我们轻松地执行向量化运算,这意味着可以一次性处理数据集而不需要使用循环。这种处理方式不仅代码更加简洁,而且运行速度也远快于传统的Python循环。在Numpy中,向量和矩阵都可以用二维数组表示,让我们来看看基本操作。
**创建向量和矩阵**
```python
import numpy as np
# 创建向量
vector = np.array([1, 2, 3])
# 创建矩阵
matrix = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
```
**向量和矩阵的基本属性**
```python
# 向量的维度
print(vector.shape) # (3, )
# 矩阵的维度
print(matrix.shape) # (3, 3)
# 矩阵的行数和列数
print(matrix.shape[0]) # 行数, 3
print(matrix.shape[1]) # 列数, 3
```
**索引和切片**
```python
# 索引
print(vector[0]) # 输出第一个元素, 1
print(matrix[1, 1]) # 输出第二行第二列的元素, 5
# 切片
print(vector[0:2]) # 输出前两个元素, [1, 2]
print(matrix[0:2, 0:2]) # 输出左上角的2x2子矩阵, [[1, 2], [4, 5]]
```
**向量和矩阵的运算**
```python
# 向量加法
vector1 = np.array([1, 2, 3])
vector2 = np.array([4, 5, 6])
print(np.add(vector1, vector2))
# [5, 7, 9]
# 矩阵乘法
matrix1 = np.array([[1, 2], [3, 4]])
matrix2 = np.array([[5, 6], [7, 8]])
print(np.dot(matrix1, matrix2)) # 或使用 matrix1 @ matrix2
# [[19, 22],
# [43, 50]]
```
### 1.2.2 利用Numpy进行线性代数基本运算
在NumPy中,我们可以利用数组的**广播机制**来进行各种线性代数运算。例如,你可以轻松地将一个标量与一个向量或矩阵相乘,而不需要编写任何循环。NumPy也提供了计算矩阵的转置、行列式、逆矩阵等常见操作的函数。
```python
import numpy as np
# 数量乘法示例
scalar = 5
scaled_vector = scalar * vector
print("Scaled vector:", scaled_vector)
# Scaled vector: [ 5 10 15]
# 矩阵的转置示例
transposed_matrix = matrix.T
print("Transposed matrix:\n", transposed_matrix)
# Transposed matrix:
# [[1, 4, 7]
# [2, 5, 8]
# [3, 6, 9]]
# 计算行列式示例
matrix_determinant = np.linalg.det(matrix)
print("Matrix determinant:", matrix_determinant)
# Matrix determinant: 0.0
# 求解线性方程组示例
A = np.array([[3, 1], [1, 2]])
b = np.array([9, 8])
solution = np.linalg.solve(A, b)
print("Solution of the linear system:", solution)
# Solution of the linear system: [2. 3.]
```
### 1.2.3 `numpy.linalg` 的使用
最后,我们不能不提NumPy中的 `linalg` 子模块,它包含了一系列关于线性代数的函数。无论是求解方程组,还是计算特征值、特征向量,乃至执行奇异值分解,`linalg` 模块都能够提供帮助。通过这些工具,我们可以探索矩阵的深层属性,并应用于各种数学和工程问题。下面是一些 `numpy.linalg` 模块的使用示例:
**计算逆矩阵**
```python
import numpy as np
# If the matrix is singular, use the pseudo-inverse
pseudo_inverse_matrix = np.linalg.pinv(matrix)
print("Pseudo-inverse of the matrix:")
print(pseudo_inverse_matrix)
# Pseudo-inverse of the matrix:
# [[-6.38888889e-01 -1.66666667e-01 3.05555556e-01]
# [-5.55555556e-02 4.20756436e-17 5.55555556e-02]
# [ 5.27777778e-01 1.66666667e-01 -1.94444444e-01]]
```
**特征值和特征向量**
```python
eigenvalues, eigenvectors = np.linalg.eig(matrix)
print(eigenvalues)
# [ 1.61168440e+01 -1.11684397e+00 -1.30367773e-15]
print(eigenvectors)
# [[-0.23197069 -0.78583024 0.40824829]
# [-0.52532209 -0.08675134 -0.81649658]
# [-0.8186735 0.61232756 0.40824829]]
```
**奇异值分解**
```python
U, S, V = np.linalg.svd(matrix)
print(U)
# [[-0.21483724 0.88723069 0.40824829]
# [-0.52058739 0.24964395 -0.81649658]
# [-0.82633754 -0.38794278 0.40824829]]
print(S)
# [1.68481034e+01 1.06836951e+00 4.41842475e-16]
print(V)
# [[-0.47967118 -0.57236779 -0.66506441]
# [-0.77669099 -0.07568647 0.62531805]
# [-0.40824829 0.81649658 -0.40824829]]
```
> 在np的计算中存在浮点数误差,特征向量形成矩阵转置乘以特征向量形成矩阵并不完全等于单位矩阵,故按步骤计算和直接调用函数存在一定误差。感谢学习者提问,这里回答一下。
**范数计算**
```python
norm = np.linalg.norm(vector)
print(norm)
# 3.7416573867739413
```
## 1.3 平面几何模型的构建
### 1.3.1 问题背景介绍
我们以2023 年高教社杯全国大学生数学建模竞赛B题为例:
单波束测深是利用声波在水中的传播特性来测量水体深度的技术。声波在均匀介质中作匀速直线传播,在不同界面上产生反射,利用这一原理,从测量船换能器垂直向海底发射声波信号,并记录从声波发射到信号接收的传播时间,通过声波在海水中的传播速度和传播时间计算出海水的深度,其工作原理如图 4 所示。由于单波束测深过程中采取单点连续的测量方法,因此,其测深数据分布的特点是,沿航迹的数据十分密集,而在测线间没有数据。

图4 单波束(左)和多波束(右)探测的工作原理
多波束测深系统是在单波束测深的基础上发展起来的,该系统在与航迹垂直的平面内一次能发射出数十个乃至上百个波束,再由接收换能器接收由海底返回的声波,其工作原理如图 4所示。多波束测深系统克服了单波束测深的缺点,在海底平坦的海域内,能够测量出以测量船测线为轴线且具有一定宽度的全覆盖水深条带(图 5)。

图5 条带的重叠区域及其与覆盖宽度、测线间距的关系
多波束测深条带的覆盖宽度$W$随换能器开角$\theta$和水深$D$的变化而变化。若测线相互平行且海底地形平坦, 则相邻条带之间的重叠率定义为$\displaystyle\eta = 1 - \frac{d}{W}$, 其中$d$为相邻两条测线的间距,$W$为条带的覆盖宽度 (图 5)。若$\eta < 0$,则表示漏测。为保证测量的便利性和数据的完整性, 相邻条带之间应有$10\% \sim 20\%$的重叠率。
但真实海底地形起伏变化大, 若采用海区平均水深设计测线间隔, 虽然条带之间的平均重叠率可以满足要求, 但在水深较浅处会出现漏测的情况 (图 6), 影响测量质量; 若采用海区最浅处水深设计测线间隔, 虽然最浅处的重叠率可以满足要求, 但在水深较深处会出现重叠过多的情况 (图 6), 数据冗余量大, 影响测量效率。
与测线方向垂直的平面和海底坡面的交线构成一条与水平面夹角为$\alpha$的斜线(图 6),称$\alpha$为坡度。请建立多波束测深的覆盖宽度及相邻条带之间重叠率的数学模型。

图6 问题一示意图
若多波束换能器的开角为 $120^{\circ}$,坡度为 $1.5^{\circ}$,海域中心点处的海水深度为 $70 ~\mathrm{m}$,利用上述模型计算表 1 中所列位置的指标值,将结果以表 1 的格式放在正文中,同时保存到 `result1.xlsx`文件中。
表1 问题一的计算结果

### 1.3.2 问题一的分析
对于问题一,这是一个几何模型问题。已知开角$120^{\circ}$,坡度为 $1.5^{\circ}$,$D=70$,求覆盖宽度,这个问题本质上就是一个已知三角形角度和角平分线长度求对边的几何问题。可以反复利用三角形中的**正弦定理**求解左右两条波束落点的距离也就是带宽,随后通过几何关系求解重复部分。
### 1.3.3 问题一的模型建立
将问题一中的几何模型进行抽象构造如图2所示。图2中船的行进方向垂直纸面向外,船的位置为$D$点。$DF$为重力方向竖直向下,而$AB$是水平面。$D$点发射出两条波束$DA$和$DE$交海底平面于$A$、$E$两点。由于$DF$竖直向下,结合问题背景可知$DF$为$\angle ADE$的角平分线。过$A$作$AC$,这是海底平面,$\angle CAB=1.5^{\circ}$。整体如图7所示:

图7 单一探测船的示意图
在图7中显然,由于$DF$竖直向下而$AB$为水平面,故$\angle DAB+\angle ADF=90^{\circ}$,$\angle CAB=1.5^{\circ}$,$\angle ADF$为开角的一半也就是$60^{\circ}$,故有:
$$
\angle DAF = 90^{\circ} - \angle CAB - \angle ADF. \tag{1.3.1}
$$
这可以推算出$\angle DAF$为$28.5^\circ$,由三角形的内角和可知$\angle DEA$为$31.5^{\circ}$。已知$DF$长度为$70\text{ m}$,且知道对角角度,可以在$\Delta DAF$和$\Delta DFE$中分别使用正弦定理:
$$
\frac{DF}{\sin 28.5^{\circ}} = \frac{AF}{\sin 60^{\circ}}, \quad \frac{DF}{\sin 31.5^{\circ}} = \frac{EF}{\sin 60^{\circ}}. \tag{1.3.2}
$$
所以待求带宽的长度也就是$AF+EF$。经过代换,可以解得在已知海底深度$D$的条件下,覆盖宽度的计算方法为:
$$
W = D \left( \frac{\sin 60^{\circ}}{\sin 28.5^{\circ}} + \frac{\sin 60^{\circ}}{\sin 31.5^{\circ}} \right). \tag{1.3.3}
$$
为了求解相邻测线的覆盖率,考虑两条相邻测线的情况,如图8所示:

图8 多艘探测船的示意图
从图8中可以看到,$G$点在$D$点左侧$d$米处。且由于船在水面上行走,$GD // AB$。那么同样地,由于$GJ$同样竖直向下,所以$GH//DF$, $GI//DF$,$\Delta GHI$和$\Delta DFE$是相似的。对于$G$点和$D$点而言,波束重叠部分的重叠覆盖宽度$\lambda=EH$。若过$J$作$AB$平行线,并延长$DK$相交于$P$,则二者形成的$\Delta JKP$中$\angle KJP=1.5^{\circ}$且$JP=d$。那么$EH$的长度显然有下面的关系:
$$
EH = JH + EK - JK. \tag{1.3.4}
$$
根据前面通过正弦定理得到的推论,有:
$$
\begin{align}
JH &= \frac{GJ}{\sin 31.5^{\circ}} \cdot \sin 60^{\circ},\tag{1.3.5}\\
EK &= \frac{DK}{\sin 28.5^{\circ}} \cdot \sin 60^{\circ}, \tag{1.3.6}\\
JK &= \frac{d}{\cos 1.5^{\circ}}, \tag{1.3.7}\\
GJ &= DK + d\tan 1.5^{\circ}. \tag{1.3.8}
\end{align}
$$
通过对上述表达式的求解,可以得到重叠部分的宽度为:
$$
\begin{aligned}
\lambda &= (DK + d\tan 1.5^{\circ})\cdot \frac{\sin 60^{\circ}}{\sin 31.5^{\circ}} + DK \cdot \frac{\sin 60^{\circ}}{\sin 28.5^{\circ}} - \frac{d}{\cos 1.5^{\circ}}\\
&= DK \cdot \left( \frac{\sin 60^{\circ}}{\sin 31.5^{\circ}} + \frac{\sin 60^{\circ}}{\sin 28.5^{\circ}} \right) + d \cdot \left( \frac{\sin 60^{\circ} \tan 1.5^{\circ}}{\sin 31.5^{\circ}} - \frac{1}{\cos 1.5^{\circ}} \right)
\end{aligned}\tag{1.3.9}
$$
利用公式可以解得若测线距离中心位置左侧$d\text{ m}$或右侧$d\text{ m}$对应的覆盖宽度。而对应测线的海底深度为$GJ$,同样给出了对应海底深度的测算方法。而若知道对应海底深度,由于$\Delta GHI$与$\Delta DFE$相似,可以利用相似求对应位置的覆盖宽度:
$$
\frac{W'}{D + d \tan\alpha} = \frac{W}{D}. \tag{1.3.10}
$$
经过一系列变换,可以达成对问题一的求解。
### 1.3.3 问题一的模型求解与讨论
通过建模与编程求解,可以得到计算结果如表2所示:
表2 问题一的解答
| $d/\text{m}$ | $-800$ | $-600$ | $-400$ | $-200$ | $0$ | $200$ | $400$ | $600$ | $800$ |
| -------------- | -------- | -------- | --------- | -------- | ------ | ------- | --------- | --------- | --------- |
| $D/\mathrm{m}$ | $90.94$ | $85.711$ | $80.474$ | $75.237$ | $70$ | $64.76$ | $59.526$ | $54.289$ | $49.051$ |
| $W/\mathrm{m}$ | $315.81$ | $297.62$ | $279.441$ | $261.25$ | $243$ | $224.8$ | $206.698$ | $188.513$ | $170.328$ |
| $\eta/\%$ | $—$ | $0.3569$ | $0.3151$ | $0.267$ | $0.21$ | $0.148$ | $0.0740$ | $0.0153$ | $0.12365$ |
在表2中,距离是自变量,表示观测点到某个参考点的距离,它在整个数据集中从-800m到800m变化。随着距离的增加,海水深度逐渐减小。这可能与沿着某一方向移动远离海岸线导致海水深度减小有关。随着距离的增加,覆盖宽度逐渐减小。这可能是由于覆盖物在距离远离某一基准点时逐渐减小,也与相似形有关。覆盖率与距离和海水深度相关,并且在大约向右偏移600m左右时出现最小值。经探究,二者呈现典型的凸函数关系,当D=563m附近时覆盖率取得最小。
**问题一的Python实现**
该脚本计算从海底反射的超声波束的覆盖宽度,考虑各种线路测量的海底坡度和深度。
```python
import numpy as np
from scipy.optimize import fsolve
# 常量定义
theta = 2 * np.pi / 3 # 全开角
alpha = 1.5 / 180 * np.pi # 海底坡度
htheta = theta / 2 # 半开角
h = 70 # 中心点的海水深度
d = 200 # 测线距离
k = np.tan(np.pi / 2 - htheta) # 超声波直线的斜率
k0 = np.tan(alpha) # 海底斜率
# 初始化
Aleft = [] # 左端点坐标
Aright = [] # 右端点坐标
Acenter = [] # 中心点坐标
W = [] # 覆盖宽度
# 求解交点
for n in range(-4, 5):
leftsolve = lambda t: k * (t - n * d) - k0 * t + h
rightsolve = lambda t: -k * (t - n * d) - k0 * t + h
tleft = fsolve(leftsolve, 0)
tright = fsolve(rightsolve, 0)
Aleft.append([tleft[0], k0 * tleft[0] - h])
Aright.append([tright[0], k0 * tright[0] - h])
Acenter.append([200 * n, k0 * 200 * n - h])
Aleft = np.array(Aleft)
Aright = np.array(Aright)
Acenter = np.array(Acenter)
D = Acenter[:, 1] # 海水深度
W = np.sqrt((Aleft[:, 0] - Aright[:, 0]) ** 2 + (Aleft[:, 1] - Aright[:, 1]) ** 2) # 覆盖宽度
# 计算重合部分
cover = np.zeros(8)
for i in range(8):
cover[i] = np.sqrt((Aright[i, 0] - Aleft[i + 1, 0]) ** 2 + (Aright[i, 1] - Aleft[i + 1, 1]) ** 2)
# 判断是否有重叠,如果没有,则重叠率为负值
if Aright[i, 0] - Aleft[i + 1, 0] < 0:
cover[i] = -cover[i]
eta = cover / W[1:]
# 打印结果
print("海水深度 D:", D)
# 海水深度 D: [-90.94873726 -85.71155294 -80.47436863 -75.23718431 -70.
# -64.76281569 -59.52563137 -54.28844706 -49.05126274]
print("覆盖宽度 W:", W)
# 覆盖宽度 W: [315.81332829 297.62756059 279.44179288 261.25602517 243.07025746
# 224.88448975 206.69872205 188.51295434 170.32718663]
print("重合部分比例 eta:", eta)
# 重合部分比例 eta: [0.35695443 0.31510572 0.26743092 0.21262236 0.14894938 0.07407224
# 0.01525164 0.12364966]
```
## 1.4 立体几何模型的构建
背景同上,问题如下:问题 2 考虑一个矩形待测海域(图 9),测线方向与海底坡面的法向在水平面上投影的夹角为 𝛽,请建立多波束测深覆盖宽度的数学模型。

图9 问题二的示意图
若多波束换能器的开角为$120^{\circ}$,坡度为$1.5^{\circ}$,海域中心点处的海水深度为 $120 \text{ m}$,利用上述模型计算表 2 中所列位置多波束测深的覆盖宽度,将结果以表 2 的格式放在正文中,同时保存到 `result2.xlsx` 文件中。
表2 问题二的计算结果

### 1.4.1问题二的分析
问题二是问题一的延续。第二个问题将场景从二维向三维扩展,在三维空间内利用正弦定理求解投影长度。可以通过做辅助平面的方式构造四棱锥,利用三面角定理求解。通过坡度和倾斜角可以解得测线与平面夹角关系,再通过波束的最大开角$120^{\circ}$可以得到直线与平面夹角,从而变换模型得到四棱锥的几何构型。通过解方程的方法求最优解。
### 1.4.2问题二模型的建立
对于第二问,问题变成了一个立体几何问题,如图10所示。

图10 问题二的三维示意图
图10中$KJ$为测线。倾斜角$\beta$为测线与坡面法向投影的夹角,平面$ABC$是垂直于直线$KJ$的(线面垂直),$CD$为垂面与海底形成的交线,也就是所需要求解的长度。平面$BEC$是平行于水平面,也平行于直线$JK$的。同时$CE$垂直于平面$ABE$,且$CE//FH$。那么很显然,直线$KJ$与直线$BC$构成异面垂直,角度为$\beta$。并且,$AE$垂直于$CE$,$AB$垂直于$BE$,$AB$垂直于$BC$,构成一个典型的四面体几何模型。
在图所示的情况中,$\beta$是一个钝角。如果过$B$作$KJ$在平面$BEC$中的投影$BK’$,$\angle EBK’$的角度为$\beta$,补角为$180-\beta$。那么很显然,由于$BK’$垂直于$BC$,$BE$垂直于$CE$,那么$\angle BCE$与$\angle CBE$互余,$\angle CBE$和$180-\beta$互余,也就是说$\angle BCE$的角度也就是$180-\beta$。
注意到$\displaystyle \cos\angle ACB = \frac{CB}{AC}$, $\displaystyle \cos\angle BCE=\frac{CE}{BC}$, $\displaystyle cos\angle ACE=\frac{CE}{AC}$,这实际上就构成了三面角公式:
$$
\cos \angle ACB \cdot \cos\angle BCE = \cos \angle ACE. \tag{1.4.1}
$$
因为$\angle ACB=30^{\circ}$,$\angle BCE=45^{\circ}$,所以$\angle ACE$是可以求解的,$\angle EAC$也是可以求解的(二者互余)。
然后另外再使用一次三面角公式,$\displaystyle \cos\angle EAC=\frac{EA}{AC}$, $\displaystyle \cos\angle EAB=\frac{AB}{EA}$, $\displaystyle \cos\angle BAC=\frac{AB}{AC}$,所以可以得到:
$$
\cos \angle EAC \cdot \cos\angle EAB = \cos \angle BAC. \tag{1.4.2}
$$
故$\angle EAB$也是可以求出来的。而$\angle DEB$为坡面角$1.5^{\circ}$,同样也就能求出$\angle ABD$。若在$\Delta ABD$中使用正弦定理,因为已知$AD$和对角,则$AE$的长满足方程:
$$
\frac{AE}{\sin\angle ADE} = \frac{d}{\sin\angle AED}. \tag{1.4.3}
$$
同时,由于前面求解过$\angle EAC$,即可求$AC$的长。因为$EC$垂直于平面$ABE$,所以$\Delta EAC$构成一个直角三角形。$AC$长度满足:
$$
EA = EC \cos \angle CAE. \tag{1.4.4}
$$
另外,在$\Delta CAD$中知道了$AC$、$AD$的长和夹角($60^{\circ}$),就可以使用余弦定理求解$CD$的值:
$$
CD^{2} = AC^{2} + AD^{2} - 2 \cdot AC \cdot AD \cdot \cos60 ^{\circ}.\tag{1.4.5}
$$
同理,对于另一条波束射线,分析方法同上。只需要将$\angle BCE$的角度从$180-\beta$变为$\beta$即可求解。所以带宽$DC+DC’$可以求解。
### 1.4.2问题二模型的求解与讨论
通过建模与编程求解,可以得到计算结果如表3所示:
表3 问题二的解答

从表3中可以看出如下规律:
1. **测量船距海域中心点处的距离与覆盖宽度之间的关系**: 从数据可以看出,随着测量船距海域中心点处的距离增加,覆盖宽度似乎也在增加。这可能表示在不同距离下,测量覆盖区域的宽度不同,这是一个直观的趋势。
2. **测量船距海域中心点处的距离与测线方向夹角之间的关系**: 表格中的数据似乎显示了不同距离下的测线方向夹角的变化。这可能表示在不同距离下,测量船需要采取不同的测线方向。这也是一个趋势。
3. **部分数据存在对称性**: 通过观察,可以看到表格的一部分数据在特定角度(如90度)下具有对称性,即某些角度下的数据在正负方向上相等。这可能反映了某种对称性或规律。
**问题二的Python实现**
```python
import numpy as np
from scipy.optimize import fsolve
# 常量定义
theta = 2 * np.pi / 3 # 全开角
alpha = -1.5 / 180 * np.pi # 坡度
htheta = theta / 2 # 半开角
h = 120 # 正中心的海水深度
unit = 1852 # 一海里等于1852米
k0 = np.tan(alpha) # 海底坡面直线的斜率
# 初始化覆盖宽度矩阵
W = np.zeros((8, 8))
# 计算不同角度和位置的声呐波束的覆盖宽度
for i in range(1, 9):
for j in range(1, 9):
beta = (i - 1) * np.pi / 4
d = (j - 1) * 0.3 * unit # 换算为米
v = np.array([np.cos(beta), np.sin(beta), 0]) # 直线的法向量
origin = v * d # 发射位置
# 波束的方向向量
v1 = np.array([-np.sin(beta) * np.sin(htheta), np.cos(beta) * np.sin(htheta), -np.cos(htheta)])
v2 = np.array([np.sin(beta) * np.sin(htheta), -np.cos(beta) * np.sin(htheta), -np.cos(htheta)])
# 用于找到波束与海底交点的函数
leftsolve = lambda t: (v1[0] * t + origin[0]) * k0 - h - (v1[2] * t + origin[2])
rightsolve = lambda t: (v2[0] * t + origin[0]) * k0 - h - (v2[2] * t + origin[2])
# 解方程找到交点
tleft = fsolve(leftsolve, 0)
tright = fsolve(rightsolve, 0)
# 计算左右交点的坐标
pleft = v1 * tleft + origin
pright = v2 * tright + origin
# 计算并存储覆盖宽度
W[i-1, j-1] = np.linalg.norm(pleft - pright)
# 打印覆盖宽度矩阵
print("覆盖宽度矩阵 W:\n", W)
# 覆盖宽度矩阵 W:
# [[415.69219382 466.09105496 516.4899161 566.88877725 617.28763839
# 667.68649953 718.08536067 768.48422182]
# [416.19152335 451.87170745 487.55189155 523.23207565 558.91225975
# 594.59244385 630.27262795 665.95281205]
# [416.69186994 416.69186994 416.69186994 416.69186994 416.69186994
# 416.69186994 416.69186994 416.69186994]
# [416.19152335 380.51133925 344.83115515 309.15097105 273.47078694
# 237.79060284 202.11041874 166.43023464]
# [415.69219382 365.29333267 314.89447153 264.49561039 214.09674925
# 163.6978881 113.29902696 62.90016582]
# [416.19152335 380.51133925 344.83115515 309.15097105 273.47078694
# 237.79060284 202.11041874 166.43023464]
# [416.69186994 416.69186994 416.69186994 416.69186994 416.69186994
# 416.69186994 416.69186994 416.69186994]
# [416.19152335 451.87170745 487.55189155 523.23207565 558.91225975
# 594.59244385 630.27262795 665.95281205]]
```
### 1.4.3 问题二的第二种解法
在问题中,以海面中央为原点,南北方向为$\mathrm{x}$轴,东西方向为$\mathrm{y}$轴,竖直向上为$\mathrm{z}$轴正方向构建三维直角坐标系如图11所示:

图11 问题二解法2的三维示意图
对于测线,若已知倾斜角,则测线的方向向量为:
$$
\boldsymbol{v} = [\cos\beta, \sin\beta, 0]^{\top}. \tag{1.4.6}
$$
而沿着测线行进的船只发射出的两道波束,因最大开角已知为$120^{\circ}$,可以求得左右两道波束的方向向量分别为:
$$
\begin{aligned}
\boldsymbol{v}_{1} &= \left[ -\cos \frac{\theta}{2} \sin\beta, \cos \frac{\theta}{2}\cos\beta, -\sin \frac{\alpha}{2} \right]^{\top}, \\
\boldsymbol{v}_{2} &= \left[ \cos \frac{\theta}{2} \sin\beta, -\cos \frac{\theta}{2}\cos\beta, -\sin \frac{\alpha}{2} \right]^{\top}.
\end{aligned}\tag{1.4.7}
$$
海底坡面在三维坐标系中的解析方程可以写作:
$$
z = -x\tan \alpha - h, \quad x \in [-1852, 1852],~y \in [-3704, 3704]. \tag{1.4.8}
$$
三维空间中的直线方程,是以方向向量为核心构造的。若以参数方程的形式写出测线的解析式,在测线通过点$P(x_{0},y_{0},0)$的情况下,有下面一组等式成立:
$$
\left\{
\begin{align}
x &= t\cos\beta + x_{0},\\
y &= t\sin\beta + y_{0},\\
z &= z_{0}.
\end{align}
\right.\tag{1.4.9}
$$
同样的,在通过$P$点的条件下,从$P$点发射出两条测线的参数方程分别为
左测线:
$$
\left\{
\begin{align}
x &= -t\cos \frac{\theta}{2}\sin\beta + x_{0},\\
y &= t\cos \frac{\theta}{2}\cos\beta + y_{0},\\
z &= -t\sin \frac{\theta}{2} + z_{0};
\end{align}
\right.\tag{1.4.10}
$$
右测线:
$$
\left\{
\begin{align}
x &= t\cos \frac{\theta}{2}\sin\beta + x_{0},\\
y &= -t\cos \frac{\theta}{2}\cos\beta + y_{0},\\
z &= -t\sin \frac{\theta}{2} + z_{0};
\end{align}
\right.\tag{1.4.10}
$$
将左测线和右测线与平面方程联立,即可求得船只在测线上$P$点处发射波束的落点坐标。而对于每一条测线,它与通过原点的测线都平行,且相邻两条测线的距离为$d$。根据距离条件,可以计算出相邻测线必定通过的一个点。例如,对于通过原点的测线而言,其右侧测线上必定存在一个点$P$,使得$OP=d$的同时$OP$垂直于测线。可以解得这个$P$点坐标为:
$$
P = [-d\sin\beta, d\cos\beta, 0]^{\top}. \tag{1.4.12}
$$
联立上述条件,可以求解出对于每一条刻线$l_{i}$,当船只P在测线上移动时形成的落点坐标集合$\{w_{i,1}\}$和$\{w_{i,2}\}$,进而可以求解所有落点形成的长度$L$。
## 1.5 使用Python解方程与方程组
在科学计算和工程应用中,经常会遇到需要求解方程或方程组的问题。Python提供了强大的数学库,如Numpy和Sympy,可以帮助我们轻松地解决这些问题。
### 1.5.1 利用Numpy求线性方程(组)的数值解
Numpy是Python中一个用于数值计算的库,它提供了很多用于矩阵运算的功能。我们可以使用Numpy中的linalg.solve函数来解线性方程组。例如,我们有以下方程组:
$$
\begin{align}
10x - y - 2z &= 72,\\
-x + 10y - 2z &= 83,\\
-x - y + 5z &= 42.
\end{align}\tag{1.5.1}
$$
我们可以使用以下代码来求解这个方程组:
```python
import numpy as np
a = np.array([[10, -1, -2], [-1, 10, -2], [-1, -1, 5]])
b = np.array([[72], [83], [42]])
c = np.linalg.solve(a, b)
print(c)
#[[11.]
# [12.]
# [13.]]
此外,我们还可以使用矩阵的逆来求解方程组,即:
x = np.linalg.inv(a).dot(b)
print(x)
# [[11.]
# [12.]
# [13.]]
```
### 1.5.2 利用Sympy求方程(组)的解析解
Sympy是Python中一个用于符号计算的库,它可以提供方程的解析解,而不仅仅是数值解。什么是解析解和数值解呢?简单来说,解析解是指用数学符号(如$x$、$y$、$\pi$等)表示的解,而数值解是指用具体的数字表示的解。
在Sympy中,我们首先需要创建符号变量,然后使用 `solve` 函数来求解方程或方程组。例如:
```python
from sympy import symbols, solve, nonlinsolve
x, y = symbols('x y')
print(solve(x * 2 - 2, x)) # 解方程2x - 2 = 0
# [1]
print(solve([x + y - 35, x * 2 + y * 4 - 94], x, y)) # 解方程组x + y = 35, 2x + 4y = 94
# {x:23, y:12}
print(solve(x**2 + x - 20, x)) # 解方程x^2 + x - 20 = 0
# [-5, 4]
a, b, c, d = symbols('a b c d', real=True)
print(nonlinsolve([a**2 + a + b, a - b], [a, b])) # 解非线性方程组a^2 + a + b = 0, a - b = 0
# {(-2, -2), (0, 0)}
```
以下是一个具体案例:

图12 平面型Stewart平台示意图
图12给出的是平面型Stewart平台示意图,它模拟一个操作装置,其中包括一个三角形($ABC$)平台,平台位于一个由3个支柱($P_{1}$, $P_{2}$和$P_{3}$)控制的固定平面中。图中的三角形($ABC$)表示平面型Stewart平台,它的尺寸由3个长度$L_{1}$,$L_{2}$,$L_{3}$确定。平台的位置由$3$个支柱的可变长度的$3$个参数$P_{1}$,$P_{2}$,$P_{3}$所控制。需要解决的问题是,在给定一组参数$P_{1}$,$P_{2}$,$P_{3}$的值后,计算出$A$点的坐标$(x, y)$和角度$\theta$的值($\theta$是$L_{3}$与$x$轴的夹角)。请你完成:
1. 数学建模:参数$L_{1}, L_{2}, L_{3}, x_{1}, x_{2}, y_{2}$是固定常数,在给定一组参数$P_{1}, P_{2}, P_{3}$的值后,判断能否得到Stewart平台的一个位置,即能否得到$A$点坐标$(x, y)$和角度$\theta$的值。如果能,则称它为Stewart平台的一个**位姿**。但位姿并不一定是唯一的,如何让你的模型能够计算出一组固定参数下的全部位姿。
2. 模型检验:假设有如下参数: $x_{1}=5$, $(x_{2}, y_{2})=(0,6)$,$L_{1}=L_{3}=3$, $L_{2}=3$, $p_{1}=p_{2}=5$, $p_{3}=3$,请根据你的模型,计算出Stewart平台的全部位姿,即计算出每个Stewart平台中的$A$点坐标$(x, y)$和角度$\theta$的值。
**定义问题的几何关系**
$A$点的坐标:由于$A$点位于支柱$P_{1}$的顶端,我们可以使用距离公式确定$A$点的坐标。这是一个圆的方程,表示所有与原点距离为$P_{1}$的点集合。方程如下:
$$
x^{2} + y^{2} = P_{1}^{2}. \tag{1.5.2}
$$
角度$\beta$的计算:角度是$\Delta ABC$的内角,根据余弦定理,我们有:
$$
\cos \beta = \frac{L_{2}^{2} + L_{3}^{2} - L_{1}^{2}}{2L_{2}L_{3}}. \tag{1.5.3}
$$
$B$点的坐标:$B$点相对于$A$点的位置可以用角度$\theta$表示,根据$A$点的坐标和角度$\theta$,我们可以写出$B$点的坐标
$$
B(x_{B}, y_{B}) = \Big(x + L^{3}\cos \theta, y + L^{3} \sin \theta\Big). \tag{1.5.4}
$$
$C$点的坐标:同样地,$C$点相对于$A$点的位置可以用角度$\beta + \theta$来确定,于是$C$点的坐标为:
$$
C(x_{C}, y_{C}) = \Big(x + L^{2}\cos(\beta + \theta), y + L^{2}\sin(\beta + \theta)\Big)
$$
**建立方程组**
$B$点与$P_{2}$的距离关系:由于$B$点还位于支柱的顶端,所以我们有第二个方程:
$$
(x_{B} - x_{1})^{2} + y_{B}^{2} = P_{2}^{2}.\tag{1.5.6}
$$
$C$点与$P_{3}$的距离关系:同理,$C$点位于支柱的顶端,所以我们有第三个方程:
$$
(x_{C} - x_{2})^{2} + (y_{C} - y_{2})^{2} = P_{3}^{2}. \tag{1.5.7}$$
**解方程组以确定$A$点坐标和角度$\theta$**
* 方程求解:我们现在有三个方程和两个未知数 $x$ 和 $y$。为了求解 $x$ 和 $y$,我们可以用任何一个方程消去 后解一个二元一次方程组。
* 多解的情况:这组方程可能有多个解,对应于不同的物理位置和Stewart平台的不同位姿。
* 数值求解:在实践中,通常需要通过数值方法来解这类方程组,例如使用牛顿法或者数值优化算法。
* 模型检验:给定的参数 $x_{1}=5$, $(x_{2}, y_{2})=(0,6)$,$L_{1}=L_{3}=3$, $L_{2}=3$, $p_{1}=p_{2}=5$, $p_{3}=3$ 可以代入上述方程组中进行求解。这将验证模型的正确性,并给出所有可能的Stewart平台位姿。
通过上述步骤,我们不仅可以找到$A$点的坐标和角度$\theta$ ,而且还可以确定Stewart平台的多个可能位姿,这些位姿对应于不同的解集。这是一个典型的运动学问题,其解决方案涉及几何、三角函数以及数值计算。
### 1.5.3 利用Scipy求方程(组)的数值解
在进行数值求解时,`fsolve` 是 Scipy 库中用于解决非线性方程组的一个非常有用的函数。它通常用于查找方程组的根,其中方程组可以是非线性的,并且不保证有解析解。接下来我们将会对1.5.2小节例题进行求解。
首先,尝试使用 Sympy 库解方程组时,我们发现问题无法解决。这是因为所面临的方程组可能没有简洁的解析解,或者是解析解超出了Sympy库的计算范围。以下是尝试解方程的代码和得到的结果:
```python
from sympy import symbols, cos, sin, pi, nonlinsolve
import numpy as np
x, y, theta = symbols('x y theta', real=True)
L1, L2, L3 = 3, 3, 3
p1, p2, p3 = 5, 5, 3
x1, x2, y2 = 5, 0, 6
# 计算内角β
b = np.arccos((L2**2 + L3**2 - L1**2) / (2 * L2 * L3))
print(b)
# 尝试解方程组
solution = nonlinsolve([
(x + L3 * cos(theta) - x1)**2 + (y + L3 * sin(theta))**2 - p1**2,
x**2 + y**2 - p2**2,
(x + L2 * cos(pi/3 + theta))**2 + (y + L2 * sin(pi/3 + theta) - y2)**2 - p3**2
], [x, y, theta])
print(solution)
# 1.0471975511965979
```
得到的输出表明,我们没有找到方程组的解析解。在这种情况下,我们转向数值解法,特别是Scipy库中的`fsolve`函数,来找到方程组的数值解。以下是使用`fsolve`的案例:
```python
from scipy.optimize import fsolve
from math import sin, cos, pi
# 定义方程组
def equations(vars):
x, y, theta = vars
L1, L2, L3 = 3, 3, 3
p1, p2, p3 = 5, 5, 3
x1, x2, y2 = 5, 0, 6
# 根据问题描述定义的方程
eq1 = (x + L3*cos(theta) - x1)**2 + (y + L3*sin(theta))**2 - p2**2
eq2 = x**2 + y**2 - p1**2
eq3 = (x + L2*cos(pi/3 + theta))**2 + (y + L2*sin(pi/3 + theta) - y2)**2 - p3**2
return [eq1, eq2, eq3]
# 初始猜测值
initial_guess = [-1.37, 4.80, 0.12]
# 使用fsolve求解方程组
result = fsolve(equations, initial_guess)
print(result)
# [1.15769945 4.86412705 0.02143414]
```
执行以上代码,我们得到了方程组的一组数值解。
上面的代码中,`equations` 函数定义了一个方程组,它接受一个变量列表(在这里是 `x`, `y`, 和 `theta`),然后返回一个方程组列表。然后,我们使用 `fsolve` 来求解这个方程组,并且提供了一个初始猜测值列表 `initial_guess`。`fsolve` 会尝试找到这些方程的根,这意味着它会寻找满足方程组为零的 `x`, `y`, 和 `theta` 的值。
在 Scipy 库中,除了 `fsolve`,还有其他几个函数可以用于解决类似的问题,比如:
* `brentq` 或 `bisection`:这些函数是用于求解单变量方程的根的,适用于在指定区间内具有一个根的情况。
* `root`:这个函数提供了一个更加通用的接口来求解多变量方程组的根,它允许选择不同的算法,比如 `hybr`, `lm`, `broyden1`, 等等。
* `newton`:用于求解单变量方程的根,当你有方程的导数信息时特别有用。
对于复杂的方程组,尤其是当没有解析解时,使用数值方法通常是解决问题的可行方式。**在使用数值方法时,很重要的一点是要有一个合理的初始猜测,因为这些方法很大程度上依赖于起始点,并且可能收敛到局部解,或者在某些情况下可能根本不收敛。**
## 本章小结
本章是数学建模的一个导引章节。在这一章节中,我们一起探索了几何模型在数学建模中的基础应用和实际操作。从向量表示法的初步了解到复杂立体几何模型的构建,再到运用Python编程语言中的 Numpy、Sympy、Scipy 等库求解方程和方程组,我们一步步深入了解了几何模型的精髓。
我们首先回顾了几何建模的基本思想,并探讨了向量表示和坐标变换的重要性。这不仅巩固了我们的几何基础知识,也为后续的学习打下了坚实的基础。接着,通过Numpy的强大功能,我们学习了线性代数中向量与矩阵的操作,以及如何运用Numpy.linalg库来进行基本的线性代数运算。
随后,我们针对平面几何模型和立体几何模型分别进行了深入的分析和建模。在每个问题中,我们不仅建立了数学模型,还通过模型的求解与讨论,实践了数学建模的全过程,这无疑增强了我们解决实际问题的能力。
在使用Python解方程与方程组的部分,我们学习了如何利用不同的Python库来解决具体的数学问题。这些内容不仅仅是理论上的,更多的是实践操作,让我们在解决实际问题时更加得心应手。
作为本章的结束,希望大家不仅理解了几何模型的构建方法,而且能够领会背后的数学原理,并将这些知识应用到现实生活中去。在接下来的章节中,我们将进一步学习更多高级的数学建模技巧和方法。请大家继续保持好奇心和探索精神,相信在数学建模的道路上会有更多的发现和成就。
祝愿各位读者在学习的旅途中收获知识,享受创造的乐趣,不断提升自己的问题解决能力。在这个以数据和模型驱动的时代,让我们共同成长为理解世界的数学建模者。
================================================
FILE: docs/CH10/第10章-图像、文本与信号数据.md
================================================
第10章 多模数据与智能模型
> 内容:@若冰(马世拓)
>
> 审稿:@陈思州
>
> 排版&校对:@陈思州
随着我们进入本书的第十章,我们将探讨多模数据与智能模型的前沿技术。在这一章中,我们将深入了解数字图像处理、计算语言学、数字信号处理等领域,以及它们在人工智能中的应用。这些技术不仅是现代科技进步的基石,也是处理复杂数据分析问题的关键。本章主要涉及到的知识点有:
- 数字图像处理与计算机视觉
- 计算语言学与自然语言处理
- 数字信号处理与智能感知
注意:除了常用的Python之外,还有其他多种软件和工具,如Matlab等,都可用于这些任务的高效处理。
## 10.1 数字图像处理与计算机视觉
视觉信息是我们第一种非常规的数据模式,在Python当中可以使用opencv处理数字图像,并提取出视觉特征用于数学建模。
### **10.1.1 数字图像的表示与处理**
计算机可以存储数字图像,可以展示数字图像,也可以对图像进行分析与处理。但一张图是怎样放到计算机中的呢?其实本质上还是依靠线性代数这个工具。计算机把数字图像看作一个很大的矩阵,把图像中每个像素看作矩阵的一个元素,用矩阵元素的值表示图像对应位置的像素点明暗程度以及颜色情况。
我们可以通过opencv来实现图像处理的Python编程。OpenCV是一个开源的计算机视觉库。它包含了大量图像和视频处理的通用算法,比如图像滤波、特征检测、目标跟踪、人脸识别等。对于想要做图像处理或计算机视觉研究、开发的人来说,OpenCV就像一个强大的工具箱,能帮助他们更轻松、高效地实现各种复杂的视觉任务。无论你是做科学研究、商业应用还是个人项目,OpenCV都能为你提供很大的帮助。例如,读取项目中的一张图并展示,可以用如下代码表示:
```python
# These imports let you use opencv
import cv2 #opencv itself
import common #some useful opencv functions
import numpy as np # matrix manipulations
# the following are to do with this interactive notebook code
%matplotlib inline
from matplotlib import pyplot as plt # this lets you draw inline pictures in the notebooks
import pylab # this allows you to control figure size
pylab.rcParams['figure.figsize'] = (10.0, 8.0) # this controls figure size in the notebook
input_image=cv2.imread('noidea.jpg')
input_image=cv2.imread('noidea.jpg')
```
就可以看到这张图片的状态:

图10.1 彩色图片状态展示
对于一张黑白的图像,它可以表示为一个M*N的矩阵。而对于一张彩色图像,就不是一个矩阵这么简单了。对于彩色图像而言,主要有RGB和HSV两种表示方法。RGB中彩色图像被分为三个通道,因为在物理学中所有的色彩都可以用红、绿、蓝三种颜色按不同比例组合而成。因此,对于彩色图像中,每个像素点的值也是一个三维数组,每一项表示对应通道的亮度值。例如,对于上述图像而言,我们可以看看它的尺寸:
```python
print(input_image.shape)
结果为(414,625,3),表明图像的大小为414*625,有三个通道。在这张图里面,有b,g,r三个通道。可以将它作分解并重新组合,我们来看看一个通道究竟长什么样子:
# split channels
b,g,r=cv2.split(input_image)
plt.imshow(r, cmap='gray')
merged=cv2.merge([r,g,b])
# merge takes an array of single channel matrices
plt.imshow(merged)
```

图10.2 黑白彩色图片对比图
可以看到,每个通道的图都可以当做一张单独的黑白图像,但经过rgb重排以后的图终于可以显示为正常的彩色。这一操作也可以用下面的代码实现:
```python
opencv_merged=cv2.cvtColor(input_image, cv2.COLOR_BGR2RGB)
plt.imshow(opencv_merged)
```
HSV表示法是一种描述颜色的方式,其中H代表色调(Hue),S代表饱和度(Saturation),V代表明度(Value)。这种颜色空间与人类对颜色的感知方式更为接近,因此,在图像处理中,HSV空间经常用于颜色分割、颜色识别等任务。
色调是颜色的一种属性,它表示的是纯色的类型,比如红色、蓝色或黄色等。在HSV颜色空间中,色调以角度来表示,从0到360度。比如0(或360)度表示红色,120度表示绿色,240度表示蓝色。饱和度表示颜色的纯度或强度。一个颜色的饱和度越高,它就越鲜艳;饱和度越低,颜色就越接近灰色。在HSV中,饱和度是一个百分比值,从0%(灰色)到100%(完全饱和)。明度表示颜色的亮度或明暗程度。它与颜色的强度或发光量有关。在HSV中,明度也是一个百分比值,从0%(黑色)到100%(白色)。
RGB是一种加色模型,它是通过红、绿、蓝三种颜色的组合来表示颜色的。每种颜色的强度都通过一个0-255的数值来表示。RGB模型在显示设备上使用得很广泛,但它不太直观地表示人类对颜色的感知,尤其是色调。而HSV则是一种更接近人类感知的颜色空间,更适合用于颜色分割等任务。
在OpenCV中,你可以使用cvtColor函数将图像从RGB空间转换到HSV空间。以下是一个简单的示例:
```python
# 读取图像
image = cv2.imread('path_to_your_image.jpg')
# 将图像从RGB转换到HSV
hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
# 在HSV空间中设置一个颜色范围(比如蓝色)
lower_blue = np.array([110, 50, 50])
upper_blue = np.array([130, 255, 255])
# 创建一个掩码,只保留在指定颜色范围内的像素
mask = cv2.inRange(hsv_image, lower_blue, upper_blue)
# 使用掩码对原图像进行颜色分割
result = cv2.bitwise_and(image, image, mask=mask)
# 显示结果
cv2.imshow('Original Image', image)
cv2.imshow('Mask', mask)
cv2.imshow('Result', result)
cv2.waitKey(0)
cv2.destroyAllWindows()
```
这段代码首先读取一个图像,然后将其从RGB空间转换到HSV空间。接着,它定义了一个蓝色的HSV范围,并创建了一个掩码,只保留在这个范围内的像素。最后,它使用这个掩码对原图像进行颜色分割,并显示结果。
使用Opencv实现图像的裁剪可以通过数组索引的方式实现,例如,可以通过下面的代码切分图像中的某个部分:
```python
dogface = input_image[60:250, 70:350]
plt.imshow(dogface)
```
我们还可以把切分出来的部分粘贴到原图上去:
```python
fresh_image=cv2.imread('noidea.jpg')
fresh_image[200:200+dogface.shape[0], 200:200+dogface.shape[1]]=dogface
print(dogface.shape[0])
print(dogface.shape[1])
plt.imshow(fresh_image)
如果想要实现翻转,可以使用flip函数。通过axis参数控制是水平翻转还是竖直翻转:
flipped_code_0=cv2.flip(input_image,0) # vertical flip
plt.imshow(flipped_code_0)
和numpy.array类似,opencv也提供了数组转置的transpose函数,这被用于翻转行列并生成镜像效果:
transposed=cv2.transpose(input_image)
plt.imshow(transposed)
```
高斯平滑(Gaussian Blurring)是图像处理中常用的一种技术,用于减少图像噪声和细节层次。它通过对图像中的每一个像素点,取其本身和邻域内的其他像素点的加权平均值来实现平滑效果。权重由高斯函数计算得出,距离中心越远的像素点,权重越小。可以通过GaussianBlur实现高斯平滑的效果:
```python
d=5
img_blur5 = cv2.GaussianBlur(input_image, (2*d+1, 2*d+1), -1)[d:-d,d:-d]
plt.imshow(cv2.cvtColor(img_blur5, cv2.COLOR_BGR2RGB))
```
腐蚀操作是一种形态学操作,它用来消除图像中的小物体、断开连接在一起的物体等。腐蚀操作的基本思想是将结构元素在图像上滑动,如果结构元素下的所有像素都是前景像素(通常为白色),则该位置的中心像素被认为是前景像素,否则为背景像素。
```python
import cv2
import numpy as np
# 读取图像,转换为灰度图像
image = cv2.imread('path_to_your_image.jpg', 0)
# 定义结构元素(核),这里使用5x5的矩形核
kernel = np.ones((5, 5), np.uint8)
# 进行腐蚀操作
eroded_image = cv2.erode(image, kernel, iterations=1)
# 显示原图像和腐蚀后的图像
cv2.imshow('Original Image', image)
cv2.imshow('Eroded Image', eroded_image)
cv2.waitKey(0)
cv2.destroyAllWindows()
```
锐化操作是一种用于增强图像边缘和细节的技术。在OpenCV中,通常通过使用滤波器来实现锐化,比如拉普拉斯滤波器或者自定义的卷积核。
```python
import cv2
import numpy as np
# 读取图像
image = cv2.imread('path_to_your_image.jpg')
# 定义锐化卷积核
kernel = np.array([[-1,-1,-1],
[-1,9,-1],
[-1,-1,-1]])
# 应用卷积核进行锐化
sharpened_image = cv2.filter2D(image, -1, kernel)
# 显示原图像和锐化后的图像
cv2.imshow('Original Image', image)
cv2.imshow('Sharpened Image', sharpened_image)
cv2.waitKey(0)
cv2.destroyAllWindows()
```
### **10.1.2 数字图像的特征点**
特征点是数字图像处理中用于图像分析和处理的关键信息点,它们可以代表图像中的显著特征,如角点、边缘和其他结构。有效的特征点检测是很多计算机视觉任务的基础,包括图像匹配、物体识别和追踪等。
Sobel特征点主要基于Sobel算子,这是一种常用于边缘检测的算子。其核心思想是将图像分成水平和垂直两个方向,然后在每个方向上计算梯度并求和,最后将两个方向上的梯度幅值差作为边缘强度。Sobel特征点的计算方法主要是通过应用两个3x3的卷积核对图像进行卷积操作,分别计算图像在水平和垂直方向上的梯度变化,从而识别出图像中的边缘。其主要作用是用于边缘检测,能够突出图像中的边缘特征,有效减少数据量,保留图像的重要结构属性,为后续图像处理和分析提供基础。例如:
```python
sobelimage=cv2.cvtColor(input_image,cv2.COLOR_BGR2GRAY)
sobelx = cv2.Sobel(sobelimage,cv2.CV_64F,1,0,ksize=9)
sobely = cv2.Sobel(sobelimage,cv2.CV_64F,0,1,ksize=9)
plt.imshow(sobelx,cmap = 'gray')
```

图10.3 Sobel特征点图
Canny算子是由John F. Canny于1986年开发出来的一个多级边缘检测算法,被认为是最好的边缘检测算法之一。它采用了一系列步骤来提取图像中的边缘信息,包括噪声抑制、梯度计算、非极大值抑制、双阈值检测以及边缘连接。
首先,Canny算子利用高斯滤波器对输入图像进行平滑处理,以减少图像中的噪声。然后,通过对平滑后的图像应用Sobel或Prewitt等算子,计算每个像素点的梯度幅值和方向。接下来,非极大值抑制过程会在梯度图像上比较每个像素点在其梯度方向上的值,并保留局部最大值点,抑制非边缘像素。之后,Canny算子使用双阈值检测,根据设定的高阈值和低阈值,将梯度图像中的像素点分为强边缘、弱边缘和非边缘三个部分。最后,通过连接强边缘像素和与之相连的弱边缘像素,形成完整的边缘。
Canny算子相比其他边缘检测算法具有更高的准确性和更低的错误率,能够产生单一像素宽度的边缘响应,并尽量减少将噪声和细节等误判为边缘的情况。同时,Canny算子提出的基于边缘梯度方向的非极大值抑制和双阈值的滞后阈值处理,改进了传统算子如Sobel和Prewitt等的不足。
```python
th1=30
th2=60 # Canny recommends threshold 2 is 3 times threshold 1 - you could try experimenting with this...
d=3 # gaussian blur
edgeresult=input_image.copy()
edgeresult = cv2.GaussianBlur(edgeresult, (2*d+1, 2*d+1), -1)[d:-d,d:-d]
gray = cv2.cvtColor(edgeresult, cv2.COLOR_BGR2GRAY)
edge = cv2.Canny(gray, th1, th2)
edgeresult[edge != 0] = (0, 255, 0) # this takes pixels in edgeresult where edge non-zero colours them bright green
plt.imshow(cv2.cvtColor(edgeresult, cv2.COLOR_BGR2RGB))
```

图10.4 Harris角点图
Harris角点检测是一种计算机视觉中常用的角点检测算法,用于在图像中检测出角点特征。角点通常被定义为两条边的交点,或者说,角点的局部邻域应该具有两个不同区域的不同方向的边界。
Harris角点检测的原理主要基于图像的灰度变化和局部窗口的协方差矩阵。具体步骤包括:首先,将彩色图像转换为灰度图像;然后,根据灰度值计算每个像素的梯度,通常使用Sobel算子;接着,计算每个像素周围窗口内梯度的自相关矩阵;最后,根据自相关矩阵计算Harris响应函数,用于评估每个像素周围区域是否为角点。当Harris响应函数的值较大时,表示该像素点处存在角点。
Harris角点检测具有对图像旋转、尺度变化和亮度变化的不变性,且计算简单快速。它在计算机视觉领域中有广泛的应用,如目标跟踪、运动检测、视频剪辑、三维建模以及目标识别等。通过结合图像的多个角度,Harris角点检测可以恢复图像的完整形状,从而准确地识别和追踪目标。
```python
harris_test=input_image.copy()
#greyscale it
gray = cv2.cvtColor(harris_test,cv2.COLOR_BGR2GRAY)
gray = np.float32(gray)
blocksize=4 #
kernel_size=3 # sobel kernel: must be odd and fairly small
# run the harris corner detector
dst = cv2.cornerHarris(gray,blocksize,kernel_size,0.05) # parameters are blocksize, Sobel parameter and Harris threshold
#result is dilated for marking the corners, this is visualisation related and just makes them bigger
dst = cv2.dilate(dst,None)
#we then plot these on the input image for visualisation purposes, using bright red
harris_test[dst>0.01*dst.max()]=[0,0,255]
plt.imshow(cv2.cvtColor(harris_test, cv2.COLOR_BGR2RGB))
```

图10.5 ORB特征点图
ORB(Oriented FAST and Rotated BRIEF)特征点是一种快速且高效的局部特征点提取和描述方法,它结合了FAST特征点检测算法和BRIEF描述子算法,并通过一系列改进和融合实现了更高的效率和鲁棒性。
ORB特征点检测的过程首先利用FAST算法来快速检测图像中的角点。这些角点通常是图像中像素值发生急剧变化的区域,如边缘、角等。为了提高特征点的旋转不变性,ORB算法会对检测到的角点进行方向计算,为每个角点分配一个主方向。接着,ORB算法使用BRIEF描述子算法为每个角点生成一个紧凑的二进制特征向量。这个特征向量仅包含0和1,其顺序根据特定角点和其周围像素区域的灰度强度变化而定。这种二进制特征向量可以节省内存空间和计算时间,同时保持较高的特征匹配准确性。
ORB算法在实时视觉应用,如SLAM(同时定位与地图构建)和无人机视觉等领域中得到了广泛应用。它提供了一种有效的方式来从图像中提取和描述关键特征点,以便进行后续的匹配、跟踪和三维重建等任务。
```python
orbimg=input_image.copy()
orb = cv2.ORB_create()
# find the keypoints with ORB
kp = orb.detect(orbimg,None)
# compute the descriptors with ORB
kp, des = orb.compute(orbimg, kp)
# draw keypoints
cv2.drawKeypoints(orbimg,kp,orbimg)
plt.imshow(cv2.cvtColor(orbimg, cv2.COLOR_BGR2RGB))
```

图10.6 ORB特征点匹配图
与此同时,ORB特征点还具有平移不变性,简单的几何变换不影响ORB特征点的计算与匹配。下例展示了ORB特征点的匹配操作:
```python
kp2 = orb.detect(img2match,None)
# compute the descriptors with ORB
kp2, des2 = orb.compute(img2match, kp2)
# create BFMatcher object: this is a Brute Force matching object
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
# Match descriptors.
matches = bf.match(des,des2)
# Sort them by distance between matches in feature space - so the best matches are first.
matches = sorted(matches, key = lambda x:x.distance)
# Draw first 50 matches.
oimg = cv2.drawMatches(orbimg,kp,img2match,kp2,matches[:50], orbimg)
plt.imshow(cv2.cvtColor(oimg, cv2.COLOR_BGR2RGB))
```

图10.7 ORB特征点匹配操作图
### **10.1.3 计算机视觉**
生物的视觉能够让它们看到现实世界并感知,那么计算机如果想要感受这个世界首先就需要看到这个世界。计算机视觉就是让计算机看到世界的一门学科,它主要聚焦的就是图像信息如何在计算机中存储、表示、处理、分析、理解并应用。在传统的计算机视觉研究中,大家主要是利用一些数学方法实现对计算机图像的计算与处理,还远远达不到真正的理解信息。但随着神经网络的发展,计算机视觉终于迎来了一场革命。
以卷积神经网络和注意力机制为代表的深度学习方法在图像分类、目标检测、语义分割、图像超分辨率、图像生成等领域有着重要作用。如果不知道什么是卷积,可以理解为一个小矩阵在图片上扫描,每一次扫描小矩阵(也叫卷积核)会对扫描到的区域执行按位乘并求和,然后生成一个新的图像,过程如图10.8所示:

图10.8 图像上执行卷积操作
在图像上执行卷积操作如图所示,卷积核扫描后按照按位乘求和的方式组织了新的图像。这一方法能够降低计算量,但保留图像的有效特征,也是特征提取的一种方法。卷积神经网络把这个卷积核中每一项的值看作一个待学习的权重从而构建神经网络。
注意:卷积神经网络是图像处理的经典方法。它的常见模型包括LeNet-5、ResNet、VGG、GoogleNet、GhostNet等多种结构,也是后继很多网络的Backbone例如Faster RCNN、YOLO、FCN等也都有卷积的影子在里面。
图像分类任务是计算机视觉领域中的一项基本任务,其目标是将输入的图像自动分配到预先定义的类别中。例如,一个图像分类系统可能将输入的图片识别为动物、植物、建筑或其他类别。这种分类依赖于从图像中提取的特征,这些特征可能包括颜色、纹理、形状等信息。研究者们一直在试图寻找不需要手工设计特征的分类模型。
ImageNet数据集的提出,标志着计算机视觉领域进入了一个新的时代。ImageNet是一个大型图像数据库,包含了数以百万计的手动标注的图像,涵盖了上千个不同的类别。这个数据集的提出极大地推动了深度学习在图像分类任务中的应用,因为它提供了足够的数据来训练复杂的神经网络模型。随着深度学习的到来,研究者们开始构建能够自动学习图像特征的神经网络,而不是依赖手工设计的特征。
在深度学习的浪潮中,面向图像分类的深度学习模型经历了多次迭代。最初的模型,如AlexNet,采用了卷积神经网络(CNN)的结构,通过堆叠多个卷积层来提取图像的特征。随后,VGGNet和GoogLeNet等模型进一步提升了性能,通过增加网络深度和宽度,以及引入新的卷积结构,如Inception模块。而ResNet模型则通过引入残差连接,解决了深度神经网络中的梯度消失问题,从而实现了更高的分类准确率。这些模型的迭代不仅推动了图像分类任务的性能提升,也为其他计算机视觉任务提供了强大的基础。

图10.9 图像分类任务
目标检测任务是计算机视觉领域的一个重要分支,旨在识别图像或视频中的特定目标,并给出它们的位置信息。这个任务通常涉及定位目标物体的边界框,并识别其类别。然而,目标检测面临着一些挑战,其中最突出的是目标尺寸变化、形状多样、遮挡情况以及背景的复杂性。这些因素使得准确检测目标变得困难,尤其是在复杂场景中。
为了评估目标检测算法的性能,我们采用一系列评价指标。这些指标主要包括准确率(Precision)、召回率(Recall)、F1分数以及mAP(mean Average Precision)。在目标检测领域,存在许多经典的数据集用于训练和评估算法。其中,PASCAL VOC、COCO(Common Objects in Context)和ImageNet Detection Challenge等数据集广受欢迎。这些数据集包含了大量标注好的图像,涵盖了各种目标类别和场景,为研究者提供了丰富的实验资源。
随着技术的不断发展,目标检测方法也在不断演进。经典的目标检测方法如YOLO、SSD、Faster R-CNN、DETR等,通过引入深度学习技术,实现了对目标的高效检测。YOLO系列算法通过改进网络结构和损失函数,逐渐提高了检测速度和精度。而Faster R-CNN则通过引入区域提议网络(RPN),实现了对目标区域的快速提取和分类,进一步提高了目标检测的性能。这些经典方法的技术演化和迭代不仅推动了目标检测领域的进步,也为后续研究提供了宝贵的经验和启示。随着技术的不断创新和发展,相信未来目标检测任务将会取得更加卓越的成果。

图10.10 YOLO模型基本结构
图像分割任务是指将图像划分为若干个互不相交的区域,每个区域都代表图像中的一个物体或场景的一部分。这一任务在计算机视觉中至关重要,因为它有助于从复杂的图像中提取出有意义的信息。然而,图像分割面临着诸多难点。首先,图像中的物体可能具有复杂的形状、纹理和颜色,使得准确区分不同物体变得困难。其次,光照条件、噪声、遮挡等因素也可能对分割结果产生负面影响。此外,处理不同尺度和分辨率的图像也是一大挑战。
在图像分割模型的技术演化与迭代方面,经典的模型如FCN(全卷积网络)为后续的研究奠定了基础。FCN通过引入全卷积层来替代传统卷积神经网络中的全连接层,从而实现了端到端的像素级预测。随后,U-Net模型进一步推动了图像分割技术的发展。U-Net采用编码器-解码器结构,通过跳跃连接将低层特征与高层特征相结合,提高了分割的准确性和细节保留能力。随着深度学习技术的不断发展,越来越多的模型如DeepLab、Mask R-CNN等不断涌现,它们在特征提取、上下文信息融合、多尺度处理等方面进行了改进和创新,进一步提升了图像分割的性能和鲁棒性。

图10.11 U-Net模型基本结构
图像生成任务是计算机视觉领域的一个重要分支,它涉及到生成具有特定属性的图像。这个任务的目标是根据给定的输入信息,生成一张新的图像。生成对抗网络(GAN)则是这一领域出色的模型。GAN网络,即生成对抗网络,是一种在图像生成任务中表现尤为出色的深度学习模型。GAN网络由两个主要部分构成:生成器和判别器。生成器的任务是生成新的数据样本,如图像,而判别器则负责判断这些生成的样本与真实数据之间的差异。这两个部分通过对抗学习的方式相互竞争,使生成器不断提高其生成逼真样本的能力,同时判别器也持续提高其辨别真伪样本的能力。它的核心思想正是我们在第3章中讲到的博弈论。由于GAN网络具有出色的表现,它已被广泛应用于计算机视觉、自然语言处理和创意艺术等多个领域。

图10.12 GAN模型基本结构
现在,随着人工智能的发展,我们也有了很多面向计算机视觉的大模型。比如midjourney和stable diffusion,SORA……如今,随着人工智能技术的飞速进步,计算机视觉领域也迎来了前所未有的发展机遇。在这一浪潮中,我们见证了众多面向计算机视觉的大模型的涌现,它们如同璀璨的明星,点亮了科技创新的天空。展望未来,随着人工智能技术的不断发展,我们相信会有更多面向计算机视觉的大模型涌现出来,为我们带来更多的惊喜和突破。这些模型将在图像识别、目标检测、图像生成等多个方面发挥越来越重要的作用,推动计算机视觉领域不断向前发展。
## 10.2 计算语言学与自然语言处理
语言同样是可以被计算的。这第二种非常规数据就是对文本的处理与分析。
### **10.2.1 Python处理字符串**
Python处理字符串的功能非常全面和强大,无论是基本的字符串操作还是复杂的文本处理任务,Python都提供了丰富的工具和方法。
首先,Python支持多种方式来创建和操作字符串。我们可以使用单引号、双引号或三引号来定义字符串。例如:
```python
str1 = 'Hello, World!'
str2 = "Welcome to Python"
str3 = '''This is a
multi-line string.'''
```
其次,Python提供了丰富的字符串方法,这些方法允许我们执行各种字符串操作。例如,使用`upper()`和`lower()`方法可以将字符串转换为大写或小写形式:
```python
str_lower = str1.lower() # 转换为小写
str_upper = str1.upper() # 转换为大写
print(str_lower) # 输出: hello, world!
print(str_upper) # 输出: HELLO, WORLD!
```
此外,Python还支持字符串的切片操作,允许我们提取字符串的子串。切片操作使用冒号分隔起始索引和结束索引,并可以指定步长:
```python
substring = str2[7:13] # 提取索引7到12之间的字符(不包括13)
print(substring) # 输出: to Python
```
Python还提供了查找和替换字符串中特定子串的功能。例如,使用`find()`方法可以查找子串的索引,而`replace()`方法则可以替换字符串中的特定部分:
```python
index = str1.find('World') # 查找'World'的索引
print(index) # 输出: 7
new_str = str1.replace('World', 'Python') # 将'World'替换为'Python'
print(new_str) # 输出: Hello, Python!
```
对于更复杂的文本处理任务,Python支持正则表达式,通过`re`模块提供了一套强大的工具。正则表达式允许我们定义复杂的字符串模式,并进行匹配、查找和替换操作:
```python
import re
# 使用正则表达式查找所有数字
numbers = re.findall(r'\d+', str3)
print(numbers) # 输出: ['1', '2'](假设str3中包含数字)
# 使用正则表达式替换字符串中的模式
new_str4 = re.sub(r'\s+', '_', str2) # 将所有空格替换为下划线
print(new_str4) # 输出: Welcome_to_Python
```
通过这些示例,我们可以看到Python处理字符串的灵活性和强大性。无论是简单的字符串拼接和切片,还是复杂的文本搜索和替换,Python都提供了直观且易于使用的工具和方法。这使得Python成为处理文本数据和字符串操作的理想选择。
### **10.2.2 文本的嵌入表示**
图像因为在计算机中以数值张量的形式存储,它的计算我们还可以理解。文本又是为什么可以作为数据而计算呢?这就要提到自然语言处理技术啦。
在自然处理领域,对文本的编码也就是向量化将其转化为序列模型是进行所有任务的基本预处理操作。文本的向量化方法有很多,从最早的基于统计自然语言处理的向量化方法,到后来的基于机器学习的向量化方法,再到目前应用广泛的基于深度学习的向量化方法(尤其是基于大规模预训练模型的向量化),向量化方法的技术路径按照一条有规律的主线向前推进。
最原始的文本向量化方法即为独热编码(One-Hot Encoding)。独热编码将所有文本中的单词进行统计,将每个单词转化为1个0-1向量。显然,独热编码方法仅适合处理单个词、短语等极短文本,不适合对长文本进行建模。基于统计方法的TF-IDF算法则很好地改进了这一问题。TF的意思是词频,IDF的意思是逆文本频率指数,它基于这样一个事实:某个单词在某一篇文章中出现频次越高,同时在其他文章中出现频次越低,则这个单词就越可能是该文章的一个关键词。TF-IDF的基本表达式形如:
|  | (10.1) |
| ----------------------- | ------ |
其中,TF(t,d)词语t 在 文档d 中出现的频率,IDF(t)是逆文本频率指数,它可以衡量 单词t 用于区分这篇文档和其他文档的重要性。IDF的公式如式3.2所示,其中Ntext表示文章总数,Ntext(t)表示含单词t的文章数,分母加1是为了避免分母为0:
|  | (10.2) |
| ----------------------- | ------ |
Word2vec是基于神经网络的模型,引入了机器学习因素,它有两类典型的模型,即:用一个词语作为输入,来预测它周围的上下文的skip-gram模型,和拿一个词语的上下文作为输入,来预测这个词语本身的CBOW模型。CBOW对小型语料比较合适,而Skip-Gram在大型语料中表现更好。图10.13为两种典型的word2vec架构:

图10.13 两种典型的word2vec架构
2018年底微软提出的BERT(Bidirectional Encoder Representation from Transformers)相较于Elmo和GPT-2取得了更好的表现,目前也是应用最广泛的文本向量化方法之一,因为在 BERT 中,特征提取器也是使用的Transformer,且 BERT 模型是真正在双向上深度融合特征的语言模型[[[] Devlin J, Chang M W, Lee K, et al. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding[J]. 2018.]]。
BERT架构如图10.14所示,与GPT、ELMo模型的区别如图所示。BERT与GPT的区别就在于BERT采用的是Transformer Encoder,也就是说每个时刻的Attention计算都能够得到全部时刻的输入,而OpenAI GPT采用了Transformer Decoder,每个时刻的Attention计算只能依赖于该时刻前的所有时刻的输入,因为OpenAI GPT是采用了单向语言模型。

图10.14 BERT的架构

图10.15 BERT的嵌入表示形式

图10.16 BERT、GPT和ELMo的对比
图10.16 BERT预处理进行下游任务的输入是三个嵌入表示叠加,如图10.15,Token embedding 表示当前词的embedding,Segment Embedding 表示当前词所在句子的index embedding,Position Embedding 表示当前词所在位置的index embedding。
在 BERT 出现之前的词嵌入技术,如 Word2Vec 中,一个句子的嵌入表示,往往简单的使用 Word2Vec 得到的各个单词的词嵌入表示进行平均或加和得到,这就导致无法得到包含深层语义和语序信息的词嵌入表示,实际任务中效果也较差。而通过BERT 得到的词嵌入表示融入了更多的语法、词法以及语义信息,而且动态的改变词嵌入也能够让单词在不同语境下具有不同的词嵌入表示。
### **10.2.3 文本的分类与话题模型**
对于文本分类任务,我们可以将其向量化后转化为一般的机器学习分类任务后用机器学习算法解决,也可以用深度学习算法解决。TextCNN网络是2014年提出的用来做文本分类的卷积神经网络,由于其结构简单、效果好,在文本分类、推荐等NLP领域应用广泛。图10.8就是一个TextCNN的模型图:

图10.17 TextCNN的模型结构
图10.17代表了TextCNN中的模型结构。与图像当中CNN的网络相比,TextCNN 最大的不同便是在输入数据的不同。图像是二维数据, 图像的卷积核是从左到右, 从上到下进行滑动,然后通过卷积核映射来进行特征抽取,而以自然语言为代表的序列模型是一维数据, 虽然经过word-embedding 生成了二维向量,但是对词向量而言无法进行从左到右的滑动卷积。TextCNN的成功更多的是发掘序列模型的卷积模式,通过引入已经训练好的词向量来在多个数据集上达到了非常良好的表现,进一步证明了构造更好的embedding是提升自然语言处理领域各项任务的关键能力。
循环神经网络被用于文本等序列模型的建模中。文本分类问题就是对输入的文本字符串进行分析判断,之后再输出结果,但字符串无法直接输入到RNN网络,因此在输入之前需要先对文本拆分成单个词组,将词组编码成一个向量,每轮输入一个词组,得到输出结果也是一个向量。嵌入表示将一个词对应为一个向量,向量的每一个维度对应一个浮点值,动态调整这些浮点值使得编码和词的意思相关。这样网络的输入输出都是向量,再最后进行全连接操作对应到不同的分类即可。

图10.18 使用RNN进行文本分类
如图10.18,RNN进行文本分类时将问题抽象为序列,最后使用softmax进行分类预判。RNN网络不可避免地带来问题就是最后的输出结果受最近的输入较大,而之前较远的输入可能无法影响结果,这就是信息瓶颈问题。可以使用双向LSTM,不仅增加了反向信息传播,而且每一轮的都会有一个输出,将这些输出进行组合之后再传给全连接层。
注意:卷积神经网络应用在文本问题上的一个最大的特点就是快。
自然语言处理模型在处理中文的时候一个很大的问题就是分词,因为中文不像英文单词用空格分隔,并且中文分词的时候要注意歧义的问题。另外,如果想自动识别语句中的专有名词,例如人名、地名、时间等,这一任务叫命名实体识别。对于分词和命名实体识别,都可以将其抽象为序列标注问题解决。
传统的命名实体识别方法依赖于手工规则的系统,结合命名实体库,对每条规则进行权重辅助,然后通过实体与规则的相符情况来进行类型判断。大多数时候,规则往往依赖具体语言领域和文本风格,难以覆盖所有的语言现象。基于机器学习的命名实体识别方法广泛应用了判别式的条件随机场和生成式的隐马尔可夫模型。目前,文本中的序列标注任务通常会采用循环神经网络与条件随机场的融合模型,必要时还可以加入BERT等大规模预训练模型等内容。
LDA(隐狄利克雷分布)在文本话题模型中的应用原理,其实可以理解为一种帮助我们自动找出文本中隐藏话题的“魔法”。想象一下,你有一堆文章,但是你不知道它们主要讲了哪些话题。LDA就能帮你把这些话题找出来。LDA是怎么做到的呢?首先,它认为每篇文章都是由几个不同的话题混合而成的。比如说,一篇文章可能同时讨论了“旅游”和“美食”这两个话题,但可能“旅游”的话题更多一些,“美食”的话题稍微少一些。然后,LDA又认为每个话题都是由一堆特定的词语组成的。比如“旅游”这个话题,就可能会有“风景”、“旅行”、“酒店”等词语;而“美食”这个话题,则可能会有“菜肴”、“口感”、“餐厅”等词语。LDA的工作就是找出每篇文章中各个话题的比例,以及每个话题中各个词语的比例。它会反复地学习、尝试,直到找到一个最合理的解释,即这些文章是如何由这些话题和词语组成的。这样,当我们再次看到一篇新的文章时,LDA就能告诉我们这篇文章主要讨论了哪些话题,以及每个话题在文章中的重要程度。注意, LDA在分析文本的话题模型的时候词汇的语序对话题模型并没有什么显著影响。
例如,我们爬取了B站上有关觉醒年代的所有视频有关数据信息,首先可以绘制一幅它的词云图:
```python
import pandas as pd
df=pd.read_excel("觉醒年代所有视频.xlsx")
import jieba
import wordcloud
# 读取文本
s=''.join(str(i) for i in df['description'])
s=s.replace('nan','')
print(s)
ls = jieba.lcut(s) # 生成分词列表
text = ' '.join(ls) # 连接成字符串
stopwords = ["的","是","了","得","将","不","导致","引起","【","】","http","https","quot",'q','u','o','t','他','你','我'] # 去掉不需要显示的词
wc = wordcloud.WordCloud(font_path="msyh.ttc",
width = 2000,
height = 1400,
background_color='white',
max_words=500,stopwords=s)
# msyh.ttc电脑本地字体,写可以写成绝对路径
wc.generate(text) # 加载词云文本
wc.to_file("词云1.png") # 保存词云文件
```

图10.19 词云图
接着,使用gensim包中的LDA模块构造话题模型:
```python
import gensim
from gensim import corpora
import matplotlib.pyplot as plt
import matplotlib
import numpy as np
import warnings
warnings.filterwarnings('ignore') # To ignore all warnings that arise here to enhance clarity
from gensim.models.coherencemodel import CoherenceModel
from gensim.models.ldamodel import LdaModel
dictionary = corpora.Dictionary([ls]) # 构建词典
corpus = [dictionary.doc2bow([text]) for text in ls] #表示为第几个单词出现了几次
ldamodel = LdaModel(corpus, num_topics=15, id2word = dictionary, passes=30,random_state = 1) #分为15个主题
print(ldamodel.print_topics(num_topics=15, num_words=20)) #每个主题输出20个单词
```
通过话题模型可以看到,文本可以分为以下话题:

最后,我们利用snownlp分析文本的情感倾向与极性:
```python
import snownlp
data=pd.DataFrame(strings,columns=['弹幕'])
data['情感极性']=data['弹幕'].apply(lambda x: snownlp.SnowNLP(x).sentiments)
data
```
得到结果如图所示:

SnowNLP的情感分析功能通常会返回一个情感分数,这个分数可以用来表示文本的情感倾向。通常,分数越高表示情感越积极,分数越低表示情感越消极。你需要了解这个分数的具体含义和取值范围。SnowNLP可能还提供了对文本中特定词汇的情感分析。你可以查看这些词汇及其对应的情感分数,了解哪些词汇对整体情感倾向的影响最大。这有助于你深入理解文本的情感内容。
## 10.3 数字信号处理与智能感知
第三种非常规数据类型是对数字信号的变换与处理。在数字信号处理的世界中,我们经常需要转换和处理各种信号,以便更好地理解和利用这些数据。本教程不准备抢走信号与系统老师的饭碗,所以对数字信号的处理只是简单补充,想要深入了解,请参阅相关的数字信号处理课程。
### **10.3.1 数字信号的傅里叶变换**
傅里叶变换是一种非常有力的数学工具,它让我们能够从一种视角(时域)切换到另一种视角(频域)。通过这种变换,我们可以揭示信号的频率内容,这对于许多应用来说是至关重要的。
想象一下,当你听音乐时,你所听到的声音随时间变化,展现出不同的音高和节奏。这种随时间变化的声音就是一个典型的时域信号,即我们可以看到音乐在时间上的波形。然而,仅仅通过波形图观察,我们很难分辨出音乐中包含的各种不同音符或频率成分。
这时,傅里叶变换就显得非常有用。它允许我们将这些时域信号转换成频域信号,从而清晰地揭示出音乐中各个不同频率成分的具体信息。在信号处理领域,我们常常将信号视为随时间变化的函数。时域分析关注的是信号随时间的变化,而频域分析则关注的是信号中各个频率成分的表现,这有助于我们深入理解信号的复杂频率特性。
傅里叶级数告诉我们,任何周期函数都可以被分解为一系列正弦和余弦函数的和。这些正弦和余弦函数具有不同的频率和相位,通过它们的组合,我们能逼近或完整表达原始的周期函数。对于那些非周期信号,我们可以将其视为周期无限大的信号,通过傅里叶变换进行分析。
傅里叶变换的数学公式如下所示:
|  | (0.3) |
| ----------------------- | ----- |
其中:
 表示信号在频率为  时的频谱分量;
 表示信号在时刻  的值;
 是复指数函数,其中  是虚数单位。
这个公式表示的是将时域信号  转换为频域信号  的过程。通过积分运算,我们可以得到信号在各个频率上的分量。简单来说,傅里叶变换就是将时间上的信号转换成显示其频率成分的信号。通过这种转换,我们能够计算并分析不同频率上的信号强度。
傅里叶变换的结果通常表示为复数,这是因为复数可以同时包含幅度和相位信息。这里的幅度表示频率成分的强度,而相位则显示了这些频率成分在时间轴上的位置。通过分析这些复数结果,我们可以得到信号的幅度谱和相位谱,进而全面理解信号的频率特征。
傅里叶变换在许多技术领域都有广泛应用。在信号处理领域,它可以帮助我们过滤或减少噪音。在图像处理中,傅里叶变换用于分析图像的频率属性,帮助进行图像增强和边缘检测。此外,它也是通信技术中不可或缺的一部分,用于编码和传输信号。总之,傅里叶变换是一种强大的数学工具,它能够将信号从时域转换到频域,帮助我们深入理解信号的频率特性并进行相应的处理。
如果你想在实际编程中实现傅里叶变换,Python提供了非常便捷的工具。例如,使用NumPy库中的fft模块,你可以轻松进行一维离散傅里叶变换。这里有一个简单的示例,演示了如何用NumPy进行这样的变换:
```python
import numpy as np
import matplotlib.pyplot as plt
# 创建一个包含一些频率成分的信号
fs = 150.0; # 采样频率
ts = 1.0/fs; # 采样间隔
t = np.arange(0,1,ts) # 时间向量
ff = 5; # 频率
y = np.sin(2*np.pi*ff*t)
# 对信号进行傅里叶变换
n = len(y) # 长度
k = np.arange(n)
T = n/fs
frq = k/T # 两侧的频率范围
Y = np.fft.fft(y)/n # fft计算并归一化
# 由于对称性,我们只取一半
Y = Y[range(n//2)]
frq = frq[range(n//2)]
# 绘图
plt.subplot(2,1,1)
plt.plot(t,y)
plt.xlabel('Time')
plt.ylabel('Amplitude')
plt.subplot(2,1,2)
plt.plot(frq,abs(Y),'r') # 绘制频谱图
plt.xlabel('Freq (Hz)')
plt.ylabel('|Y(freq)|')
plt.tight_layout()
plt.show()
```

图10.20 原始信号与频谱图展示
在这个例子中,我们首先创建了一个包含单一频率成分的信号。然后,我们使用`np.fft.fft`函数对该信号进行傅里叶变换,并绘制了原始信号和频谱图。值得注意的是,由于傅里叶变换的结果在频率轴上是对称的,我们通常只展示一半的频谱图。
这只是一个入门级的示例。在更复杂的实际应用中,你可能需要处理包含多个频率成分的信号,或者进行更高级的操作,如窗函数处理和滤波等。
此外,如果你要处理的数据是二维的(如图像),你可以使用二维傅里叶变换,这可以通过np.fft.fft2函数来实现。二维傅里叶变换在图像处理中特别有用,比如在图像增强或边缘检测等应用中。
### **10.3.2 数字信号的统计指标**
在处理信号数据时,尽管其处理方式相比图像和文本数据可能显得更为直接,通常涉及各种类型的滤波技术,我们还需要深入理解信号的内在特性。这些特性通常在时域、频域以及时频域内分析,以提供全面的信号表征。但在介绍信号的滤波之前,我需要先列举一些信号的统计特性,包括时域、频域、时频域:
时域分析是信号分析中最直观的方法,它涉及信号随时间变化的特性。在时域中,我们可以提取以下几种关键的统计特性:
我们提取的时域特征包括:
- 最大值:信号在观测期间的最高点,表示信号能够达到的最大幅度。
- 最小值:与最大值相对,表示信号在观测期间的最低点。
- 峰值:信号最大值和最小值的差值,常用于描述信号的振幅。
- 偏度:度量信号分布的对称性。正偏度意味着信号的尾部向右延伸较长,负偏度则表示尾部向左延伸较长。
|  | (10.4) |
| ----------------------- | ------ |
其中表示偏度,用于表示统计数据分布偏斜方向和程度。
- 整流平均值
|  | (10.5) |
| ----------------------- | ------ |
其中Xarv表示整流平均值。
- 均值:即为信号中心值,随机信号在均值附近波动,其定义为:
|  | (10.6) |
| ----------------------- | ------ |
其中N为样本大小。
- 标准差:反映出信号的波动程度,其大小与波动程度正相关:
|  | (10.7) |
| ----------------------- | ------ |
- 均方根值:即有效值,能作为振动信号振动幅度大小的一个量度,也可以度量故障的严重程度:
|  | (10.8) |
| ----------------------- | ------ |
- 峰值指标:用于表示信号中是否存在冲击:
|  | (10.9) |
| ----------------------- | ------ |
C即为峰值指标,Xp为信号的峰值。
- 峭度指标:用于反映信号中冲击的特征:
|  | (10.10) |
| ----------------------- | ------- |
- 波形指标:用于检测信号中是否有冲击:
|  | (10.11) |
| ----------------------- | ------- |
- 裕度指标:用于检测设备的磨损情况:
|  | (10.12) |
| ----------------------- | ------- |
- 脉冲指标:用于检测信号中是否存在冲击:
|  | (10.13) |
| ----------------------- | ------- |
这些特性帮助我们把握信号在时间上的基本行为和波动特点。
当我们分析信号时,确实需要考虑其复杂的自然属性,尤其是当信号的幅值、频率、和相位随机变化时。这些变化给直接的傅里叶变换带来了挑战,因为傅里叶变换需要信号具有稳定的周期性,这在许多实际应用中是不现实的。因此,我们转向功率谱密度分析,它不要求信号具有长期的稳定性,而是关注于信号功率如何在各个频率上分布,从而适用于分析广泛的信号类型,包括随机和非平稳信号。
在频域分析中,除了传统的频率和幅度分析,我们还可以提取一些更为细致的特征,来描述和理解信号的行为和状态:
- 重心频率:当设备发生故障时,可推知某一处频率的振动幅值会发生变化,进而导致功率谱的重心位置发生变化,而重心频率可以反映功率谱的重心位置,故可用重心频率来判断故障状态。
|  | (10.14) |
| ----------------------- | ------- |
其中为重心频率,和分别为时刻对应的频率值与幅值。
- 均方频率:这是一个评估功率谱重心稳定性的指标,可用于追踪功率谱中心的动态变化。其计算方法通常涉及到功率谱的二阶矩。
|  | (10.15) |
| ----------------------- | ------- |
- 频率方差:此参数反映了频率谱能量的分散程度,是评价信号频率分布稳定性的一个关键指标。频率方差越大,表明信号的能量分布越分散。
|  | (10.16) |
| ----------------------- | ------- |
时频域分析提供了一种同时观察信号在时间和频率两个维度变化的方法,适合分析那些在短时间内频率特性快速变化的信号:
- 频带能量:通过计算特定频带内的总能量,可以帮助我们理解信号在特定频段的能量分布情况。
- 相对功率谱熵:这是度量功率谱分布均匀性的指标。高的功率谱熵意味着信号的能量较为均匀地分布在不同的频率上,而低的功率谱熵则表明信号的能量集中在少数几个频率上。这一指标对于分析信号的复杂性和预测其行为模式非常有用。
|  | (10.17) |
| ----------------------- | ------- |
通过深入分析这些特性,我们不仅能更好地理解信号的基本行为和波动特点,还能在实际应用中,如设备维护和故障预测等领域,提供更准确的数据支持。
### **10.3.3 数字信号的滤波与分解**
数字信号处理(DSP)是一门研究信号的表示、变换和信息提取的学科。在众多DSP应用中,滤波是一项基本而重要的技术,用于去除信号中不需要的成分或提取有用的信息。Python 提供了多个强大的库来支持数字信号处理,其中 scipy 和 numpy 是最常用的工具。
在Python中,你可以使用多种库来对信号进行滤波,其中最常见的库包括`scipy`和`numpy`。
准备工作:安装必要的库。在Python中开始信号处理之前,我们需要确保已经安装了必要的库。通过以下命令,您可以快速安装numpy和scipy:
```python
pip install numpy scipy
```
然后,我们从创建一个包含两个不同频率成分的合成信号开始。这种信号可以帮助我们演示滤波效果的直观性,并应用不同类型的滤波器:
```python
import numpy as np
import matplotlib.pyplot as plt
# 设置采样频率
fs = 1000 # Hz,表示每秒采样1000次
# 生成时间向量,持续1秒
t = np.linspace(0, 1, fs, endpoint=False)
# 定义信号中的两个频率
f1, f2 = 5, 120 # Hz
# 使用正弦波叠加生成信号
x = np.sin(2 * np.pi * f1 * t) + 0.5 * np.sin(2 * np.pi * f2 * t)
# 绘制这个复合信号
plt.figure(figsize=(10, 4))
plt.plot(t[:100], x[:100], label='Original Signal')
plt.title('原始信号')
plt.xlabel('时间 [秒]')
plt.ylabel('振幅')
plt.legend()
plt.show()
```

图10.21 原始信号图
我们的目标是去除信号中的高频部分。为此,我们将设计一个低通滤波器,只允许频率低于60Hz的部分通过。
```python
from scipy import signal
# 设计一个低通滤波器,截止频率为60Hz
b, a = signal.iirfilter(N=8, Wn=60 / (fs / 2), btype='low', ftype='butter')
# 现在,我们使用刚刚设计的滤波器处理信号,并观察滤波效果。
# 通过滤波器处理信号
y = signal.lfilter(b, a, x)
# 绘制滤波后的信号与原始信号对比
plt.figure(figsize=(10, 8))
plt.subplot(2, 1, 1)
plt.plot(t[:100], x[:100])
plt.title('原始信号')
plt.xlabel('时间 [秒]')
plt.ylabel('振幅')
plt.subplot(2, 1, 2)
plt.plot(t[:100], y[:100])
plt.title('经过低通滤波的信号')
plt.xlabel('时间 [秒]')
plt.ylabel('振幅')
plt.tight_layout()
plt.show()
```

图10.22 原始信号与经过滤波信号对比图
在这个例子中,我们创建了一个包含两个频率成分(5Hz和120Hz)的信号。然后,我们设计了一个8阶的IIR低通滤波器,其截止频率为60Hz,并使用`signal.lfilter`函数将滤波器应用于信号。最后,我们绘制了原始信号和滤波后的信号,如图10.22所示。
除了低通滤波器外,scipy.signal模块还为我们提供了一系列强大的工具,可以用来设计不同类型的滤波器,从基本的低通和高通滤波器到更复杂的带通和带阻滤波器。你可以通过调整滤波器的类型和参数来满足你的需求。在这里举出一些所支持的高级滤波。
巴特沃斯滤波器:通过scipy.signal.butter函数设计。这种滤波器以其平坦的通带特性而闻名,能够在通带内保持较一致的幅度响应,非常适合需要避免频率失真的应用场合。
切比雪夫滤波器:通过scipy.signal.cheby1(类型I)和scipy.signal.cheby2(类型II)函数设计。这些滤波器在通带或阻带中具有等波纹性能,使得它们在特定的频率范围内可以实现更快的衰减速率,适用于对滤波器性能要求较高的情况。
椭圆滤波器:通过scipy.signal.ellip设计,这类滤波器在通带和阻带都具有等波纹特性,并且能够在较低的滤波器阶数下实现非常陡峭的截止特性,非常适合对过渡带有严格要求的应用。
贝塞尔滤波器:通过scipy.signal.bessel设计,这种滤波器在所有滤波器中最注重相位特性的线性,使之成为处理音频和其他需要精确相位信息的信号的理想选择。
FIR滤波器设计:scipy.signal还提供了firwin和firwin2函数,用于设计具有指定频率响应的有限脉冲响应(FIR)滤波器。这类滤波器通常更易于设计并且能够完全实现线性相位特性。
除了基本的滤波器设计,scipy.signal还支持更高级的功能,如使用窗函数法设计滤波器、优化滤波器系数等。这些高级技术允许用户在保证滤波性能的同时,优化滤波器的结构和效率。使用这些滤波方法时,你可以通过调整滤波器的阶数、截止频率、类型等参数来优化滤波器的性能。此外,`scipy.signal`还提供了其他功能强大的信号处理函数,如卷积、相关、频谱分析等,以支持更复杂的信号处理任务。
如果你需要更高级的滤波功能,例如窗函数设计、滤波器系数优化等,你可能需要深入了解数字信号处理的理论,并查阅`scipy.signal`模块的文档以获取更多信息。请注意,设计滤波器时需要根据具体的应用需求选择合适的滤波器类型和参数。在设计滤波器之前,了解数字信号处理的基本原理和滤波器的特性是非常重要的。
在数字信号处理的实践中,我们不仅需要考虑如何设计和优化滤波器,还经常需要处理信号的非线性和非平稳特性。这就引出了一种非常有用的分析方法:经验模态分解(Empirical Mode Decomposition,简称EMD)是一种用于分析非线性、非平稳信号的方法。它可以将一个复杂的信号分解成一系列本征模态函数(Intrinsic Mode Functions,简称IMF),这些IMF代表了信号在不同时间尺度上的特征。
简单来说,EMD就像是一种“筛”信号的方法。想象你手里有一堆不同大小的沙子,你想要把它们分开。EMD就像是一个筛子,它可以帮助你逐步把大颗粒、中颗粒和小颗粒的沙子分开。对于信号来说,这个“筛子”就是EMD算法,而“沙子”就是信号中的不同成分。这使得EMD成为分析复杂信号的有效工具,尤其是在处理音频和生物医学信号等领域中非常有用。
接下来我们会进行EMD的编程实战,你需要安装 PyEMD 库。打开你的命令行工具,并输入以下命令来安装:
```python
pip install EMD-signal
```
这行命令会从 Python 包索引(PyPI)下载并安装 PyEMD 库,让你可以在你的代码中使用它。接下来,我们将编写一个 Python 脚本来执行经验模态分解。这里是详细的步骤和代码解释:
首先,我们需要导入几个 Python 库,包括用于数据操作的 numpy,用于数据可视化的 matplotlib.pyplot,以及 PyEMD 库中的 EMD 类。我们还使用 scipy.signal 来添加噪声,增强示例的实用性。
```python
import numpy as np
import matplotlib.pyplot as plt
from PyEMD import EMD
import scipy.signal as signal
我们将生成一个简单的测试信号,包括两个不同频率的正弦波,并加上随机噪声。这个信号将作为 EMD 分解的输入。
t = np.linspace(0, 1, 200) # 时间向量
s = np.sin(11*2*np.pi*t) + np.sin(22*2*np.pi*t) + 0.2*np.random.normal(size=t.size) # 信号生成
使用 EMD 类初始化一个分解器,并对信号 s 进行分解,提取出多个内在模态函数(IMF)。
emd = EMD()
IMFs = emd(s)
最后,我们使用 matplotlib 库来绘制原始信号和每个分解出的 IMF。这有助于可视化分解的效果,更好地理解每个 IMF 的物理意义。
plt.figure(figsize=(12, 9))
plt.subplot(len(IMFs)+1, 1, 1)
plt.plot(t, s, 'r')
plt.title("Input signal: 's(t)'")
plt.xlabel("Time [s]")
for n, imf in enumerate(IMFs):
plt.subplot(len(IMFs)+1, 1, n+2)
plt.plot(t, imf, 'g')
plt.title("IMF "+str(n+1))
plt.xlabel("Time [s]")
plt.tight_layout()
plt.show()
```

图10.23 原始信号与IMF图
如图10.23所示,这段代码首先生成了一个包含两个不同频率正弦波和噪声的信号。然后,它使用`EMD`类来初始化一个经验模态分解器,并将信号传递给分解器。分解器会返回一系列IMFs,这些IMFs代表了信号中的不同成分。最后,代码使用matplotlib库来绘制原始信号和每个IMF的图形。
通过这个教程,你应该能够理解如何使用 Python 和 PyEMD 库来处理和分析信号。这种技能在许多领域都非常有用,比如声音分析、经济数据处理和其他需要信号分解的场合。
## 10.4 多模态数据与人工智能
### **10.4.1 多模态概念与意义**
模态是一个指生物通过感知器官(如眼睛、耳朵)和经验来接收和处理信息的方式的概念。人类有视觉、听觉、触觉、味觉和嗅觉五种基本的感知方式,这些都可以被视为不同的模态。
多模态是指结合多种感知方式或信息来源的概念。在技术领域,这意味着通过结合声音、图片、视频、文字等多种形式的信息来让机器更好地理解和与人类交流。例如,一个多模态的人工智能系统可以同时理解语音指令和用户的表情,从而做出更合适的反应。
多模态学习的核心在于它能够让机器通过多种方式理解世界,这对于构建高效且自然的人机交互系统尤为重要。例如,多模态系统可以在自动驾驶车辆中同时处理视觉数据和传感器数据,或者在智能助手中同时理解语音和文本输入。
传统的深度学习模型通常专注于单一模态的数据处理,如仅处理图像或文本。多模态学习则通过融合不同的数据类型(如图像和文本),在许多领域都有广泛的应用,应用方向不限于自然语言处理、计算机视觉、音频处理等。具体任务又可以分为文本和图像的语义理解、图像描述、视觉定位、对话问答、视觉问答、视频的分类和识别、音频的情感分析和语音识别等。

图10.24 多模态任务介绍
未来,多模态学习的发展将重点在于增强模型的跨模态处理能力,使其能够更好地整合和理解来自不同模态的信息。这不仅能提升机器的理解能力,还能推动AI技术在自动化和智能化应用中的广泛应用。接下来我们将介绍多模态模型目前的发展情况。
### **10.4.2 多模态模型发展关系及时间线**

图10.25 多模态模型发展时间
引用来源:论文《MMLLMs: Recent Advances in MultiModal Large Language Models》
上述的大多多模态模型结构可以总结为五个主要关键组件,具体如下图所示:

图10.26多模态模型基本结构
引用来源:论文《MMLLMs: Recent Advances in MultiModal Large Language Models》
在多模态模型的发展中,主要可以分为以下五个核心组件:
***1. 模态编码器(Modality Encoder, ME)***:
图像编码器(Image Encoder):用于将图像数据转化为机器可处理的特征表示。
视频编码器(Video Encoder):专门处理视频数据,提取时间和空间特征。
音频编码器(Audio Encoder):转换音频输入为特征,便于后续的处理和分析。
***2. 输入投影器(Input Projector, IP)***:
线性投影器(Linear Projector):通过简单的线性变换将输入数据映射到一个新的空间。
多层感知器(MultiLayer Perceptron, MLP):使用深层网络结构进行更复杂的数据转换。
交叉注意力(CrossAttention):允许模型关注输入数据中的关键部分,以提高信息的相关性和准确性。
Q-Former:一种基于Transformer的结构,用于处理和优化多模态数据的交互。
***3. 大模型基座(LLM Backbone)***:
ChatGLM、LLaMA、Qwen、Vicuna等:这些大模型为多模态学习提供了强大的基础架构,支持高效的信息处理和复杂任务的执行。
***4. 输出投影器(Output Projector, OP)***:
Tiny Transformer:一个轻量级的Transformer模型,专注于高效的输出生成。
Multi-Layer Perceptron (MLP):用于将内部表示转换为最终输出,如文本、图像或音频。
***5. 模态生成器(Modality Generator, MG)***:
Stable Diffusion、Zeroscope、AudioLDM等:这些生成器用于创造新的多模态输出,如图像、音频或其它类型的合成数据。
按上述五部分结构对经典多模态模型进行总结,结果如下:

图10.27多模态现有模型类型总结
引用来源:论文《MMLLMs: Recent Advances in MultiModal Large Language Models》
以上各部分的具体应用示例包括基于VIT(Vision Transformer)的视觉预训练模型,这类模型通过Transformers架构有效地对视觉信息进行表征,逐渐成为视觉信息编码的主流方式。这部分主要梳理了以VIT为基础的预训练及其在多模态对齐中的应用,具体分类如下:

图10.28 VIT视觉预训练模型
通过这些技术组件的综合应用,多模态预训练模型能够在多种数据类型之间建立深入的联系和理解,从而在各种复杂环境下提供更为准确和自然的交互体验。上述多模态预训练模型发展关系如下:

图10.28 多模态预训练模型发展关系
### **10.4.3 多模态基础知识--Transformer**
目前,主流的多模态大模型大多以Transformer为基础。Transformer是一种由谷歌在2017年提出的深度学习模型,主要用于自然语言处理(NLP)任务,特别是序列到序列(Sequence-to-Sequence)的学习问题,如文本生成。Transformer彻底改变了之前基于循环神经网络(RNNs)和长短期记忆网络(LSTMs)的序列建模范式,并且在性能提升上取得了显著成效。Transformer结构如下图所示:

图10.29 Transformer架构图
***Transformer的核心构成包括:***
***自注意力机制(Self-Attention Mechanism):*** Transformer模型摒弃了传统RNN结构的时间依赖性,通过自注意力机制实现对输入序列中任意两个位置之间的直接关联建模。每个词的位置可以同时关注整个句子中的其他所有词,计算它们之间的相关性得分,然后根据这些得分加权求和得到该位置的上下文向量表示。这种全局信息的捕获能力极大地提高了模型的表达力。
***多头注意力(Multi-Head Attention):*** Transformer进一步将自注意力机制分解为多个并行的“头部”,每个头部负责从不同角度对输入序列进行关注,从而增强了模型捕捉多种复杂依赖关系的能力。最后,各个头部的结果会拼接并经过线性变换后得到最终的注意力输出。
***位置编码(Positional Encoding):*** 由于Transformer不再使用RNN的顺序处理方式,为了引入序列中词的位置信息,它采用了一种特殊的位置编码方法。这种方法对序列中的每个位置赋予一个特定的向量,该向量的值与位置有关,确保模型在处理过程中能够区分不同的词语顺序。
***编码器-解码器架构(Encoder-Decoder Architecture):*** Transformer采用了标准的编码器-解码器结构,其中,编码器负责理解输入序列,将其转换成高级语义表示;解码器则依据编码器的输出,结合自身产生的隐状态逐步生成目标序列。在解码过程中,解码器还应用了自注意力机制以及一种称为“掩码”(Masking)的技术来防止提前看到未来要预测的部分。
***残差连接(Residual Connections):*** Transformer沿用了ResNet中的残差连接设计,以解决随着网络层数加深带来的梯度消失或爆炸问题,有助于训练更深更复杂的模型。
***层归一化(Layer Normalization):*** Transformer使用了层归一化而非批量归一化,这使得模型在小批量训练时也能获得良好的表现,并且有利于模型收敛。
### **10.4.4 多模态任务对齐**
本节主要介绍文本和图像的多模态数据如何进行对齐。首先,我们将从文本数据的处理开始。
### **文本转Embedding**
Tokenization(分词):Tokenization 是将文本切分为模型可处理的token或子词的过程。此步骤通常采用 BPE 或 WordPiece 分词算法,不仅帮助控制词典大小,同时保留了表达文本序列的能力。相关的关键点总结如下:

图10.30 Tokenizer分词总结
Embedding(嵌入):Embedding 过程将 token 或子词映射到多维空间中的向量,以捕捉其语义含义。这些连续的向量使模型能处理离散的 token,并学习单词间的复杂关系。使用 Tramsformer (BERT) 模型的步骤包括:
· 输入文本:"thank you very much"
· Tokenization后: ["thank", "you", "very","much"]
· Embedding:假设每个token被映射到一个2048维的向量,“thank you very much”被转换成4*2048的embeddings
### **图像转换Embedding**
图像数据也采用与文本类似的处理方法,通过 Vit Transformer 模型进行处理。首先,把图像分成固定大小的patch,类比于LLM中的Tokenization操作;然后通过线性变换得到patch embedding,类比LLM中的Embedding操作。由于Transformer的输入就是token embeddings序列,所以将图像的patch embedding送入Transformer后就能够直接进行特征提取,得到图像全局特征的embeddings。具体步骤如下:
· 输入图像大小:224x224像素,3个颜色通道(RGB)+ 预处理:归一化,但不改变图像大小图像切分:
· 假设每个patch大小为14x14像素,图像被切分成(224/14) × (224/14) =256个patches 线性嵌入:
· 将每个14x14x3的patch展平成一个一维向量,向量大小为 14×14×3=588
· 通过一个线性层将每个patch的向量映射到其他维的空间(假设是D维),例如D=768 , 每个patch被表示为一个D维的向量。最后,由于Transformer内部不改变这些向量的大小,就可以用256*768的embeddings表示一张图像。

图10.31 图像Embedding处理
### **模态对齐**
模态对齐是指将来自不同模态的数据转化为能够相互对应的统一形式,从而使得不同模态之间可以协同工作,共同完成任务。在处理图像和文本的任务中,模态对齐特别关键,因为它允许模型理解并关联视觉信息和语言信息。
例如,在图像标注任务中,模型需要不仅识别出图像中的物体,如"小狗",还需要将其与相应的文本描述对齐。如果图像中的"小狗"和文本中的"小狗"在各自的模态空间中被不同地表示,模型就需要通过某种方式来桥接这种差异,使得两者能够匹配。常用的模态对齐方法包括但不限于使用联合嵌入空间、对齐损失函数和跨模态转换网络。

图10.32 图片对齐语言示意图
这种对齐通常涉及到以下几个步骤:
1. 特征提取: 这就像是从故事和图片中各自提炼出关键信息。对于计算机,这意味着使用算法从图片中识别出像是轮廓、颜色和形状这样的视觉元素,同时从文本中识别出关键词和语义结构。这样做的结果是,无论是图片还是文字,都转换成了计算机能理解的数字列表——也就是向量。
2. 特征变换: 接下来,就像是将故事的翻译成插画的“语言”,或者反过来。这个过程需要确保提取的特征可以在一个共同的“向量空间”中进行比较。有时候,这就像是在不同的文化中找到共同的表达方式,让一种表达形式能够反映另一种的含义。
3. 对齐策略: 现在,我们需要一种方法来确认故事和插图确实是对应的。在计算机的世界里,这可以通过增加两者之间相似度的方法来实现。想象一下,你通过比较故事和插图之间的相似点,来确认它们是匹配的。
4. 端到端训练: 最后,就像在不断的游戏中学习,计算机通过不停地尝试和调整来更好地匹配故事和图片。它会逐渐学会如何提取特征、变换它们,并找到最佳的对齐方式。
通过这种方式,计算机最终能够处理并理解多种形式的信息,无论是看到的图片还是读到的文本。这项技术不仅应用于图像和文本,还可以扩展到视频、音频和更多其他类型的数据。模态对齐让计算机能够在更复杂、更接近人类理解世界的方式上工作,这对于创造聪明、灵活的人工智能系统来说至关重要。
### **10.4.5 多模态模型训练流程**
在这个领域,我们的教程继续深入了解多模态大型语言模型的训练,这个过程分成两个紧密联系的阶段:多模态预训练(Multimodal Pre-Training, MM PT)和多模态指令调优(Multimodal Instruction Tuning, MM IT)。让我们一起走近这两个阶段的具体细节:
(1)多模态预训练(MM PT):
目标:在这个阶段,我们专注于训练输入投影器(Input Projector, IP)和输出投影器(Output Projector, OP),目的是让模型学会如何把视觉、听觉和语言等不同的信息模态转换成统一的表示形式。简单来说,就是教会大型语言模型(Large Language Model, LLM)的主干网络如何理解和处理这些不同来源的信息。
数据集:通常,这里涉及的是像X-Text这样的多模态数据集,其中包含了图像-文本对(Image-Text),视频-文本对(Video-Text)以及音频-文本对(Audio-Text)。这样的数据集让模型有机会学习如何把不同形式的信息对应起来,就好比学习多种语言的翻译。
优化:在预训练的过程中,核心的任务是优化IP和OP的参数,以最小化条件文本生成损失(conditional text generation loss)。这涉及到一个过程,让模型学会如何将模态编码器(Modality Encoder)输出的特征和文本特征相对齐,从而生成可以直接喂给LLM主干网络的特征。
(2)多模态指令调优(MM IT):
目标:当模型基本理解了不同模态信息后,我们进入指令调优阶段。这里的目标是细化模型的能力,使其能够更好地根据特定的、格式化的指令来执行任务。就像在预训练后教会模型如何根据具体的操作手册来行动。
方法:这个阶段的方法分为两种。首先是监督式微调(Supervised Fine-Tuning, SFT),它将前一阶段学习到的数据转换成特定的指令格式,然后对模型进行进一步训练。另外一种方法是基于人类反馈的强化学习(Reinforcement Learning from Human Feedback, RLHF),这种方法依赖于对模型产生的输出给予反馈,进一步改进模型的性能。
数据集:在这个阶段使用的数据集一般包括了视觉问答(Visual Question Answering, VQA)任务、基于指令的执行任务等,这些可以是简单的单轮问答形式,也可以是更复杂的多轮对话形式。
通过以上两个阶段,我们不仅让模型学会了如何处理和理解不同的信息模态,还教会了它如何根据特定指令行动。这使得模型在面对多样化的实际任务时,能够有着更为出色的表现和更高的灵活性。
================================================
FILE: docs/CH2/第2章-微分方程与动力系统.md
================================================
第2章 微分方程与动力系统
> 内容:@若冰(马世拓)
>
> 审稿:@陈思州
>
> 排版&校对:@何瑞杰
这一章我们主要介绍微分方程与一些动力系统模型。数学上对微分方程的研究是一项热点问题,在工程当中微分方程与动力系统也有着广泛的应用。我们除了会从高等数学与计算数值方法的角度分析微分方程的求解方法与底层逻辑,还会介绍在数学模型当中被广泛应用的微分方程模型。本章主要涉及到的知识点有:
* 微分方程的解法
* 如何用python解微分方程
* 偏微分方程及其求解方法
* 微分方程的基本案例
* 差分方程的求解
* 数值计算方法
* 元胞自动机
注意:本章的重点是理解微分方程在实际工程中的应用。如果对数学基础有疑问,可以参考相关的高等数学和数值分析教材。
## 2.1 微分方程的理论基础
微分方程是什么?如果你参加过高考,可能在高三备考中遇到过这样的问题:给定函数$f(x)$及其导数之间的等式,然后分析函数的性质,如单调性、零点等,但没有给出函数的解析式。这时你可能会想,如果能通过这个方程求出函数的通项形式该多好!微分方程的目的就是这样,它通过将函数$f$和它的若干阶导数联系起来形成一个方程(组),来求出函数的解析式或函数值随自变量变化的曲线。
### 2.1.1 函数、导数与微分
微分和导数其实是紧密相关的概念。我们通常将导数理解为函数在某一点处切线的斜率。而微分则描述的是当我们对自变量x施加一个非常小的增量$\mathrm dx$时,函数值相应的变化量与$\mathrm dx$之间的关系。当$\mathrm dx$非常小的时候,函数的变化量就接近于在该点处切线的变化量$\mathrm{d}y$。因此,我们可以用这种方式来理解微分:
$$
\frac{\mathrm{d}y}{\mathrm{d}x} = f'(x). \tag{2.1.1}
$$
在图2.1.1中,我们展示了函数、导数和微分之间的关系。微分实际上描述的是点$M$处切线的斜率;导数则描述的是割线$MN$的斜率。但当$\mathrm{d}x$足够小的时候,切线的斜率和割线的斜率就会非常接近,这就是微分的核心概念。而微分方程,就是描述函数与其导数之间关系的方程。

图2.1.1 函数、导数和微分之间的关系
相对于求微分,我们还有求积分的概念。积分本质上是根据已知的导数反推出原函数,这就是不定积分。而定积分则是在反推出原函数后,还需要计算该函数在特定区间内的值的差异。通常情况下,我们可以通过查阅常见函数的导数表来进行微分和不定积分的计算。
> 注意:割线斜率等于切线斜率的前提是dx非常小,这是一种极限思想的体现。虽然它们之间存在一个无穷小量PN的差距,但当我们在考虑dx时,这种差异就可以忽略不计了。这就是微分和积分的基本思想。
### 2.1.2 一阶线性微分方程的解
一阶线性微分方程描述的是怎么一回事呢?它是指形如下方的方程:
$$
\frac{\mathrm{d}y}{\mathrm{d}x} + yP(x) = Q(x). \tag{2.1.2}
$$
这里的$y$是一个未知函数,而$P$和$Q$是已知的函数。我们的目标是找出$y$的解,即它的通解形式。为了解这个方程,我们通常会使用分离变量积分法和常数变易法这两种方法。首先,我们尝试解一个特殊情况的齐次方程,即当$Q(x)=0$时:
$$
\frac{\mathrm{d}y}{\mathrm{d}x} + yP(x) = 0. \tag{2.1.3}
$$
通过变量分离和变形,我们可以得到:
$$
\frac{1}{y} \mathrm{d}y = P(x)\mathrm{d}x.\tag{2.1.4}
$$
接着,对两边进行不定积分,我们可以得到解的通式为$\displaystyle y = C\exp\left\{-\int {P(x)} \, \mathrm d{x}\right\}$,其中$C$是一个常数。但在一般情况下,$Q(x)$不一定为$0$,所以我们需要将常数$C$替换为一个函数$C(x)$,然后对$y$求导并将其代入原方程中以求得$C(x)$的通解。这就是所谓的常数变易法。有兴趣的读者可以进一步推导出方程的通解为(其中$C$为常数):
$$
y = \exp \left\{ -\int {P(x)} \, \mathrm d{x} \right\} \left[ \int {Q(x)}\exp \left\{ \int {P(x)} \, \mathrm d{x} \right\} \, \mathrm d{x} + C \right]. \tag{2.1.5}
$$
> 注意:这里的定积分符号用于求原函数。这就是为什么我们在高中学习的积分符号应该按照这种方式书写的原因。齐次方程指的是方程右边等于$0$的情况,而非齐次方程则是方程右边不恒等于$0$的情况。解非齐次方程更具有一般性,但很多非齐次方程的解也是基于齐次方程的解进行拓展的。
### 2.1.3 二阶常系数线性微分方程的解
二阶常系数线性微分方程可以表示为:
$$
f''(x) + pf'(x) + qf(x) = C(x). \tag{2.1.6}
$$
这个方程关联了二阶导数、一阶导数和函数本身。解决这个方程的一般策略是先考虑对应的齐次方程,即让$C(x)$为$0$:
$$
f''(x) + pf'(x) + qf(x) = 0. \tag{2.1.7}
$$
解这种二阶常系数齐次线性微分方程时,我们通常使用特征根法。这个方法的关键是求解特征方程:
$$
r^{2} + pr + q = 0. \tag{2.1.8}
$$
这个齐次方程的解的形式取决于特征方程的根。根据特征方程的不同实根、相同实根、或共轭复根,齐次微分方程的解会有不同的形式:
$$
\left\{
\begin{align}
y &= C_{1}e^{\alpha_{1}x} + C_{2}e^{\alpha_{2}x} & r_{1} = \alpha_{1}, r_{2} = \alpha_{2}\\[0.5em]
y &= (C_{1}x + C_{2})e^{\alpha x}, & r_{1} = r_{2} = \alpha\\[0.5em]
y &= e^{\alpha x}\Big[C_{1}\sin\big( \beta x\big) + C_{2}\cos \big(\beta x\big)\Big]. & r = \alpha \pm \beta \,\mathrm{i}
\end{align}
\right.\tag{2.1.9}
$$
> 注意:这里可能有些读者不太明白为什么二次方程的根与齐次方程的解之间会有联系,这正是数学之美的体现之一。如果想检验这个方程的解是否正确,实际上并不难,可以使用 Vieta 定理将 $p$ 和 $q$ 代入,将两个方程统一起来,再通过换元法将其降为一阶微分方程进行验证。
对于一般的二阶非齐次线性微分方程,我们可以根据右侧$C(x)$的形式推导出一个特解。非齐次方程的通解等于齐次方程的通解加上非齐次方程的特解。求微分方程的特解有时需要观察法,但幸运的是,存在两种特殊形式:
$$
\begin{align}
C(x) &= P_{m}(x) e^{\lambda x},\\[0.5em]
C(x) &= e^{\lambda x} \Big[ P_{m}\cos \big( \omega x \big) + Q_{n}(x)\sin \big( \omega x \big) \Big]
\end{align}
$$
其中,$P_m(x)$是一个$m$次多项式,$Q_n(x)$是一个$n$次多项式。这两种形式的特解分别为:
$$
\begin{align}
f(x) &= x_{k} P_{m}(x)e^{\lambda x},\\[0.5em]
f(x) &= x^{k} e^{\lambda x} \Big[ P_{i}\cos \big( \omega x \big) + Q_{i}(x)\sin \big( \omega x \big) \Big]. & i = \max \left\{ m,n \right\}
\end{align} \tag{2.1.11}
$$
其中$k$的取值取决于特征方程根的个数:如果有两个不同的实根,则$k=2$;如果有两个相同的实根,则$k=1$;如果没有实根,则$k=0$。通过上述形式,我们可以解出二阶线性微分方程。
特征根法和“特解+通解”的策略不仅适用于二阶线性微分方程,也适用于一般的高阶线性微分方程。只要特征方程是多项式,它至少满足韦达定理。在后续的差分方程中,特征根法同样会发挥重要作用。
### 2.1.4 利用Python求函数的微分与积分
在Python中,我们可以使用Numpy和SciPy这两个库来进行函数的微分和积分计算。下面将通过具体示例来说明如何使用这些库来求解函数的微分和积分。
假设我们需要计算函数`f(x) = cos(2πx) * exp(-x) + 1.2`在区间`[0, 0.7]`上的定积分。我们可以使用SciPy库中的`quad`函数来完成这个任务:
```python
import numpy as np
from scipy.integrate import quad
# 定义函数
def f(x):
return np.cos(2 * np.pi * x) * np.exp(-x) + 1.2
# 计算定积分
integral, error = quad(f, 0, 0.7)
print(f'定积分的结果是:{integral}')
# 定积分的结果是:0.7951866427656943
```
除了使用SciPy库中的`quad`函数求解定积分外,我们还可以使用数值积分的方法来近似计算。一种常见的数值积分方法是梯形法则。下面我们将通过一个示例来说明如何使用梯形法则来近似计算函数的定积分。
假设我们需要计算函数`f(x) = cos(2πx) * exp(-x) + 1.2`在区间`[0, 0.7]`上的定积分。我们可以使用梯形法则来近似求解:
```python
h=x[1]-x[0]
xn=0.7
s=0
for i in range(1000):
xn1=xn+h
yn=np.cos(2*np.pi*xn)*np.exp(-xn)+1.2
yn1=np.cos(2*np.pi*xn1)*np.exp(-xn1)+1.2
s0=(yn+yn1)*h/2
s+=s0
xn=xn1
s
# 24.31183595181452
```
对于函数的微分,我们可以使用Numpy库中的`gradient`函数来近似求解。例如,我们想要求解函数`f(x) = x^2`在点`x = 1`处的导数:
```python
import numpy as np
# 定义x的取值范围和步长
x = np.linspace(0, 2, 100)
y = x**2
# 计算导数
dydx = np.gradient(y, x)
# 在x=1处的导数值
derivative_at_1 = dydx[np.argmin(abs(x - 1))]
print(f'在x=1处的导数值是:{derivative_at_1}')
# 在x=1处的导数值是:1.9797979797979792
```
以上示例展示了如何在Python中求解函数的积分和微分。在实际应用中,可以根据具体问题调整函数表达式、积分区间和微分点等参数。
## 2.2 使用Scipy和Sympy解微分方程
前面我们见过了求微分方程解析解的一些方法,我们知道,微分方程的解本质上是通过给定函数与微分之间的关系求解出函数的表达式。但是事实上,大多数微分方程是没有解析解的,也就是无法求解出函数的具体解析式。这是不是意味着这样的微分方程不可解呢?也不尽然。在上一章中我们已经见过了,以前我们难以求解的超越方程也是可能给出数值解的,那么微分方程是否也会存在数值解呢?
### 2.2.1 使用sympy求解微分方程解析解
我们此前介绍的一阶、二阶常系数线性微分方程通解的形式就是一种解析解,但在科学与工程实际中我们遇到的微分方程形式会比这些基本形式更为复杂,条件也更多。事实上多数情况下,大多数微分方程其实是求不出解析解的,只能在不同取值条件下求一个数值解。那么如何编写算法去求数值解才能使精度尽可能提高呢?数值解会随着初始条件而变化,怎么变化呢?函数值又与自变量之间怎么变化呢?
在回答这些问题之前,请让我们先了解一番:如何使用python求解微分方程的解析解呢?但凡涉及到符号运算,通常都是使用sympy库实现。Sympy是一个数学符号运算库。能解决积分、微分方程等各种数学运算方法,用起来也是很简单,效果可以和Matlab媲美。其中内置的Sympy.dsolve方法是解微分方程解析解的一种良好方式,而对于有初始值的微分方程问题,我们通常在求出其通解形式后通过解方程组的方法得到参数。这个方法通过声明符号变量的方式求得最优解。
例如,我们看下面这个例子:
**例2.1** 使用sympy解下面这个微分方程:
$$
y'' + 2y' + y = x^{2}. \tag{2.2.1}
$$
若使用sympy,我们首先要声明两个符号变量,其中变量`y`是变量`x`的函数。代码如下:
```python
from sympy import *
y = symbols('y', cls=Function)
x = symbols('x')
eq = Eq(y(x).diff(x,2)+2*y(x).diff(x,1)+y(x), x*x)
## y''+4y'+29y=0
print(dsolve(eq, y(x)))
```
这段代码通过sympy中的`symbols`类创建两个实例化的符号变量`x`和`y`,在`y`中我们通过`cls`参数声明`y`是一个`scipy.Function`对象(也就是说,`y`是一个函数)。表达微分方程解析解的方法是通过创建一个`Eq`对象,这个对象分别存储方程左右两边。其中,`y(x).diff(x,2)`表明`y`是`x`的函数,然后需要取函数对`x`的2阶导数。最后,若想求解函数`y`的解析式,只需要调用`dsolve(eq,y(x))`函数即可。代码返回结果:
```python
Eq(y(x), x**2 - 4*x + (C1 + C2*x)*exp(-x) + 6)
```
可以看到,代码能够给出完整的解析式。之所以还保留了参数`C1`和`C2`是因为在求解过程中没有给微分方程指定初值。
我们再来看一个例子,这个例子是使用sympy解一个常微分方程组:
**例2.2** 使用sympy解下面这个常微分方程组:
$$
\left\{
\begin{align}
\frac{\mathrm{d}x_{1}}{\mathrm{d}t} &= 2x_{1} - 3x_{2} + 3x_{3}, & x_{1}(0) = 1\\
\frac{\mathrm{d}x_{2}}{\mathrm{d}t} &= 4x_{1} - 5x_{2} + 3x_{3}, & x_{2}(0) = 2\\
\frac{\mathrm{d}x_{3}}{\mathrm{d}t} &= 4x_{1} - 4x_{2} + 2x_{3}. & x_{3}(0) = 3\\
\end{align}
\right. \tag{2.2.2}
$$
这个方程组里面的$x_{1}, x_{2}, x_{3}$都是关于$t$的函数,所以需要声明四个符号变量。不同的是,在这里每个函数都指定了初始值,并且三个函数的导数高度相关,该怎么描述这种相关呢?我们来看下面的例子:
```python
t=symbols('t')
x1,x2,x3=symbols('x1,x2,x3',cls=Function)
eq=[x1(t).diff(t)-2*x1(t)+3*x2(t)-3*x3(t),
x2(t).diff(t)-4*x1(t)+5*x2(t)-3*x3(t),
x3(t).diff(t)-4*x1(t)+4*x2(t)-2*x3(t)]
con={x1(0):1, x2(0):2, x3(0):3}
s=dsolve(eq,ics=con)
print(s)
```
sympy当中内置的`symbols`工具是可以通过字符串批量创建变量的,这为我们带来了很大的方便。如果需要求解的是一个方程组,则使用列表将每一个方程表达出来即可。这里我们采取了不创建对象的方式,而是直接将方程组移项使每个方程右侧都为`0`。通过字典的方式保存函数的初始值,并利用`ics`参数传入`dsolve`从而得到方程的解。
```python
[Eq(x1(t), 2*exp(2*t) - exp(-t)), Eq(x2(t), 2*exp(2*t) - exp(-t) + exp(-2*t)), Eq(x3(t), 2*exp(2*t) + exp(-2*t))]
```
结果返回的是一个`Eq`对象构成的列表,每个对象代表了一个函数的解析式。对于这个例子,大家可以发现:它是一个线性的微分方程组,而针对线性方程我们还可以使用矩阵的形式去表示。所以,这个问题还有第二种写法:
```python
x=Matrix([x1(t),x2(t),x3(t)])
A=Matrix([[2,-3,3],[4,-5,3],[4,-4,2]])
eq=x.diff(t)-A*x
s=dsolve(eq,ics={x1(0):1, x2(0):2, x3(0):3})
print(s)
```
通过sympy中内置的符号矩阵`Matrix`对象构造函数向量和系数矩阵,通过对方程组矩阵化也可以得出一样的结果。返回值同上。使用sympy中的符号函数绘图得到结果如下:
```python
from sympy.plotting import plot
from sympy import *
t=Symbol('t')
plot(2*exp(2*t) - exp(-t), line_color='red')
plot(2*exp(2*t) - exp(-t) + exp(-2*t), line_color='blue')
plot(2*exp(2*t) + exp(-2*t), line_color='green')
```

图2.1.2 sympy求解图
sympy通过plotting下面的plot功能可以进行一些符号函数的绘图,但每一次调用都会创建一个独立的图窗,难以在同一张图上绘制多个函数的曲线。若要绘制多个函数则需要使用matplotlib来完成。
### 2.2.2 使用scipy求解微分方程数值解
微分方程的数值解是什么样子的呢?虽然大多数微分方程没有解析解,但解析式也并不是唯一可以表示函数的形式。函数的表示还可以用列表法和作图法来表示,而微分方程的数值解也正是像列表一样针对自变量数组中的每一个取值给出相对精确的因变量值。
Python求解微分方程数值解可以使用scipy库中的`integrate`包。在这当中有两个重要的函数:`odeint`和`solve_ivp`。但本质上,从底层来讲求解微分方程数值解的核心原理都是Euler 法和Runge-Kutta 法。关于这两个方法,我们会在后面进行进一步探讨。
我们先来了解一下`odeint`的用法吧。`odeint()`函数需要至少三个变量,第一个是微分方程函数,第二个是微分方程初值,第三个是微分的自变量。为了具体了解它的用法,我们通过一个例子来分析:
**例2.3** 使用scipy解下面这个微分方程的数值解:
$$
y' = \frac{1}{1 + x^{2}} - 2y^{2}, \quad y(0) = 0.\tag{2.2.3}
$$
首先需要通过`def`语句或者`lambda`表达式定义微分方程的表达式,然后定义微分方程的初值。代码如下:
```python
import matplotlib.pyplot as plt
dy=lambda y,x: 1/(1+x**2)-2*y**2 # y'=1/(1+x^2)-2y^2
'''
def dy(y,x):
return 1/(1+x**2)-2*y**2
'''
x=np.arange(0,10.5,0.1) #从0开始,每次增加0.1,到10.5为止(取不到10.5)
sol=odeint(dy,0,x) # odeint输入:微分方程dy,y的首项(y(0)等于多少),自变量列表
print("x={}\n对应的数值解y={}".format(x,sol.T))
plt.plot(x,sol)
plt.show()
```
这里`odeint`函数传入的三个参数分别是函数表达式,函数的初值与自变量。自变量是一个数组,通过`numpy.arange`生成一个范围在`[0, 10.5)`的等差数列,公差为`0.1`。返回的结果`sol`是针对数组`x`中每个值的对应函数值,可以通过`matplotlib.pyplot`绘图得到函数的结果。函数的图像如图所示:

图2.2.1 odeint函数求解图
我们再来看一个例子,这个例子是一个不可积函数的积分问题:
**例2.4** 使用scipy解下面这个微分方程的数值解:
$$
y'(t) = \sin t^{2}, \quad y(0) = 1 \tag{2.2.4}
$$
仿照例2.3中的代码,这个问题可以改写为:
```python
def dy_dt(y,t):
return np.sin(t**2)
y0=[1]
t = np.arange(-10,10,0.01)
y=odeint(dy_dt,y0,t)
plt.plot(t, y)
plt.show()
```
得到的结果必然是一个奇函数,图像为:

图2.2.2 scipy函数求解图
刚刚两个例子都是讲述了一阶微分方程如何求解,那么二阶及以上的高阶微分方程如何求解呢?事实上,Python求解微分方程数值解的时候是无法直接求解高阶微分方程的,必须通过换元降次的方法实现低阶化,把一个高阶微分方程替换成若干个一阶微分方程组成的微分方程组才能求解。具体的,我们可以看下面这个例子:
**例2.5** 使用scipy解下面这个高阶微分方程的数值解:
$$
\begin{align}
y'' - 20(1 - y^{2})y' + y = 0, \quad y(0) = 0, y'(0) = 2 \tag{2.2.5}
\end{align}
$$
这很显然是个二阶微分方程,并且不是常系数所以不能直接给出解析解。为了给这个方程做降次,令$u=y'$,那么$y''=u'$,式子就可以代换为:
$$
\left\{
\begin{align}
u &= y',\\[0.5em]
u' - 20(1 - y^{2})u + y &= 0,\\[0.5em]
y(0) &= 0,\\[0.5em]
u(0) &= 2.
\end{align}
\right. \tag{2.2.6}
$$
对于微分方程组,我们传入`[y,u]`两个函数的原函数值,返回的函数值为`[y’,u’]`。所以,只需要对每个微分表达式给出解析形式就可以了。代码如下:
```python
# odeint是通过把二阶微分转化为一个方程组的形式求解高阶方程的
# y''=20(1-y^2)y'-y
def fvdp(y,t):
'''
要把y看出一个向量,y = [dy0,dy1,dy2,...]分别表示y的n阶导,那么
y[0]就是需要求解的函数,y[1]表示一阶导,y[2]表示二阶导,以此类推
'''
dy1 = y[1] # y[1]=dy/dt,一阶导 y[0]表示原函数
dy2 = 20*(1-y[0]**2) * y[1] - y[0] # y[1]表示一阶微分
# y[0]是最初始,也就是需要求解的函数
# 注意返回的顺序是[一阶导, 二阶导],这就形成了一阶微分方程组
return [dy1, dy2]
# 求解的是一个二阶微分方程,所以输入的时候同时输入原函数y和微分y'
# y[0]表示原函数, y[1]表示一阶微分
# dy1表示一阶微分, dy2表示的是二阶微分
# 可以发现,dy1和y[1]表示的是同一个东西
# 把y''分离变量分离出来: dy2=20*(1-y[0]**2)*y[1]-y[0]
def solve_second_order_ode():
'''
求解二阶ODE
'''
x = np.arange(0,0.25,0.01)#给x规定范围
y0 = [0.0, 2.0] # 初值条件
# 初值[3.0, -5.0]表示y(0)=3,y'(0)=-5
# 返回y,其中y[:,0]是y[0]的值,就是最终解,y[:,1]是y'(x)的值
y = odeint(fvdp, y0, x)
y1, = plt.plot(x,y[:,0],label='y')
y1_1, = plt.plot(x,y[:,1],label='y‘')
plt.legend(handles=[y1,y1_1]) #创建图例
plt.show()
solve_second_order_ode()
```
定义函数`fvdp`,传入`y`的原函数值和一阶导数值(列表传入),返回`y`的一阶导数值和二阶导数值。初值条件`y(0)=0`和`y'(0)=2`传入`odeint`函数中,自变量是取值`[0, 0.25)`的一个等距数组。解得的`y`其实包含两列,第一列是函数值,第二列是导数值。结果的图像如下。

图2.2.3 fvdp函数求解图
图2.1.5展示的是原函数$y(x)$与一阶导数$y'(x)$的图像。从图像中可以看到,原函数$y(x)$呈现出一种振荡衰减的趋势,随着$x$的增加,$y(x)$的振幅逐渐减小,最终趋于稳定。这是因为二阶微分方程中的非线性项起到了阻尼作用,当y的绝对值接近$1$时,该项的值变小,从而减弱了$y$的增长速率,导致振荡的衰减。
同时,一阶导数$y'(x)$的图像显示出与原函数相似的振荡衰减模式,但相比之下,其变化更加剧烈。这是因为直接受到非线性阻尼项的影响,而$y$则是间接受到影响。
总的来说,这个微分方程组描述了一个非线性阻尼振荡系统,其解的行为随着初始条件和时间的变化而发生变化。在这个例子中,初始条件$y(0) = 0$和$y'(0) = 2$导致了一个振荡衰减的解,这种解在物理学和工程学中很常见,用于描述许多实际系统的动态行为。
我们再来看一个更高阶函数的求解的案例。
**例2.6** 使用scipy解下面这个高阶微分方程的数值解:
$$
\begin{align}
y''' + y'' - y' + y = \cos t, \quad y(0) = 0,~y'(0) = \pi,~y''(0) = 0 \tag{2.2.7}
\end{align}
$$
这个案例当然可以和上面一样如法炮制,输入`[y, y', y'']`返回`[y', y'', y''']`。这里再次介绍一个案例是想引出Python求微分方程数值解的另一个函数`solve_ivp`的用法。
首先,仍然是通过换元法对函数进行定义:
```python
def f(y,t):
dy1 = y[1]
dy2 = y[2]
dy3 = -y[0]+dy1-dy2-np.cos(t)
return [dy1,dy2,dy3]
```
`Solve_ivp`函数的用法与`odeint`非常类似,只不过比`odeint`多了两个参数。一个是`t_span`参数,表示自变量的取值范围;另一个是`method`参数,可以选择多种不同的数值求解算法。常见的内置方法包括`RK45`, `RK23`, `DOP853`, `Radau`, `BDF`等多种方法,通常使用`RK45`多一些。它的使用方法与`odeint`对比起来很类似,对这个问题进行代码实现如下:
```python
def solve_high_order_ode():
'''
求解高阶ODE
'''
t = np.linspace(0,6,1000)
tspan = (0.0, 6.0)
y0 = [0.0, pi, 0.0]
# 初值[0,1,0]表示y(0)=0,y'(0)=1,y''(0)=0
# 返回y, 其中y[:,0]是y[0]的值 ,就是最终解 ,y[:,1]是y'(x)的值
y = odeint(f, y0, t)
y_ = solve_ivp(f,t_span=tspan, y0=y0, t_eval=t)
plt.subplot(211)
l1, = plt.plot(t,y[:,0],label='y(0) Initial Function')
l2, = plt.plot(t,y[:,1],label='y(1) The first order of Initial Function')
l3, = plt.plot(t,y[:,2],label='y(2) The second order of Initial Function')
plt.legend(handles=[l1,l2,l3])
plt.grid('on')
plt.subplot(212)
l4, = plt.plot(y_.t, y_.y[0,:],'r--', label='y(0) Initial Function')
l5,= plt.plot(y_.t,y_.y[1,:],'g--', label='y(1) The first order of Initial Function')
l6, = plt.plot(y_.t,y_.y[2,:],'b-', label='y(2) The second order of Initial Function')
plt.legend(handles=[l4,l5,l6]) # 显示图例
plt.grid('on')
plt.show()
solve_high_order_ode()
```
> solve_ivp 和 odeint 两个函数都可以解偏微分方程,但是solve_ivp可以不考虑参数顺序,odeint必须要考虑参数顺序(经验之谈)。感谢学习者提问&勘误。
这里通过`matplotlib.pyplot`中提供的绘图接口绘制了两个数值解的图像。由于没有设置`method`参数,这里默认`solve_ivp`使用`RK45`(4-5阶 Runge-Kutta 法)方法进行求解。所解得的结果如图所示:

图2.2.4 Runge-Kutta 法求解图
图中上半部分是使用`odeint`求解得到的结果,下半部分是由`solve_ivp`得到的结果,二者大差不差。一般来说对于普通的微分方程`odeint`与`solve_ivp`得到的结果差异不会太大,但有些情况下函数的微分容易发散,就会导致求解结果出现比较大的差异。`odeint`内置的原理也是4-5阶 Runge-Kutta 法,版本比较早所以求解也相对较为稳定。但`solve_ivp`则是后来新增的方法,有可能出现不太稳定的现象,内置方法较多所以也更加灵活。
Python求解微分方程组的模式有两种:一是采用基于基本原理自己写相关函数,这样操作比较繁琐,但是对于整个的求解过程会比较清晰明了;第二就是利用python下面的ode求解器,熟悉相关的输入输出,就可以完成数值求解。基于这个demo,在不同方向领域可以套用不同的微分方程组模型,进行仿真求解。但无论是常微分方程组还是偏微分方程组,使用的都是同一套思路,就是用差分代替微分。
**例2.7** 使用scipy解下面这个微分方程组的数值解:
$$
\left\{
\begin{align}
x'(t) &= - x^{3} - y, \\
y'(t) &= -y^{3} + x.
\end{align}
\right.
$$
这个例子和例2.2很像,但不同的是这是也一个非线性方程组。那么,输入就需要以数组的形式传入`[x, y]`两个函数的函数值,返回它们的导数值。这里使用`solve_ivp`对这个方程组进行求解如下:
```python
def fun(t, w):
x = w[0]
y = w[1]
return [-x**3-y,-y**3+x]
# 初始条件
y0 = [1,0.5]
yy = solve_ivp(fun, (0,100), y0, method='RK45',t_eval = np.arange(0,100,0.2) )
t = yy.t
data = yy.y
plt.plot(t, data[0, :])
plt.plot(t, data[1, :])
plt.xlabel("时间s")
plt.show()
```
绘制图像如图所示:

图2.2.5 solve_ivp函数求解图
可以看到二者处于相互干扰的震荡状态,振幅随着时间的推移逐渐收敛并趋于稳定。
如果在微分方程组里面还出现了高阶微分,我们又应该怎么做呢?来看下面这个例子:
**例2.8** 使用 scipy 解下面这个微分方程组的数值解:
$$
\left\{
\begin{align}
x''(t) + y'(t) + 3x(t) &= \cos(2t), \\[0.5em]
y''(t) - 4x'(t) + 3y(t) &= \sin(2t), \\[0.5em]
x'(0) = \frac{1}{5}, y'(0) = \frac{6}{5}, x(0) &= y(0) = 0.
\end{align}
\right.
$$
这个例子就比较有趣了,在方程组里面涉及到了两个函数的导数怎么求解呢?本质上还是要使用换元法。完全可以令$u=x’$, $v=y’$,然后带入到方程组中把一个二元二阶微分方程组变为四元一阶微分方程组。代码实现如下所示:
```python
def fun(t, w):
x = w[0]
y = w[1]
dx = w[2]
dy = w[3]
# 求导以后[x,y,dx,dy]变为[dx,dy,d2x,d2y]
# d2x为w[2],d2y为w[5]
return [dx,dy,-dy-3*x+np.cos(2*t),4*dx-3*y+np.sin(2*t)]
# 初始条件
y0 = [0,0,1/5,1/6]
yy = solve_ivp(fun, (0,100), y0, method='RK45',t_eval = np.arange(0,100,0.2) )
t = yy.t
data = yy.y
plt.figure(figsize=(12,6))
plt.subplot(1,2,1)
plt.plot(t, data[0, :])
plt.plot(t, data[1, :])
plt.legend(['x','y'])
plt.xlabel("时间s")
plt.subplot(1,2,2)
plt.plot(t, data[2, :])
plt.plot(t, data[3, :])
plt.legend(["x' ","y' "])
plt.xlabel("时间s")
plt.show()
```
得到的图像如图所示:

图2.2.6 四元一阶方程求解图
可以看到,图像呈现出一定的周期规律但并不是简谐运动。这样的方程解往往在物理学中有着实际意义,例如,这样的方程可以描述物体同时出现平动和摆动的过程中,位移-速度-加速度与角度-角速度-角加速度之间存在的关系。这样的例子曾出现在2022年全国大学生数学建模竞赛A题中,我们后面可以看到。
最后一个例子是对蝴蝶效应的求解。
例2.9 使用scipy求解洛伦兹系统的数值解,参数与初始值自设:
$$
\left\{
\begin{align}
\frac{\mathrm{d}x}{\mathrm{d}t} &= p(y - x), \\
\frac{\mathrm{d}x}{\mathrm{d}t} &= x(r - z), \\
\frac{\mathrm{d}z}{\mathrm{d}t} &= xy - bz,
\end{align}
\right.
$$
有前面的例子作为经验,我们知道x y z都是函数其余的都是参数。利用solve_ivp很容易求解这个系统的代码:
```python
import numpy as np
from scipy.integrate import odeint
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt
def dmove(Point, t, sets):
p, r, b = sets
x, y, z = Point
return np.array([p*(y-x), x*(r-z), x*y-b*z])
t = np.arange(0, 30, 0.001)
P1 = odeint(dmove, (0., 1., 0.), t, args=([10., 28., 3.],))
P2 = odeint(dmove, (0., 1.01, 0.), t, args=([10., 28., 3.],))
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
ax.plot(P1[:,0], P1[:,1], P1[:,2])
ax.plot(P2[:,0], P2[:,1], P2[:,2])
plt.show()
```
`Mpl_toolkits.mplot3d`提供了进行三维曲线、曲面绘制的函数,这里使用里面提供的三维坐标系绘制洛伦兹系统中点的运动轨迹。我们这里基于不同的初值绘制了两个点的轨迹`P1`和`P2`,并展示在图中:

图2.2.7 洛伦兹系统中点的运动轨迹图
可以看到,曲线的形状呈现双螺旋状,有些像蝴蝶的翅膀。所以洛伦兹系统又被叫做“蝴蝶效应”。蝴蝶效应本质上就是指,即使给这个系统的初始值一点微小的变化,曲线的形状也会出现很大不同。仅仅是把$y$改变了$0.01$,曲线的密集程度与蝴蝶翅膀的大小也是有所不同的。这是个混沌系统里面的典型案例。
## 2.3 偏微分方程的数值求解
偏微分方程是针对多元函数来说的,它在物理学中有着很深刻的现实意义。但是,偏微分方程往往比常微分方程更难求解,并且Python也没有提供偏微分方程的专用工具包。怎么求解这些偏微分方程呢?我们要始终把握一个思想:就是把连续的问题离散化。这一节会通过一系列的物理案例来看到Python如何求解一些典型的偏微分方程。
### 2.3.1 偏微分方程数值解的理论基础
偏微分方程实际上就是由多元函数、自变量与多元函数的偏导数及高阶偏导数之间构成的方程。它在工程中很多地方都有深刻应用,比如波动力学、热学、电磁学等。我们常研究的就是二元函数的二阶偏微分方程,其基本形式为:
$$
A \frac{ \partial^{2} f }{ \partial x^{2} } + 2B \frac{ \partial^{2} f }{ \partial x\partial y } + C \frac{ \partial^{2} f }{ \partial y^{2} } + D\frac{ \partial f }{ \partial x } + E \frac{ \partial f }{ \partial y } + Ff = 0. \tag{2.3.1}
$$
在方程中,如果$A$、$B$、$C$三个常系数不全为$0$,定义判别式$\Delta = B^{2} - 4AC$,当判别式大于$0$称其为双曲线式方程;若判别式等于$0$,则称其为抛物线式方程;若判别式小于$0$,则称其为椭圆式方程。有关于这几类方程的基本性质与边界条件等内容请感兴趣的读者自行参考偏微分方程领域的书籍,我们的主要目光更多的聚焦在它的应用上。
刚刚我们说到,二阶偏微分方程主要有三类:椭圆方程,抛物方程和双曲方程。双曲方程描述变量以一定速度沿某个方向传播,常用于描述振动与波动问题。椭圆方程描述变量以一定深度沿所有方向传播,常用于描述静电场、引力场等稳态问题。抛物方程描述变量沿下游传播,常用于描述热传导和扩散等瞬态问题。它们都在工程中有实际应用。
偏微分方程的定解问题通常很难求出解析解,只能通过数值计算方法对偏微分方程的近似求解。常用偏微分方程数值解法有包括有限差分方法、有限元方法、有限体方法、共轭梯度法,等等。在使用这些数值方法时通常先对问题的求解区域进行网格剖分,然后将定解问题离散为代数方程组,求出在离散网格点上的近似值。
偏微分方程的典型应用有很多。描述热源传热过程中温度变化的热传导方程本质上是一个抛物类微分方程。大名鼎鼎的韦东奕大神所着重研究的纳维-斯托克斯方程,所描述的是流体流速与流体密度、压力、外阻力之间的关系,在机械工程、能源工程等制造领域有着重要应用。还有电磁场中非常重要的麦克斯韦方程组,本质上也是偏微分方程。下面我们会根据一系列的具体应用来看如何去构建与求解微分方程。
### 2.3.2 偏微分方程数值解的应用案例
> 本节参考https://youcans.blog.csdn.net/article/details/119755450,特别鸣谢!
解偏微分方程由于缺少特定的工具包,更多的情况下需要自己写代码求解。这就迫使我们不得不理解微分方程求解的核心思想:以离散代替连续,用差分逼近微分。
一类有限差分法求解偏微分方程数值解的步骤包括如下几步:
1. 定义初始条件函数 $U(x,0)$;
2. 输入模型参数,对于待求解函数$F(x,t)$定义求解的时间域$(\mathrm{tStart}, \mathrm{tEnd})$和空间域$(\mathrm{xMin}, \mathrm{xMax})$,设置差分步长$\mathrm{d}t$, $\mathrm{nNodes}$;
3. 初始化解空间;
4. 根据递推规则递推求解差分方程在区间`[xa, xb]`的数值解,获得网格节点的处的函数值,最终填满整个解空间。
我们首先用一个常微分方程的应用揭示这一过程。
**例2.10** 使用偏微分方程对RC电路进行建模,分析电容放电过程中电量随时间的变化。

图2.3.1 洛伦兹系统中点的运动轨迹图
$$
\left\{
\begin{align}
IR + \frac{Q}{C} &= 0, \\
I &= \frac{\mathrm{d}Q}{\mathrm{d}t},
\end{align}
\right. \tag{2.3.2}
$$
式子中$I$代表了电流,$R$代表电阻值,$Q$代表电容电量,$C$代表电容值。为什么这个电路叫$RC$电路?这不仅仅是由于电路中包括电阻和电容两个核心元件,也是指$\tau = RC$的乘积是一个重要的参数。令,这个常微分方程存在很明显的解析解:
$$
Q(t) = Q_{0} e^{- t/\tau}. \tag{2.3.3}
$$
那么我们为什么还要来谈这个案例?我想通过这个案例来给大家讲述一下把一个连续问题离散化的方法。我们先从一阶微分方程的计算原理说起。将一阶微分方程离散化,实际上也就是把它写成迭代、差分的形式。对于一个连续的问题有
$$
\left\{
\begin{align}
\frac{\mathrm{d}y}{\mathrm{d}t} &= f(y, t), \\[0.5em]
y_{0}(t_{0}) &= y_{0}.
\end{align}
\right. \tag{2.3.4}
$$
画出$y(t)$的图像如图所示:

图2.3.2 y(t)图象
在图中,如果求解区间为$[x_0, x_0+h]$,已知点$(x_{0},f(x_{0}))$,要求解$f(x_0+h)$应该怎么做?根据微分方程$f(x,y)=0$可以很容易地解得每个点的导数值。在距离$h$很小的情况下,我们说,切线与割线是可以逼近的,也就可以用切线的对应值逼近函数值。但在上图中,我们发现:如果使用$x_0$点处的导数作为切线斜率,那么得到的估计值是比实际值要小的;但如果使用$x_0+h$点的导数作为斜率,那么估计值比实际值又大一些。能不能取一个折中的方案呢?很简单,把二者进行一个平均就可以了:
$$
y_{n+1} = y_{n} + \frac{f(y_{n}, t_{n}) + f(y_{n+1}, t_{n+1})}{2} h. \tag{2.3.5}
$$
那么对于例2.10中的电容放电过程,这个问题的代码可以这样写:
```python
import numpy as np
import matplotlib.pyplot as plt
rc = 2.0 #设置常数
dt = 0.5 #设置步长
n = 1000 #设置分割段数
t = 0.0 #设置初始时间
q = 1.0 #设置初始电量
#先定义三个空列表
qt=[] #用来盛放差分得到的q值
qt0=[] #用来盛放解析得到的q值
time = [] #用来盛放时间值
for i in range(n):
t = t + dt
q1 = q - q*dt/rc #qn+1的近似值
q = q - 0.5*(q1*dt/rc + q*dt/rc) #差分递推关系
q0 = np.exp(-t/rc) #解析关系
qt.append(q) #差分得到的q值列表
qt0.append(q0) #解析得到的q值列表
time.append(t) #时间列表
plt.plot(time,qt,'o',label='Euler-Modify') #差分得到的电量随时间的变化
plt.plot(time,qt0,'r-',label='Analytical') #解析得到的电量随时间的变化
plt.xlabel('time')
plt.ylabel('charge')
plt.xlim(0,20)
plt.ylim(-0.2,1.0)
plt.legend(loc='upper right')
plt.show()
```
这个案例我们没有用任何包里面的微分方程求解器,纯手写的情况下解了这个微分方程。它的结果如图所示:

图2.3.3 电容放电曲线
这个方法被称为**Euler 法**,是在求解常微分方程中的一种常见数值方法。
**例2.11** 一维热传导方程是一个典型的抛物型二阶偏微分方程。设$u(x,t)$表示在时间$t$,空间$x$处的温度,则根据傅里叶定律(单位时间内流经单位面积的热量和该处温度的负梯度成正比),可以导出热传导方程:
$$
\frac{ \partial u }{ \partial t } = \lambda \frac{ \partial^{2} u }{ \partial x^{2} }
$$
其中$\lambda$称为热扩散率,$k,C,p$分别为热导率,比热和质量密度,是由系统本身确定的常量。问题的形式为:
$$
\begin{align}
&\frac{ \partial u(x, t) }{ \partial t } = \frac{ \partial^{2} u(x,t) }{ \partial x^{2} } \\[0.5em]
& 0 \leqslant t \leqslant 1000, 0 \leqslant x \leqslant 3\\[0.5em]
& u(x,0) = 4x(3-x)\\[0.5em]
& u(0,t) = u(3, t) = 0\\[0.5em]
\end{align} \tag{2.3.7}
$$
请求解这个问题的数值解。
一元函数的微分方程可以绘制曲线,那么二元函数的偏微分方程应该就可以绘制曲面。那么,怎么对这个问题进行离散化呢?对于一个一般的二阶抛物型偏微分方程:
$$
\begin{align}
&\frac{ \partial u(x, t) }{ \partial t } = \frac{ \partial^{2} u(x,t) }{ \partial x^{2} } \\[0.5em]
& 0 \leqslant t \leqslant T, 0 \leqslant x \leqslant l\\[0.5em]
& u(x,0) = f(x)\\[0.5em]
& u(0,t) = g_{1}(t)\\[0.5em]
& u(3, t) = g_{2}(t)\\[0.5em]
\end{align} \tag{2.3.7}
$$
对时间和空间的一阶偏导数微分是容易离散化的:
$$
\begin{align}
\frac{ \partial u(x_{i}, t_{k}) }{ \partial t_{k} } &\implies \frac{u(x_{i}, t_{k+1}) - u(x_{i}, t_{k})}{\Delta t}, \\
\frac{ \partial u(x_{i}, t_{k}) }{ \partial x_{i} } &\implies \frac{u(x_{i+1}, t_{k}) - u(x_{i}, t_{k})}{\Delta x}. \\
\end{align}
$$
那么,对于空间的二阶微分,可以看作是一阶微分的再微分:
$$
\frac{ \partial^{2} u(x_{i}, t_{k}) }{ \partial x_{i}^{2} } \implies \frac{\displaystyle \frac{u(x_{i+1}, t_{k}) - u(x_{i}, t_{k})}{\Delta x} - \frac{u(x_{i}, t_{k}) - u(x_{i-1}, t_{k})}{\Delta x}}{\Delta x}. \tag{2.3.10}
$$
也就是$\displaystyle \frac{u(x_{i+1}, t_{k}) + u(x_{i-1}, t_{k}) - 2u(x_{i}, t_{k})}{(\Delta x)^{2}}$。
了解了这个原理,我们将例2.11中的边界条件和迭代规则进行翻译如下:
```python
import numpy as np
import matplotlib.pyplot as plt
h = 0.1#空间步长
N =30#空间步数
dt = 0.0001#时间步长
M = 10000#时间的步数
A = dt/(h**2) #lambda*tau/h^2
U = np.zeros([N+1,M+1])#建立二维空数组
Space = np.arange(0,(N+1)*h,h)#建立空间等差数列,从0到3,公差是h
#边界条件
for k in np.arange(0,M+1):
U[0,k] = 0.0
U[N,k] = 0.0
#初始条件
for i in np.arange(0,N):
U[i,0]=4*i*h*(3-i*h)
#递推关系
for k in np.arange(0,M):
for i in np.arange(1,N):
U[i,k+1]=A*U[i+1,k]+(1-2*A)*U[i,k]+A*U[i-1,k]
```
将解空间抽象为以时间为横坐标、空间为纵坐标的网格,翻译时间与空间的边界条件,对于网格内每个点使用二重循环遍历每个点,根据差分后的迭代方程进行演化。不同时刻的温度随空间坐标的变化图像如下:
```python
plt.plot(Space,U[:,0], 'g-', label='t=0',linewidth=1.0)
plt.plot(Space,U[:,3000], 'b-', label='t=3/10',linewidth=1.0)
plt.plot(Space,U[:,6000], 'k-', label='t=6/10',linewidth=1.0)
plt.plot(Space,U[:,9000], 'r-', label='t=9/10',linewidth=1.0)
plt.plot(Space,U[:,10000], 'y-', label='t=1',linewidth=1.0)
plt.ylabel('u(x,t)', fontsize=20)
plt.xlabel('x', fontsize=20)
plt.xlim(0,3)
plt.ylim(-2,10)
plt.legend(loc='upper right')
plt.show()
```

图2.3.4 温度稳态分布图
在图中可以看到,随着时间的推进,温度分布呈现出一种动态变化的过程。在$t = 0$时(绿线),我们看到起始的温度分布情况;随着时间的推移,图中的曲线显示出温度在不同位置的变化。
* 在 $\displaystyle t = \frac{3}{10}$(蓝线)时,曲线稍微上升,表明温度在增加。
* 到了 $\displaystyle t=\frac{6}{10}$(黑线),温度继续上升,但上升的速度似乎开始减缓。
* $\displaystyle t = \frac{9}{10}$(红线)时,曲线达到高点之后开始回落,这可能意味着一个冷却过程的开始。
* 到 $t = 1$(黄线)时,温度分布与时相比$t=0$明显增高,但比$\displaystyle t = \frac{9}{10}$时有所降低,显示了经过一段时间后温度分布的稳定状态。
整体上,这些曲线描述了温度如何随着时间从初始状态演变到一个稳态分布。这种分析对于理解热传导、扩散过程,以及如何在时间上控制温度分布都是非常有用的。图形的$\mathrm{y}$轴显示的是$u(x,t)$,即位置$x$在时间$t$的温度,而$\mathrm{x}$轴表示空间坐标。曲线下方较深的颜色表示较低的温度值,而曲线顶部较浅的颜色表示较高的温度值。通过设置坐标轴的范围和图例的位置,这张图为观察者提供了清晰的数据解读。
图中的$\mathrm{y}$轴标记为 ,表示在位置$x$和时间$t$的温度,$\mathrm{x}$轴标记为$x$表示空间坐标。可视窗口的设置为$x$值从$0$到$3$,$u(x,t)$的值从$-2$到$10$。
将整个网格空间的温度分布热力图绘制如图所示:
```python
#温度等高线随时空坐标的变化,温度越高,颜色越偏红
extent = [0,1,0,3] #时间和空间的取值范围
levels = np.arange(0,10,0.1)#温度等高线的变化范围0-10,变化间隔为0.1
plt.contourf(U,levels,origin='lower',extent=extent,cmap=plt.cm.jet)
plt.ylabel('x', fontsize=20)
plt.xlabel('t', fontsize=20)
plt.show()
```

图2.3.5 温度分布热力图
从图中可以看到的是一个温度分布的热力图,其中颜色的变化表明了不同温度的区域。温度越高,颜色越偏向红色,温度较低的区域则显现为蓝色。这种热力图通常用于显示温度如何在空间内分布以及如何随时间变化。
图中的$\mathrm{y}$轴(标记为$x$)代表空间坐标,而$\mathrm{x}$轴(标记为$t$)代表时间。热力图覆盖的范围是时间从 $0$ 到 $1$,空间从 $0$ 到 $3$。可以清晰地看到,在图的左侧(时间较早)温度整体较低,而在图的右侧(时间较晚)温度较高,这表示随着时间的推移,整体温度有所上升。
等温线的密集区表示温度变化较大的区域,而等温线的稀疏区则表示温度变化较小的区域。从热力图中我们可以推断,最高温区域集中在图的右上角,而最低温区域则在左下角。通过色彩的深浅变化,我们可以直观地看到温度在空间中如何变化以及时间对这种分布的影响。
**例2.12** 平流过程是大气运动中重要的过程。平流方程(Advection equation)描述某一物理量的平流作用而引起局地变化的物理过程,最简单的形式是一维平流方程。
$$
\begin{align}
& \frac{ \partial u }{ \partial t } + v \frac{ \partial u }{ \partial x } = 0,\\[0.5em]
& u(x,0) = F(x).
\end{align} \tag{2.3.11}
$$
式中$u$为某物理量,$v$为系统速度,$x$为水平方向分量,$t$为时间。该方程可以求得解析解:
$$
u(x,t) = F(x-vt). \tag{2.3.12}
$$
考虑一维线性平流偏微分方程的数值解法,采用有限差分法求解。简单地, 采用一阶迎风格式的差分方法(First-order Upwind),一阶导数的差分表达式为:
$$
\begin{align}
\left. \frac{ \partial u }{ \partial x } \right|_{i,j} &\implies \frac{u_{i+1,j} - u_{i,j}}{\Delta x}, \\[0.5em]
u_{i,j+1} &= u_{i,j} - v\frac{\Delta t}{\Delta x}\big( u_{i,j} - u_{i-1,j} \big).
\end{align} \tag{2.3.13}
$$
代码实现如下:
```python
# eg.3.
import numpy as np
import matplotlib.pyplot as plt
# 初始条件函数 U(x,0)
def funcUx_0(x, p):
u0 = np.sin(2 * (x-p)**2)
return u0
# 输入参数
v1 = 1.0 # 平流方程参数,系统速度
p = 0.25 # 初始条件函数 u(x,0) 中的参数
tc = 0 # 开始时间
te = 1.0 # 终止时间: (0, te)
xa = 0.0 # 空间范围: (xa, xb)
xb = np.pi
dt = 0.02 # 时间差分步长
nNodes = 100 # 空间网格数
# 初始化
nsteps = round(te/dt)
dx = (xb - xa) / nNodes
x = np.arange(xa-dx, xb+2*dx, dx)
ux_0 = funcUx_0(x, p)
u = ux_0.copy() # u(j)
ujp = ux_0.copy() # u(j+1)
# 时域差分
for i in range(nsteps):
plt.clf() # 清除当前 figure 的所有axes, 但是保留当前窗口
# 计算 u(j+1)
for j in range(nNodes + 2):
ujp[j] = u[j] - (v1 * dt/dx) * (u[j] - u[j-1])
# 更新边界条件
u = ujp.copy()
u[0] = u[nNodes + 1]
u[nNodes+2] = u[1]
tc += dt
# 绘图
plt.plot(x, u, 'b-', label="v1= 1.0")
plt.axis((xa-0.1, xb + 0.1, -1.1, 1.1))
plt.xlabel("x")
plt.ylabel("U(x)")
plt.legend(loc=(0.05,0.05))
plt.show()
```

图2.3.6 平流大气运动图
从图中可以发现,函数$U(x)$显示了随空间$x$变化的波动性质。这个波动可能代表一维平流方程在某一特定时间$t$的数值解。图中蓝色的线表示速度$v_{1} = 1.0$下的解,而波形的变化暗示了初态条件$U(x,0) = \sin \big[ 2(x-p)^{2} \big]$随时间的演化。
注意到曲线在$\mathrm{x}$轴的不同位置出现了波峰和波谷,这表明了函数值随位置的变化并非均匀。由于是一阶迎风格式的数值解法,我们可能会观察到与理论解相比有一定程度的数值扩散或者数值耗散,这是由于一阶方法在数值传输过程中的固有特性。
在这个示例中,曲线的形状可能表示了经过一段时间演化后,平流作用在初始条件$U(x,0)$上的效果。如果初始波形移动的速度是$v_{1}$,那么这个图形可能表明波形随着时间的推进而向右移动。然而,由于是一阶迎风差分,我们也可以预期波形会有一定程度的变形,这在实际中表现为波峰变得不那么尖锐,以及波形整体变得更加平坦。这是数值方法的离散化误差导致的结果。
这个数值解可以帮助理解平流方程在数值模拟中的行为,特别是当理论解难以获得时,数值方法提供了一种有效的途径来近似解决实际问题。
**例2.13** 波动方程(wave equation)是典型的双曲偏微分方程,广泛应用于声学,电磁学,和流体力学等领域,描述自然界中的各种的波动现象,包括横波和纵波,例如声波、光波和水波。考虑如下二维波动方程的初边值问题:
$$
\begin{align}
\frac{ \partial^{2} u }{ \partial t^{2} } &= c^{2} \left( \frac{ \partial^{2} u }{ \partial x^{2} } + \frac{ \partial^{2} u }{ \partial y^{2} } \right), \\[0.5em]
\frac{ \partial u(0,x,y) }{ \partial t } &= 0,\\[0.5em]
u(x,y,0) &= u_{0}(x,y),\\[0.5em]
u(0,y,t) &= u_{a}(t), \quad u(1,y,t) = u_{b}(t),\\[0.5em]
u(x,0,t) &= u_{c}(t), \quad u(x,1,t) = u_{d}(t),\\[0.5em]
\end{align}\tag{2.3.14}
$$
式中:$u$ 是振幅;$c$ 为波的传播速率,$c$ 可以是固定常数,或位置的函数 $c(x,y)$,也可以是振幅的函数 $c(u)$。
考虑二维波动偏微分方程的数值解法,采用有限差分法求解。简单地, 采用迎风法的三点差分格式, 将上述的偏微分方程离散为差分方程:
$$
\begin{align}
&\frac{u_{i,j,k} + u_{i,j,k+1} - 2u_{i,j,k}}{(\Delta t)^{2}} \\&= c^{2} \left[ \frac{u_{i-1,j,k} + u_{i+1,j,k} - 2u_{i,j,k}}{(\Delta x)^{2}} + \frac{u_{i,j-1,k} + u_{i,j+1,k} - 2u_{i,j,k}}{(\Delta y)^{2}} \right]. \tag{2.3.15}
\end{align}
$$
可以得到迭代规则为:
$$
\begin{align}
r_{x} &= c^{2} \frac{(\Delta t)^{2}}{(\Delta x)^{2}}, \\[0.5em]
r_{y} &= c^{2} \frac{(\Delta t)^{2}}{(\Delta y)^{2}}, \\[0.5em]
u_{i,j,k+1} &= r_{x}\big( u_{i-1,j,k} + u_{i+1,j,k} \big) \\ &+ 2\big( 1 - r_{x} - r_{y} \big) u_{i,j,k} + r_{y} \big( u_{i,j-1,k} + u_{i,j+1,k} \big) - u_{i,j,k-1}.
\end{align} \tag{2.3.16}
$$
它的代码实现如下:
```python
import numpy as np
import matplotlib.pyplot as plt
# 模型参数
c = 1.0 # 波的传播速率
tc, te = 0.0, 1.0 # 时间范围,01 则算法不稳定)
r = 4 * c2 * dt*dt / (dx*dx+dy*dy)
print("dt = {:.2f}, dx = {:.2f}, dy = {:.2f}, r = {:.2f}".format(dt,dx,dy,r))
assert r < 1.0, "Error: r>1, unstable step ratio of dt2/(dx2+dy2) ."
rx = c*c * dt**2/dx**2
ry = c*c * dt**2/dy**2
# 绘图
fig = plt.figure(figsize=(8,6))
ax1 = fig.add_subplot(111, projection='3d')
# 计算初始值
U = np.zeros([tNodes+1, xNodes+1, yNodes+1]) # 建立三维数组
U[0] = np.sin(6*np.pi*xx)+np.cos(4*np.pi*yy) # U[0,:,:]
U[1] = np.sin(6*np.pi*xx)+np.cos(4*np.pi*yy) # U[1,:,:]
surf = ax1.plot_surface(xx, yy, U[0,:,:], rstride=2, cstride=2, cmap=plt.cm.coolwarm)
# 有限差分法求解
for k in range(2,tNodes+1):
for i in range(1,xNodes):
for j in range(1,yNodes):
U[k,i,j] = rx*(U[k-1,i-1,j]+U[k-1,i+1,j]) + ry*(U[k-1,i,j-1]+U[k-1,i,j+1])\
+ 2*(1-rx-ry)*U[k-1,i,j] -U[k-2,i,j]
surf = ax1.plot_surface(xx, yy, U[k,:,:], rstride=2, cstride=2, cmap='rainbow')
ax1.set_xlim3d(0, 1.0)
ax1.set_ylim3d(0, 1.0)
ax1.set_zlim3d(-2, 2)
ax1.set_title("2D wave equationt (t= %.2f)" % (k*dt))
ax1.set_xlabel("x")
ax1.set_ylabel("y")
plt.show()
```
这个函数是一个三元函数,实际上是可以做出一个曲面随时间变化的动画的。大家可以尝试使用matplotlib提供的动画功能进行绘制,这里展示其中一个瞬时状态:

图2.3.7 波形方程求解图
**例2.14** 热传导方程(heat equation)是典型的抛物形偏微分方程,也成为扩散方程。广泛应用于声学,电磁学,和流体力学等领域,描述自然界中的各种的波动现象,包括横波和纵波,例如声波、光波和水波。之前的例2.11我们已经看到了一维热传导方程的求解,现在考虑如下二维热传导方程的初边值问题:
$$
\begin{align}
\frac{ \partial u }{ \partial t } &= \lambda \left( \frac{ \partial^{2} u }{ \partial x^{2} + \frac{ \partial^{2} u }{ \partial y^{2} } } \right) + q_{v}\\[0.5em]
\frac{ \partial u(0, x, y) }{ \partial t } &= 0\\[0.5em]
u(x,y,0) &= u_{0}(x,y),\\[0.5em]
u(0,y,t) &= u_{a}(t), \quad u(1,y,t) = u_{b}(t),\\[0.5em]
u(x,0,t) &= u_{c}(t), \quad u(x,1,t) = u_{d}(t).\\[0.5em]
\end{align} \tag{2.3.17}
$$
类似的,同上一个例子中的有限差分法,我们将这个方程离散化可以得到:
$$
\begin{align}
r_{x} &= \lambda^{2} \frac{(\Delta t)^{2}}{(\Delta x)^{2}}, \\[0.5em]
r_{y} &= \lambda^{2} \frac{(\Delta t)^{2}}{(\Delta y)^{2}}, \\[0.5em]
u_{i,j,k+1} &= r_{x}\big( u_{i-1,j,k} + u_{i+1,j,k} \big) \\ &+ 2\big( 1 - r_{x} - r_{y} \big) u_{i,j,k} + r_{y} \big( u_{i,j-1,k} + u_{i,j+1,k} \big) - u_{i,j,k-1}.
\end{align} \tag{2.3.18}
$$
事实上,这个方程还有一种矩阵形式:
$$
U_{k+1} = U_{k} + r_{x}AU_{k} + r_{y}BU_{k} + q_{v}\Delta t, \tag{2.3.19}
$$
其中
$$
A = \left[ \begin{matrix}
-2 & -1 & 0 & \cdots & 0 & 0\\
1 & -2 & 1 & \cdots & 0 & 0\\
0 & 1 & -2 & \cdots & 0 & 0\\
\vdots & \vdots & \vdots & \ddots & \vdots & \vdots\\
0 & 0 & 0 & \cdots & 1 & -2
\end{matrix} \right]_{N_{x} \times N_{x}}, \quad
B = \left[ \begin{matrix}
-2 & -1 & 0 & \cdots & 0 & 0\\
1 & -2 & 1 & \cdots & 0 & 0\\
0 & 1 & -2 & \cdots & 0 & 0\\
\vdots & \vdots & \vdots & \ddots & \vdots & \vdots\\
0 & 0 & 0 & \cdots & 1 & -2
\end{matrix} \right]_{N_{y} \times N_{y}}. \tag{2.3.20}
$$
我们这次就用这个矩阵形式去进行偏微分方程的求解:
```python
import numpy as np
import matplotlib.pyplot as plt
def showcontourf(zMat, xyRange, tNow): # 绘制等温云图
x = np.linspace(xyRange[0], xyRange[1], zMat.shape[1])
y = np.linspace(xyRange[2], xyRange[3], zMat.shape[0])
xx,yy = np.meshgrid(x,y)
zMax = np.max(zMat)
yMax, xMax = np.where(zMat==zMax)[0][0], np.where(zMat==zMax)[1][0]
levels = np.arange(0,100,1)
showText = "time = {:.1f} s\nhotpoint = {:.1f} C".format(tNow, zMax)
plt.plot(x[xMax],y[yMax],'ro') # 绘制最高温度点
plt.contourf(xx, yy, zMat, 100, cmap=plt.cm.get_cmap('jet'), origin='lower', levels = levels)
plt.annotate(showText, xy=(x[xMax],y[yMax]), xytext=(x[xMax],y[yMax]),fontsize=10)
plt.colorbar()
plt.xlabel('X')
plt.ylabel('Y')
plt.title('Temperature distribution of the plate')
plt.show()
# 模型参数
uIni = 25 # 初始温度值
uBound = 25.0 # 边界条件
c = 1.0 # 热传导参数
qv = 50.0 # 热源功率
x_0, y0 = 0.0, 3.0 # 热源初始位置
vx, vy = 2.0, 1.0 # 热源移动速度
# 求解范围
tc, te = 0.0, 5.0 # 时间范围,0图2.3.8 温度空间分布图
最终状态下的温度分布图显示了热源随时间在平板中移动的情况,并将热量传递给周围材料。图像中的红点标记了最高温度点,即‘热点’,对应于最后时间步骤中热源的当前位置。颜色渐变代表温度分布,红色是最热的区域,蓝色是最冷的。等温线表示温度相等的水平线。
热点周围的温度梯度平滑,这表明热量通过平板均匀扩散。这样的模拟在许多应用中都很有用,例如在散热器设计、理解制造过程中的热梯度,甚至在诸如地热源传播热量的自然现象中。
**例2.15** 椭圆偏微分方程是一类重要的偏微分方程,主要用来描述物理的平衡稳定状态,如定常状态下的电磁场、引力场和反应扩散现象等,广泛应用于流体力学、弹性力学、电磁学、几何学和变分法中。
考虑如下二维 Poisson 方程:
$$
\frac{ \partial^{2} u }{ \partial x^{2} } + \frac{ \partial^{2} u }{ \partial y^{2} } = f(x,y), \tag{2.3.21}
$$
这个方程怎么解呢?考虑二维椭圆偏微分方程的数值解法,采用有限差分法求解。简单地,采用五点差分格式表示二阶导数的差分表达式,将上述的偏微分方程离散为差分方程:
$$
\frac{u_{i-1,j}+u_{i+1,j} - 2u_{i,j}}{(\Delta x)^{2}} + \frac{u_{i,j-1}+u_{i,j+1} - 2u_{i,j}}{(\Delta y)^{2}} = f(x_{i}, y_{j}), \tag{2.3.22}
$$
椭圆型偏微分描述不随时间变化的均衡状态,没有初始条件,因此不能沿时间步长递推求解。对上式的差分方程,可以通过矩阵求逆方法求解,但当$h$较小时网格很多,矩阵求逆的内存占用和计算量极大。于是,可以使用迭代松弛法递推求得二维椭圆方程的数值解。假定$x$和$y$的间距都为$h$,松弛系数为$w$,则迭代过程可以表示为:
$$
u_{i,j}^{k+1} \leftarrow (1-w)u_{i,j}^{k} + \frac{w}{4} \Big[ u_{i,j+1}^{k} + u_{i,j-1}^{k} + u_{i+1,j}^{k} + u_{i-1,j}^{k} - h^{2}f(x_{i},x_{j}) \Big], \tag{2.3.23}
$$
考虑一个特殊情况,也就是当$f(x,y)=0$的情况下,迭代松弛法的代码如下:
```python
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
# 求解范围
xa, xb = 0.0, 1.0 # 空间范围,xa图2.3.9 二维椭圆偏微分方程求解图
从图中可以看到,解呈现出一系列波峰和波谷,这与边界条件的正弦函数有关。每个波峰和波谷都是颜色映射中的热点和冷点,分别代表着方程解的局部最大值和最小值。波峰出现在和轴的中间位置,而波谷则出现在四个角和边缘。颜色的变化代表了解的幅度,从红色的高值到蓝色的低值。整体上,解的形状表现出了椭圆方程特有的对称性和周期性。
sympy中的`pdsolve`给出了一些简单偏微分方程的解析解法,但sympy目前不支持二阶偏微分方程的求解。`pdsolve`的调用格式形如`pdsolve(eq, func=None, hint='default', dict=False, solvefun=None, **kwargs)`,具体使用我们可以看到下面的例子:
**例2.16** 使用sympy中的`pdsolve`解下面这个偏微分方程:
$$
-2 \frac{ \partial f }{ \partial x } + 4 \frac{ \partial f }{ \partial y } + 5f = e^{x+3y}, \tag{2.3.24}
$$
可以给出如下代码:
```python
from sympy.solvers.pde import pdsolve
from sympy import Function, pprint, exp
from sympy.abc import x,y
f = Function('f')
eq = -2*f(x,y).diff(x) + 4*f(x,y).diff(y) + 5*f(x,y) - exp(x + 3*y)
pdsolve(eq)
```
给出的结果为:
```python
Eq(f(x, y), (F(4*x + 2*y)*exp(x/2) + exp(x + 4*y)/15)*exp(-y))
```

图2.3.10 pdsolve求解方程图
## 2.4 微分方程的应用案例
在本节中,我们将探讨微分方程在现实世界中的应用,特别是在工业和日常生活中的一些实例。通过这些案例,我们可以加深对微分方程建模过程的理解。
### 2.4.1 人口增长的两种经典模型—— Malthus 模型和 Logistic 模型
人口增长是微分方程建模的一个经典例子。我们可能在高中生物课上已经接触过指数增长(J型曲线)和 Logistic 增长(S型曲线),但可能没有深入了解它们的计算方法和曲线的具体特性。实际上,这些曲线可以通过微分方程来描述。不仅生物种群的增长遵循这些规律,人口增长也是如此。
最简单的模型是 Malthus 模型,它假定人口增长率是一个恒定的常数,因此每年的人口增加量与当年的人口总数成正比:
$$
\frac{\mathrm{d}x}{\mathrm{d}t} = rx, \quad x(t_{0}) = x_{0}. \tag{2.4.1}
$$
即使不用Python,我们也可以轻松手动求解这个微分方程:
$$
x(t) = x_{0}e^{r(t-t_{0})}, \tag{2.4.2}
$$
其中,增长率$r$是一个恒定的值。这个模型的假设包括:
1. 忽略死亡率对人口增长的影响,只考虑净增长率
2. 忽略人口迁移对问题的影响,只关注自然增长
3. 忽略重大突发事件对人口的突变性影响
4. 忽略人口增长率变化的滞后性
然而,仔细想一想,无论是人还是动物,增长率怎么可能持续不变地指数增长呢?如果一直稳步增长,地球很快就会人满为患。实际上,增长率会受到许多外部因素的影响,比如食物供应、天敌、疾病等突发事件,甚至种群内部的竞争也会降低增长率。那么,这个模型是否完全没有解释价值呢?并非如此,至少在没有天敌、疾病且食物充足的时期,物种的增长确实接近指数增长。一个典型的例子是澳大利亚的野兔。
因此,我们可以对增长率进行一些调整:假设某个地区的人口存在一个最大承载量$x_{m}$(也就是我们以前接触到的$K$值),随着人口增长,增长率呈线性下降。这样,原始的 Malthus 模型就被修正为:
$$
\frac{\mathrm{d}x}{\mathrm{d}t} = r \left( 1 - \frac{x}{x_{m}} \right), \quad x(t_{0}) = x_{0}. \tag{2.4.3}
$$
该模型被称为 Logistic 模型。事实上,这个方程可以求解出解析解,并且如果绘制它的曲线,会呈现出S形。方程的解析解为:
$$
x(t) = \frac{x_{m}}{\displaystyle 1 + \left( \frac{x_{m}}{x_{0}} - 1 \right) e^{-r(t-t_{0})}}. \tag{2.4.4}
$$
> 注意:这两个模型都是人口预测中的经典模型,需要重点掌握。然而,这两个模型都是从宏观角度出发,没有考虑诸如人口结构、人口迁移等更微观的因素。此外,这些模型对数据量的要求并不高,如果数据量太大,可能需要使用时间序列方法进行分析。
接下来,我们可以通过一个例子来进一步了解这些模型。
**例3.7** 假设我们有某地1980年至2010年间的人口变化数据,我们想比较 Malthus 模型和 Logistic 模型对人口变化的拟合效果,并预测2011年的人口数据。假设数据已经存储在文件中,我们可以将其读取并保存到变量中进行分析。
```python
import pandas as pd
import numpy as np
from scipy import optimize
data=pd.read_excel("总人口.xls")
data1=data[['年份','年末总人口(万人)']].values
y=data['年末总人口(万人)'].values.astype(float)
x_0=y[0]
#Logistic
def f2(x,r,K):
return K/(1+(K/x_0-1)*np.exp(-r*x))
#Malthus
def f(x,r):
return x_0*np.exp(r*x)
x=np.arange(0,30,1)
x_00=np.arange(1990,2020,1)
plt.figure(figsize=(16,9))
plt.grid()
fita,fitb=optimize.curve_fit(f,x,y)
print(fita)
plt.plot(x_00,10000*y,'.')
x1=f(x,fita[0])
plt.plot(x_00,10000*x1,'r*-')
fita,fitb=optimize.curve_fit(f2,x,y)
print(fita)
x1=f2(x,0.0578,11019)
plt.plot(x_00,10000*x1,'b^-')
plt.legend(["Origin","Malthus","Logistic"])
plt.xlabel("Year")
plt.ylabel("Population")
plt.title('Population Prediction')
plt.show()
```
最终可以画出模型的拟合图,如图2.4.1所示。

图2.4.1 Logistic 模型拟合效果图
从图2.4.1中可以看出,与 Malthus 模型相比, Logistic 模型的拟合效果更接近实际数据。当然,模型的参数或形式可能有更好的选择,但在这里,我们可以认为该模型已经足够接近实际情况,可以用于人口预测。在代码中,我们可以看到, Logistic 模型中测试的最大人口为11000人,人口增长率的初始值为0.001。将这些值代入公式,我们可以用它来预测2011年的人口。
### 2.4.2 波浪能受力系统
本节内容改编自2022年全国大学生数学建模竞赛A题。
随着社会的进步和经济的增长,对绿色能源技术的需求日益增加。波浪能,作为一种清洁的可再生能源,具有巨大的潜力和广阔的发展前景。波浪能的有效利用是能源技术研究的重要方向之一。一个典型的波浪能装置由浮标、振荡器、中心轴和能量转换系统(PTO,包括弹簧和阻尼器)组成。在这种装置中,振荡器、中心轴和PTO被封装在浮标内部。当浮标受到周期性波浪的作用时,它会受到波浪激励力、附加质量力、波浪阻尼力和静水复原力等多种力的影响。

图2.4.2 波浪能受力系统图
我们需要解决以下问题,以了解系统的动力学行为及其参数:
1. 假设浮标和振荡器仅在垂直方向上进行一维振动。计算在波浪激励力作用下,前40个波浪周期内,每隔0.2秒浮标和振荡器的垂直位移和速度。
2. 若考虑浮标和振荡器不仅有一维振动,还有平面内的摆动。此时,波浪的输入除了提供振动的波浪激励力外,还会提供一个用于摆动的波浪激励力矩。此外,除了直线阻尼器,转轴上还安装有旋转阻尼器和扭转弹簧,这些元件共同输出能量。计算在波浪激励力和波浪激励力矩作用下,前40个波浪周期内,每隔0.2秒浮标与振荡器的垂直位移和速度以及纵摇角位移和角速度。
这是一个动力学系统建模问题。对于浮标和振荡器组成的整体系统,其受到的外力包括浮力、波浪激励力、波浪阻尼力和附加质量力的综合作用,这些力共同决定了浮标和振荡器系统的加速度。而浮力与系统的重力之间的差值实际上就是静水复原力。对于振荡器来说,其受到的力仅包括重力、阻尼力和弹簧力。在初始状态下,由于系统处于静止状态,初始阻尼力为零,弹簧力与振荡器的重力相平衡。在运动过程中,这三种力的合力提供了合加速度。通过求解二阶微分方程组,我们可以模拟整个动力学过程。对于不同的阻尼系数,我们只需进行分类讨论即可。相比于问题一,问题二考虑了转动问题。为了研究方便,我们将运动分解为沿轴方向的垂直振动和平面内的纵摇摆动。纵摇和垂直振动的方程实际上非常相似,本质上是外力矩提供了振荡器和浮标的角速度。
在平衡状态下,如果将浮标和振荡器视为一个整体系统,系统的浮力与两者的重力相平衡。在系统内部,弹簧力和阻尼器的阻尼力作为系统的内力。但由于在平衡状态下浮标和振荡器的相对速度为零,因此阻尼器不提供阻尼力。在这种状态下,弹簧力与振荡器的重力平衡,阻尼器由于速度差为零而不提供阻尼力。根据胡克定律,我们可以得到:
$$
mg - kl_{0} = 0. \tag{2.4.5}
$$
带入$k=80000\text{ N/m}$,我们可以得到初始弹簧的初始压缩量$l_{0}$为$0.2980\text{ m}$。
在初始状态下,由于浮力与系统的重力平衡,根据阿基米德原理,浮力等于排出的液体重力,我们可以得到:
$$
\rho gV_{0} = (m+M)g. \tag{2.4.6}
$$
解得初始平衡状态下浮标浸没在水面下的体积为$7.1210$立方米。已知浮标的几何参数,我们可以求解其浸没在水面下的深度。经过计算,锥体部分的体积为$0.8378$立方米,而柱体部分半径为$1$米,解得柱体浸没在水面下的深度为$2$米,加上锥体高度$0.8$米,因此整体浸没在水面下的高度为$2.8$米。
如果想让系统进入垂直振动状态,需要给浮标一个初始速度或加速度。波浪为系统提供动力,但由于波浪在提供动力的同时也为周围的海水提供动能,海水反过来作用于浮标,因此产生了附加质量力。所以,附加质量力的作用对象是海水,它与浮标一起以相同的加速度运动。在运动过程中,我们取竖直向上为正方向,浮标和振荡器的位移分别为和\。对于振荡器来说,其受力始终只包括弹簧力、阻尼力和自身的重力。根据 Newton第二定律,我们可以得到振荡器的运动方程:
$$
k(l_{0} + x_{1} - x_{2}) + \eta \left( \frac{\mathrm{d}x_{1}}{\mathrm{d}t} - \frac{\mathrm{d}x_{2}}{\mathrm{d}t_{2}} \right) - mg = m \frac{\mathrm{d}^{2}x_{2}}{\mathrm{d}t^{2}}. \tag{2.4.8}
$$
在初始状态下,振子的速度和加速度均为零。同时,由于初始状态下振子的重力与弹簧的弹力处于平衡状态,我们可以简化相关的方程为:
$$
k(x_{1} - x_{2}) + \eta \left( \frac{\mathrm{d}x_{1}}{\mathrm{d}t} - \frac{\mathrm{d}x_{2}}{\mathrm{d}t_{2}} \right)= m \frac{\mathrm{d}^{2}x_{2}}{\mathrm{d}t^{2}}. \tag{2.4.9}
$$
考虑将振子和浮子作为一个整体系统,阻尼器提供的阻尼力与弹簧的弹力构成系统的内力。根据 Newton第二定律,系统的外力包括波浪提供的激励力、兴波阻尼力、系统受到的浮力以及系统的重力。同时,考虑到虚拟质量对浮子产生的惯性力,我们在计算加速度时需要将虚拟质量考虑在内。因此,系统的合力决定了浮子、振子以及虚拟质量的加速度:
$$
\rho g V + f \cos (\omega t) - \psi \frac{\mathrm{d}x_{1}}{\mathrm{d}t} - (m + M)g = (M + m_{0}) \frac{\mathrm{d}^{2}x_{1}}{\mathrm{d} t^{2}} + m \frac{\mathrm{d}^{2}x_{2}}{\mathrm{d} t^{2}}. \tag{2.4.9}
$$
由于初始平衡状态下浮力与系统重力相互抵消,因此在运动状态中,浮力与重力的差值即静水恢复力等于浸没在水下的体积变化所对应的重力:
$$
\rho g V - (m + M) g = \rho g \int_{d}^{x_{1}} \pi z^{2}(h) \, \mathrm d{h}. \tag{2.4.10}
$$
那么,方程(7)可以简化为:
$$
\rho g \int_{d}^{x_{1}} {\pi z^{2}(h)} \, \mathrm d{h} + f \cos (wt) - \psi \frac{\mathrm{d}x_{1}}{\mathrm{d}t} = (M + m_{0}) \frac{\mathrm{d}^{2}x_{1}}{\mathrm{d} t^{2}} + m \frac{\mathrm{d}^{2}x_{2}}{\mathrm{d}t^{2}}. \tag{2.4.11}
$$
而对于下面两种不同的情况,阻尼器的阻尼系数不同。
**情形一**:阻尼器的阻尼系数为常数:
$$
\eta = 10000. \tag{2.4.12}
$$
**情形二**:阻尼器的阻尼系数与速度差的绝对值有关,幂指数为$0.5$:
$$
\eta = 1000 \left[ \frac{\mathrm{d}x_{1}}{\mathrm{d}t} - \frac{\mathrm{d}x_{2}}{\mathrm{d}t} \right]^{0.5}. \tag{2.4.13}
$$
给出代码实现如下:
> 代码实现参考过https://github.com/Solus-sano/2022_cumcm_A,特别鸣谢!
```python
import numpy as np
from matplotlib import rcParams
from math import *
from sympy.abc import t
from scipy.integrate import odeint
import matplotlib.pyplot as plt
def pfun_1(ip,t):
"""第1小问微分方程模型"""
x,y,z,w=ip
return np.array([z,
w,
(-k*(x-y)-beta*(z-w))/m2,
(k*(x-y)+beta*(z-w)+f*cos(omega*t)-gama*y-Ita*w)/(m1+m_d)
])
def pfun_2(ip,t):
"""第2小问微分方程模型"""
x,y,z,w=ip
return np.array([z,
w,
(-k*(x-y)-beta*sqrt(abs(z-w))*(z-w))/m2,
(k*(x-y)+beta*sqrt(abs(z-w))*(z-w)+f*cos(omega*t)-gama*y-Ita*w)/(m1+m_d)
])
if __name__=='__main__':
dt=0.01
m1=4866#浮子质量
m2=2433#振子质量
m_d=1335.535#垂荡附加质量 (kg)
k=80000#弹簧刚度 (N/m)
beta=10000#平动阻尼系数
f=6250#垂荡激励力振幅 (N)
omega=1.4005#入射波浪频率 (s-1)
T_max=(2*pi/omega)*40#模拟最大时间
gama=1025*9.8*pi#静水恢复力系数
Ita=656.3616#垂荡兴波阻尼系数 (N·s/m)
t_lst=np.arange(0,T_max,dt)
pfun=pfun_2#选择计算第1小问还是第2小问
sol=odeint(pfun,[0.0,0.0,0.0,0.0],t_lst)
rcParams['font.sans-serif']=['SimHei']
plt.figure()
plt.plot(t_lst,sol[:,0],label='振子位移')
plt.plot(t_lst,sol[:,1],label='浮子位移')
plt.legend()
plt.figure()
plt.plot(t_lst,sol[:,2],label='振子速度')
plt.plot(t_lst,sol[:,3],label='浮子速度')
plt.legend()
```
得到结果如图所示:

图2.4.3 振子和浮子位移对比图
从图2.4.3中可以看到,在运动初期,振子和浮子开始阶段存在明显的位移差异,且两者的位移均不超过总高度($3.8\text{ m}$)。在$20$秒后,系统达到稳定状态,此时浮子与振子的相对位移振幅约为$0.12\text{ m}$,呈现出一定的周期性。系统开始运动时,波浪的冲击力为浮子提供了向上的初始加速度,导致浮子开始上升,进而使弹簧和阻尼器进一步压缩,对振子产生更大的推力,使振子开始振动。但当系统趋于稳定时,尽管两者的波形大体相似,但都不是简谐振动,并且存在一定的相位差。这种现象的原因在于,两者的运动受到多种力的影响,其中弹簧和阻尼器的系数以及波浪频率各自提供了不同的频率成分。如果需要进一步分析这些成分,可以进行傅里叶变换。我们将情形一和情形二的位移变化和速度变化数据都放在附件表格中。经模拟,在几个重要时间节点对应的位移和速度如表1所示:
表1 部分时间点对应的位移和速度

系统的摇荡过程包括沿自身轴线的垂荡运动和在一个平面内的纵摇运动。系统各部分在运动过程中受到的力如图2.4.4所示:

图2.4.4 振子和浮子位移对比图
在纵摇运动的平衡位置,我们将竖直方向作为参考。对于振子而言,其运动可以分解为沿杆的垂荡分量和平面内的纵摇分量。在垂荡方向上,各自沿着自身轴线的方向为正方向,满足与问题一中类似的关系式。但需要注意的是,重力在杆的方向上存在一个分量,这一分量与振子的摆动角度有关:
$$
k(l_{0} + x_{1} - x_{2}) + \eta \left[ \frac{\mathrm{d}x_{1}}{\mathrm{d}t} - \frac{\mathrm{d}x_{2}}{\mathrm{d}t} \right]+ mg (1 - \cos \theta_{2}) = m \frac{\mathrm{d}^{2}x_{2}}{\mathrm{d}t^{2}}, \tag{2.4.14}
$$
对于纵摇方向上的运动,重力在垂直于杆的方向上提供了力矩,加上弹簧的扭矩和旋转阻尼力矩,我们可以得到一个基于角动量定理的表达式:
$$
K(\theta_2 - \theta_{1}) + \lambda \left[ \frac{\mathrm{d}\theta_{2}}{\mathrm{d}t} - \frac{\mathrm{d}\theta_{1}}{\mathrm{d}t} \right] - mgL_{2} \sin \theta_{2} = J_{2} \frac{\mathrm{d}^{2}\theta_{2}}{\mathrm{\mathrm{d}t^{2}}}. \tag{2.4.15}
$$
对于由浮子和振子构成的系统,垂荡运动的表达式类似于问题一:
$$
\begin{align}
&\rho gV \cos \theta_{1} + f \cos (\omega t) - \psi \frac{\mathrm{d}x_{1}}{\mathrm{d}t} - (mg \cos \theta_{2} + MgL_{1}\cos \theta_{1}) \\&= (M + m_{0}) \frac{\mathrm{d}^{2}x}{\mathrm{d}t^{2}} + m \frac{\mathrm{d}^{2}x_{2}}{\mathrm{d}t^{2}}.
\end{align} \tag{2.4.16}
$$
在海水中进行纵摇运动时,静水恢复力矩可以使浮体转正,其大小与浮体相对于静水面的转角成正比,比例系数称为静水恢复力矩系数。因此,我们假设静水恢复力矩、总体重力力矩和浮力力矩的矢量和为零:
$$
\rho g V x_{1} \sin \theta_{1} - (mgL_{2} \sin \theta_{2} + MgL_{1} \sin \theta_{1}) = \varepsilon \theta_{1}, \tag{2.4.17}
$$
其中$\varepsilon$为静水恢复力矩系数。在纵摇方向上,浮力和重力在垂直于竖直方向上提供力矩,加上外部的波浪激励力矩和兴波阻尼力矩:
$$
\varepsilon \theta_{1} + L \cos(\omega t) - \phi \frac{\mathrm{d}\theta_{1}}{\mathrm{d}t} = (J_{1} + J_{0}) \frac{\mathrm{d}^{2}\theta_{1}}{\mathrm{d}t^{2}} + J_{2} \frac{\mathrm{d}^{2}\theta_{2}}{\mathrm{d}t}. \tag{2.4.18}
$$
实现这个模型的代码如下:
```python
import numpy as np
from matplotlib import rcParams
from math import *
from sympy.abc import t
from scipy.integrate import odeint
import matplotlib.pyplot as plt
def pfun(ip,t):
"""第3问微分方程模型"""
x,y,z,w,th1,th2,ph1,ph2=ip
A=np.array([[1,0,0,0,0,0,0,0],
[0,1,0,0,0,0,0,0],
[0,0,m_v,m_v*cos(th2),0,0,0,0],
[0,0,0,m_f+m_d,0,0,0,0],
[0,0,0,0,1,0,0,0],
[0,0,0,0,0,1,0,0],
[0,0,0,0,0,0,J_f+J_d,0],
[0,0,0,-m_v*(l0+x+0.5*h_v)*sin(th2),0,0,0,J_v+m_v*(l0+x+0.5*h_v)**2]])
b=np.array([[z],
[w],
[m_v*g*(1-cos(th2))+m_v*(l0+x+0.5*h_v)*ph2**2-k1*x-beta_1*z],
[m_v*g*(1-cos(th2))+(k1*x+beta_1*z)*cos(th2)+f*cos(omega*t)-Ita_1*w-gama_1*y],
[ph1],
[ph2],
[k2*(th2-th1)+beta_2*(ph2-ph1)-Ita_2*ph1-(((2+15*(2-y)**2)/(8+30*(2-y)))*((m_v+m_f)*g-1025*g*pi*y)+gama_2)*th1+L*cos(omega*t)],
[m_v*g*(l0+x+0.5*h_v)*sin(th2)-2*z*ph2*m_v*(l0+x+0.5*h_v)-k2*(th2-th1)-beta_2*(ph2-ph1)]])
Op=np.dot(np.linalg.inv(A),b)
return Op.reshape((-1,))
if __name__=='__main__':
dt=0.01
g=9.8
l0=0.2019575#弹簧初始长度
m_f=4866#浮子质量
m_v=2433#振子质量
m_d=1028.876#垂荡附加质量 (kg)
J_d=7001.914#纵摇附加转动惯量 (kg·m^2)
J_v=202.75#振子绕质心转动惯量
J_f=16137.73119#浮子转动惯量
k1=80000#弹簧刚度 (N/m)
k2=250000#扭转弹簧刚度 (N·m)
beta_1=10000#直线阻尼器阻尼系数
beta_2=1000#旋转阻尼器阻尼系数
f=3640#垂荡激励力振幅 (N)
L=1690#纵摇激励力矩振幅 (N·m)
omega=1.7152#入射波浪频率 (s-1)
T_max=(2*pi/omega)*40#模拟最大时间
gama_1=1025*g*pi#静水恢复力系数
gama_2=8890.7#静水恢复力矩系数 (N·m)
Ita_1=683.4558#垂荡兴波阻尼系数 (N·s/m)
Ita_2=654.3383#纵摇兴波阻尼系数 (N·m·s)
h_v=0.5#振子高度
t_lst=np.arange(0,T_max,dt)
sol=odeint(pfun,[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0],t_lst)
"""将振子相对铰链中心的径向位移、速度转换成垂荡位移、垂荡速度"""
xx=np.zeros(sol.shape[0])
vx=np.zeros(sol.shape[0])
for idx in range(xx.shape[0]):
xx[idx]=(l0+sol[idx,0])*cos(sol[idx,5])-l0+sol[idx,1]
vx[idx]=sol[idx,2]*cos(sol[idx,5])+sol[idx,3]
rcParams['font.sans-serif']=['SimHei']
plt.figure()
plt.title('')
plt.plot(t_lst,xx,label='振子垂荡位移')
plt.plot(t_lst,sol[:,1],label='浮子垂荡位移')
plt.legend()
plt.figure()
plt.plot(t_lst,vx,label='振子垂荡速度')
plt.plot(t_lst,sol[:,3],label='浮子垂荡速度')
plt.legend()
plt.figure()
plt.plot(t_lst,sol[:,4],label='浮子纵摇角度')
plt.plot(t_lst,sol[:,5],label='振子纵摇角度')
plt.legend()
plt.figure()
plt.plot(t_lst,sol[:,6],label='浮子纵摇角速度')
plt.plot(t_lst,sol[:,7],label='振子纵摇角速度')
plt.legend()
```
得到结果图像如图所示:

图2.4.5 振子和浮子位移对比图
通过对比图2.4.5中的上面两张子图,我们发现在摇摆过程中,两者的位移差最大不超过$0.15$米,并在$40$秒后稳定在$0.05$米左右。在一个完整的摆动周期内,仍然存在轻微的波动。初始阶段,相对位移的波动由于受到冲击的影响较大,振幅较大,但随着时间的推移,经过两次冲击后,逐渐趋于稳定。其波形与问题一中的波形有所不同,主要是因为重力在不同摆动方向上的分量发生了周期性的变化。
值得注意的是,类似于问题一,图13展示了振子和浮子质心高度随时间的变化曲线。初始状态以平衡态浮子的锥顶高度作为$0$高度基准,而位移表示的是相对变化量。因此,在实际求解过程中,需要减去初始高度。
如图2.4.5右上子图所示,振子和浮子的速度变化情况可以观察到,浮子的运动速度变化范围大于振子,其上下速度波动范围介于$-1.5$至$1.5$米/秒。尽管速度差异不显著,但可以看出振子的速度有时会超过$1.5$米/秒,而浮子的速度则不会超过这一值。因此,尽管两者的速度波形相似,但仍存在一定的波动和相位差。
在分析纵摇运动时,我们也考察了振子和浮子的转动情况。以竖直方向作为平衡位置,若开始时两者均以逆时针方向运动,并以此方向为正方向定义,则物体的纵摇运动和垂荡运动构成了一个整体,不能单独对纵摇或垂荡进行分析,因为重力的分量不同,且这一分量的变化是周期性的。
从图2.4.5中可以看出,在初始阶段,浮子的纵摇角速度会突然增加,而振子的增加速度相对缓慢。这是因为海浪给予的初始力矩较大,使浮子获得了较大的角加速度。在模拟过程中,由于$t=0.2$秒这一时间间隔可能略长,导致模拟的速度在初始阶段增长非常快。浮子通过旋转压缩弹簧和阻尼器,提供的弹力迫使振子和浮子保持相同的旋转方向运动。
通过求解,我们得到了几个重要时间节点的垂荡运动和纵摇运动情况,如表2所示。
表2 部分时间点对应相关变量值表

### 2.4.3 新冠病毒的传播模型—— SI、SIS、SIR、SEIR模型
自2020年起,新冠疫情的爆发彻底打乱了我们的日常生活。在这场全球性的疫情中,不仅医院的医生和科研人员在前线奋斗,许多互联网公司也开发了健康码和行程码来帮助防疫。此外,高校的数学研究者和学生们也发挥了重要作用,他们通过数学建模来预测和分析新冠病毒的传播趋势,比如英国的帝国理工学院、中国的西安交通大学和武汉大学等。
为了有效控制疫情,模型需要能够预测病毒在不同阶段的传播情况。新冠病毒传播迅速,感染周期短,变异速度快,这使得疫苗研发面临巨大挑战。因此,我们需要考虑易感人群、无症状感染者和已感染者之间的动态平衡。
在编写代码时,只需明确传染速率和恢复速率以及人员流动情况即可。可以将人群分为绿码、黄码和红码三种状态,这种分类具有以下特点:
1. 传播速度快,影响范围广,但可以连续变化。
2. 绿码、黄码、红码人群的比例总和为1。
3. 绿码减少的数量等于黄码增加的数量,黄码减少的数量等于红码增加的数量,红码康复的数量等于绿码增加的数量,即总体保持平衡
SI模型是一种简单的传播模型,将人群分为易感者(S类)和感染者(I类)两类。通过SI模型,我们可以预测疫情高峰的到来。通过提高卫生标准和加强防控措施,可以延缓疫情高峰的出现。

图2.4.6 SI模型的模型图
如图所示,SI模型将易感者与感染者的有效接触视为感染事件,无潜伏期、无治愈情况、无免疫力。模型以一天为时间单位,假设总人数N固定,不考虑人口的出生、死亡、迁入和迁出。在任意时刻$t$,易感者和感染者占总人口的比例分别为$S(t)$、$I(t)$,其数量分别为$S(t)$、$I(t)$。初始时刻,各类人群的比例为$s_0$、$i_0$。每个感染者每天有效接触的易感者平均数为$\lambda$。
数学模型为
$$
\begin{align}
N \frac{\mathrm{d}i(t)}{\mathrm{d}t} &= \lambda N s(t) i(t), \\[0.5em]
s(t) + i(t) &= 1.
\end{align} \tag{2.4.19}
$$
你可能已经注意到,这实际上是一个 Logistic 模型。如果尝试编程解决这个模型,最终结果会是一条S型曲线,即所有人最终都会被感染。这个结论显然不合理,说明模型中还有一些未考虑到的因素。
SI模型的仿真代码如下。
```python
# 设置模型参数
lamda = 1.0 # 日接触率, 患病者每天有效接触的易感者的平均人数
y0 = 1e-6 # 患病者比例的初值
tEnd = 50 # 预测日期长度
t = np.arange(0.0,tEnd,1) # (start,stop,step)
def dy_dt(y, t): # 定义导数函数 f(y,t)
dy_dt = lamda*y*(1-y) # di/dt = lamda*i*(1-i)
return dy_dt
yAnaly = 1/(1+(1/i0-1)*np.exp(-lamda*t)) # 微分方程的解析解
yInteg = odeint(dy_dt, y0, t) # 求解微分方程初值问题
yDeriv = lamda * yInteg *(1-yInteg)
# 绘图
plt.plot(t, yAnaly, '-ob', label='analytic')
plt.plot(t, yInteg, ':.r', label='numerical')
plt.plot(t, yDeriv, '-g', label='dy_dt')
plt.title("Comparison between analytic and numerical solutions")
plt.legend(loc='right')
plt.axis([0, 50, -0.1, 1.1])
plt.show()
```
SI模型的仿真结果如图2.4.7所示。

图2.4.7 SI模型的仿真结果
在SI模型基础上考虑病愈免疫的康复者(R类)就得到SIR模型,对应疾病被治愈或死亡的状态,感染人群以单位时间传染概率由感染状态转移至移除状态,如图2.4.8所示。

图2.4.8 SIR模型的模型图
除了日感染率$\lambda$,还引入一个概念叫日治愈率$\mu$。模型修正为
$$
\begin{align}
N \frac{\mathrm{d}s}{\mathrm{d}t} &= -N \lambda si, \\[0.5em]
N \frac{\mathrm{d}i}{\mathrm{t}} &= N\lambda si - N \mu i, \\[0.5em]
s(t) + i(t) + r(t) &= 1.
\end{align} \tag{2.4.20}
$$
SIR模型的仿真代码如下。
```python
def dySIR(y, t, lamda, mu): # SIR 模型,导数函数
i, s = y
di_dt = lamda*s*i - mu*i # di/dt = lamda*s*i-mu*i
ds_dt = -lamda*s*i # ds/dt = -lamda*s*i
return [di_dt,ds_dt]
# 设置模型参数
lamda = 0.2 # 日接触率, 患病者每天有效接触的易感者的平均人数
sigma = 2.5 # 传染期接触数
mu = lamda/sigma # 日治愈率, 每天被治愈的患病者人数占患病者总数的比例
fsig = 1-1/sigma
tEnd = 200 # 预测日期长度
t = np.arange(0.0,tEnd,1) # (start,stop,step)
i0 = 1e-6 # 患病者比例的初值
s0 = 1-i0 # 易感者比例的初值
Y0 = (i0, s0) # 微分方程组的初值
print("lamda={}\tmu={}\tsigma={}\t(1-1/sig)={}".format(lamda,mu,sigma,fsig))
ySIR = odeint(dySIR, Y0, t, args=(lamda,mu)) # SIR 模型
# 绘图
#plt.title("Comparison among SI, SIS and SIR models")
plt.xlabel('t')
plt.axis([0, tEnd, -0.1, 1.1])
plt.axhline(y=0,ls="--",c='c') # 添加水平直线
plt.plot(t, ySIR[:,0], '-r', label='i(t)-SIR')
plt.plot(t, ySIR[:,1], '-b', label='s(t)-SIR')
plt.plot(t, 1-ySIR[:,0]-ySIR[:,1], '-m', label='r(t)-SIR')
plt.legend(loc='best') # youcans
plt.show()
```
SIR模型的仿真结果如图2.4.9所示。

图2.4.9 SIR模型的仿真结果
当我们在使用SIR模型探索疾病传播时,我们近乎触及了模拟真实世界传染病动态的边缘。然而,对于具有潜伏期的疾病,如COVID-19,我们需要引入一个额外的群体——那些高风险接触者,或者说是疑似病例。让我们假设易感个体变成这类高风险接触者的转变速率是$\lambda$,而疑似病例确诊为感染者的转化速率是$\delta$,最后,感染者康复的速率是$\mu$。通过这样的设置,我们得到了一个更加贴近现实的模型,即SEIR模型。

图2.4.10 SEIR模型的模型图
模型形式形如:
$$
\begin{align}
N \frac{\mathrm{d}s}{\mathrm{d}t} &= -N \lambda si, \\[0.5em]
N \frac{\mathrm{d}e}{\mathrm{d}t} &= N \lambda si - N \delta e, \\[0.5em]
N \frac{\mathrm{d}i}{\mathrm{d}t} &= N \delta e - N \mu i, \\[0.5em]
N \frac{\mathrm{d}r}{\mathrm{d}t} &= -N \mu si, \\[0.5em]
s(t) + e(t) + i(t) + r(t) &= 1.
\end{align} \tag{2.4.21}
$$
> 注意:为了使模型更加全面,我们可以考虑加入更多的转变路径。比如,康复者可能由于病毒的变异重新变成易感个体;或者易感个体通过接种疫苗而直接获得免疫,成为康复者。这些补充路径让模型更加精细,更贴近于COVID-19的实际传播模式,为我们预测疾病的转折点提供了工具。
SEIR模型的仿真代码如下。
```python
def dySEIR(y, t, lamda, delta, mu): # SEIR 模型,导数函数
s, e, i = y # youcans
ds_dt = -lamda*s*i # ds/dt = -lamda*s*i
de_dt = lamda*s*i - delta*e # de/dt = lamda*s*i - delta*e
di_dt = delta*e - mu*i # di/dt = delta*e - mu*i
return np.array([ds_dt,de_dt,di_dt])
# 设置模型参数
lamda = 0.3 # 日接触率, 患病者每天有效接触的易感者的平均人数
delta = 0.03 # 日发病率,每天发病成为患病者的潜伏者占潜伏者总数的比例
mu = 0.06 # 日治愈率, 每天治愈的患病者人数占患病者总数的比例
sigma = lamda / mu # 传染期接触数
fsig = 1-1/sigma
tEnd = 300 # 预测日期长度
t = np.arange(0.0,tEnd,1) # (start,stop,step)
i0 = 1e-3 # 患病者比例的初值
e0 = 1e-3 # 潜伏者比例的初值
s0 = 1-i0 # 易感者比例的初值
Y0 = (s0, e0, i0) # 微分方程组的初值
# odeint 数值解,求解微分方程初值问题
ySEIR = odeint(dySEIR, Y0, t, args=(lamda,delta,mu)) # SEIR 模型
# 输出绘图
print("lamda={}\tmu={}\tsigma={}\t(1-1/sig)={}".format(lamda,mu,sigma,fsig))
plt.title("Comparison among SI, SIS, SIR and SEIR models")
plt.xlabel('t')
plt.axis([0, tEnd, -0.1, 1.1])
plt.plot(t, ySEIR[:,0], '--', color='r', label='s(t)-SEIR')
plt.plot(t, ySEIR[:,1], '-.', color='g', label='e(t)-SEIR')
plt.plot(t, ySEIR[:,2], '-', color='b', label='i(t)-SEIR')
plt.plot(t, 1-ySEIR[:,0]-ySEIR[:,1]-ySEIR[:,2], ':', color='k', label='r(t)-SEIR')
plt.legend(loc='right') # youcans
plt.show()
```
SEIR模型的仿真结果如图2.4.11所示。

图2.4.11 SEIR模型的仿真结果
从图2.4.11中可以看到,此时模型已经非常接近实际的新冠传播情况,可以用此预判新冠的拐点。
注意:值得注意的是,随着疫情的发展,研究人员发现,COVID-19在最初爆发时的参数并非固定不变。相反,它们应当根据疾病传播的不同阶段进行调整,以更准确地反映疾病的传播情况。这种阶段性的模拟方法为我们深入理解疾病传播提供了新的视角。
### 2.4.4 被捕食者-捕食者模型 —— Volterra 模型
考虑一个关于森林中狼和羊共存的扩展模型。在这个模型中,狼是捕食者,羊是食草动物。我们之前已经讨论了 Logistic 生长模型,现在我们将进一步考虑不同物种之间的相互影响。
假设羊有充足的食物资源,如果没有狼的捕食,羊的数量将按照Malthus模型以固定的增长率r1呈指数增长。然而,由于狼的捕食,羊的数量会减少,我们假设这种减少的速率与狼和羊的数量成正比:
$$
\begin{align}
\frac{\mathrm{d}x_{1}}{\mathrm{d}t} &= x_{1}(r_{1} - \lambda_{1}x_{2}), \\[0.5em]
\frac{\mathrm{d}x_{2}}{\mathrm{d}t} &= x_{2}(r_{2} - \lambda_{2}x_{1}).
\end{align}
$$
可以对这一模型进行仿真模拟,代码如下。
```python
alpha = 1.
beta = 1.
delta = 1.
gamma = 1.
x_0 = 4.
y0 = 2.
def derivative(X, t, alpha, beta, delta, gamma):
x, y = X
dotx = x * (alpha - beta * y)
doty = y * (-delta + gamma * x)
return np.array([dotx, doty]
Nt = 1000
tmax = 30.
t = np.linspace(0.,tmax, Nt)
x_0 = [x_0, y0]
res = odeint(derivative, x_0, t, args = (alpha, beta, delta, gamma))
x, y = res.T
plt.figure(figsize=(16,9))
plt.grid()
plt.title("odeint method")
plt.plot(t, x, 'xb-', label = 'Deer')
plt.plot(t, y, '+r-', label = "Wolves")
plt.xlabel('Time t, [days]')
plt.ylabel('Population')
plt.legend()
plt.show()
plt.figure()
IC = np.linspace(1.0, 6.0, 9) # initial conditions for deer population (prey)
# np.linspace和arange不同,从1.0开始,到6.0结束,均分为9段
for deer in IC:
x_0 = [deer, 1.0]
Xs = odeint(derivative, x_0, t, args = (alpha, beta, delta, gamma))
plt.plot(Xs[:,0], Xs[:,1], "-", label = "$x_0 =$"+str(x_0[0]))
plt.xlabel("Deer")
plt.ylabel("Wolves")
plt.legend()
plt.title("Deer vs Wolves")
plt.show()
```
得到狼羊的物种数量变化曲线与相轨线,如图2.4.12所示。

图2.4.12 物种数量变化曲线和相轨线
在这个模型中,我们关注的是系统的平衡点,即两个物种的数量不再变化的点。显然,当狼和羊的数量都为零时,系统处于一个平衡点,但这个点没有实际意义。我们更关心的是另一个平衡点,即。如图2.4.12所示,相轨线是以狼和羊的数量为坐标绘制的闭合曲线,它有助于我们分析系统的稳定性。感兴趣的读者可以尝试改变模型参数,观察相轨线的变化。
## 2.5 差分方程的典型案例
接下来,我们讨论差分方程,它与微分方程的关系就像离散与连续的关系。实际上,数列的差分问题本身就可以视为差分方程的一个应用案例。无论是差分方程还是微分方程,它们的某些思想、理论和背景都是相通的。
### 2.5.1 差分方程与微分方程建模的异同
差分方程可以看作是微分方程的离散形式,其解法通常采用递推方法。值得注意的是,常微分方程的通解方法与相应的差分方程的解法是相似的。微分方程以其简洁的思想和纯粹的条件,通过微元分析,使得建模变得容易,能够处理多变量系统。然而,微分方程的模型相对原始,求解并不总是容易。相比之下,差分方程通过对连续系统的离散化处理,能够考虑更多因素,尽管有时求解同样困难,但在应用上更为广泛。
### 2.5.2 人口模型的新讨论 —— Leslie模型
以前我们讨论的 Logistic 模型和 Malthus 模型主要关注增长率,没有考虑人口结构、性别比例和人口迁移等因素。Leslie模型则对这些因素进行了考虑和改进。
在正常的社会或自然条件下,生育率和死亡率与群体的年龄结构密切相关。因此,我们需要根据年龄对整个群体进行层次划分,建立与年龄相关的人口模型。Leslie模型是一种基于不同年龄段人群生育率差异的模型,它通过构建变化矩阵进行分析,能够充分考虑种群内的性别比和年龄段差异。
我们将女性人口按年龄等间隔划分为n个年龄组,并将时间离散化,间隔与年龄组相同。假设各个年龄组的生育率b和存活率s不随时间变化,我们可以建立一个模型来描述这种情况。
$$
\left\{
\begin{align}
f_{i}(t+1) &= \sum\limits_{i=1}^{n} b_{i}f_{i}(t), \\[0.5em]
f_{i+1}(t+1) &= s_{i}f_{i}(t).
\end{align}
\right. \tag{2.5.1}
$$
其中$i=1,2,...,n-1$。
在式(2.5.1)中,假设中已扣除了在t时段以后出生而活不到t+1时段的婴儿,记Leslie矩阵:
$$
\boldsymbol{L} = \left[ \begin{matrix}
b_{1} & b_{2} & \cdots & b_{n}\\
s_{1} & 0 & \cdots & 0\\
0 & s_{2} & \cdots & 0\\
\vdots & \vdots & \ddots & \vdots\\
0 & 0 & \cdots & s_{n}
\end{matrix} \right], \tag{2.5.2}
$$
记$\boldsymbol f(t)=[f_{1}(t),f_{2}(t),...,f_{n}(t)]^{\top}$,则式(3.31)可以写作:
$$
\boldsymbol{f}(t+1) = \boldsymbol{Lf}(t). \tag{2.5.3}
$$
通过计算 Leslie 矩阵$\boldsymbol{L}$并使用初始的人口分布向量$\boldsymbol f(0)$,我们能够预测任何给定时刻$t$的女性人口分布$\boldsymbol f(t)$。然后,将这些预测值除以女性在总人口中的比例,就可以得到全国总人口$N(t)$在时刻$t$的估计值。
利用2021年的人口统计数据,我们可以对中国未来一段时间内的人口变化进行预测。在此,要感谢华中科技大学电信学院2020级的邓立桐同学,他协助我们从国家统计年鉴中获取了所需的数据,并进行了相关仿真分析。仿真结果显示了全国人口总数以及不同年龄段人口比例的预期变化趋势,如图2.5.1所示。

图2.5.1 经模拟仿真的人口变化趋势
> 注意:模拟仿真用数据可以从国家统计局和人口年鉴中查找。
图2.5.1展示了经过模拟仿真得到的人口变化趋势。需要注意的是,这些仿真数据可以从国家统计局和人口年鉴中获得。从图中我们可以看出,在实施三胎政策之后,未来十五年内,15至64岁的人口数量及其在总人口中的比重仍将继续减少,老龄化问题依旧难以得到有效缓解。预计到2035年,65岁及以上的人口将占总人口的$23.7\%$。这一趋势反映了当前人口老龄化已经相当严重,加之生活压力较大,放宽生育政策对于不愿生育的人群和只有一个孩子的家庭影响有限,主要对有两个孩子的家庭产生一定影响。
仿真代码如下:
```python
import pandas as pd
import numpy as np
a=pd.read_excel("data3.xlsx")
p=0.48764
N00=a['总人口(万人)']
N00=np.array(N00)
A=np.eye(90)
b=a['生存率'][0:90]
b1=b/100
for i in range(90):
A[i,:]*=b1[i]
c=a['调节后生育率'][0:90]
c1=c/1000
M=sum(c1)
d=np.zeros((91,1))
B=np.vstack([c1,A])
L=np.hstack([B,d])
G=[]
K=[]
S1=[]
S2=[]
S3=[]
for i in range(1,13):
L0=np.power(L,i)
X=N00@L0
G.append(X)
Z=X/p
K.append(sum(Z))
S1.append(sum(Z[0:15]))
S2.append(sum(Z[15:65]))
S3.append(sum(Z[65:-1]))
K=np.array(K)
S1=np.array(S1)
S2=np.array(S2)
S3=np.array(S3)
x=range(2023,2035)
y1=S1/K
y2=S2/K
y3=S3/K
plt.figure(2)
plt.plot(x,y1,'-or')
plt.plot(x,y2,'-ob')
plt.plot(x,y3,'-og')
plt.title('我国全国各年龄段人口变化趋势')
plt.xlabel('时间(单位:年)')
plt.ylabel('人口数量(单位:万人)')
plt.legend(['年龄在0-14岁总人数','年龄在15-64岁总人数','年龄在65及65岁以上总人数'])
plt.show()
```
另外,补充一个有趣的小事,这个问题正是笔者在2021年冬季为笔者学校数学建模校赛所命制的F题,2022年夏季的中青杯也考的几乎一模一样。
## 2.6 元胞自动机与仿真模拟
在之前的讨论中,我们主要关注了如何针对连续问题建立函数和微分方程模型进行求解。对于一些离散问题,我们尝试使用了差分方程模型。然而,差分方程模型往往难以有效地模拟大多数离散模型。因此,我们将介绍一种强大的工具——元胞自动机。
### 2.6.1 元胞自动机的理论
元胞自动机是一种具有时间、空间和状态离散性的网格动力学模型,其中空间相互作用和时间因果关系局部化。它能够模拟复杂系统的时空演化过程。元胞自动机的一个显著特点是它不依赖于固定的数学公式。因此,在数学建模中,有一个俗语:“遇到难题时,就用元胞自动机”。
自从元胞自动机被提出以来,对其分类的研究成为了一个重要的研究课题和核心理论。根据不同的角度和标准,元胞自动机可以有多种分类方式。1990年,Howard A. Gutowitz 提出了一种基于元胞自动机行为的马尔可夫概率测量的层次化、参数化分类体系。S. Wolfram 对[一维元胞自动机](https://www.netlogoweb.org/launch#https://www.netlogoweb.org/assets/modelslib/Sample%20Models/Computer%20Science/Cellular%20Automata/CA%201D%20Elementary.nlogo)的演化行为进行了详细分析,并在大量计算机实验的基础上,将所有元胞自动机的动力学行为归纳为四大类:
* 平稳(Stable):从任何初始状态开始,经过一定时间运行后,元胞空间趋于一个空间平稳的构形。这里的空间平稳指的是每个元胞都处于固定状态,不随时间变化,例如规则254;
* 周期(Periodic):经过一定时间运行后,元胞空间趋于一系列简单的固定结构或周期结构。这些结构可以被视为一种滤波器,因此可用于图像处理研究,例如规则90;
* 混沌(Chaos):从任何初始状态开始,经过一定时间运行后,元胞自动机表现出混沌的非周期行为。所生成的结构的统计特征保持不变,通常表现为分形维特征,例如规则110;
* 复杂(Complex):出现复杂的局部结构,或者说是局部的混沌,其中有些会不断地传播,例如规则30。
理论上,元胞空间应该是无限的,但实际上这是不可能的。因此,我们需要像处理偏微分方程一样为其添加一些“邻居”,即所谓的边界条件。
对于固定型边界,我们在扩展边界时将扩展值设置为相同的常数值,如图所示:

图2.6.1 固定型边界图
周期型边界则是按照对应行或列的另一侧端点值进行填充,使之呈现出周期变化:

图2.6.2 周期型边界图
绝热型边界是指按照扩充时最近邻的格点值进行扩展,如图所示:

图2.6.3 绝热型边界图
映射型边界是针对对应的行或列按照特定规则映射生成填充值的边界方法。例如,定义映射规则为:这一行或这一列中待扩充节点相邻的下一个节点值,则边界如图所示:

图2.6.4 映射型边界图
### 2.6.2 元胞自动机的应用——生命游戏
生命游戏(Game of Life)是由英国数学家约翰·康威(John Horton Conway)于1970年发明的一种元胞自动机。它是一个零玩家游戏,意味着游戏的演化是由初始状态决定的,不需要玩家的进一步输入。生命游戏的规则非常简单,但它能够产生极其复杂的行为,被誉为“元胞自动机的代表作”。生命游戏的规则如下:
* (Exposure)当前细胞为存活状态时,当周围的存活细胞低于2个时(不包含2个),该细胞变成死亡状态
* (Survive)当前细胞为存活状态时,当周围有2个或3个存活细胞时,该细胞保持原样
* (Overcrowding)当前细胞为存活状态时,当周围有超过3个存活细胞时,该细胞变成死亡状态
* (Reproduction)当前细胞为死亡状态时,当周围有3个存活细胞时,该细胞变成存活状态
尽管规则简单,生命游戏却能够展示出类似于生物系统中的复杂行为,如细胞的出生、死亡和繁衍。它不仅在数学和计算机科学中被广泛研究,还成为了人工生命和复杂系统理论的一个重要模型。
生命游戏的一个有趣特点是,它能够产生各种各样的图案,包括静态图案、振荡图案和移动图案(如滑翔机)。这些图案的演化可以被看作是计算过程的一种形式,因此生命游戏也被认为是一种基本的计算模型。
下面的Python代码示例展示了如何使用NumPy和Matplotlib库来模拟生命游戏。该示例包括创建初始随机网格、添加滑翔机图案以及实现环形边界条件的功能。通过调整参数和初始条件,可以探索生命游戏中各种有趣的行为和图案。
```python
## eg.1.
import sys,argparse #argparse是python的一个命令行解析包
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from matplotlib.colors import ListedColormap
yeah =('purple','yellow')
cmap = ListedColormap(yeah)
ON = 255
OFF = 0
vals = [ON, OFF]
def randomGrid(N):
"""returns a grid of NxN random values"""
return np.random.choice(vals, N*N, p=[0.2, 0.8]).reshape(N, N) #采用随机的初始状态
def addGlider(i, j, grid):
"""adds a glider with top-left cell at (i, j)"""
glider = np.array([[0, 0, 255],
[255, 0, 255],
[0, 255, 255]])
# 3×3 的 numpy 数组定义了滑翔机图案(看上去是一种在网格中平稳穿越的图案)。
grid[i:i+3, j:j+3] = glider
#可以看到如何用 numpy 的切片操作,将这种图案数组复制到模拟的二维网格中
# 它的左上角放在 i和 j指定的坐标,即用这个方法在网格的特定行和列增加一个图案,
#实现环形边界条件
def update(frameNum, img, grid, N):
newGrid = grid.copy()
for i in range(N):
for j in range(N):
total = int((grid[i, (j-1)%N] + grid[i, (j+1)%N] +
grid[(i-1)%N, j] + grid[(i+1)%N, j] +
grid[(i-1)%N, (j-1)%N] + grid[(i-1)%N, (j+1)%N] +
grid[(i+1)%N, (j-1)%N] + grid[(i+1)%N, (j+1)%N])/255)
# 因为需要检测网格的 8个边缘。更简洁的方式是用取模(%)运算符
# 可以用这个运算符让值在边缘折返
# Conway实现规则 :生命游戏的规则基于相邻细胞的 ON 或 OFF 数目
# 为了简化这些规则的应用,可以计算出处于 ON 状态的相邻细胞总数。
if grid[i, j] == ON:
if (total < 2) or (total > 3):
newGrid[i, j] = OFF
else:
if total == 3:
newGrid[i, j] = ON
# update data
img.set_data(newGrid)
grid[:] = newGrid[:]
return img,
#向程序发送命令行参数,mian()
def main():
# command line arguments are in sys.argv[1], sys.argv[2], ...
# sys.argv[0] is the script name and can be ignored
# parse arguments
parser = argparse.ArgumentParser(description="Runs Conway's Game of Life simulation.")
# add arguments
parser.add_argument('--grid-size', dest='N', required=False) #定义了可选参数,指定模拟网格大小N
parser.add_argument('--mov-file', dest='movfile', required=False) #指定保存.mov 文件的名称
parser.add_argument('--interval', dest='interval', required=False) #设置动画更新间隔的毫秒数
parser.add_argument('--glider', action='store_true', required=False) #用滑翔机图案开始模拟
parser.add_argument('--gosper', action='store_true', required=False)
args = parser.parse_args()
#初始化模拟
# set grid size
N = 100
if args.N and int(args.N) > 8:
N = int(args.N)
# set animation update interval
updateInterval = 50
if args.interval:
updateInterval = int(args.interval)
# declare grid
grid = np.array([])
# check if "glider" demo flag is specified,设置初始条件,要么是默认的随机图案,要么是滑翔机图案。
if args.glider:
grid = np.zeros(N*N).reshape(N, N) #创建 N×N 的零值数组,
addGlider(1, 1, grid) #调用 addGlider()方法,初始化带有滑翔机图案的网格
else:
# populate grid with random on/off - more off than on
grid = randomGrid(N)
# 设置动画
fig, ax = plt.subplots(facecolor='pink') #配置 matplotlib 的绘图和动画参数
img = ax.imshow(grid,cmap=cmap, interpolation='nearest') #用plt.show()方法将这个矩阵的值显示为图像,并给 interpolation 选项传入'nearest'值,以得到尖锐的边缘(否则是模糊的)
ani = animation.FuncAnimation(fig, update, fargs=(img, grid, N, ),#animation.FuncAnimation()调用函数 update(),该函数在前面的程序中定义,根据 Conway 生命游戏的规则,采用环形边界条件来更新网格。
frames=10,
interval=updateInterval,
save_count=50)
# number of frames?
# set the output file
if args.movfile:
ani.save(args.movfile, fps=30, extra_args=['-vcodec', 'libx264'])
plt.show()
# call main
if __name__ == '__main__':
main()
```

图2.6.5 元胞自动机生命游戏的初始演化状态
图2.6.5显示的是元胞自动机在某一特定时间点的状态,可以用来说明生命游戏中的复杂动态。在这个矩阵中,黄色的点代表活细胞(ON状态),紫色背景代表死细胞(OFF状态)。从图中可以观察到,活细胞聚集在一起形成了多种结构,包括一些小的集群和更大的复杂图案。
### 2.6.3 元胞自动机的应用 —— 森林山火扩散模型
在此小节中,我们利用元胞自动机模拟了森林火灾的扩散过程。通过建立一个简单的模型,我们能够观察火势如何在森林中蔓延,以及如何受到植被分布、环境因素和偶发事件(如闪电)的影响。本模型的基本假设包括:
* 空地(黑色)、树木(绿色)、火焰(红色)三种状态;
* 绿色树木可以因相邻的火焰而燃烧;
* 空地上有一定的概率生长出新的树木;
* 闪电可以随机引燃树木。
通过设定不同的参数,如树木密度、燃烧概率、生长率和闪电概率,我们可以模拟出多种火灾情景。该模拟为理解火灾的动力学和预防措施提供了一个直观的平台。代码执行后,展示了500个模拟步骤,每一步都在更新显示火灾蔓延的状态。模拟的动态演示不仅可以帮助研究人员理解和预测真实世界中的火灾行为,也可用于教育和展示目的,以提高公众对森林火灾危险性的认识。
在本节的代码中,我们使用numpy和matplotlib库来创建并迭代森林的状态,numpy用于高效的矩阵计算,而matplotlib则用于可视化每一步的结果。森林的每个单元格都可以根据邻居的状态和随机事件来更新其状态,从而产生一个动态且引人入胜的模拟过程。
```python
import numpy as np#科学计算库 处理多维数据(矩阵)
import matplotlib.pyplot as plt#绘图工具库
import matplotlib as mpl
#设置基本参数
global Row,Col#定义全局变量行和列
Row=100#行
Col=100#列
forest_area=0.8#初始化这个地方是树木的概率
firing=0.8#绿树受到周围树木引燃的概率
grow=0.001#空地上生长出树木的概率
lightning=0.00006#闪电引燃绿树的概率
forest=(np.random.rand(Row,Col)0)&(rule1prob0)&(p21图2.6.6 森林火灾模拟的森林初始状态
图2.6.6展示了元胞自动机模型中森林火灾模拟的起始状态,步骤为$0$。图中绿色单元代表树木,黑色空间代表没有树木的空地。在此状态下,森林尚未遭遇火灾,绿色树木的分布随机且密集,这提供了一个丰富的模拟环境,用于观察未来火势的蔓延路径和行为。随着模拟的进行,我们将能够见证由外界因素(如闪电)或树木间相互影响导致的火灾爆发和蔓延过程。
### 2.6.4 元胞自动机的应用——蒲公英的生长
在本节中,我们使用元胞自动机模型来探索蒲公英的生长模式,以及各种环境因素如何影响其生存和扩散。
* **年度温度波动**:温度受多种因素影响,包括纬度、地形以及靠近水体的距离等。然而,在特定地点,年平均温度和最高最低温差已确定的情况下,温度变化主要由季节变化决定。数据分析表明,特定月份的温度可以通过在年平均温度上加上一个正弦波函数来确定。
$$
\text{Temp} = \text{Temp\_Avg} + \text{Temp\_Diff} \cdot \cos \left( 2\pi \cdot \frac{\text{time}}{12} \right). \tag{2.6.1}
$$
* **日照时长的年度变化**:与受天气条件影响的日照时长不同,日照主要由季节决定。由于地球围绕太阳的周期性运动,各季节的日出和日落时间不同。日照时长用基础时长表示,即平均时长,并加上一个正弦波函数。
$$
\text{Light} = \text{Daylight\_Avg} + \text{Daylight\_Diff} \cdot \cos \left( 2\pi \cdot \frac{\text{time}}{12} \right) . \tag{2.6.2}
$$
* **降水和风**:由于降水和风受到水体近程、温度、山脉存在、海流以及彼此相互影响等多种因素的影响,它们很难预测。然而,降水和风条件存在年度模式。因此,我们编制了三组数据集,每组包含12个元素,代表特定月份的平均降水和风条件。
蒲公英种群和可用养分通过以下公式计算:
$$
\begin{align}
\text{Fertility}(t + \Delta t) &= \text{Fertility}(t) + \text{Area} \cdot \text{Repletion\_Con} \cdot \text{Rain} \\&~ ~ ~ ~- \text{Population\_Adult} \cdot \text{Comsumption\_Rate\_Adult} \\&~ ~ ~ ~- \text{Population\_Seed} \cdot \text{Comsumption\_Rate\_Seed}.
\end{align} \tag{2.6.3}
$$
* **生育力**:土地中的总养分。最初,土地含有一定量的养分。
* **面积**:土地的总面积。它与养分恢复率成正比,因为大块土地含有更多有机物和细菌。
* **恢复_常数**:该常数用于评估每平方米面积的恢复率。
* **降雨**:降雨量与养分恢复率成正比,因为单个细菌在给定时间内消耗一定量的水,并将一定量的有机物分解为养分。因此,能够存活的细菌数量与降雨量成正比。从而,降雨量与养分恢复率成正比。
* **成熟植株消耗率** vs. **种子植株消耗率**:成熟植株需要产生种子,这是一个消耗大量能量的过程,而未成熟植株则没有繁殖压力。因此,成熟植株和未成熟植株的养分消耗率不同,应存储在不同的变量中。
#### 解决问题—— 蔓延预测模型建立
**蔓延率**:考虑一块土地。来自源头$S_1$、$S_2$、$S_3$等的种子落在它上面的概率分别是$P_{1}$、$P_{2}$、$P_{3}$等。因此,它不接收到来自源头$S_{1}$、$S_{2}$、$S_{3}$等任何一个种子的概率就是$(1-P_{1})$、$(1-P_{2})$、$(1-P_{3})$等。据此,该土地不接收到任何种子的概率是$(1-P_{1}) \cdot (1-P_{2}) \cdot (1-P_{3})$等的乘积。
而该土地至少接收到一个种子,从而让蒲公英生长的概率是$1$减去上述概率:$1-(1-P_{1}) \cdot (1-P_{2}) \cdot (1-P_{3})$等。
在没有风的情况下,蒲公英种子的扩散与距离呈指数关系递减,这是由下式建模的:
$$
\text{Possibility} = \text{Spread\_Calm}^{-\text{Distance}}. \tag{2.6.4}
$$
风的存在能将种子传播。我们假设种子源与风向一致,并以矢量加法简化此过程,其中风被视为一个矢量。因此,特定数量的种子被分布在变化的面积$A = \pi \cdot \text{Distance}^2$上。该可能性由下式建模:
$$
\text{Possibility} = \frac{\text{Spread\_Wind}}{\text{Distance}^{2}}. \tag{2.6.5}
$$
**生长率**:种子要发育到蒲公英蒴果阶段,需要一定的生长期。在更多养分和有利条件下,未成熟植株可以更快生长。我们创建了变量$K$来确定未成熟植株每月的生长率。
蒲公英生长的最适温度区间能带来最佳结果。然而,在这一区间之外,蒲公英的生长速率呈指数级下降。
$$
\begin{aligned}\\
&\rule{200mm}{0.4pt} \\
&\textbf{Algorithm 1 蒲公英的生长速率}\\[-1.ex]
&\rule{200mm}{0.4pt} \\
&\textbf{input} :
\text{Temp}(环境温度)
\\
&\textbf{output} :
k_\text{Temp}(温度影响的生长速率)
\\
&\mathbf{initialize}: T_\text{high}(高温阈值),~ T_\text{low}(低温阈值),~ \text{Temp\_Coeff}(温度参数)
\\[-1.ex]
&\rule{200mm}{0.4pt}
\\
&\mathbf{if}~ T_{\text{high}} > \text{Temp} > T_{\text{low}} \\
&\hspace{2em} k_{\text{Temp}} \leftarrow 1 \\
&\mathbf{else~if}~ T_{\text{high}} < \text{Temp} \\
&\hspace{2em} k_{\text{Temp}} \leftarrow \text{Temp\_Coeff}^{T_{\text{high}} - \text{Temp}} \\
&\mathbf{else}\\
&\hspace{2em} k_{\text{Temp}} \leftarrow \text{Temp\_Coeff}^{\text{Temp} - T_{\text{low}}} \\
&\rule{200mm}{0.4pt} \\
&\mathbf{return~}k_{\text{Temp}}\\
&\rule{200mm}{0.8pt}
\end{aligned}
$$
同样,蒲公英生长的最佳降水也是一个区间。在这个区间之外,生长速率呈指数级下降。
$$
\begin{aligned}\\
&\rule{200mm}{0.4pt} \\
&\textbf{Algorithm 2 根据降水计算蒲公英的生长速率}\\[-1.ex]
&\rule{200mm}{0.4pt} \\
&\textbf{input} :
\text{Rain}(环境降水)
\\
&\textbf{output} :
k_\text{Rain}(降水影响的生长速率)
\\
&\mathbf{initialize}: R_\text{high}(高降水阈值),~ R_\text{low}(低降水阈值),~ \text{Rain\_Coeff}(降水参数)
\\[-1.ex]
&\rule{200mm}{0.4pt}
\\
&\mathbf{if}~ R_{\text{Rain}} > \text{Rain} > R_{\text{low}} \\
&\hspace{2em} k_{\text{Temp}} \leftarrow 1 \\
&\mathbf{else~if}~ R_{\text{high}} < \text{Rain} \\
&\hspace{2em} k_{\text{Rain}} \leftarrow \text{Rain\_Coeff}^{R_{\text{high}} - \text{Rain}} \\
&\mathbf{else}\\
&\hspace{2em} k_{\text{Rain}} \leftarrow \text{Rain\_Coeff}^{\text{Rain} - R_{\text{low}}} \\
&\rule{200mm}{0.4pt} \\
&\mathbf{return~}k_{\text{Rain}}\\
&\rule{200mm}{0.8pt}
\end{aligned}
$$
日照对于光合作用至关重要,这意味着蒲公英的生长将随着平均每日日照时数的增加而增加。然而,效果并非线性,因为 $k_\text{Lit}$ 的最大值为:
$$
k_{\text{Lit}} = \frac{\text{Light}}{\text{Light} + \text{Daylight\_Coeff}}. \tag{2.6.6}
$$
生长率($K$)可以使用以下公式计算:
$$
K = k_\text{Rain} \cdot k_{\text{Temp}} \cdot k_{\text{Lit}} \cdot \left( \frac{\text{Fertility}}{\text{Area}} - \text{Comsumption\_Rate\_Seed} \right). \tag{2.6.7}
$$
我们定义蒲公英的起始状态为$0$,结束状态为$1$。每个月,每株蒲公英的生长状态都会增加该月的$K$值。当生长状态超过$1$时,就将其标记为$1$,表示蒲公英已达到蒴果阶段。
**死亡率**:蒲公英可能因营养不足、极端温度、过多或过少的降雨以及日照不足而死亡。为了评估每月死亡的可能性,我们开发了一个函数。值得注意的是,生长系数与存活系数不同,因为在极端条件下,植物的新陈代谢可以被减缓以提高其生存概率。
$$
\begin{aligned}\\
&\rule{200mm}{0.4pt} \\
&\textbf{Algorithm 3 计算蒲公英种群的生存率}\\[-1.ex]
&\rule{200mm}{0.4pt} \\
&\textbf{input} :
\text{Temp}(环境温度),~ \text{Rain}(环境降水)
\\
&\textbf{output} :
\text{Survival\_Rate\_Seed}(幼年体生存率, \text{SS}),~ \text{Survival\_Rate\_Adult}(成年体生存率, \text{SA})
\\
&\mathbf{initialize}: T_\text{high}(高温阈值),~ T_\text{low}(低温阈值),~ \text{Temp\_Coeff}(温度参数), \\
&\hspace{5em}R_\text{high}(高降水阈值),~ R_\text{low}(低降水阈值),~ \text{Rain\_Coeff}(降水参数)
\\[-1.ex]
&\rule{200mm}{0.4pt}
\\
&\mathbf{if}~ T_\text{high} > \text{Temp} > T_\text{low}: \\
&\hspace{2em} k_\text{Temp} \leftarrow 1 \\
&\mathbf{else~if}~ T_\text{high} < \text{Temp}: \\
&\hspace{2em} k_\text{Temp} \leftarrow \text{Temp\_Coeff}^{T_\text{high}-\text{Temp}}\\
&\mathbf{else}\\
&\hspace{2em} k_\text{Temp} \leftarrow \text{Temp\_Coeff}^{\text{Temp}-T_\text{low}} \\
\\
&\mathbf{if}~ R_\text{high} > \text{Rain} > R_\text{low}: \\
&\hspace{2em} k_{\text{Rain}} \leftarrow 1\\
&\mathbf{else~if}~ R_\text{high} < \text{Rain}:\\
&\hspace{2em} k_{\text{Rain}} \leftarrow \text{Temp\_Coeff}^{R_\text{high}-\text{Rain}}\\
&\mathbf{else}~ R_\text{low} > \text{Rain}:\\
&\hspace{2em} k_{\text{Rain}} \leftarrow \text{Temp\_Coeff}^{\text{Rain}-R_\text{low}}\\
\\
&k_{\text{Lit}} \leftarrow \frac{\text{Light}}{\text{Light} + \text{Daylight\_Coeff}}\\
\\
&\text{Survival\_Rate\_Seed} \leftarrow k_{\text{Rain}} \cdot k_\text{Temp} \cdot k_{\text{Lit}} \cdot \left( \frac{\text{Fertility}}{\text{Area}} - \text{Comsumption\_Rate\_Seed} \right)\\
&\text{Survival\_Rate\_Adult} \leftarrow k_{\text{Rain}} \cdot k_\text{Temp} \cdot k_{\text{Lit}} \cdot \left( \frac{\text{Fertility}}{\text{Area}} - \text{Comsumption\_Rate\_Adult} \right)\\
&\rule{200mm}{0.4pt} \\
&\mathbf{return~}\text{Survival\_Rate\_Seed},~ \text{Survival\_Rate\_Adult} \\
&\rule{200mm}{0.8pt}
\end{aligned}
$$
每个月,每株未成熟的蒲公英都有一个$\text{SS}$的死亡可能性,而每株成熟的蒲公英有一个$\text{SA}$的死亡可能性。
现在,让我们看一下生成这些图像的代码。代码使用Python编写,通过模拟环境因素如温度、日照、降雨以及风向对蒲公英生长和死亡率的影响来预测蒲公英种群在特定区域的扩散。通过对光合作用、温度和降雨的最佳范围进行建模,这个程序能够模拟蒲公英如何在不同月份内生长并影响土地的肥沃程度。此外,模拟还考虑了成熟植株和种子植株的养分消耗率差异,以及不同生长阶段植株的生存率。这些参数合作模拟了蒲公英如何在一个假设的生态系统中扩散,最终达到一个动态平衡。
```python
import random
import matplotlib.pyplot as plt
#spread factors
Spread_Calm = math.e
Spread_wind = 0.5
#a variable that describe the fertility of land
Fertility = 10000
#a constant that describe rate of repletion per area
Repletion_Con = 0.006
#a constant that describe the rate of energy use of a dandelion
Consumption_rate_Adult = 1
Consumption_rate_Seed = 0.5
#a variable that describe the population of dandelion
population_adult = 0
population_seed = 0
#a constant describe effect of temperature on germination
Temp_Coefficient = 1.125
#a constant describe effect of temperature on survival
Temp_Survival = 1.01
#a constant describe effect of rainfall on germination
Rain_Coefficient = 1.03
#a constant describe effect of rainfall on survival
Rain_Survival = 1.005
#a constant describe effect of daylight on germination
Daylight_Coefficient = 1
#a constant describe effect of daylight on survival
Daylight_Survival = 0.3
#upper limit of optimum temperature
Thigh = 25
#lower limit of optimum temperature
Tlow = 15
#upper limit of optimum rainfall
Rhigh = 200
#lower limit of optimum rainfall
Rlow = 50
#initialize a variable that describe possibility of germination
K = 0
#initialize a variable that describe possibility of survival
SA = 0
SS = 0
#initialize a variable that describe growth rate
Rate = 0
#a constant that describe the size of a single plant in metre
width = 1
#initialize time of a year in month
time = 1
#number of months want to predict
Period = 48
#time counter
Tcounter = 0
#wind factor of west-east wind in each month
W_west_east = [0,0,0,0,0,0,0,0,0,0,0,0]
#wind factor of south-north wind in each month
W_south_north = [0,0,0,0,0,0,0,0,0,0,0,0]
Rainfall = [200,200,200,200,200,200,200,200,200,200,200,200]
Daylight_Avg = 12
Daylight_Diff = 0
Temperature_Avg = 16
Temperature_Diff = 0
def Temperature(Avg,Diff,T):
temperature = Avg - Diff*math.cos(2*(math.pi)*(T/12))
return temperature
def Daylight(Avg,Diff,T):
daylight = Avg - Diff*math.cos(2*(math.pi)*(T/12))
return daylight
def Germination(Tem,Rain,Lit):
k_Tem = 0
k_Rain = 0
k_Lit = 0
if Tem < Tlow:
k_Tem = (Temp_Coefficient)**(Tem-Tlow)
elif Tem > Thigh:
k_Tem = (Temp_Coefficient)**(Thigh-Tem)
else:
k_Tem = 1
if Rain < Rlow:
k_Rain = (Rain_Coefficient)**(Rain-Rlow)
elif Rain > Rhigh:
k_Rain = (Rain_Coefficient)**(Rhigh-Rain)
else:
k_Rain = 1
k_Lit = Lit/(Lit+Daylight_Coefficient)
k = k_Tem*k_Rain*k_Lit*((Fertility/10000)-Consumption_rate_Seed)
return k
def Survival(Tem,Rain,Lit,type):
k_Tem = 0
k_Rain = 0
k_Lit = 0
if Tem < Tlow:
k_Tem = (Temp_Survival)**(Tem-15)
elif Tem > Thigh:
k_Tem = (Temp_Survival)**(25-Tem)
else:
k_Tem = 1
if Rain < Rlow:
k_Rain = (Rain_Survival)**(Rain-Rlow)
elif Rain > Rhigh:
k_Rain = (Rain_Survival)**(Rhigh-Rain)
else:
k_Rain = 1
k_Lit = Lit/(Lit+Daylight_Survival)
if type == 0:
s = k_Tem*k_Rain*k_Lit*((Fertility/10000)-Consumption_rate_Adult)
else:
s = k_Tem*k_Rain*k_Lit*((Fertility/10000)-Consumption_rate_Seed)
return s
#initialize the field
#(0,0,0) represent empty land, (0,255,0) represent seed, (255,255,255) represent dandelion
field = [[(0,0,0) for i in range(int(100/width))] for j in range(int(100/width))]
#initialize the field for growth time
growth_table = [[0 for i in range(int(100/width))] for j in range(int(100/width))]
#set the first dandelion
field[0][0] = (255,255,255)
growth_table[0][0] = 1
for Tcounter in range(Period):
population_adult = 0
population_seed = 0
for Column_index in range(int(100/width)):
for Row_index in range(int(100/width)):
if field[Column_index][Row_index] == (255,255,255):
population_adult = population_adult + 1
if field[Column_index][Row_index] == (0,255,0):
population_seed = population_seed + 1
temp = Temperature(Temperature_Avg,Temperature_Diff,time)
light = Daylight(Daylight_Avg,Daylight_Diff,time)
rain = Rainfall[time-1]
Fertility = Fertility + 10000*Repletion_Con*rain
Fertility = Fertility - population_adult*Consumption_rate_Adult - population_seed*Consumption_rate_Seed
K = Germination(temp,rain,light)
SA = Survival(temp,rain,light,0)
SS = Survival(temp,rain,light,1)
for Column_index in range(int(100/width)):
for Row_index in range(int(100/width)):
if growth_table[Column_index][Row_index] >= 1 and field[Column_index][Row_index] == (0,255,0):
field[Column_index][Row_index] = (255,255,255)
growth_table[Column_index][Row_index] = 1
for Column_index in range(int(100/width)):
for Row_index in range(int(100/width)):
if field[Column_index][Row_index] == (0,0,0):
Spreading = 1
no_seed = 1
for inner_column in range(int(100/width)):
for inner_row in range(int(100/width)):
if field[inner_column][inner_row] == (255,255,255):
Distance = ((Column_index-(inner_column+W_south_north[time-1]))**2+(Row_index-(inner_row+W_west_east[time-1]))**2)**(0.5)
if W_south_north[time-1] != 0 or W_west_east[time-1] != 0:
if Distance == 0:
have_seed = Spread_wind
else:
have_seed = Spread_wind/((Distance)**2)
else:
have_seed = Spread_Calm**(-1*Distance)
no_seed = no_seed*(1-have_seed)
Spreading = 1-no_seed
Possibility_factor = random.random()
if Spreading>=Possibility_factor:
field[Column_index][Row_index] = (0,255,0)
growth_table[Column_index][Row_index] = 0
for Column_index in range(int(100/width)):
for Row_index in range(int(100/width)):
if field[Column_index][Row_index] == (0,255,0):
growth_table[Column_index][Row_index] = growth_table[Column_index][Row_index] + K
for Column_index in range(int(100/width)):
for Row_index in range(int(100/width)):
if field[Column_index][Row_index] == (0,255,0):
Possibility_factor = random.random()
if SS <= Possibility_factor:
field[Column_index][Row_index] = (0,0,0)
growth_table[Column_index][Row_index] = 0
elif field[Column_index][Row_index] == (255,255,255):
Possibility_factor = random.random()
if SA <= Possibility_factor:
field[Column_index][Row_index] = (0,0,0)
growth_table[Column_index][Row_index] = 0
if time <12:
time = time + 1
else:
time = 1
plt.imshow(field)
plt.title('Field')
plt.xlabel('distance/m')
plt.ylabel('distance/m')
plt.show()
```

图2.6.7 蒲公英生长和扩散模拟图
图2.6.7展示了蒲公英在不同月份的生长状态。从左上角的“January”开始,显示了一片黑暗的土地,仅有一点绿色表示蒲公英的存在。随着时间的推移,我们可以看到蒲公英的种子开始扩散(如“July”和“August”所示),覆盖区域逐渐增大。到了“October”和“November”,绿色区域大幅增加,表明蒲公英种群迅速增长。在“December”和次年的“January”中,整个区域几乎完全被蒲公英所覆盖。进入次年“February”,蒲公英的覆盖面积略有减少,可能是由于模型中设定的环境因素影响。整体上,这些图像展现了从一片几乎未被侵占的土地到蒲公英占据主导地位的过程,以及不同月份条件下生态系统的动态变化。
### 2.6.5 元胞自动机的应用——新冠病毒的扩散
> 本节代码参考自https://github.com/Windxy/CA_SEIR/,特别鸣谢
在本节中,我们探讨使用元胞自动机模型模拟新冠病毒(COVID-19)的扩散。在这个模型中,每个细胞代表一个人,可以处于以下状态之一:易感者(S0),感染者(S1),治愈者(S2),或潜伏者(S3)。模型中包含以下转换规则:
* 易感者(S0)根据邻近感染者的数量以一定概率变成潜伏者(S3)或直接变成感染者(S1);
* 感染者(S1)具有传播病毒的能力,经过一段时间后自动变成治愈者(S2);
* 治愈者(S2)拥有暂时免疫力,但在本模型中,我们假设治愈者获得永久免疫;
* 潜伏者(S3)在经过一定潜伏期后变成感染者(S1)。
通过这个模型,我们能够模拟和观察疾病如何在人群中传播,以及不同的干预措施如何影响疾病的扩散。例如,可以通过调整感染率、潜伏期和治愈时间的参数来模拟不同的公共卫生策略,如社交距离、戴口罩和隔离措施的效果。此模型为疾病控制决策提供了一个有力的工具,并有助于公共卫生专家和政策制定者更好地理解和应对疫情。
我们使用Python编写了一个简单的模拟程序,其中包括初始化各状态的人群分布、定义状态转换规则以及视觉展示模拟过程。pygame库用于绘制和更新模拟的图形界面,而matplotlib用于绘制随时间变化的人群状态图表。
```python
# 状态:
# S0表示易感者S
# S1表示感染者I
# S2表示治愈者R
# S3表示潜伏者E
# 规则:
# 1. 当S=S0时,为易感者,被感染率为p=k*(上下左右邻居的感染者)/4+l*(左上左下右上右下邻居的感染者)/4,概率c1变为潜伏者,概率1-c1变为感染者
# 2. 当S=S1时,为感染者,具有一定的感染能力,等待t_max时间,会自动变为S2治愈者,染病时间初始化为0
# 3. 当S=S2时,为治愈者,[具有一定的免疫能力,等待T_max时间,会自动变为S0易感者,免疫时间初始化为0],改为永久免疫
# 4. 当S=S3时,为潜伏者,等待t1_max时间(潜伏时间),会自动变为S1感染者,潜伏时间初始化为0,
from pylab import *
import random
import numpy as np
import pygame
import sys
import matplotlib.pyplot as plt
# 初始化相关数据
k=0.85 #上下左右概率
l=0.55 #其它概率
c1=0.4 #c1概率变为潜伏者
t1_max=15 #潜伏时间
t_max=50 #治愈时间
T_max=75 #免疫时间
# p = np.array([0.6, 0.4])
# probability = np.random.choice([True, False],
# p=p.ravel()) # p(感染or潜伏)=(self.s1_0*k+self.s1_1*l) p(易感)=1-(self.s1_0*k+self.s1_1*l)
def probablity_fun():
np.random.seed(0)
# p = np.array([(self.s1_0*k+self.s1_1*l),1-(self.s1_0*k+self.s1_1*l)])
p = np.array([0.6, 0.4])
probability = np.random.choice([True, False],
p=p.ravel()) # p(感染or潜伏)=(self.s1_0*k+self.s1_1*l) p(易感)=1-(self.s1_0*k+self.s1_1*l)
return probability
RED = (255, 0, 0)
GREY = (127, 127, 127)
Green = (0, 255, 0)
BLACK = (0, 0, 0)
"""细胞类,单个细胞"""
class Cell:
# 初始化
stage = 0
def __init__(self, ix, iy, stage):
self.ix = ix
self.iy = iy
self.stage = stage #状态,初始化默认为0,易感者
# self.neighbour_count = 0 #周围细胞的数量
self.s1_0 = 0 #上下左右为感染者的数量
self.s1_1 = 0 #左上左下右上右下感染者的数量
self.T_ = 0 #免疫时间
self.t_ = 0 #患病时间
self.t1_ = 0 #潜伏时间
# 计算周围有多少个感染者
def calc_neighbour_count(self):
count_0 = 0
count_1 = 0
pre_x = self.ix - 1 if self.ix > 0 else 0
for i in range(pre_x, self.ix+1+1):
pre_y = self.iy - 1 if self.iy > 0 else 0
for j in range(pre_y, self.iy+1+1):
if i == self.ix and j == self.iy: # 判断是否为自身
continue
if self.invalidate(i, j): # 判断是否越界
continue
if CellGrid.cells[i][j].stage == 1 or CellGrid.cells[i][j] == 3 : #此时这个邻居是感染者
#如果是在上下左右
if (i==self.ix and j==self.iy-1) or \
(i==self.ix and j==self.iy+1) or \
(i==self.ix-1 and j==self.iy) or \
(i==self.ix+1 and j==self.iy):
count_0+=1
else:
count_1+=1
# print(count_0)
self.s1_0 = count_0
# if self.s1_1!=0:
# print(count_1,count_0,self.ix,self.iy)
self.s1_1 = count_1
# 判断是否越界
def invalidate(self, x, y):
if x >= CellGrid.cx or y >= CellGrid.cy:
return True
if x < 0 or y < 0:
return True
return False
# 定义规则
def next_iter(self):
# 规则1,易感者
if self.stage==0:
probability=random.random()#生成0到1的随机数
s1_01 = self.s1_0 * k + self.s1_1 * l
if (s1_01>probability) and (s1_01!=0):
p1 = random.random()
if p1>c1:
self.stage=1
else:
self.stage=3
else:
self.stage = 0
# 规则2,感染者
elif self.stage == 1:
if self.t_ >= t_max:
self.stage = 2
else:
self.t_ = self.t_ + 1
# 规则3,治愈者(永久免疫规则)
elif self.stage == 2:
if self.T_ >= T_max:
self.stage = 0
else:
self.T_ = self.T_ + 1
# 规则4,潜伏者
elif self.stage == 3:
if self.t1_ >= t1_max:
self.stage = 1 # 转变为感染者
else:
self.t1_ += 1
"""细胞网格类,处在一个长cx,宽cy的网格中"""
class CellGrid:
cells = []
cx = 0
cy = 0
# 初始化
def __init__(self, cx, cy):
CellGrid.cx = cx
CellGrid.cy = cy
for i in range(cx):
cell_list = []
for j in range(cy):
cell = Cell(i, j, 0) #首先默认为全是易感者
if (i == cx/2 and j ==cy/2) or (i==cx/2+1 and j==cy/2) or (i==cx/2+1 and j==cy/2+1):#看26行就可以了
cell_list.append(Cell(i,j,1))
else:
cell_list.append(cell)
CellGrid.cells.append(cell_list)
def next_iter(self):
for cell_list in CellGrid.cells:
for item in cell_list:
item.next_iter()
def calc_neighbour_count(self):
for cell_list in CellGrid.cells:
for item in cell_list:
item.calc_neighbour_count()
def num_of_nonstage(self):
# global count0_,count1_,count2_
count0 = 0
count1 = 0
count2 = 0
count3 = 0
for i in range(self.cx):
for j in range(self.cy):
# 计算全部的方格数
cell = self.cells[i][j].stage
if cell == 0:
count0 += 1
elif cell == 1:
count1 += 1
elif cell == 2:
count2 += 1
elif cell == 3:
count3 += 1
return count0, count1, count2, count3
'''界面类'''
class Game:
screen = None
count0 = 0
count1 = 9
count2 = 0
count3 = 0
def __init__(self, width, height, cx, cy):#屏幕宽高,细胞生活区域空间大小
self.width = width
self.height = height
self.cx_rate = int(width / cx)
self.cy_rate = int(height / cy)
self.screen = pygame.display.set_mode([width, height])#
self.cells = CellGrid(cx, cy)
def show_life(self):
for cell_list in self.cells.cells:
for item in cell_list:
x = item.ix
y = item.iy
if item.stage == 0:
pygame.draw.rect(self.screen, GREY,
[x * self.cx_rate, y * self.cy_rate, self.cx_rate, self.cy_rate])
elif item.stage == 2:
pygame.draw.rect(self.screen, Green,
[x * self.cx_rate, y * self.cy_rate, self.cx_rate, self.cy_rate])
elif item.stage == 1:
pygame.draw.rect(self.screen, RED,
[x * self.cx_rate, y * self.cy_rate, self.cx_rate, self.cy_rate])
elif item.stage == 3:
pygame.draw.rect(self.screen, BLACK,
[x * self.cx_rate, y * self.cy_rate, self.cx_rate, self.cy_rate])
# def count_num(self):
# self.count0, self.count1, self.count2,self.count3 = self.cells.num_of_nonstage()
mpl.rcParams['font.sans-serif'] = ['FangSong'] # 指定默认字体
mpl.rcParams['axes.unicode_minus'] = False # 解决保存图像是负号'-'显示为方块的问题
if __name__ == '__main__':
count0_ = []
count1_ = []
count2_ = []
count3_ = []
pygame.init()
pygame.display.set_caption("传染病模型")
game = Game(800, 800, 200, 200)
clock = pygame.time.Clock()
k1 = 0
while True:
k1 += 1
print(k1)
# game.screen.fill(GREY)#底部全置灰
clock.tick(100) # 每秒循环10次
for event in pygame.event.get():
if event.type == pygame.QUIT:
sys.exit()
game.cells.calc_neighbour_count()
count0, count1, count2,count3 = game.cells.num_of_nonstage()
# count0,count1,count2 = game.count_num()
count0_.append(count0)
count1_.append(count1)
count2_.append(count2)
count3_.append(count3)
if count2 > 200*190: # 退出条件
break
plt.plot(count0_, color='y', label='易感者')
plt.plot(count3_, color='b', label='潜伏者')
plt.plot(count1_, color='r', label='感染者')
plt.plot(count2_, color='g', label='治愈者')
# plt.ylim([0,80000])
plt.legend()
plt.xlabel('时间单位')
plt.ylabel('人数单位')
plt.pause(0.1)#0.1秒停一次
plt.clf()#清除
# plt.close()#退出
game.show_life()
pygame.display.flip()
game.cells.next_iter()
plt.show() #显示
```

图2.6.8 新冠病毒扩散模拟及人群状态动态
图2.6.8左侧展示了一个使用元胞自动机模拟新冠病毒扩散过程的界面。在模拟环境中,红色区域代表感染者(S1),绿色区域代表治愈者(S2),黑色点代表潜伏者(S3),而易感者(S0)则未在图中标出。我们可以看到,随着时间的推移,感染区从中心向外扩散,形成了明显的红色圆环,而治愈者则位于环的内部。
右侧图是随着时间推移的人群状态变化曲线图,横轴表示时间,纵轴表示人数。不同颜色的曲线代表不同状态的人群数量:黄色曲线代表易感者(S0),蓝色曲线代表潜伏者(S3),红色曲线代表感染者(S1),绿色曲线代表治愈者(S2)。从曲线图可以看出,随着时间的推移,感染者人数先增加后减少,治愈者人数逐渐增加,潜伏者人数有一个短暂的峰值,而易感者人数则持续减少。这反映了疾病传播和人群免疫状态的动态变化。
## 2.7 数值计算方法与微分方程求解
在使用Python求微分方程的数值解或函数极值时,你可能会发现其与MATLAB在使用上有所不同。但你有没有想过,Python是如何求出这些数值解的呢?这背后涉及到的算法,通常会在工程数学和数值分析课程中学到。为了帮助大家更好地理解这些计算的基本原理,我们特别增加了一节内容,介绍一些基础的数值计算方法。如果你感兴趣,甚至可以尝试自己编写一个求解器。
### 2.7.1 Python通过什么求数值解
在Python中,数值计算方法主要依赖于一些专门的库,如NumPy、SciPy和SymPy等。这些库提供了丰富的数学函数和算法,用于处理线性代数、微积分、优化问题等。例如,SciPy中的`scipy.optimize`模块可以用于求解函数的极值,`scipy.integrate`模块可以用于数值积分,而`scipy.linalg`模块则提供了线性代数的相关功能。通过这些工具,我们可以在Python中有效地进行数值计算和求解微分方程。
数值计算方法是一种求解数学问题的近似方法,它在计算机上被广泛应用于各种数学问题的求解。无论是在科学研究还是工程技术中,计算方法都扮演着重要的角色。随着计算机技术的快速发展,计算方法已经成为理工科学生的一门必修课程。计算方法主要研究微积分、线性代数和常微分方程中的数学问题,内容涵盖插值和拟合、数值微分和积分、线性方程组的求解、矩阵特征值和特征向量的计算,以及常微分方程的数值解等。
在Python中,类似MATLAB中的`fsolve`和`odeint`这样的函数,正是依赖于这些数值计算方法来工作的。下面,我们将介绍几种经典的数值计算方法。
### 2.7.2 梯度下降法
梯度下降法是解决优化问题的一种普遍手段,尤其在机器学习和深度学习领域中。其核心思路是:从一个初始位置开始,沿着目标函数梯度的相反方向(即最速下降方向)逐步更新位置,直至达到最小值点。在Python中,我们通常利用NumPy等工具包来计算梯度,并按照梯度下降的更新规则调整变量值。
其基本原理简洁明了,即从一个初始点出发,计算当前点的梯度,并依照以下迭代规则进行更新:
$$
x_{t+1} \leftarrow x_{t} - \alpha \cdot \text{grad}(f). \tag{2.7.1}
$$
当前后两次迭代的函数值之差满足一个很小的阈值(误差的容许范围)时我们认为迭代基本成功。或者从另一个角度,由于极值点的偏导数为$0$,那么当梯度的模接近$0$时,也可认为找到了极小值点。不过,个人建议使用函数值差异作为更准确的判断标准。
> 注意:事实上,在机器学习的实际应用中,由于数据量庞大,梯度下降通常有三种变体:随机梯度下降、批量梯度下降和小批量梯度下降。此外,还可以引入动量等技术来优化梯度下降过程。这些内容将在后续章节详细介绍。
以函数的极值为例,我们利用梯度下降函数去进行求解:
```python
import numpy as np
import matplotlib.pyplot as plt
x=np.linspace(-6,4,100)
y=x**2+2*x+5
可以编写如下的梯度下降函数。
#将迭代的点描绘出来更直观形象
x_iter=1#设置x的初始值
yita=0.06#步长
count=0#记录迭代次数
while True:
count+=1
y_last=x_iter**2+2*x_iter+5
x_iter=x_iter-yita*(2*x_iter+2)
y_next=x_iter**2+2*x_iter+5
plt.scatter(x_iter,y_last)
if abs(y_next-y_last)<1e-100:
break
print('最小值点x=',x_iter,'最小值y',y_next,'迭代次数n=',count)
x=np.linspace(-4,6,100)
y=x**2+2*x+5
plt.plot(x,y,'--')
plt.show()
最终解得:
最小值点x= -0.9999999616185459 最小值y 4.000000000000002 迭代次数n= 139
```
已经非常接近精确解$-1$,虽然有一定误差但很小,结果也确实满足了我们预先设置的要求。图像如下:

图2.7.1 梯度下降的迭代结果
### 2.7.3 Newton法
Newton法,也称为切线法,是一种寻找函数零点的有效方法。其思路是从一个初始估计值开始,不断迭代更新,直到找到零点或达到预定精度。在求解函数极值问题时, Newton法通过寻找函数导数的零点来实现。它的原理如图3.16所示:

图2.7.2 Newton法示意图
如图2.7.2,假设我们想要求解方程$y=x^{2}-C$的零点,,我们从一个初始点$x_0$开始。 Newton法首先在$x=x_0$处求函数的切线,并找到切线与$\mathrm{x}$轴的交点$x_{1}$,然后在$x=x_{1}$处再次求切线,找到新的交点$x_{2}$,如此不断迭代下去,最终会逼近$x_0$附近的零点。以下是用Python实现的 Newton法示例代码:
```python
import numpy as np
def f(x):
y=x**3-x-1#求根方程的表达式
return y
def g(x):
y=3*x**2-1#求根方程的导函数
return y
def main():
x_0=1.5 #取初值
e=10**(-9) #误差要求
L=0 #初始化迭代次数
while L<100: #采用残差来判断
x1=x_0-f(x_0)/g(x_0) #迭代公式,x(n+1)=x(n)-f(x(n))/f'(x(n))
x_0=x1
L=L+1 #统计迭代次数
if abs(f(x_0)-0)图2.7.3 Runge-Kutta 法示意图
如图2.7.3所示,我们考虑一个小的区间$h$。在数值微分中,我们希望在这个小区间内,函数的差分增量(即直线的斜率)能够尽可能接近其微分增量(即曲线的斜率)。但是,如图所示,如果我们仅仅使用x_0点处的斜率,差分增量会比微分增量小;而如果使用$(x_0+h)$点处的斜率,差分增量又会比微分增量大。那么,我们能否找到一种折中的方法呢?
* 第一种思路是取$x_0$点和$(x_0+h)$点处斜率的平均值作为差分增量的斜率。这样,我们得到的差分增量就会更接近微分增量,这是改进 Euler 法的基本思想。
> 注意:使用当前点和下一点的导数值进行迭代的方法称为前向 Euler 法和后向 Euler 法。
* 另一种思路是在x_0和(x_0+h)之间取不同点的斜率进行平均,然后进行迭代。这是 Runge-Kutta 法的基本思想
典型的四阶 Runge-Kutta 法会使用四个不同点处的斜率进行迭代计算。经典四阶Runge-Kutta 法的迭代斜率如下:
$$
\begin{align}
K_{1} &\leftarrow f(x_{i}, y_{i}), \\[0.5em]
K_{2} &\leftarrow f\left( x_{i} + \frac{h}{2}, y_{i} + \frac{K_{1}}{2}\cdot h\right), \\[0.5em]
K_{4} &\leftarrow f\left( x_{i} + \frac{h}{2}, y_{i} + \frac{K_{2}}{2}\cdot h\right), \\[0.5em]
K_{4} &\leftarrow f(x_{i} + h, y_{i} + K_{3}h), \\[0.5em]
y_{i+1} &\leftarrow y_{i} + \frac{h}{6}(K_{1} + 2K_{2} + 2K_{3} + K_{4}).
\end{align} \tag{2.7.2}
$$
我们可以编写一个程序自己实现 Runge-Kutta 法:
```python
import math
import numpy as np
import matplotlib.pyplot as plt
def runge_kutta(y, x, dx, f):
""" y is the initial value for y
x is the initial value for x
dx is the time step in x
f is derivative of function y(t)
"""
k1 = dx * f(y, x)
k2 = dx * f(y + 0.5 * k1, x + 0.5 * dx)
k3 = dx * f(y + 0.5 * k2, x + 0.5 * dx)
k4 = dx * f(y + k3, x + dx)
return y + (k1 + 2 * k2 + 2 * k3 + k4) / 6.
t = 0.
y = 0.
dt = .1
ys, ts = [0], [0]
def func(y, t):
return 1/(1+t**2)-2*y**2
while t <= 10:
y = runge_kutta(y, t, dt, func)
t += dt
ys.append(y)
ts.append(t)
YS=odeint(func,y0=0, t=np.arange(0,10.2,0.1))
plt.plot(ts, ys, label='runge_kutta')
plt.plot(ts, YS, label='odeint')
plt.legend()
plt.show()
```

图2.7.4 使用 Python 实现的 Runge-Kutta 法与odeint函数的对比
图2.7.4展示了使用两种不同数值解法求解微分方程的结果对比:Runge-Kutta 方法(标记为 `runge_kutta`)和 Python 的`odeint`函数。水平轴代表时间变量`t`,范围从`0`到`10`,垂直轴代表函数`y(t)`的值。
图中,我们可以看到两条曲线:一条代表 Runge-Kutta 法,另一条代表`odeint`函数。两条曲线从`t = 0`时的同一个初始`y`值开始,随着时间的推移而分开。Runge-Kutta 方法的曲线呈现出开始时的急剧上升,达到顶点后逐渐下降,并在`t`接近`10`时趋近于零。`odeint`函数的曲线紧跟 Runge-Kutta 方法的曲线,表明两种方法提供了相似的微分方程解,虽然存在轻微的变化。
我们可以用自己写的龙格库塔测试洛伦兹系统的结果:
```python
import numpy as np
def move(P, steps, sets):
x, y, z = P
sgima, rho, beta = sets
# 各方向的速度近似
dx = sgima * (y - x)
dy = x * (rho - z)
dz = x * y - beta * z
return [x + dx * steps, y + dy * steps, z + dz * steps]
# 设置sets参数
sets = [10., 28., 3.]
t = np.arange(0, 30, 0.01)
# 位置1:
P0 = [0., 1., 0.]
P = P0
d = []
for v in t:
P = move(P, 0.01, sets)
d.append(P)
dnp = np.array(d)
# 位置2:
P02 = [0., 1.01, 0.]
P = P02
d = []
for v in t:
P = move(P, 0.01, sets)
d.append(P)
dnp2 = np.array(d)
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
ax.plot(dnp[:, 0], dnp[:, 1], dnp[:, 2])
ax.plot(dnp2[:, 0], dnp2[:, 1], dnp2[:, 2])
plt.show()
```
在洛伦兹方程上测试得到结果如图2.7.5所示。洛伦兹方程实际上就是“蝴蝶效应”的数学原理,它的曲线非常像一只蝴蝶。这是在混沌系统研究中得到的结论,当给这个系统一个微小的扰动就会引起极大的变化。也证明这一系统是不稳定的。

图2.7.5 使用 Runge-Kutta 法模拟洛伦兹系统
读者若有兴趣,还可以试着改写上面的函数实现改进 Euler 法。
### 2.7.5 Crank-Nilkson 法在热传导问题中的应用
> 本节参考自 [【数学建模之Python】13.手撕抛物型方程的差分解法(如一维热传导方程)_crank-nicolson差分格式-CSDN博客](https://blog.csdn.net/m0_53392188/article/details/120116474) ,特别鸣谢!
Crank-Nicolson 法是数值分析中一种用于求解热传导方程和类似偏微分方程的时间推进技术。与纯显式或隐式方法相比,它通过时间中心差分的方式,既稳定又准确。在本节中,我们将探讨如何使用Crank-Nicolson 法求解一维热传导问题,并将其与传统的显式和隐式方法进行比较。
首先,我们需要设定一些初始参数,包括热传导系数、空间和时间的最大范围、步长,以及相关的网格比。在给定这些参数后,我们用Python创建一个矩阵来初始化温度分布,并设定边界条件。在边界处,温度随时间的变化采用指数函数来模拟。本文所示的代码包含几个不同的数值方法:
* **古典显式方法**:一种简单但当时间步长较大时可能不稳定的方法;
* **古典隐式方法**:利用逆矩阵求解,计算上更稳定,适用于更大的时间步长;
* **古典隐式方法**(追赶法):一个更高效的算法,避免了直接计算矩阵的逆;
* **Crank-Nicolson 法**(逆矩阵):结合显式和隐式方法的优点,提高了稳定性和精确度;
* **Crank-Nicolson 法**(追赶法):同样结合了显式和隐式的优点,但使用追赶法提高计算效率;
除此之外,我们还提供了一个精确解函数,用于验证数值解的准确性。所有的数值方法均以Python函数的形式实现,并在结束时将结果输出到Excel文件中,便于分析和对比。
```python
import numpy as np
import pandas as pd
import datetime
start_time = datetime.datetime.now()
np.set_printoptions(suppress=True)
def left_boundary(t): # 左边值
return np.exp(t)
def right_boundary(t): # 右边值
return np.exp(t + 1)
def initial_T(x_max, t_max, delta_x, delta_t, m, n): # 给温度T初始化
T = np.zeros((n + 1, m + 1))
for i in range(m + 1): # 初值
T[0, i] = np.exp(i * delta_x)
for i in range(1, n + 1): # 注意不包括T[0,0]与T[0,-1]
T[i, 0] = left_boundary(i * delta_t) # 左边值
T[i, -1] = right_boundary(i * delta_t) # 右边值
return T
# 一、古典显格式
def one_dimensional_heat_conduction1(T, m, n, r):
# 可以发现当r>=0.5时就发散了
for k in range(1, n + 1): # 时间层
for i in range(1, m): # 空间层
T[k, i] = (1 - 2 * r) * T[k - 1, i] + r * (T[k - 1, i - 1] + T[k - 1, i + 1])
return T.round(6)
# 二、古典隐格式(乘逆矩阵法)
def one_dimensional_heat_conduction2(T, m, n, r):
A = np.eye(m - 1, k=0) * (1 + 2 * r) + np.eye(m - 1, k=1) * (-r) + np.eye(m - 1, k=-1) * (-r)
a = np.ones(m - 1) * (-r)
a[0] = 0
b = np.ones(m - 1) * (1 + 2 * r)
c = np.ones(m - 1) * (-r)
c[-1] = 0
F = np.zeros(m - 1) # m-1个元素,索引0~(m-2)
for k in range(1, n + 1): # 时间层range(1, n + 1)
F[0] = T[k - 1, 1] + r * T[k, 0]
F[-1] = T[k - 1, m - 1] + r * T[k, m]
for i in range(1, m - 2): # 空间层
F[i] = T[k - 1, i + 1] # 给F赋值
for i in range(1, m - 1):
T[k, 1:-1] = np.linalg.inv(A) @ F # 左乘A逆
return T.round(6)
# 三、古典隐格式(追赶法)
def one_dimensional_heat_conduction3(T, m, n, r):
a = np.ones(m - 1) * (-r)
a[0] = 0
b = np.ones(m - 1) * (1 + 2 * r)
c = np.ones(m - 1) * (-r)
c[-1] = 0
F = np.zeros(m - 1) # m-1个元素,索引0~(m-2)
for k in range(1, n + 1): # 时间层range(1, n + 1)
F[0] = T[k - 1, 1] + r * T[k, 0]
F[-1] = T[k - 1, m - 1] + r * T[k, m]
y = np.zeros(m - 1)
beta = np.zeros(m - 1)
x = np.zeros(m - 1)
y[0] = F[0] / b[0]
d = b[0]
for i in range(1, m - 2): # 空间层
F[i] = T[k - 1, i + 1] # 给F赋值
for i in range(1, m - 1):
beta[i - 1] = c[i - 1] / d
d = b[i] - a[i] * beta[i - 1]
y[i] = (F[i] - a[i] * y[i - 1]) / d
x[-1] = y[-1]
for i in range(m - 3, -1, -1):
x[i] = y[i] - beta[i] * x[i + 1]
T[k, 1:-1] = x
return T.round(6)
# 四、Crank-Nicolson(乘逆矩阵法)
def one_dimensional_heat_conduction4(T, m, n, r):
A = np.eye(m - 1, k=0) * (1 + r) + np.eye(m - 1, k=1) * (-r * 0.5) + np.eye(m - 1, k=-1) * (-r * 0.5)
C = np.eye(m - 1, k=0) * (1 - r) + np.eye(m - 1, k=1) * (0.5 * r) + np.eye(m - 1, k=-1) * (0.5 * r)
for k in range(0, n): # 时间层
F = np.zeros(m - 1) # m-1个元素,索引0~(m-2)
F[0] = r / 2 * (T[k, 0] + T[k + 1, 0])
F[-1] = r / 2 * (T[k, m] + T[k + 1, m])
F = C @ T[k, 1:m] + F
T[k + 1, 1:-1] = np.linalg.inv(A) @ F
return T.round(6)
# 五、Crank-Nicolson(追赶法)
def one_dimensional_heat_conduction5(T, m, n, r):
C = np.eye(m - 1, k=0) * (1 - r) + np.eye(m - 1, k=1) * (0.5 * r) + np.eye(m - 1, k=-1) * (0.5 * r)
a = np.ones(m - 1) * (-0.5 * r)
a[0] = 0
b = np.ones(m - 1) * (1 + r)
c = np.ones(m - 1) * (-0.5 * r)
c[-1] = 0
for k in range(0, n): # 时间层
F = np.zeros(m - 1) # m-1个元素,索引0~(m-2)
F[0] = r * 0.5 * (T[k, 0] + T[k + 1, 0])
F[-1] = r * 0.5 * (T[k, m] + T[k + 1, m])
F = C @ T[k, 1:m] + F
y = np.zeros(m - 1)
beta = np.zeros(m - 1)
x = np.zeros(m - 1)
y[0] = F[0] / b[0]
d = b[0]
for i in range(1, m - 1):
beta[i - 1] = c[i - 1] / d
d = b[i] - a[i] * beta[i - 1]
y[i] = (F[i] - a[i] * y[i - 1]) / d
x[-1] = y[-1]
for i in range(m - 3, -1, -1):
x[i] = y[i] - beta[i] * x[i + 1]
T[k + 1, 1:-1] = x
return T.round(6)
def exact_solution(T, m, n, r, delta_x, delta_t): # 偏微分方程精确解
for i in range(n + 1):
for j in range(m + 1):
T[i, j] = np.exp(i * delta_t + j * delta_x)
return T.round(6)
a = 1 # 热传导系数
x_max = 1
t_max = 1
delta_x = 0.1 # 空间步长
delta_t = 0.1 # 时间步长
m = int((x_max / delta_x).__round__(4)) # 长度等分成m份
n = int((t_max / delta_t).__round__(4)) # 时间等分成n份
t_grid = np.arange(0, t_max + delta_t, delta_t) # 时间网格
x_grid = np.arange(0, x_max + delta_x, delta_x) # 位置网格
r = (a * delta_t / (delta_x ** 2)).__round__(6) # 网格比
T = initial_T(x_max, t_max, delta_x, delta_t, m, n)
print('长度等分成{}份'.format(m))
print('时间等分成{}份'.format(n))
print('网格比=', r)
p = pd.ExcelWriter('有限差分法-一维热传导-题目1.xlsx')
T1 = one_dimensional_heat_conduction1(T, m, n, r)
T1 = pd.DataFrame(T1, columns=x_grid, index=t_grid) # colums是列号,index是行号
T1.to_excel(p, '古典显格式')
T2 = one_dimensional_heat_conduction2(T, m, n, r)
T2 = pd.DataFrame(T2, columns=x_grid, index=t_grid) # colums是列号,index是行号
T2.to_excel(p, '古典隐格式(乘逆矩阵法)')
T3 = one_dimensional_heat_conduction3(T, m, n, r)
T3 = pd.DataFrame(T3, columns=x_grid, index=t_grid) # colums是列号,index是行号
T3.to_excel(p, '古典隐格式(追赶法)')
T4 = one_dimensional_heat_conduction4(T, m, n, r)
T4 = pd.DataFrame(T4, columns=x_grid, index=t_grid) # colums是列号,index是行号
T4.to_excel(p, 'Crank-Nicolson格式(乘逆矩阵法)')
T5 = one_dimensional_heat_conduction5(T, m, n, r)
T5 = pd.DataFrame(T5, columns=x_grid, index=t_grid) # colums是列号,index是行号
T5.to_excel(p, 'Crank-Nicolson格式(追赶法)')
T6 = exact_solution(T, m, n, r, delta_x, delta_t)
T6 = pd.DataFrame(T6, columns=x_grid, index=t_grid) # colums是列号,index是行号
T6.to_excel(p, '偏微分方程精确解')
p.save()
end_time = datetime.datetime.now()
print('运行时间为', (end_time - start_time))
```
通过比较这些方法,我们可以更好地了解不同算法在求解偏微分方程时的性能和适用场景。Crank-Nicolson方法的引入提供了一个稳定且准确的解决方案,特别适合于那些对时间步长有限制的问题。
我们的程序记录了计算过程的开始和结束时间,这样可以衡量不同方法的运算效率。这个案例不仅展示了如何在Python中实现复杂的数值算法,而且还揭示了数值解法在工程和科学研究中的重要性,特别是在解决现实世界问题时。
================================================
FILE: docs/CH3/第三章-函数极值与规划模型.md
================================================
第3章 函数极值与规划模型
> 内容:@若冰(马世拓)
>
> 审稿:@刘旭
>
> 排版&校对:@何瑞杰
在这一章中我们将介绍函数极值与规划模型。约束条件下的极值求解是优化问题和运筹学研究的重点,也是各大数学建模竞赛中考察的重难点。它主要针对的就是及目标函数在约束条件下的极值,以及多种方案中的最优方案。本章主要涉及到的知识点有:
* 线性规划的基本模型与求解
* 非线性规划的基本模型与求解
* 整数规划的基本模型与求解
* 动态规划的基本模型与求解
* 多目标规划的一般策略
> 注意:本章内容也可以在凸优化理论相关资料中学习到,是比较难的一章。除了Matlab、Python以外有很多优化求解软件例如Lingo、gurobi等也适合做优化问题。
## 3.1 从线性代数到线性规划
我想各位在中学阶段其实已经是对线性规划有所了解了,不过如果有些学校没学的话也没关系。在这一节当中我们会回顾以前中学接触到的线性规划,补充一些线性代数的知识和Python解线性代数问题的指令,最后引出线性规划的基本形式。数学建模是一门应用数学课程,与基础数学不同,我这里不打算抢读者线性代数老师的饭碗太严重,但我们会尽可能多补充一些基础的常用的有关理论。
### 3.1.1 中学阶段线性规划的局限性
在高中阶段我们其实就学习过线性规划的知识,但是当时我们可能没有觉得它有多重要所以可能忘掉了。我们通过一个例子回忆一下:
**例3.1** 若$x$, $y$满足条件$\displaystyle \left\{\begin{align} &x + y \geqslant 4\\ &x - y \leqslant 2 \\ &y \leqslant 3 \end{align}\right.$,则$z = 3x + y$ 的最小值为()
A. 18 B. 10 C. 6 D. 4
我想这个问题大家不会感到陌生了。答案很简单,选择C。血脉觉醒的高中生们会将不等式组中的不等式两两配对成三个二元一次方程组求解,得到三个点,最后把三个点分别代入$z=3x+y$最后得到最小值。这是比较快的方法,在90%的情况下这种策略是很奏效的。而学霸们也会采取画图的策略,平移直线来求解。这个问题的可行域如图2.1所示:

图2.1 例题2.1的可行域
这个题目的解其实很明显,就是把不等式组里面每个不等式在平面直角坐标系中表示出来,然后根据不等号的方向确定可行域。将目标函数进行移项以后转化为$y=-3x+z$,通过平移直线的方式找到可行域内使目标函数截距最大的点,就是正确答案。大家回忆起来了吗?
> 注意:平移直线法是一种通用方法,解方程组得到的最优解很多情况下确实有效但不排除有些特殊情况下可行域是开放的,这个时候不一定存在最小值或最大值。
我为什么把线性规划作为第一个知识点呢?因为线性规划是真的非常重要也非常实用,也最贴近一个入门级小白认识数学建模所具备的数学基础。线性规划的实际应用有很多,比如说:我们可能见过这样一种问题,去运输一批货物的时候大车能运五箱,小车只能运三箱,但我们大车和小车数量都有限,怎么安排运输方案能够在车辆够用的情况下运费还能最小?如果我们把大车数量记为$x$,小车数量记为$y$,那么除了$x$和$y$的范围,$5x+3y$也有自己的一个范围,算上运费作为优化目标,这不就构成了一个线性规划吗?背景熟悉吧,甚至于有一种小学应用题的恐惧感。
再打个比方,生产原料问题,生产产品A、B需要原材料甲、乙、丙;生产一吨A需要多少多少甲、多少多少乙、多少多少丙,这样就有了对AB的三个原料约束。再来一个利润最大作为目标,这也是线性规划。我猜很多读者看到这些例子可能就会暗想:“这就是数学建模?我怎么感觉梦回小学应用题?难不成我被骗了?”,是的,数学建模其实没有那么恐怖,小时候我们做的是应用题,到大了我们只不过是需要用更多知识和编程方法解背景更产业化更学术化的应用题。因为这是一门应用数学学科。
我们大概可以总结出,中学的线性规划通常就两个**变量**$x$和$y$,**约束条件**三个不等式,最后一个线性的形如$z=ax+by$(这里$a$和$b$都是常数)的**目标函数**。这样的式子我们解方程可以解,画图也可以解,总能在两分钟之内算出正确结果。但在实际情况中,问题真的有这么容易吗?其实不然。同样是拿生产问题做文章,如果我们这里生产的原料不止甲乙丙三种呢(通常在有机化合物合成的时候原料可能有十几种甚至上百种),产出的产品也不止AB而是能够产出数十种化合物,还能简单地用高中的方法写吗?
所以我们说,中学的规划存在这样一些局限性:
* 决策变量(如果不好理解暂且称之为自变量吧)往往不止两三个;
* 当变量个数超过三个的时候还能在直角坐标系里面画图吗?不能了;
* 约束条件往往不止三个不等式,不等式可能比变量更多一些;
* 当变量较多的时候,还可能出现方程形式下的约束;
* 中学阶段我们只讨论了线性规划,但如果不等式或者目标函数非线性呢。
这么多情况不知读者朋友会不会被吓到。如果十几个甚至几十个不等式方程组成约束条件,那我草稿纸甚至不知道写不写的下,况且中学阶段没有接触过高维问题。于是,为了以更简单的形式描述更一般的线性规划,我们需要借助一样数学工具——线性代数。
有关于线性代数基本知识的回顾与编程实现,大家可以参考下一节中的内容。如果有学习过线性代数的读者,可以跳过3.2.1节。
### 3.1.2 线性规划的基本形式
前面我们已经看到,中学线性规划的局限性在于难以描述多约束、多变量,但无论是目标函数还是方程还是不等式,我们都可以看成是一个系数向量与变量向量在做乘法(例如,$2a+3b-c$实际上可以看成向量$[2,3,-1]$与向量$[a,b,c]$做内积)。多个约束无非就是把多个向量拼接在一块做成了一个矩阵而已。
我们把所有的方程约束中系数做成系数矩阵$A_{\text{eq}}$,等号右边的常数作为列向量$b_\text{eq}$;不等式约束中的系数矩阵$A$和不等号右边的常数$b$,为了方便起见通常将不等式统一为小于等于;变量$x$在向量$l_b$到$u_b$之间取值;目标函数的系数向量为$c$,那么线性规划的标准形式就如下所示:
$$
\begin{align}
\text{minimize}~&f = c^{\top}x\\[0.5em]
\text{subject to}~& Ax \leqslant b\\
& A_{\text{eq}}\,x = b_{\text{eq}}\\
&l_{b} \leqslant x \leqslant u_{b}
\end{align} \tag{3.1.1}
$$
> 这里的$\leqslant$是指两边向量的对应分量的小于等于关系,即对任意$i$满足$(Ax)_{i} \leqslant b_{i}$。第二行的 subject to 也常简写为 **s.t.**
为了方便matlab编程,我们通常将问题统一为函数极小值问题,不等约束统一为小于等于。如果原问题是最大值或者有大于等于,那就乘$-1$进行取反即可。可能我说这么半天读者并不一定能理解,这里我举一个例子,在matlab中整理一个线性规划的标准形式:
**例3.2** 将该线性规划整理为标准形式并将各矩阵存储在matlab变量区中:
$$
\begin{align}
\text{minimize}~&f = 3x_{1} + 2x_{2} - x_{3}\\[0.5em]
\text{s.t.}~
& 3x_{1} - x_{2} + x_{3} \leqslant 18\\
& x_{1} + 2x_{2} \leqslant 16\\
&x_{1} + x_{2} + x_{3} \geqslant 2\\
&x_{1} + 2x_{2} + 3x_{3} = 15\\
&x_{1} - x_{3} = 4\\
& 0 \leqslant x_{i} \leqslant 16, ~i=1,2,3.
\end{align} \tag{3.1.2}
$$
我们注意到有一个不等式里面是大于等于,所以左右两边乘-1;一个不等式一个方程里面缺少了一项,这是因为缺少的那一项系数是$0$。把各个矩阵和向量存在matlab里面:
==这里缺了东西==
## 3.2 使用Numpy进行矩阵运算
### 3.2.1 线性代数基本知识回顾
线性代数是一门基础数学科目,基本上所有理工科学生大一的时候都得学线性代数。如果是数学系可能学的就是高等代数了。这门课主要是研究矩阵与向量的数学理论,也会探究线性方程组的解等相关问题。我并不是一个线代老师,这里也不打算抢线代老师的饭碗,这一小节我们仅仅引入线性代数中比较重要的一些定义和计算方法。
我相信大多数同学高中毕业是记得向量这个概念的,但中学阶段我们也仅仅是接触到了三维向量。事实上向量的维数可以是很多维,从代数的意义上你可以认为向量是一个集合,从几何的意义上你又可以认为向量是一个$n$维 Euclid 空间中的一个点:
$$
\boldsymbol{x} = [x_{1}, x_{2}, \dots, x_{n}]^{\top} \tag{3.2.1}
$$
> 注意:向量常用粗体($\boldsymbol{a}$)或带箭头标识的字母($\vec{a}$)表示。在本教程中统一用前者;在不引起歧义的情形下,不对向量和矩阵的符号加粗。
和二维、三维空间中的向量一样,高维空间中的向量同样可以进行加减运算、数量乘运算和数乘运算。但毕竟这是一门应用数学课程,我们不打算把太多精力放在任何一本线代课本里面都能找到的公式上,使用matlab举例子恐怕会更加直观。从程序设计的角度来看,如果读者接触过C语言应该会了解数组的概念,而在C++语言中STL里面已经包含了`vector`类型。
数通过集合形成了向量,那向量集合以后又会变成什么呢?如果向量只是沿着同一个方向进行拼接,那么得到的只不过是一个更长一些的向量。但如果是在纵向上做拼接,那么我们或许可以把一个向量排成表格:
$$
A = \left[ \begin{matrix}
a_{1,1} & \cdots & a_{1,n}\\
\vdots & \ddots & \vdots\\
a_{m,1} & \cdots & a_{m,n}
\end{matrix} \right]_{m \times n} \tag{3.2.2}
$$
> 注意:
> 1. 本教程中的矩阵统一使用方括号;
> 2. 右下角的 $m \times n$ 表示矩阵的**形状**,在上文的例子中,若所有分量均为实数,则 $A \in \mathbb{R}^{m \times n}$;
> 3. 矩阵下标常用 $a_{ij}$ 表示。为避免歧义,本教程统一记为 $a_{i,j}$。
这个数表要求每个矩阵的维度相同,排成的这个表格就可以称作一个矩阵。那么矩阵作为向量的集合,自然也保留了向量的一些特性,包括行列相同的矩阵的加减法、数量乘。比较有趣的是矩阵的乘法,它把两个矩阵分别按行、列规约:
$$
A_{m \times n} B_{n \times k} = \left[ \begin{matrix}
\boldsymbol a_{1}^{\top}\\
\boldsymbol a_{2}^{\top}\\
\vdots\\
\boldsymbol{a}_{m}^{\top}
\end{matrix} \right]
[\boldsymbol{b}_{1}, \dots, \boldsymbol{b}_{k}] =
\left[ \begin{matrix}
\boldsymbol{a}_{1}^{\top}\boldsymbol{b}_{1} & \cdots & \boldsymbol{a}_{1}^{\top}\boldsymbol{b}_{k}\\
\vdots & \ddots & \vdots \\
\boldsymbol{a}_{m}^{\top}\boldsymbol{b}_{1} & \cdots & \boldsymbol{a}_{m}^{\top}\boldsymbol{b}_{k}
\end{matrix} \right]_{m \times k},\tag{3.2.3}
$$
其中对所有的$i$和$j$,都有$\boldsymbol a_{i}, \boldsymbol{b}_{j} \in \mathbb{R}_{n}$。矩阵排列好以后除了按行可以规约为一群向量的纵向分布,也可以按列规约成一群向量的横向分布。于是才有了(2.10)中矩阵乘法的这个计算方法。每一项都是两个同为$n$维的向量数乘。
> 注意:矩阵乘法的维度要求第一个矩阵的列数和第二个矩阵的行数相等,因此要注意维度问题。另外**矩阵的乘法没有交换律**,在$AB$和$BA$都存在时,一般有$\color{red} AB \ne BA$ 。
==需要修改,改成“平行多面体的体积”==
那我们能否类比向量的模,提出“矩阵的模”这样一个概念呢?在线性代数中确实存在这样一个类似的概念,这个概念叫行列式:
行列式虽然同样是排成了一个表,但注意,矩阵是一个表格,行列式是一个数,它的值是可以算出来的!有关行列式的计算方法有很多,但最经典最通用的方法是代数余子式展开法。代数余子式的本质就是递归式求解,将行列式A中下标为(i,j)的元素所在行和所在列全部去除以后求新的行列式,再乘上对应的符号。而对于行列式A,计算定义为:
> 注意:行列式除了按某一行展开也可以按某一列展开,这一展开行或展开列的选取是任意的,方便计算即可。另外,矩阵可以不要求行列数相等但行列式必须行列数相等!
而递归到最后,我们逃不开最低的二阶行列式求解。二阶行列式的定义为:
将$n$阶行列式降低到$n-1$阶,$n-1$阶再降低为n-2阶,逐层展开到最后二阶,整个行列式求解就可以完成了。不过高阶行列式如果不是特殊行列式计算会有些复杂,我们可以将这个过程交给计算机程序来完成。
这样我们就可以在命令行输出矩阵A对应的行列式的值。有了行列式的概念,我们可以用它定义矩阵的逆矩阵计算方法。一个行列数均为n的矩阵的逆矩阵满足这样的定义:
**定义2.1.2** 方阵$A$的逆$A^{-1}$若存在,则满足下面的等式:
$$
A^{-1}A = AA^{-1} = I = \left[ \begin{matrix}
1 \\
&1\\
&&\ddots&\\
&&&1
\end{matrix} \right] = \text{diag}(1, \dots, 1).\tag{3.2.4}
$$
### 3.2.2 numpy基本使用
使用numpy之前首先需要导入模块:
```python
import numpy as np
```
接下来我们需要了解一下numpy的几种属性:
* `ndim`:维度
* `shape`:行数和列数
* `size`:元素个数
```python
print('number of dim:',array.ndim) # 维度
# number of dim: 2
print('shape :',array.shape) # 行数和列数
# shape : (2, 3)
print('size:',array.size) # 元素个数
# size: 6
```
#### 1 Numpy 的创建`array`
* `array`:创建数组
* `dtype`:指定数据类型
* `zeros`:创建数据全为`0`
* `ones`:创建数据全为`1`
* `empty`:创建数据接近`0`
* `arrange`:按指定范围创建数据
* `linspace`:创建线段
创建数组
```python
a = np.array([2,23,4]) # list 1d
print(a)
# [2 23 4]
```
指定数据类型
```python
a = np.array([2,23,4],dtype=np.int64)
print(a.dtype)
# int 64
```
创建特定数据
```python
a = np.array([[2,23,4],[2,32,4]]) # 2d 矩阵 2行3列
print(a)
"""
[[ 2 23 4]
[ 2 32 4]]
"""
```
#### 2 numpy 的几种基本运算
```python
import numpy as np
a=np.array([10,20,30,40]) # array([10, 20, 30, 40])
b=np.arange(4) # array([0, 1, 2, 3])c=a-b # array([10, 19, 28, 37])
c=a+b # array([10, 21, 32, 43])
c=a*b # array([ 0, 20, 60, 120])
c=b**2 # array([0, 1, 4, 9])
c=10*np.sin(a)
# array([-5.44021111, 9.12945251, -9.88031624, 7.4511316 ])
print(b<3)
# array([ True, True, True, False], dtype=bool)
a=np.array([[1,1],[0,1]])
b=np.arange(4).reshape((2,2))
print(a)
# array([[1, 1],
# [0, 1]])
print(b)
# array([[0, 1],
# [2, 3]])
c_dot = np.dot(a,b)
# array([[2, 4],
# [2, 3]])
c_dot_2 = a.dot(b)
# array([[2, 4],
# [2, 3]])
a=np.random.random((2,4))
print(a)
# array([[ 0.94692159, 0.20821798, 0.35339414, 0.2805278 ],
# [ 0.04836775, 0.04023552, 0.44091941, 0.21665268]])
np.sum(a) # 4.4043622002745959
np.min(a) # 0.23651223533671784
np.max(a) # 0.90438450240606416
print("a =",a)
# a = [[ 0.23651224 0.41900661 0.84869417 0.46456022]
# [ 0.60771087 0.9043845 0.36603285 0.55746074]]
print("sum =",np.sum(a,axis=1))
# sum = [ 1.96877324 2.43558896]
print("min =",np.min(a,axis=0))
# min = [ 0.23651224 0.41900661 0.36603285 0.46456022]
print("max =",np.max(a,axis=1))
# max = [ 0.84869417 0.9043845 ]
A = np.arange(2,14).reshape((3,4))
# array([[ 2, 3, 4, 5]
# [ 6, 7, 8, 9]
# [10,11,12,13]])
print(np.argmin(A)) # 0
print(np.argmax(A)) # 11
print(np.mean(A)) # 7.5
print(np.average(A)) # 7.5
print(np.cumsum(A))
# [2 5 9 14 20 27 35 44 54 65 77 90]
print(np.diff(A))
# [[1 1 1]
# [1 1 1]
# [1 1 1]]
A = np.arange(14,2, -1).reshape((3,4))
# array([[14, 13, 12, 11],
# [10, 9, 8, 7],
# [ 6, 5, 4, 3]])
print(np.sort(A))
# array([[11,12,13,14]
# [ 7, 8, 9,10]
# [ 3, 4, 5, 6]])
print(np.transpose(A))
print(A.T)
# array([[14,10, 6]
# [13, 9, 5]
# [12, 8, 4]
# [11, 7, 3]])
# array([[14,10, 6]
# [13, 9, 5]
# [12, 8, 4]
# [11, 7, 3]])
```
#### 3 Numpy `array` 合并
```python
import numpy as np
A = np.array([1,1,1])
B = np.array([2,2,2])
print(np.vstack((A,B))) # vertical stack
"""
[[1,1,1]
[2,2,2]]
"""
D = np.hstack((A,B)) # horizontal stack
print(D)
# [1,1,1,2,2,2]
C = np.concatenate((A,B,B,A),axis=0)
print(C)
"""
array([[1],
[1],
[1],
[2],
[2],
[2],
[2],
[2],
[2],
[1],
[1],
[1]])
"""
D = np.concatenate((A,B,B,A),axis=1)
print(D)
"""
array([[1, 2, 2, 1],
[1, 2, 2, 1],
[1, 2, 2, 1]])
"""
```
#### 4 Numpy `array`的分割
```python
import numpy as np
A = np.arange(12).reshape((3, 4))
print(A)
"""
array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
"""
print(np.split(A, 2, axis=1))
"""
[array([[0, 1],
[4, 5],
[8, 9]]), array([[ 2, 3],
[ 6, 7],
[10, 11]])]
"""
print(np.split(A, 3, axis=0))
# [array([[0, 1, 2, 3]]), array([[4, 5, 6, 7]]), array([[ 8, 9, 10, 11]])]
print(np.array_split(A, 3, axis=1)) #不等量分割
"""
[array([[0, 1],
[4, 5],
[8, 9]]), array([[ 2],
[ 6],
[10]]), array([[ 3],
[ 7],
[11]])]
```
## 3.3 线性规划的算法原理
前面我们简单的回顾了线性规划和线性代数的基本知识,接下来我们将具体的介绍线性规划的算法原理。
### 3.3.1 单纯形法
在2.1节的最后我们提出了线性规划的标准形式,但注意这种标准形式是针对程序设计工具而言的。如果读者有凸优化理论的背景可能会感到狐疑,说:为什么我看到的标准形式和你这里写的不太一样?是的,经典的凸优化教材会把模型写成另外一种形式:
$$
\begin{align}
\text{maximize}~& f = c^{\top}X\\[0.5em]
\text{s.t.}~
&A^{*}\tilde{X} = b^{*}\\
& X \geqslant 0.
\end{align}\tag{3.3.1}
$$
为了和2.1当中提出的标准形式区分,我们暂且把这种形式称作**规范形式**。规范形式求的是函数的极大值,并且把不等关系和等式关系统一为等式关系方便求解。读者朋友可能会有些疑惑,说:不等式怎么可以充当为方程呢?这就是一种数学思想。可能读者朋友可以理解方程是不等式的特例,但不一定理解不等式也可以视作方程的特例,我举个例子。比如对于不等式$2a+3b+c<10$,左边比右边小,但是小多少呢?我们把这个差额记作d,左边如果补上这个差额就可以写作$2a+3b+c+d=10$,这样就转化成了等式。这里的d被称为**松弛变量**。包括决策变量的上下界$l_b$和$u_b$也会被转化为不等关系引入松弛量。
在单纯形法中,我们解决问题通常从理论上都会把问题转换为规范形式来求解,对每一个不等式都引入一个松弛变量去增广我们的原问题。但这些松弛变量不会出现在目标函数当中。
> 注意:在程序设计中我们输入的是它的标准形式,而在matlab底层以规范形式进行运算,只是从标准形式到规范形式这个操作我们看不见。不等式条件中增广了n个松弛变量的同时等式条件也会增广,只不过在增广后的等式条件中松弛变量的系数都是0。
单纯形法的步骤包括如下几个步骤:
1. 确定初始可行基和初始基可行解,并建立初始单纯形表。
2. 在当前表的目标函数对应的行中,若所有非基变量的系数非正,则得到最优解,算法终止;否则进入下一步。
3. 若单纯形表中$1 \cdots m$列构成单位矩阵,在$j=m+1\cdots n$列中,若有某个对应$x_k$的系数列向量$P_k \leqslant 0$,则停止计算。否则,转入下一步。
4. 挑选目标函数对应行中系数最大的非基变量作为进基变量。假设$x_{k}$为进基变量,按如下规则计算,可确定$x_{u}$为出基变量,转下一步:$$\theta = \min \left.\left\{ \frac{b_{i}}{a_{i,k}} \right| a_{i,k} > 0 \right\} = \frac{b_{u}}{a_{u,k}}, \tag{3.3.2}$$ 其中$b_{i}$是规范型规划的常数项,$a_{i,k}$即为在第$i$个约束中变量$k$的系数。
5. 以$a_{u,k}$为主元素进行迭代,对$x_k$所对应的列向量进行如下变换:$$P_{k} = [a_{1,k}, a_{2,k}, \dots, a_{u,k}, \dots, a_{m,k}]^{\top} = [0, 0, \dots, 1, \dots, 0]^{\top}\tag{3.3.3}$$ 也即令向量$P_{k}$除了第$u$个元素为$1$外其他元素都为零。
6. 重复2-5步,直到所有检验数非正后终止,得到最优解。
**例3.3** 利用单纯形法解下面这个简单的规划案例:
$$
\begin{align}
\text{minimize}~&f = -5x_{1} - x_{2}\\[0.5em]
\text{s.t.}~
& x_{1} + x_{2} \leqslant 5\\
& 2x_{1} + 0.5x_{2} \leqslant 8\\
& x_{1},x_{2} \geqslant {0.}
\end{align} \tag{3.3.4}
$$
首先我们利用松弛变量将这个问题变成规范型:
$$
\begin{align}
\text{minimize}~&f = -5x_{1} - x_{2}\\[0.5em]
\text{s.t.}~
& x_{1} + x_{2} + x_{3} = 5\\
& 2x_{1} + 0.5x_{2} + x_{4} = 8\\
& x_{1},x_{2}, x_{3}, x_{4} \geqslant {0.}
\end{align} \tag{3.3.5}
$$
针对这个问题,列出如图所示的规划表:

最上面一行写下目标函数的系数,然后写下两行等式约束的系数与常数项。第一次迭代时令$z=0$, $u=f-z$,初始状况下$u$为目标函数的系数(后面会进行迭代变化),选择$u$中最大的一项也就是$5$。故选择$5$对应的元素作为入基向量,计算常数项与基向量的系数之比,发现$5/1>8/2$,选择第二行对应的松弛向量$x_4$出基。迭代后的新表格为:

此时,将$x_4$替换为$x_1$,将$x_1$系数$5$填入表格中,原来的表格都减去第二个约束条件除以$x_1$在第二行约束中的系数$2$得到新的系数与常数项。此时,$z$等于$x_3$的系数$0$乘以第一行新约束加x_1的系数5乘以第二行新约束,得到新的z结果。$u=f-z$得到$u$的结果,所有的数都是非正数,此时迭代就结束了。常数项对应的$1,4$其实就是解,计算可以得到极值。
单纯形法的实现代码如下:
```python
import numpy as np
def pivot(d,bn):
l = list(d[0][:-2])
jnum = l.index(max(l)) #转入编号
m = []
for i in range(bn):
if d[i][jnum] == 0:
m.append(0.)
else:
m.append(d[i][-1]/d[i][jnum])
inum = m.index(min([x for x in m[1:] if x!=0])) #转出下标
s[inum-1] = jnum
r = d[inum][jnum]
d[inum] /= r
for i in [x for x in range(bn) if x !=inum]:
r = d[i][jnum]
d[i] -= r * d[inum]
#定义基变量函数
def solve(d,bn):
flag = True
while flag:
if max(list(d[0][:-1])) <= 0: #直至所有系数小于等于0
flag = False
else:
pivot(d,bn)
def printSol(d,cn):
for i in range(cn - 1):
if i in s:
print("x"+str(i)+"=%.2f" % d[s.index(i)+1][-1])
else:
print("x"+str(i)+"=0.00")
print("objective is %.2f"%(-d[0][-1]))
d = np.array([[5,1,0,0,0],[1,1,1,0,5],[2,1/2,0,1,8]])
(bn,cn) = d.shape
s = list(range(cn-bn,cn-1)) #基变量列表
solve(d,bn)
printSol(d,cn)
```
此时我们可以看到$x_1=4$,$x_2=0$,$x_3=1$,$x_4=0$,极值为$20$。
### 3.3.2 内点法
单纯形法之所以需要遍历所有顶点才能获得最优解,归根结底还是在于单纯形算法的搜索过程是从一个顶点出发,然后到下一个顶点,就这样一个顶点一个顶点的去搜寻最优解。单纯形算法的搜索路径始终是沿着多面体的边界的。显然,当初始点离最优点的距离很远的时候单纯形算法的搜索效率就会大大降低。
能否直接从多边形内部打进来呢?这就需要用到内点法,如图所示:

内点法的算法原理:
* 令 $k=0$,获得初始可行解$x_0$,满足如下条件: $Ax_0>b$,$x_0>b$
* 计算损失:$$r_{k} = c - A^{\top}w_{k}, \quad w_{k} = (AX_{k}^{2}A)^{-1}AX_{k}^{2}c \tag{3.3.6}$$
* 检查是否为最优解,若满足,则输出最优解;若不满足,执行下一步
* 计算搜索方向:$$d_{y, k} = -\Big[ I - X_{k}A^{\top}(AX_{k}^{2}A)^{-1}AX_{k} \Big]X_{k}c = -X_{k}r_{k} \tag{3.3.7}$$
* 若梯度大于$0$,表明该问题无解;若存在$0$项,则存在最优解
* 计算步长$$a_{k} = \min \left.\left\{ \frac{a}{-(\boldsymbol{d}_{y, k})_{i}} \right| (\boldsymbol{d}_{y,k})_{i} < 0 \right\}, \quad 0 \leqslant a \leqslant 1 \tag{3.3.8}$$
* 更新当前解$$x_{k+1} = x_{k} + a_{k}X_{k}d_{y,k} \tag{3.3.9}$$
使用内点法实现上述用例的代码如下:
```python
import numpy as np
def Interior_Point(c,A,b):
# 当输入的c,A,b有缺失值时,输出错误原因,函数执行结束
if c.shape[0] != A.shape[1]:
print("A和C形状不匹配")
return 0
if b.shape[0] != A.shape[0]:
print("A和b形状不匹配")
return 0
# 初值的设置
x=np.ones((A.shape[1],1)) # 将x的初值设为1
v=np.ones((b.shape[0],1)) # 将v的初值设为1
lam=np.ones((x.shape[0],1)) # 将lam的初值设为1
one=np.ones((x.shape[0],1))
mu=1 # 将mu的初值设为1
n=A.shape[1]
x_=np.diag(x.flatten()) # 将x转换为对角矩阵
lam_=np.diag(lam.flatten()) # 将lam转换为对角矩阵
# 初始的F,r=F
r1=np.matmul(A,x)-b
r2=np.matmul(np.matmul(x_,lam_),one)-mu*one
r3=np.matmul(A.T,v)+c-lam
r=np.vstack((r1,r2,r3))
F=r
# 求得r1、r2、r3的初始范数
n1=np.linalg.norm(r1)
n2=np.linalg.norm(r2)
n3=np.linalg.norm(r3)
# nablaF中零矩阵和单位阵的设置
zero11=np.zeros((A.shape[0],x.shape[0]))
zero12=np.zeros((A.shape[0],A.shape[0]))
zero22=np.zeros((x.shape[0],A.shape[0]))
zero33=np.zeros((A.shape[1],A.shape[1]))
one31=np.eye(A.shape[1])
tol=1e-8 # 设置最优条件的容忍度
t=1
alpha = 0.5
while max(n1,n2,n3)>tol:
print("-----------------step",t,"-----------------")
# F的Jacobian矩阵
nablaF = np.vstack((np.hstack((zero11, zero12, A))
, np.hstack((x_, zero22, lam_))
, np.hstack((-one31, A.T, zero33))))
# F+nablaF@delta=0,解方程nablaF@delta=-r
delta = np.linalg.solve(nablaF, -r) # 解方程,求出delta的值
delta_lam = delta[0:lam.shape[0], :]
delta_v = delta[lam.shape[0]:lam.shape[0] + v.shape[0], :]
delta_x = delta[lam.shape[0] + v.shape[0]:, :]
# 更新lam、v、x、mu
alpha=Alpha(c,A,b,lam,v,x,alpha,delta_lam,delta_v,delta_x)
lam=lam+alpha*delta_lam
v=v+alpha*delta_v
x=x+alpha*delta_x
x_ = np.diag(x.flatten()) # 将x转换为对角矩阵
lam_ = np.diag(lam.flatten()) # 将lam转换为对角矩阵
mu=(0.1/n)*np.dot(lam.flatten(),x.flatten()) #更新mu的值
# 计算更新后的F
r1 = np.matmul(A, x) - b
r2 = np.matmul(np.matmul(x_, lam_), one) - mu * one
r3 = np.matmul(A.T, v) + c - lam
r = np.vstack((r1, r2, r3))
F = r
# 计算更新后F的范数
n1 = np.linalg.norm(r1)
n2 = np.linalg.norm(r2)
n3 = np.linalg.norm(r3)
t=t+1
print("x的取值",x.flatten())
print("v的取值",v.flatten())
print("lam的取值",lam.flatten())
print("mu的取值",mu)
print("alpha的取值",alpha)
z=(c.T @ x).flatten()[0]
print("值为",z)
print("##########################找到最优点##########################")
print("x的取值",x.flatten())
print('最优值为',z)
# 寻找alpha
def Alpha(c,A,b,lam,v,x,alpha,delta_lam,delta_v,delta_x):
alpha_x=[]
alpha_lam=[]
for i in range(x.shape[0]):
if delta_x.flatten()[i]<0:
alpha_x.append(x.flatten()[i]/-delta_x.flatten()[i])
if delta_lam.flatten()[i]<0:
alpha_lam.append(lam.flatten()[i]/-delta_lam.flatten()[i])
if len(alpha_x)==0 and len(alpha_lam)==0:
return alpha
else:
alpha_x.append(np.inf)
alpha_lam.append(np.inf)
alpha_x = np.array(alpha_x)
alpha_lam= np.array(alpha_lam)
alpha_max = min(np.min(alpha_x), np.min(alpha_lam))
alpha_k = min(1,0.99*alpha_max)
return alpha_k
c = np.array([-5, -1, 0,0]).reshape(-1, 1)
A = np.array([[1, 1, 1, 0], [2, 0.5, 0, 1]])
b = np.array([5, 8]).reshape(-1, 1)
# Interior_Point(c,A,b)
```
## 3.4 线性规划的建模案例
这一节我们主要学习一下线性规划的一些建模案例。
### 3.4.1 加工厂的加工计划
**例3.4** 一家加工厂使用牛奶生产A,B两种奶制品,1桶牛奶经甲机器加工12小时得到3kgA,也可以经过乙机器8小时得到4kgB,根据市场需求,生产的A、B可以全部出售并且每kgA获利24元、每kgB获利16元。现在该工厂每天获得50桶牛奶供应,所有工人的最大劳动时间之和为480小时,甲机器每天最多加工100kgA,乙机器加工不限量,请你为该工厂设计生产计划,使得每天的利润最大。
假设每天用于生产A产品的牛奶为$x_{1}$桶,用于生产B产品的牛奶为$x_{2}$桶,每天的利润为$z$元。问题中要优化的目标是利润,一桶牛奶给甲机器能得到3kgA,1kgA能获利24元,也就是一桶牛奶给甲能获得72元利润。同理,一桶牛奶给乙也就能获得$4\times 16=64$元利润。约束条件包括:牛奶总数只有50桶,工人劳动时间之和不能超过480小时,甲机器的加工容量上限,自变量非负。决策变量为甲乙各自使用的牛奶桶数。
根据题意建立数学模型:
$$
\begin{align}
\text{minimize}~& z = 3 \times 14x_{1} + 4 \times 16x_{2} \\[0.5em]
\text{s.t.}~
& x_{1} + x_{2} \leqslant 50\\
& 12x_{1} + 8x_{2} \leqslant 480\\
& 3x_{1} \leqslant 100\\
& x_{1}, x_{2} \geqslant 0
\end{align} \tag{3.4.1}
$$
这里用python实现这个问题的代码如下:
```python
from scipy.optimize import linprog
c=[-72,-64]
A=[[1,1],[12,8]]
b=[[50],[480]]
bounds=((0,100/3.0),(0,None))
res=linprog(c=c, A_ub=A, b_ub=b, A_eq=None, b_eq=None, bounds=bounds)
res
```
得到甲用$20$桶牛奶,乙用$30$桶牛奶时得到最优解$3360$。值得注意的是,自变量必须取得整数,这个约束条件作为隐含条件其实也可以加入模型中。
#Checkpoint
### 3.4.2 油料的采购与加工计划
**例3.5** 某加工厂加工一种油,原料为五种油(植物油1,植物油2、非植物油1,非植物油2、非植物油3),每种油的价格、硬度如图表所示,最终生产的成品将以150英镑/吨卖出。

每个月能够提炼的植物油不超过200吨、非植物油不超过250吨,假设提炼过程中油料没有损失,提炼费用忽略不计,并且最终的产品的硬度需要在(3-6)之间(假设硬度的混合时线性的)。根据以上信息,请你为加工厂指定月采购和加工计划。
假设$x_1$,$x_2$,$x_3$,$x_4$,$x_5$分别为每月需要采购的原料油吨数,$x_6$为每个月加工的成品油吨数,由于不考虑油料损失,存在关系:
$$
x_{6} = x_{1} + x_{2} + x_{3} + x_{4} + x_{5}, \tag{3.4.2}
$$
平均的硬度为:
$$
\eta = \frac{\sum\limits_{i=1}^{5} w_{i}x_{i}}{x_{6}}, \tag{3.4.3}
$$
加上植物油重量限制和非植物油重量限制,根据题意,可以列出规划模型如下:
$$
\begin{align}
\text{minimize}~& z = -110x_{1} - 120x_{2} - 130x_{3} - 110x_{4} - 115x_{5} + 150x_{6} \\[0.5em]
\text{s.t.}~
& x_{1} + x_{2} \leqslant 200\\
& x_{3} + x_{4} + x_{5} \leqslant 250\\
& 8.8x_{1} + 6.1x_{2} + 2.0x_{3} + 4.2x_{4} + 5.0x_{5} \leqslant 6x_{6}\\
& 8.8x_{1} + 6.1x_{2} + 2.0x_{3} + 4.2x_{4} + 5.0x_{5} \geqslant 3x_{6}\\
& x_{1} + x_{2} + x_{3} + x_{4} + x_{5} = x_{6}\\
& x_{i} \geqslant 0, \quad i = 1, 2, \dots, 6
\end{align} \tag{3.4.4}
$$
实现这个问题的代码如下:
```python
c=[110,120,130,110,115,-150]
A=[[1,1,0,0,0,0],[0,0,1,1,1,0],[8.8,6.1,2.0,4.2,5.0,-6],[-8.8,-6.1,-2.0,-4.2,-5.0,3]]
b=[[200],[250],[0],[0]]
aeq=[[1,1,1,1,1,-1]]
beq=[[0]]
bounds=((0, None),(0, None),(0, None),(0, None),(0,None),(0,450))
# bounds=((0, None),(0, None),(0, None),(0, None),(0,None),(0,None))
res=linprog(c=c, A_ub=A, b_ub=b, A_eq=aeq, b_eq=beq, bounds=bounds)
```
我们可以看到,五种原料油的采购量分别为`[159.25,40.7407,0,250,0]`(吨),此时总利润可以达到最大,约为$17592$英镑/月。
### 3.4.3 农民承包土地问题
除了scipy以外,还有没有其他工具可以求解规划问题呢?可以再关注一个工具包*pulp*,这个工具包专门针对线性规划问题做出了很多补充。使用pulp求解规划问题主要是三个组成部分:
* `LpProblem(name='NoName', sense=LpMinimize)`用于定义问题与约束条件
* `solve(solver=None, **kwargs)`用于定义求解方法
* `LpVariable(name, lowBound=None, upBound=None, cat='Continuous', e=None)`用于定义决策变量
例如,如果我们要求解这样一个问题:
$$
\begin{align}
\text{minimize}~& z = 2x_{1} + 3x_{2} + x_{3} \\[0.5em]
\text{s.t.}~
& x_{1} + 2x_{2} + 4x_{3} = 101\\
& x_{1} + 4x_{2} + 2x_{3} \geqslant 8\\
& 3x_{1} + 2x_{2} \geqslant 6\\
& x_{i} \geqslant 0, \quad i=1,2,3
\end{align} \tag{3.4.5}
$$
使用pulp的代码如下:
```python
import pulp as pp
# 目标函数的系数
z = [2, 3, 1]
a = [[1, 4, 2], [3, 2, 0]]
b = [8,6]
aeq = [[1,2,4]]
beq = [101]
# 确定最大最小化问题,当前确定的是最大化问题
m = pp.LpProblem(sense=pp.LpMaximize)
# 定义三个变量放到列表中
x = [pp.LpVariable(f'x{i}', lowBound=0) for i in [1, 2, 3]]
# 定义目标函数,并将目标函数加入求解的问题中
m += pp.lpDot(z, x) # lpDot 用于计算点积
# 设置比较条件
for i in range(len(a)):
m += (pp.lpDot(a[i], x) >= b[i])
# 设置相等条件
for i in range(len(aeq)):
m += (pp.lpDot(aeq[i], x) == beq[i])
# 求解
m.solve()
# 输出结果
print(f'优化结果:{pp.value(m.objective)}')
print(f'参数取值:{[pp.value(var) for var in x]}')
```
可以得出优化结果为$202.0$,此时的参数取值为`[101.0, 0.0, 0.0]`。
现在我们来看下面这个问题:
**例3.6** 一个农民承包了6块耕地共300亩,准备播种小麦、玉米、水果和蔬菜四种农产品,各种农产品的计划播种面积、每块土地种植不同农产品的单产收益如下表:

问如何安排种植计划,可得到最大收益。
这是一个产销平衡的运输问题。可以建立下列的运输模型:

代入产销平衡的运输模板得到的种植计划方案如下表:

上述问题的代码如下:
```python
import pulp
import numpy as np
def transportation_problem(costs, x_max, y_max):
row = len(costs)
col = len(costs[0])
prob = pulp.LpProblem('Transportation Proble',sense=pulp.LpMaximize)
var = [[pulp.LpVariable(f'x{i}{j}',lowBound=0,cat=pulp.LpInteger) for j in range(col)] for i in range(row)]
# 转为一维
flatten = lambda x:[y for l in x for y in flatten(l)] if type(x) is list else [x]
prob += pulp.lpDot(flatten(var),costs.flatten())
for i in range(row):
prob += (pulp.lpSum(var[i]) <= x_max[i])
for j in range(col):
prob += (pulp.lpSum([var[i][j] for i in range(row)]) <= y_max[j])
prob.solve()
return {'objective':pulp.value(prob.objective),'var':[[pulp.value(var[i][j]) for j in range(col)] for i in range(row)]}
costs = np.array([[500,550,630,1000,800,700],
[800,700,600,950,900,930],
[1000,960,840,650,600,700],
[1200,1040,980,860,880,780]])
max_plant = [76,88,96,40]
max_cultivation = [42,56,44,39,60,59]
res = transportation_problem(costs, max_plant, max_cultivation)
print(f'最大值为{res["objective"]}')
print("各个变量的取值为:")
print(res['var'])
# 最大值为284230.0
# 各变量的取值为:
# [[0.0, 0.0, 6.0, 39.0, 31.0, 0.0],
# [0.0, 0.0, 0.0, 0.0, 29.0, 59.0],
# [2.0, 56.0, 38.0, 0.0, 0.0, 0.0],
# [40.0, 0.0, 0.0, 0.0, 0.0, 0.0]]
```
## 3.5 从线性规划到非线性规划
如果目标函数或约束条件中至少有一个是非线性函数时的最优化问题就叫做非线性规划问题。
### 3.5.1 二次规划 (Quadratic Programming, QP)
二次规划是非线性规划中的一类特殊规划问题,如果目标函数是二次函数,约束函数是线性函数时,这就是一个二次规划问题。一个有n个变量与m个限制的二次规划问题可以用以下的形式描述。首先给定:
* 一个 $n$ 维的向量$g$
* 一个$n\times n$ 维的对称矩阵$H$($x^{\top}Hx$是二次型)
* 一个$m\times n$ 维的矩阵$A$
* 一个$m$维的向量$b$
则此二次规划问题的目标即是在限制条件为$Ax \leqslant b$(不等式约束) 或$Ax = b$ (等式约束)的条件下,找一个$n$维的向量$x$ ,使得$\displaystyle f(x)=\frac{1}{2} x^{\top}H x + g^{\top}x$为最小。
可以看出QP问题的特点在于其目标函数。当$x$为一维向量时,问题变成了求二次函数的极值问题。如下图所示,二次函数具有唯一的极值,也是其最值,这是一个很理想的优化问题,能够实现快速求解,而且局部最优即为全局最优。

那么,当维数增大时,是否具有类似的性质呢?再此不做证明,直接给出结论:
* 如果$H$是*半正定矩阵*,那么$f(x)$是一个*凸函数*。相应的二次规划为*凸二次规划问题*;此时如果有局部最优解,那这个局部最优解就是全局最优解。但这个全局最小值可能是不唯一的,如下图所示。

如果$H$是*正定矩阵*,则该问题有唯一的全局最小值,如下图所示。

* 若$H$为非正定矩阵,则目标函数是有多个驻点和局部极小点的*NP难问题*。
* 如果$H=0$,二次规划问题就变成线性规划问题。
下面我们想一下为什么要引入二次规划,以及为什么二次规划问题被写成了上面那种标准形式。首先要明确两个概念,维度是自变量的个数,次数是自变量最高的阶数,两个不能弄混。我们解决问题的思路是将不熟悉的问题,转换成熟悉的问题。其中优化轨迹点是不熟悉的问题,但是求解多项式的极值是熟悉的问题。因此我们希望将复杂问题转换为一个与多项式求极值类似的问题。而求解多项式极值也要看多项式的次数,如果是一次,那就是直线,没什么好讨论的。如果是二次,那函数将有唯一的极值,也是最值,这是我们最想看到的。如果超过2次,那问题就复杂了。
多项式求极值是二维空间的问题,只有$x$和$y$两个变量。而运动规划的优化问题是高维的。高维空间的优化问题是十分复杂的,我们如果将其转换为一个类似于我们熟悉的多项式求极值问题,我们当然希望选择对优化来说最理想的二次。如果问题的维度也高,次数也高,就很难求解了。于是我们希望将问题转化为这样一个高维空间中,类似于二次函数求极值的问题,所以目标函数这样定义:
$$
f(x) = x^{\top}Hx + g^{\top}x. \tag{3.5.1}
$$
### 3.5.2 非线性规划的求解
类比线性规划的基本形式,我们把所有的线性方程约束中系数做成系数矩阵$A_{\text{eq}}$,等号右边的常数作为列向量$b_{\text{eq}}$;线性不等式约束中的系数矩阵$A$和不等号右边的常数$b$,为了方便起见通常将不等式统一为小于等于;变量$x$在向量$l_{b}$到$u_{b}$之间取值;非线性不等式和非线性方程分别用$C(x)$和$C_{\text{eq}}(x)$,则线性规划的标准形式如下所示:
$$
\begin{align}
\text{minimize}~& f(x) \\[0.5em]
\text{s.t.}~
& Ax \leqslant b\\
& C(x) \leqslant 0\\
& A_{\text{eq}}x = b_{\text{eq}}\\
& C_{\text{eq}}(x) = 0\\
& l_{b} \leqslant x \leqslant u_{b}
\end{align} \tag{3.5.2}
$$
我们仍然将问题统一为函数极小值问题,不等约束统一为小于等于。如果原问题是最大值或者有大于等于,那就乘$-1$进行取反即可。
> 注意:非线性规划中只要破坏了等式约束、不等约束和目标函数当中任何一个的线性就可以说是一个非线性规划。仅仅破坏一条问题的求解难度会上来很多。
从线性到非线性,多元函数可能初来乍到的同学并不一定理解。在高中我们说,函数是从一个集合(定义域)到另一个集合(值域)的一一对应的映射,那现在多元函数?不就变成多个集合到一个集合的映射了嘛?这,怎么也能叫一一对应呢?诶,这个时候你就注意了,多元函数仍然是一个集合到另一个集合的映射,只不过自变量的集合不是数集,而是点集,或者说是$n$维空间里面向量的集合。
举个例子,比如说一个地方的温度与海拔,海拔越高温度就越低,另外一天24小时的温度是中午高早晚低还呈现周期变化,那么气温就受到海拔和时间两个因素影响。我们不妨假定,这个地方的温度规律为$\displaystyle T = (40 - 25 \cos t)\left( 1 - \frac{h}{4} \right)$,可以做出一个如图2.4所示的曲面图:

图2.4 气温、海拔与时间的关系图
如果高中学过导数的同学可能知道,一元函数求极值的一个方法就是求导导数为$0$。对于多元函数也类似,如果是无约束就只是给了一个多元函数求极值,只需要针对每个变量分别求导数(我们把这个过程叫做求偏导),所有偏导为$0$解方程组得到的就是极值点的候选解。当然,我们有可能解出来的是$x_3=0$的这种极值解,这种情况下我们会通过二阶导数的符号去进一步判定。
> 注意:在求对某一个变量的偏导数的时候通常把其他变量视作常数,我们把这种策略叫主元策略(我想在中学阶段老师应该讲过)。
当我们碰上了有约束条件下的非线性函数求极值的时候,我们通常使用 Lagrange 法。例如,对于广义的含等式条件(先暂时只考虑等式问题)的极值问题:
$$
\begin{align}
\text{minimize}~& f(x) \\[0.5em]
\text{s.t.}~
& C_{i}(x) = 0, \quad i=1,\dots,n
\end{align} \tag{3.5.3}
$$
我们通过引入$n$个称之为**Lagrange 乘子**的常数把原问题改写为新的函数:
$$
\min L (x, \lambda) = f(x) + \sum\limits_{i=1}^{n} \lambda_{i}C_{i}(x). \tag{3.5.4}
$$
接下来就像解无约束极值一样对每个$x$和乘子求偏导即可解决问题。而当我们在问题中考虑不等条件,那么这个问题的求解策略其实类似,我们称其为**KKT条件**。对于问题
$$
\begin{align}
\text{minimize}~& f(x) \\[0.5em]
\text{s.t.}~
& h(x) = 0\\
& g(x) \leqslant 0,
\end{align} \tag{3.5.5}
$$
我们分别引入两个不同乘子,函数$L$将在$x$取得极值当且仅当它在下面的问题中取得极值:
$$
\begin{align}
\text{minimize}~& L(x, \lambda, \mu) = f(x) + \lambda h(x) + \mu g(x) \\[0.5em]
\text{s.t.}~
& \frac{ \partial L }{ \partial x } = 0\\
& \lambda \ne 0\\
& \mu \geqslant 0\\
& \mu g(x) = 0\\
& h(x) = 0\\
& g(x) \leqslant 0
\end{align} \tag{3.5.6}
$$
当然,如果想求的不是一个精确解而是一个近似的数值解,那么我们的方法同样有很多。除了在下一章中会介绍的一些数值方法外,Monte Carlo模拟几乎是用的最广泛的一种。
## 3.6 非线性规划的建模案例
在这一节中我们会看到一些非线性规划的建模案例。
### 3.6.1 商品选购优化
现在有三种产品,产品的收益与成本的关系分别为:
$$
\begin{cases}
P_{1} = 3 + 0.004x_{1} + 0.0025x_{1}^{2}, & 100 \leqslant x_{1} \leqslant 200\\
P_{2} = 4 - 0.02x_{2} + 0.0033 x_{2}^{2}, & 150 \leqslant x_{2} \leqslant 250\\
P_{3} = 6 + 0.0015x_{3}, & 150 \leqslant x_{3} \leqslant 300
\end{cases} \tag{3.6.1}
$$
假如你的手中有七百万作为本金,你应该如何投资使你的总收益最大?
这个例子也很简单,我们的约束条件无非只有$x_{1} + x_{2} + x_{3} \leqslant 700$,将目标函数写出来:
```python
def func(x):
return 10.5+0.3*x[0]+0.32*x[1]+0.32*x[2]+0.0007*x[0]**2+0.0004*x[1]**2+0.00045*x[2]**2
cons=({'type':'eq','fun':lambda x: x[0]+x[1]+x[2]-700})
b1,b2,b3=(100,200),(120,250),(150,300)
x_0=np.array([100,200,400])
res=minimize(func,x_0,method='SLSQP',constraints=cons,bounds=(b1,b2,b3))
print(res)
from sko.GA import GA
def func(x):
return 10.5+0.3*x[0]+0.32*x[1]+0.32*x[2]+0.0007*x[0]**2+0.0004*x[1]**2+0.00045*x[2]**2
cons=lambda x: x[0]+x[1]+x[2]-700
b1,b2,b3=(100,200),(120,250),(150,300)
ga=GA(func=func,n_dim=3,size_pop=500,max_iter=500,constraint_eq=[cons],lb=[100,120,150],ub=[200,250,300])
best_x,best_y=ga.run()
print("best x:\n",best_x,"\nbest_y:\n",best_y)
```
### 3.6.2 工地选址
某公司有$6$个建筑工地要开工,每个工地的位置(用平面坐标系$a,b$表示,距离单位:千米)及水泥日用 量$d$(吨)由下表给出。规划设立两个料场位于$A$,$B$,日储量各为20吨。假设从料场到工地之间均有 直线道路相连,试确定料场的位置,并制定每天的供应计划,即从$A$,$B$两料场分别向各工地运送多少吨水泥,使总的吨千米数最小。

我们可以分析这一个问题。我们说,规划问题的核心有三样:**决策变量**,**目标函数**和**约束条件**。
决策变量包括哪些?首先两个料场的坐标未知吧,坐标有横纵坐标于是这就有了四个变量;两个料场到六个工地$12$条线路上的运输量也未知吧,于是这就又来了12个变量,一共是$16$个。距离可以用 Euclid 距离来计算,所以可以写出一个目标函数:
```python
def tkm(x):
s=0
j=3
a=[1,25,8.75,0.5,5.75,3,7.25]
b=[1.25,0.75,4.75,5,6.5,7.25]
for i in range(6):
s+=x[j+1]*np.sqrt((x[0]-a[i])**2+(x[1]-b[i])**2)+x[j+2]*np.sqrt((x[2]-a[i])**2+(x[3]-b[i])**2)
j+=2
return s
```
这里的$x$是一个$16$维的向量,前四维表示两个料场的坐标,后面$12$个分别表示料场$1$到六个工地的运输量和料场$2$到六个工地的运输量。运输量之间需要满足限制。首先,料场$1$到六个工地的运输量之和与料场2到六个工地的运输量之和都不能超过$20$吨,这是存量的限制;其次,每个工地从两个料场获取的运输量之和得等于自己的一个需求量。这里其实如果把每个工地的限制看作一个大于等于也是说得通的,但我们这里为了求解方便把这一部分看作等式约束也是没问题的。
$$
\begin{align}
\text{minimize}~& \sum\limits_{i=1}^{6} \sum\limits_{j=1}^{2} m_{i,j}\sqrt{ (x_{j} - a_{i})^{2} + (y_{j} - b_{i})^{2} } \\[0.5em]
\text{s.t.}~
& \sum\limits_{i=1}^{6} m_{i,j} \leqslant 20, \quad j=1,2\\
& \sum\limits_{j=1}^{2} m_{i,j} = d_{i}, \quad i=1,2,\dots,6\\
& m_{i,j} \geqslant 0, \quad \forall i,j\\
& x_{1}, x_{2}, y_{1}, y_{2} \geqslant 0
\end{align} \tag{3.6.2}
$$
这样我们就可以写出等式约束和不等式约束:
```python
lccons=({'type':'eq','fun':lambda x: x[4]+x[10]-3},
{'type':'eq','fun':lambda x: x[5]+x[11]-5},
{'type':'eq','fun':lambda x: x[6]+x[12]-7},
{'type':'eq','fun':lambda x: x[7]+x[13]-7},
{'type':'eq','fun':lambda x: x[8]+x[14]-6},
{'type':'eq','fun':lambda x: x[9]+x[15]-11},
{'type':'ineq','fun':lambda x: x[4]+x[5]+x[6]+x[7]+x[8]+x[9]-20},
{'type':'ineq','fun':lambda x: x[10]+x[11]+x[12]+x[13]+x[14]+x[15]-20})
b=((0,None),(0,None),(0,None),(0,None),(0,None),(0,None),(0,None),(0,None),(0,None),(0,None),(0,None),(0,None),(0,None),(0,None),(0,None),(0,None))
x_0=np.ones((16,1))
res=minimize(tkm,x_0,method='SLSQP',constraints=lccons,bounds=b)
print(res.x)
```
当你去运行这个函数,求得的最小吨千米数为$69.6134$,两个料场的坐标分别为$(3,6.5)$和$(0.5,4.75)$。
### 3.6.3 职称晋级与评审规划
某单位在考虑本单位职工的升级调薪方案时要求相关部门遵守以下的规定:
* 年工资总额不超过$1500000$元;
* 每级的人数不超过定编规定的人数;
* II、III级的升级面尽可能达到现有人数的$20\%$;
* III级不足编制的人数可录用新职工,又I级职工中$10\%$要退休。
相关资料汇总于表中,请为单位领导拟定一个满足要求的调资方案。
| 等级 | 工资(元/年) | 现有人数 | 编制人数 |
| :-: | :-----: | :--: | :--: |
| I | $50000$ | $10$ | $12$ |
| II | $30000$ | $12$ | $15$ |
| III | $2000$ | $15$ | $15$ |
| 合计 | | $37$ | $42$ |
为了考虑选取最优的调资方案,需要考虑三个约束条件,显然前两个约束条件为*刚性约束*,而第三个约束条件为*柔性约束*。分别建立目标约束:设由II晋升为I的人数为$x_{1}$,由III晋升为II的人数为$x_2$,招聘为III的人数为$x_3$,$d_n^-$为未满误差,$d_n^+$为过盈误差,其中$n=1,2,3,4,5$。
为保证调资后的年工资预算仍在指标范围内:
$$
\begin{align}
\text{minimize}~& d_{1}^{+} \\[0.5em]
\text{s.t.}~
& 50000(9 + x_{1}) + 30000(12 - x_{1}) + 20000(15 - x_{2} + x_{3}) + d_{1}^{-} - d_{1}^{+} = 1500000
\end{align} \tag{3.6.3}
$$
每一级人数不超过定编规定人数:
$$
\begin{align}
\text{minimize}~& d_{2}^{+} + d_{3}^{+} + d_{4}^{+} \\[0.5em]
\text{s.t.}~
& 9 + x_{1} + d_{2}^{-} - d_{2}^{+} = 12\\
& 12 - x_{1} + x_{2} + d_{3}^{-} - d_{3}^{+} = 15\\
& 15 - x_{2} + x_{3} + d_{4}^{-} - d_{4}^{+} = 15\\
\end{align} \tag{3.6.4}
$$
II,III的升级面尽量达到现有人数的$20\%$:
$$
\begin{align}
\text{minimize}~& d_{5}^{-} - d_{5}^{+} + d_{6}^{-} - d_{6}^{+} \\[0.5em]
\text{s.t.}~
& x_{1} + d_{5}^{-} - d_{5}^{+} = 3\\
& x_{2} + d_{6}^{-} - d_{6}^{+} = 3
\end{align} \tag{3.6.5}
$$
最终得到的目标函数与约束如下:
$$
\begin{align}
\text{minimize}~& p_{1}\cdot(d_{1}^{+}) + p_{2}\cdot(d_{2}^{+} + d_{3}^{+} + d_{4}^{+}) + p_{3}\cdot(d_{5}^{-} - d_{5}^{+} + d_{6}^{-} - d_{6}^{+}) \\[0.5em]
\text{s.t.}~
& 50000(9 + x_{1}) + 30000(12 - x_{1}) + 20000(15 - x_{2} + x_{3}) + d_{1}^{-} - d_{1}^{+} = 1500000\\
& 9 + x_{1} + d_{2}^{-} - d_{2}^{+} = 12\\
& 12 - x_{1} + x_{2} + d_{3}^{-} - d_{3}^{+} = 15\\
& 15 - x_{2} + x_{3} + d_{4}^{-} - d_{4}^{+} = 15\\
& x_{1} + d_{5}^{-} - d_{5}^{+} = 3\\
& x_{2} + d_{6}^{-} - d_{6}^{+} = 3
\end{align} \tag{3.6.6}
$$
其中$p_{1}, p_{2}, p_{3}$是权重值。实现这个问题的代码如下:
```python
from scipy.optimize import linprog
c=np.array([0,0,0,0,1,0,1,0,1,0,1,-1,1,-1,1])
Aeq=np.array([[20000,10000,20000,-1,1,0,0,0,0,0,0,0,0,0,0],
[1,0,0,0,0,-1,1,0,0,0,0,0,0,0,0],
[-1,1,0,0,0,0,0,-1,1,0,0,0,0,0,0],
[0,-1,1,0,0,0,0,0,0,-1,1,0,0,0,0],
[1,0,0,0,0,0,0,0,0,0,0,-1,1,0,0],
[0,1,0,0,0,0,0,0,0,0,0,0,0,-1,1]])
beq=np.array([[300000,3,3,0,3,3]])
bounds=[(0,10)]*15
res=linprog(c,None,None,Aeq,beq,bounds)
res
```
## 3.7 整数规划与指派问题
### 3.7.1 整数规划的基本概念
离散和连续是一对重要的概念。这一对概念其实非常好理解:比如说,连续的变量取值是连续的实数,取值可以是任意的浮点数(小数),但离散变量取值是不连续的,是有间隔的。离散量的取值往往是整数,也可能是有限的取值。
离散优化的例子其实也很简单,如果把前面的线性规划或者非线性规划当中加上一条约束:自变量取整数,这个问题就开始有意思了。可能最优解是一个全浮点数,但加上整数约束以后究竟在哪个整数点上取到最优解那还真说不好,你如果用枚举的方式去解那复杂度是成倍上涨。我们想,一定有更快的求解策略。
另一类离散优化的典型例子是匹配问题和组合优化问题,比如有$100$个人匹配$100$项任务,百配百就有五千种匹配模式。而这么多的匹配模式中究竟哪一个是最优解呢?那就得在五千种匹配模式中搜索,变量就是某一个人是否匹配某一项任务,取值只能是$\{0,1\}$。所以这就是一种离散优化。下面我们也会对这类问题进行介绍。
### 3.7.2 分支定界法
同单纯形法与Monte Carlo法之于线性规划,解整数规划的基础原理其实更多。最典型的两种算法就是**分支定界法**和**割平面法**。
分支定界法是一种经典的搜索算法,这里把它用在规划当中主要是为了对上下界进行**搜索**。分支定界本质上是构造一棵搜索树进行上下界搜索,它会把问题的搜索空间组织成一棵树,而分支就是从根出发将原始问题按照整数约束去分支为左子树和右子树,通过不断检查子树的上下界去搜索最优解的过程。
我们举一个例子:
**例3.7** 求规划的最优解。
首先我们忽略取值$\{0,1\}$这个条件,把它就当做一个取值范围$[0,1]$之间的线性规划去做,它肯定是有最优解的,这毋庸置疑。现在,我们从$x_3$开始分支,分别按取$0$和取$1$去对原始问题进行划分。如果$x_3$取值为$1$,那么原始问题变成了$7x_1+8x_2\leqslant 7$的条件下最大化$x_1+x_2+1$;如果取值为$0$那么原始问题变成了$7x_1+8x_2\leqslant 14$的条件下最大化$x_1+x_2$。然后我们分别计算两边的最优解,两边都可能存在最优整数解于是对这两种情况再进行划分。每划分一次我们就会对同一层的子问题求解对应的线性规划(把整数条件换成区间条件)观察谁最小谁可分,到最后遍历完成就得到了最优解。如图2.6所示:

图2.6 分支定界法的流程图
> 注意:我们每经过一层就会更新一个线性规划的最优解(也可以叫*松弛解*),在根节点我们将它设置为负无穷,而在子节点中只要能够比上一次的最优解更优我们就会更新这个最优解。比如说在第三层,节点4的最优解$2$就是最优,而节点6的最优解$1$和节点7的最优解$1.8571$再怎么分不会比节点4更优,所以我们下一步只对节点4分支并按照这一分支为子问题定界。
```python
import math
from scipy.optimize import linprog
import sys
def integerPro(c, A, b, Aeq, beq, t=1.0E-8):
res = linprog(c, A_ub=A, b_ub=b, A_eq=Aeq, b_eq=beq)
bestVal = sys.maxsize # 很大一个数
bestX = res.x
if not (type(res.x) is float or res.status != 0):
bestVal = sum([x * y for x, y in zip(c, bestX)])
if all(((x - math.floor(x)) <= t or (math.ceil(x) - x) <= t) for x in bestX):
return bestVal, bestX
else:
ind = [i for i, x in enumerate(bestX) if (x - math.floor(x)) > t and (math.ceil(x) - x) > t][0]
newCon1 = [0] * len(A[0])
newCon2 = [0] * len(A[0])
newCon1[ind] = -1
newCon2[ind] = 1
newA1 = A.copy()
newA2 = A.copy()
newA1.append(newCon1)
newA2.append(newCon2)
newB1 = b.copy()
newB2 = b.copy()
newB1.append(-math.ceil(bestX[ind]))
newB2.append(math.floor(bestX[ind]))
r1 = integerPro(c, newA1, newB1, Aeq, beq)
r2 = integerPro(c, newA2, newB2, Aeq, beq)
if r1[0] < r2[0]:
return r1
else:
return r2
if __name__ == '__main__':
c = [3, 4, 1]
A = [[-1, -6, -2], [-2, 0, 0]]
b = [-5, -3]
Aeq = [[0, 0, 0]]
beq = [0]
print(integerPro(c, A, b, Aeq, beq))
```
### 3.7.3 指派问题与匈牙利法
0-1规划是整数规划中最特殊的一种。它的限制不仅仅是要求变量是整数,而且只能是0或1,故而名曰0-1规划。事实上,在数学建模竞赛当中,0-1规划可以说是最常见的整数规划,是学习的重点。指派问题又是怎么一回事呢?我们可以看到这样一个例子:
**例3.8** 现在有4个人$\{A,B,C,D\}$可以做四项工作$\{1,2,3,4\}$,他们每个人只能做一项工作,所需要的时间按照表2.3给出:
表2.3 例2.8中的附件表
| 时间 | 1 | 2 | 3 | 4 |
| :-: | --- | --- | --- | --- |
| A | 6 | 7 | 11 | 2 |
| B | 4 | 5 | 9 | 8 |
| C | 3 | 1 | 10 | 4 |
| D | 5 | 9 | 8 | 2 |
我们把四个人与$4$项工作对应的$4\cdot4=16$项安排作为决策变量,变量取值为$\{ 0,1 \}$,表示某个人是否执行某项工作。一个人只能做一项工作,一项工作也只能一个人来做,比如对$A$而言,$A$如果做了$2$就不能做$1,3,4$,所以$A$匹配的四个变量只能有一个是$1$其余三个都是$0$。同样地,对任务$2$而言如果它被安排给了$A$那么$B,C,D$也都不能去做,所以任务$2$匹配的四个变量也只能一个是$1$其余三个是$0$。由此,我们给出定义:
$$
\begin{align}
\text{minimize}~& f(x) = \sum\limits_{i=1}^{4} \sum\limits_{j=1}^{4} x_{i,j}T_{i,j} \\[0.5em]
\text{s.t.}~
& \sum\limits_{i=1}^{4} x_{i,j} = 1, \quad \forall j\\
& \sum\limits_{j=1}^{4} x_{i,j} = 1, \quad \forall i\\
& x_{i,j} \in \{ 0,1 \}, \quad \forall i,j.
\end{align} \tag{3.7.1}
$$
指派问题和0-1规划的解法可以从matlab的整数规划函数中进行约束,但还有一种经典的算法可以解0-1规划。这个方法被称作匈牙利法。匈牙利法的操作比较有趣,对于时间排布表,首先将每行减去当前行的最小值,然后将每一列的值减去当前列的最小值。接下来一步比较有趣,需要用最少的水平线和竖直线覆盖所有的$0$项。如果线条总数为$4$那么算法停止,给出指派方案;如果少于4条那么则计算没有被覆盖的最小值,将没有被覆盖的每行减去最小值,被覆盖的每列加上最小值,然后重新进行覆盖。整个过程如图2.7:

图2.7 指派问题的解法流程
```python
from scipy.optimize import linear_sum_assignment
import numpy as np
T=np.array([[25,29,31,42],[39,38,26,20],[34,27,28,40],[24,42,36,23]])
row_ind,col_ind=linear_sum_assignment(T)
print(row_ind)
print(col_ind)
print(T[row_ind,col_ind])
print(T[row_ind,col_ind].sum())
```
## 3.8 使用Scipy和Cvxpy解决规划问题
Python 提供了多种库和工具来解决线性规划问题,如 scipy、cvxpy等。本节将介绍如何使用这些库来解决线性规划问题,并给出示例代码和运行结果。
### 3.8.1 使用scipy求解函数优化问题
在 `scipy.optimize` 模块中,提供了多种用于非线性规划问题的方法,适用于不同类型的问题:
* `brent()`: 适用于单变量无约束优化问题,结合了*Newton 法*和*二分法*。
* `fmin()`: 适用于多变量无约束优化问题,采用*单纯形法*,只需利用函数值,无需函数的导数或二阶导数。
* `leastsq()`: 用于解决非线性*最小二乘*问题,用于求解非线性最小二乘拟合问题。
* `minimize()`: 适用于约束优化问题,利用 *Lagrange 乘子法*将约束优化问题转化为无约束优化问题。
`minimize(fun, x_0[, args, method, jac, hess, ...])`:用于对一个或多个变量的标量函数进行最小化。
**对于无约束问题优化算法**:
* `method='CG'`: *非线性共轭梯度算法*,只能处理无约束优化问题,需要使用一阶导数函数。
* `method='BFGS'`: *拟 Newton 法*,只能处理无约束优化问题,需要使用一阶导数函数。BFGS 算法性能良好,是无约束优化问题的默认算法。
* `method='Newton-CG'`: *截断 Newton 法*,只能处理无约束优化问题,需要使用一阶导数函数,适合处理大规模问题。
* `method='dogleg'`: *Dog-leg 信赖域算法*,需要使用梯度和 Hessian 矩阵(必须正定),只能处理无约束优化问题。
* `method='trust-ncg'`: 采用 *Newton 共轭梯度信赖域算法*,需要使用梯度和 Hessian 矩阵(必须正定),只能处理无约束优化问题,适合大规模问题。
* `method='trust-exact'`: 求解*无约束极小化问题的信赖域方法*,需要梯度和 Hessian 矩阵(不需要正定)。
* `method='trust-krylov'`: 使用 *Newton-GLTR 信赖域算法*,需要使用梯度和 Hessian 矩阵(必须正定),只能处理无约束优化问题,适合中大规模问题。
**对于边界约束条件问题优化算法**:
* `method='Nelder-Mead'`: *下山单纯形法*,可以处理边界约束条件(决策变量的上下限),只使用目标函数,不使用导数函数或二阶导数,具有较强的鲁棒性。
* `method='L-BFGS-B'`: *改进的 BFGS 拟Newton 法*,"L" 指有限内存,"B" 指边界约束,可以处理边界约束条件,需要使用一阶导数函数。L-BFGS-B 算法性能良好,内存消耗小,适合处理大规模问题,是边界约束优化问题的默认算法。
* `method='Powell'`: *改进的共轭方向法*,可以处理边界约束条件(决策变量的上下限)。
* `method='TNC'`: *截断Newton 法*,可以处理边界约束条件。
**对于带有约束条件问题优化算法**:
* `method='COBYLA'`: *线性近似约束优化方法*,通过线性逼近目标函数和约束条件来处理非线性问题。只使用目标函数,不需要导数或二阶导数值,可以处理约束条件。
* `method='SLSQP'`: *序贯最小二乘规划算法*,可以处理边界约束、等式约束和不等式约束条件。SLSQP 算法性能良好,是带有约束条件优化问题的默认算法。
* `method='trust-constr'`: *信赖域算法*,通用的约束最优化方法,适合处理大规模问题。
下面给出了一些利用 `scipy.optimize` 模块进行求解的代码示例:
```python
from scipy.optimize import brent, fmin, fmin_ncg, minimize
import numpy as np
# 1. Demo1:单变量无约束优化问题(Scipy.optimize.brent)
def objf(x): # 目标函数
fx = x**2 - 8*np.sin(2*x+np.pi)
return fx
xIni = -5.0
xOpt= brent(objf, brack=(xIni,2))
print("xIni={:.4f}\tfxIni={:.4f}".format(xIni,objf(xIni)))
print("xOpt={:.4f}\tfxOpt={:.4f}".format(xOpt,objf(xOpt)))
def objf2(x): # Rosenbrock benchmark function
fx = 100.0 * (x[0] - x[1] ** 2.0) ** 2.0 + (1 - x[1]) ** 2.0
return fx
xIni = np.array([-2, -2])
xOpt = fmin(objf2, xIni)
print("xIni={:.4f},{:.4f}\tfxIni={:.4f}".format(xIni[0],xIni[1],objf2(xIni)))
print("xOpt={:.4f},{:.4f}\tfxOpt={:.4f}".format(xOpt[0],xOpt[1],objf2(xOpt)))
```

```python
#目标函数:
def func(args):
fun = lambda x: 60 - 10*x[0] - 4*x[1] + x[0]**2 + x[1]**2 - x[0]*x[1]
return fun
#约束条件,包括等式约束和不等式约束
def con(args):
cons = ({'type': 'eq', 'fun': lambda x: x[0]+x[1]-8})
return cons
# 解决问题是:当x_1+x_2=8时,求解函数60-10x_1-4x_2+x_1^2+x_2^2-x_1x_2的极小值
if __name__ == "__main__":
args = ()
args1 = ()
cons = con(args1)
x_0 = np.array((2.0, 1.0)) #设置初始值,初始值的设置很重要,很容易收敛到另外的极值点中,建议多试几个值
#求解#
res = minimize(func(args), x_0, method='SLSQP', constraints=cons)
print(res)
def objF4(x): # 定义目标函数
a, b, c, d = 1, 2, 3, 8
fx = a*x[0]**2 + b*x[1]**2 + c*x[2]**2 + d
return fx
# 定义约束条件函数
def constraint1(x): # 不等式约束 f(x)>=0
return x[0]** 2 - x[1] + x[2]**2
def constraint2(x): # 不等式约束 转换为标准形式
return -(x[0] + x[1]**2 + x[2]**3 - 20)
def constraint3(x): # 等式约束
return -x[0] - x[1]**2 + 2
def constraint4(x): # 等式约束
return x[1] + 2*x[2]**2 -3
# 定义边界约束
b = (0.0, None)
bnds = (b, b, b)
# 定义约束条件
con1 = {'type': 'ineq', 'fun': constraint1}
con2 = {'type': 'ineq', 'fun': constraint2}
con3 = {'type': 'eq', 'fun': constraint3}
con4 = {'type': 'eq', 'fun': constraint4}
cons = ([con1, con2, con3,con4]) # 3个约束条件
# 求解优化问题
x_0 = np.array([1., 2., 3.]) # 定义搜索的初值
res = minimize(objF4, x_0, method='SLSQP', bounds=bnds, constraints=cons)
print("Optimization problem (res):\t{}".format(res.message)) # 优化是否成功
print("xOpt = {}".format(res.x)) # 自变量的优化值
print("min f(x) = {:.4f}".format(res.fun)) # 目标函数的优化值
def objF5(x,args): # 定义目标函数
a,b,c,d = args
fx = lambda x: a*x[0]**2 + b*x[1]**2 + c*x[2]**2 + d
return a*x[0]**2 + b*x[1]**2 + c*x[2]**2 + d
def constraint1(): # 定义约束条件函数
cons = ({'type': 'ineq', 'fun': lambda x: (x[0]**2 - x[1] + x[2]**2)}, # 不等式约束 f(x)>=0
{'type': 'ineq', 'fun': lambda x: -(x[0] + x[1]**2 + x[2]**3 - 20)}, # 不等式约束 转换为标准形式
{'type': 'eq', 'fun': lambda x: (-x[0] - x[1]**2 + 2)}, # 等式约束
{'type': 'eq', 'fun': lambda x: (x[1] + 2*x[2]**2 - 3)}) # 等式约束
return cons
# 定义边界约束
b = (0.0, None)
bnds = (b, b, b)
# 定义约束条件
cons = constraint1()
args1 = (1,2,3,8) # 定义目标函数中的参数
# 求解优化问题
x_0 = np.array([1., 2., 3.]) # 定义搜索的初值
res1 = minimize(objF5, x_0=x_0, args=[1,2,3,8], method='SLSQP', bounds=bnds, constraints=cons)
print("Optimization problem (res1):\t{}".format(res1.message)) # 优化是否成功
print("xOpt = {}".format(res1.x)) # 自变量的优化值
print("min f(x) = {:.4f}".format(res1.fun)) # 目标函数的优化值
```
### 3.8.2 使用cvxpy求解函数优化问题
CVXPY 是一种用于凸优化问题的 Python 嵌入式建模语言。它允许您以遵循数学的自然方式表达您的问题,而不是求解器要求的限制性标准形式
| 求解状态 | 含义 |
| --------------------- | --- |
| OPTIMAL | 最优解 |
| INFEASIBLE | 不可行 |
| UNBOUNDED | 无界 |
| OPTIMAL_INACCURATE | 不精确 |
| INFEASIBLE_INACCURATE | 不精确 |
| UNBOUNDED_INACCURATE | 不精确 |

**CVXPY 的变量类型**
变量可以是标量、向量以及矩阵 cvxpy 中可以做常数使用的有:
- NumPy `ndarray`s
- NumPy `matrice`s
- SciPy sparse matrices
CVXPY 的约束可以使用 `==`, `<=`,`>=` ,不能使用`<` ,`>`。也不能使用`0 <= x <= 1` or `x == y == 2`。
`parameters`可以理解为参数求解问题里的一个常数,可以是标量、向量、矩阵。在没有求解问题前(调用类`xxx.solve()`),其允许你改变其值。
**例1**

解决这个问题的代码如下
```python
import cvxpy as cp
import numpy as np
coef = np.array([72, 64])#输入目标函数系数
left = np.array([[1, 1], [12, 8], [3, 0]])#输入约束条件系数
right = np.array([50, 480, 100])#输入约束条件上限值
x = cp.Variable(2)#构造决策变量
obj = cp.Maximize(coef @ x)#构造目标函数
cons = [x >= 0, left @ x <= right]#构造约束条件
prob = cp.Problem(obj, cons)#构建模型
prob.solve(solver='GUROBI')#模型求解
print("最优值:", prob.value)
print("最优解:", x.value)
print("剩余牛奶:", right[0] - sum(left[0] * x.value))
print("剩余劳动时间:", right[1] - sum(left[1] * x.value))
print("A1剩余加工能力:", right[2] - sum(left[2] * x.value))
```
**例2**

解决这个问题的代码如下
```python
import cvxpy as cp
import numpy as np
#输入目标函数系数
coef = np.array([160, 130, 220, 170,
140, 130, 190, 150,
190, 200, 230])
#输入约束条件系数
left = np.array([[1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0],
[0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0],
[0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1],
[0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]])
right_min = np.array([30, 70, 10, 10])#输入约束条件下限值
right_max = np.array([80, 140, 30, 50])#输入约束条件上限值
x = cp.Variable(11)#构造决策变量
obj = cp.Minimize(coef @ x)#构造目标函数
#构造约束条件
cons = [x >= 0,
left @ x <= right_max,
left @ x >= right_min,
cp.sum(x[0:4]) == 50,
cp.sum(x[4:8]) == 60,
cp.sum(x[8:11]) == 50]
prob = cp.Problem(obj, cons)#构建模型
prob.solve(solver="GUROBI")#模型求解
print("管理费用最小值为:", prob.value)
print("最优分配方案为:", x.value)
```
**例3**

解决这个问题的代码如下
```python
import cvxpy as cp
import numpy as np
coef = np.array([2, 3, 4])#输入目标函数系数
left = np.array([[1.5, 3, 5], [280, 250, 400]])#输入约束条件系数
right = np.array([600, 60000])#输入输入约束条件上限值
x = cp.Variable(3, integer=True)#创建决策变量,并且为整数
obj = cp.Maximize(coef @ x)#构造目标函数
cons = [x >= 0, left @ x <= right]#构造约束条件
prob = cp.Problem(obj, cons)#构建模型
prob.solve(solver="GUROBI")#模型求解
print("最优值:", prob.value)
print("最优解:", x.value)
print("钢材剩余量:", right[0] - sum(left[0] * x.value))
print("劳动时间剩余量:", right[1] - sum(left[1] * x.value))
```
**例4**

解决这个问题的代码如下
```python
import cvxpy as cp
import numpy as np
coef = np.array([2, 3, 4])
left = np.array([[1.5, 3, 5], [280, 250, 400]])
right = np.array([600, 60000])
x = cp.Variable(3, integer=True)
y = cp.Variable(3, integer=True)
obj = cp.Maximize(coef @ x)
cons = [x >= 0, left @ x <= right,
y >= 0, y <= 1,
x[0] >= 80 * y[0], x[0] <= 1000 * y[0],
x[1] >= 80 * y[1], x[1] <= 1000 * y[1],
x[2] >= 80 * y[2], x[2] <= 1000 * y[2], ]
prob = cp.Problem(obj, cons)
prob.solve(solver="GUROBI")
print("最优值:", prob.value)
print("最优解:", x.value)
print("钢材剩余量:", right[0] - sum(left[0] * x.value))
print("劳动时间剩余量:", right[1] - sum(left[1] * x.value))
```
**例5**

解决这个问题的代码如下
```python
import cvxpy as cp
import numpy as np
coef_x = np.array([4.8, 5.6, 4.8, 5.6])#输入目标函数x对应系数
coef_cx = np.array([0, 5000, 9000, 12000])#输入用z表示cx的系数
coef_buy_x = np.array([0, 500, 1000, 1500])#输入用z表示x的系数
left = np.array([[0, 0, 1, 1], [-1, 0, 1, 0], [0, -2, 0, 3]])#输入约束条件系数
right = np.array([1000, 0, 0])#输入约束条件上限值
x = cp.Variable(4)#创建决策变量x
y = cp.Variable(3, integer=True)#创建0-1变量y
z = cp.Variable(4)#创建变量z
obj = cp.Maximize(coef_x @ x - coef_cx @ z)#构造目标函数
#构造约束条件
cons = [cp.sum(x[0:2]) <= 500 + cp.sum(coef_buy_x @ z),
left @ x <= right,
sum(coef_buy_x @ z) <= 1500,
x >= 0,
z[0] <= y[0], z[1] <= y[0] + y[1], z[2] <= y[1] + y[2], z[3] <= y[2],
cp.sum(z[:]) == 1, z >= 0,
cp.sum(y[:]) == 1,
y >= 0, y <= 1]
prob = cp.Problem(obj, cons)#构造模型
prob.solve(solver="GUROBI")#求解模型
print("最优值:", prob.value)
print("最优解:", x.value)
print("购买原油A:", sum(coef_buy_x * z.value), "t")
```
**例6**

解决这个问题的代码如下
```python
import cvxpy as cp
import numpy as np
#输入目标函数系数
coef = np.array([66.8, 75.6, 87, 58.6,
57.2, 66, 66.4, 53,
78, 67.8, 84.6, 59.4,
70, 74.2, 69.6, 57.2,
67.4, 71, 83.8, 62.4])
x = cp.Variable(20, integer=True)#构造决策变量
#构造目标函数
obj = cp.Minimize(coef @ x)
#输入约束条件
cons = [x >= 0, x <= 1,
cp.sum(x[0:4]) <= 1,
cp.sum(x[4:8]) <= 1,
cp.sum(x[8:12]) <= 1,
cp.sum(x[12:16]) <= 1,
cp.sum(x[16:20]) <= 1,
cp.sum(x[0:20:4]) == 1,
cp.sum(x[1:20:4]) == 1,
cp.sum(x[2:20:4]) == 1,
cp.sum(x[3:20:4]) == 1]
prob = cp.Problem(obj, cons)#构造模型
prob.solve(solver="GUROBI")#模型求解
print("最优值:", prob.value)
print("最优解:", x.value)
```
**例7**

解决这个问题的代码如下
```python
import cvxpy as cp
import numpy as np
#输入目标函数的系数
coef_obj = np.array([-0.8, -0.5, -0.5, -0.2, -0.5, -0.2, 0.1, 0.1, -0.2])
coef_credits = np.array([5, 4, 4, 3, 4, 3, 2, 2, 3])#输入课程学分系数
x = cp.Variable(9, integer=True)#构造决策变量
obj = cp.Minimize(coef_obj @ x)#构造目标函数
#输入约束条件
cons = [cp.sum(x[0:5]) >= 2,
x[2] + [4] + x[5] + x[7] + x[8] >= 3,
x[3] + x[5] + x[6] + x[8] >= 2,
2 * x[2] - x[0] - x[1] <= 0,
x[3] - x[6] <= 0,
2 * x[4] - x[0] - x[1] <= 0,
x[5] - x[6] <= 0,
x[7] - x[4] <= 0,
2 * x[8] - x[0] - x[2] <= 0,
x >= 0, x <= 1]
prob = cp.Problem(obj, cons)#模型构建
prob.solve(solver="GUROBI")#模型求解
print("选课结果:", x.value)
print("学分总和:", sum(coef_credits * x.value))
```
## 3.9 动态规划与贪心算法
动态规划和递归问题是算法研究中一项非常经典的话题啦。这一类问题更多的会考虑一个局部最优与全局最优的问题,是离散优化中一项重要的组成部分。
### 3.9.1 递归与动态规划
我想读者朋友在这里第一次看到“递归”应该是在2.1节当中我提到行列式的求法的时候。当时读者朋友可能就有些疑惑,这个“递归展开”和“递归到最后”应该作何理解?究竟何为递归?我这里呢,可以举个简单的例子:
读者朋友在上小学的时候可能听过这样一个数学故事叫做生兔子:一对小兔子,在成长一年后就可以生下一对小兔子,小兔子再长一年就又能生下一对新的小兔子......这个兔子数列又叫斐波那契 (Fibonacci) 数列,形式是$1,1,2,3,5,8,\dots$。那么,如果不考虑兔子死亡,我怎么推算一百年以后有多少只兔子呢?
我们知道,斐波那契数列的算法是从第三项往后,每一项等于前两项之和。如果我们把它写成数学上的递推公式,它就被写作:
$$
F_{n} = F_{n-1} + F_{n-2}, n \geqslant 3. \tag{3.9.1}
$$
而斐波那契数列的前两项为$1$。如果我们把这个公式翻译为python代码,为:
```python
def fibonacci(n):
if n == 1 or n == 2:
return 1
else:
return fibonacci(n-1) + fibonacci(n-2)
```
这段代码最有意思的地方就是这个函数好像在“自己调用自己”,这就是递归的结构。如果我想求$F_{100}$,我就得先求$F_{99}$和$F_{98}$,而想求$F_{99}$就又得计算$F_{98}$和$F_{97}$,以此类推一直到$F_{2}$和$F_{1}$。当我递归到最后的$F_{2} = F_{1} = 1$时,我就又可以自底而上击破所有的通项。但当读者朋友真的去运行这段代码去求`fibonacci(100)`的时候会发现python卡在那里久久不能释怀。为什么会造成这么慢的运行速度呢?我们不妨把这个递归结构画出来:

图2.8 斐波那契数列的递归树
图2.8 为斐波那契数列的递归树示意图。从这个递归树当中可以看到,诸如$F_{98}$、$F_{97}$等都重复计算了很多次,也正是由于太多次的重复计算导致python算了很多不必要的东西也多了很多不必要的时间开销。能不能尽可能减少重复计算的次数呢?有没有这样的一种办法呢?
一种可行的办法就是把重复计算的东西存到一张表格里面,到时候如果需要计算我可以不用从头开始递归而是采取查表的方法开箱即用。当然,这样子减少重复计算时间开销的代价就是存储这张中间表需要一定的存量,也就是空间开销。这种以空间换时间的方式就是动态规划。而在动态规划当中,最主要的就是上面的递归方程。当然,如果像图这样把问题的递归状态空间树画出来是一种很好的理解方式,但是在画状态空间树的时候其实你会发现搜索过程中对于某些问题有些节点(也就是解)是不合理的,是可以采取剪枝的策略的。这就是一种优化方法。
动态规划没有解题范式,但宏观上有一个通用方法论:
* 问题分解为若干个子问题
* 找状态,选择合适的状态$S(k)$,需要描述多种可能结果的演变
* 做决策,确定决策变量以及可行的动作
* 确定状态转移方程$S(k+1)$=$f(S(k))$
* 确定目标$V(k,n)$,即最终需要达到的目标
* 寻找目标的终止条件
**最核心的步骤是找到状态转移方程/递推方程**
### 3.9.2 背包问题的求解
在上一节的最后给的一个背包问题的案例其实就可以用动态规划去做它,由于物品只能是拿与不拿,不能拿一部分,所以又叫0-1背包问题。0-1背包问题在python当中没有内置的函数去调用,所以如果用python写动态规划结构只能自己写因为情况很多很灵活。
以上一节的最后给出的一个背包问题为例,首先我们为了记录方便可以建立一个数组存储每一次操作的背包容量和商品的利润。开始状况下我们的背包容量有$20$,在检索每一个物品的过程中我们会对比物品的体积和包的容量情况,如果体积比现有剩余容量大,我们会考虑需不需要从包中取出某样物品后放入这样物品;如果现有剩余容量还可以装得下它,那么我们也会考虑装下它的最优利润和不装它的最优利润。因为每一次装载会引起容量变化,当容量不再是$20$,物品被排除一样以后最优利润的求解难度也就不一样了;如果子问题能够在我们建立的备忘录数组中检索到,那么这个问题也就可解了。
```python
import numpy as np
m = 5 # 投资总额
n = 6
k = 4 # 项目数
# m元钱,n项投资 k个项目
dp = np.zeros((m, n)) # dp[i][j] 从1-i号项目中选择,投资j万元,所取得的最大收益
mark = np.zeros((m, n)) # 从1-i号项目中选择,投资j万元,获得最大收益时,在第i号项目中投资了多少钱
f = np.array([[0, 0, 0, 0, 0, 0],
[0, 11, 12, 13, 14, 15],
[0, 0, 5, 10, 15, 20],
[0, 2, 10, 30, 32, 40],
[0, 20, 21, 22, 23, 24]])
# 初始化第一行
for j in range(m + 1):
dp[1][j] = f[1][j]
mark[1][j] = j
for i in range(1, k + 1):
for j in range(1, m + 1):
for k in range(j):
if dp[i][j] < f[i][k] + dp[i - 1][j - k]:
dp[i][j] = f[i][k] + dp[i - 1][j - k] # 更新当前最优解
mark[i][j] = k # 更新标记函数
print("最大收益", dp[4][5])
for i in range(1, k + 1):
for j in range(m + 1):
print("(%d, %d)" % (dp[i][j], mark[i][j]), end="\t")
print("\n")
for i in range(k, 0, -1):
print(f"第{i}个项目投资{mark[i][m]}元")
m = m - int(mark[i][m])
```
### 3.9.3 贪心策略
如果读者对算法感兴趣,可能还听说过一个概念叫贪心策略。这个东西又是什么呢?还是以上面的背包问题举例子,如果是一个贪心策略的人,它就不会考虑容量问题和全局最优,他只想着怎么把当前的最优选择拿到。他第一步会选择货物1因为它最贵重,这个时候你只剩下了12吨;第二步看到第二贵重的6号货物,这时他只剩下6吨空余;第三步他看到第三贵重的4号货物并装上车,结果只剩下了1吨什么也装不了。但显然,这并不是使利益最大化的方案。
贪心策略在某些问题中往往是有效的,因为它坚信“只要我每一步的选择都是最好的,我最后的结果一定是最好的”。而动态规划是一种全局思想,它认为“我暂时可以不要那么好的选择,退而求其次,但我能保证最后的结果最好”。
二者最大的共同之处就在于:它们的思想都是通过每一步选择一件物品来把原始问题划分为子问题,都有子问题结构和可分性。而且它们的最终目标都是使得最后的总价值是最优的。但究竟什么问题适合贪心,什么问题适合动态规划,尚需要读者见招拆招。
## 3.10 博弈论与排队论初步
### 3.10.1博弈论
博弈可分为合作与非合作两种,其核心差异在于决策者间的相互作用是否能导致一项具有约束力的协议。若有,则为合作博弈;若无,则为非合作博弈。
在合作博弈中,关键问题是如何分享合作带来的成果;而在非合作博弈中,每个决策者都需考虑如何选择自身行动,即决策变量的取值。更广泛地说,每个决策者必须制定自身的行动策略,决定在何种情况下采取何种行动。根据决策者行动是否同时进行或按顺序进行,非合作博弈可分为静态与动态。
此外,根据决策者在决策时所掌握的信息量不同,非合作博弈可分为完全信息与不完全信息博弈。博弈的要素包括参与者、决策者、策略空间、决策变量的取值范围、效用函数以及决策的目标函数。
**例3.9** 点球大战
在点球大战中,我们假设踢球方和守门方都有相同的进球概率,用p表示。因此,守门方的失分概率也是$p$,这形成了一个*零和博弈*。在这种情况下,踢球方得分的概率也是$p$,与守门方失分概率相等。
| | 扑向左边 | 扑向右边 |
| ---- | ---- | ---- |
| 踢到左边 | 0.58 | 0.95 |
| 踢到右边 | 0.93 | 0.70 |
**Nash 均衡**:单向改变战略并不能提高自己效用,即每一方的策略对对方而言都是最优的点球大战的进球概率。
$$
\begin{align}
S_{1} &= \left\{ p=(p_{1}, p_{2}): 0 \leqslant p_{i} \leqslant 1, \sum\limits_{i=1}^{2} p_{i} = 1 \right\} \tag{3.10.1}\\
S_{2} &= \left\{ q=(q_{}{1}, q_{2}): 0 \leqslant q_{i} \leqslant 1, \sum\limits_{i=1}^{2} q_{i} = 1 \right\} \tag{3.10.2}\\
\end{align}
$$
对于守门员来说,他希望平均的进球概率尽可能小,但攻球人的目标相反:
$$
\max_{p} \min_{q} p^{\top}Mq, \tag{3.10.3}
$$
对函数进行进一步优化:
$$
\begin{align}
y &= \max_{p} \min_{q} p^{\top}Mq\\
&= (0.23 - 0.6p)q + (0.25p + 0.7)\\
&= (0.25 - 0.6q)p + (0.23q + 0.7)\\
\end{align} \tag{3.10.4}
$$
棒球的得分概率也有一个类似于点球大战的目标:

击球手为了使击球平均分最大,面临什么样的约束呢?投球手可以全部投出快球或者弧线球,也就是说,投球手可以采用它的两个纯策略之一来应对击球手的混合策略,这两个纯策略给击球手最大化击球平均分能力施加了一个上限。
此时原问题可以退化为一个线性规划问题:
$$
\begin{align}
\text{maximize}~& A \\[0.5em]
\text{s.t.}~
& A < 0.4p_{1} + 0.1(1-p_{1})\\
& A < 0.2p_{1} + 0.3(1-p_{1})
\end{align} \tag{3.10.5}
$$
其中
* $A$:击球平均分
* $p$:击球手猜中快球的比例
* $1-p$:击球手猜中弧线球的比例。
投球手为了使击球平均分最小,面临什么样的约束?击球手可以全部猜测快球或者弧线球。也就是击球手可以采用两个纯策略之一应对投球手的混合策略,这两个纯策略给投球手最小化击球平均分的能力施加了一个下限。
$$
\begin{align}
\text{minimize}~& A \\[0.5em]
\text{s.t.}~
& A > 0.4p_{1} + 0.2(1-p_{1})\\
& A > 0.1p_{1} + 0.3(1-p_{1})
\end{align} \tag{3.10.6}
$$
其中
* $q$:投球手投出快球的比例
* $1-q$:投球手投出弧线球的比例
**例3.10** 企业经营策略博弈
企业的经营活动可以被视作一种博弈,其目标是找到一种策略,无论经济环境如何变化,都能够保障企业获得稳定的经营结果。

在这个模型中,$V$ 代表企业的净利润,$x$ 表示企业采用小规模生产策略的比例,$(1-x)$ 则代表企业采用大规模生产策略的比例。
当经济环境较为不利时,企业的利润满足 $V<500x+100(1-x)$;而当经济环境较为有利时,企业的利润满足 $V<300x+900(1-x)$。
因此,我们可以将这一问题总结为以下的线性规划优化问题:
$$
\begin{align}
\text{maximize}~& V \\[0.5em]
\text{s.t.}~
& V<500x+100(1-x)\\
& V<300x+900(1-x)\\
& 0500y+300(1-y)$企业采用纯小规模生产的策略;
当经济采用差策略时,企业的利润满足$V>500y+300(1-y)$;而当经济采用好策略时,企业的利润满足 $V>100y+900(1-y)$。
因此,我们可以将这一问题总结为以下的线性规划优化问题:
$$
\begin{align}
\text{maximize}~& V \\[0.5em]
\text{s.t.}~
& V>500y+300(1-y)\\
& V>100y+900(1-y)\\
& 0 例3.13参考,特别鸣谢!
**例3.13** 以电动出租车与换电站为例,假设电动出租车及换电站均属于同一家公司,公司想通过换电站价格定价措施去控制目标区域内的出租车数量达到预期分布。
对于司机而言,有两个成本,一个是距离成本$d$,一个是支付成本$p$,支付成本即是换电地所支付的电价,我们可以设立权重因子$a$将两者合并构建为一个效用函数,司机会选择该函数最小的换电站更换电池,更换电池后可机一般会在周围开始接单:
$$U_n =a_1\cdot d_n^i +a_2\cdot p_i, \tag{3.10.14}$$
对于公司而言,目标函数则是不同地区的出租车实际分布e与期望分布E的绝对差之和,公司通过调整价格去影响司机的选择,从而调整司机在不同区域的分布:
$$
\min_{\boldsymbol{p}} \sum\limits_{i=1}^{m} |e_{i} - E_{i}|. \tag{3.10.15}
$$
出租车与换电站的安排:
* 第一阶段,充电站统计出各电动出租车的换电请求后,根据优化目标,制定价格策略
* 第二阶段,电动出租车根据自身效用函数从所有换电站中选择出目标换电站进行跟换电池
* 第一阶段和第二阶段交替往复进行,直到达到均衡
代码实现如下:
```python
#导入相关库
import numpy as np
import math
import random
from collections import Counter
import matplotlib.pyplot as plt
#解决图标题中文乱码问题
import matplotlib as mpl
mpl.rcParams['font.sans-serif'] = ['SimHei'] # 指定默认字体
mpl.rcParams['axes.unicode_minus'] = False # 解决保存图像是负号'-'显示为方块的问题
#初始化各参数
n=900 #换电需求数
min_price=170 #换电价格范围
max_price=230
A=np.random.normal(36, 5, 25) #初始期望,平均值为36,方差为5的高斯分布
E=np.floor(A)#朝0方向取整,如,4.1,4.5,4.8取整都是4
# 下面是根据需求数调整E的大小
E=np.floor(A)
a=sum(E)-n
A=A-a/25
E=np.floor(A)
b=sum(E)-n
A=A-b/25
E=np.floor(A)
a1=0.05;a2=0.95;#距离成本与换点价格权重
x=[random.random()*20000 for i in range(n)]#初始化需求车辆位置
y=[random.random()*20000 for i in range(n)]
H=np.mat([[2,2],[2,6],[2,10],[2,14],[2,18],
[6,2],[6,6],[6,10],[6,14],[6,18],
[10,2],[10,6],[10,10],[10,14],[10,18],
[14,2],[14,6],[14,10],[14,14],[14,18],
[18,2],[18,6],[18,10],[18,14],[18,18]])*1000
# 制初始化的司机与换电站的位置图
plt.plot(x,y,'r*')
plt.plot(H[:,0],H[:,1],'bo')
plt.legend(['司机','换电站'], loc='upper right', scatterpoints=1)
plt.title('初始位置图')
plt.show()
# 计算期望与实际期望
D=np.zeros((len(H),n)) #需求车辆到各换电站的需求比例
price=200*np.ones((1,25))
for i in range(len(H)):
for j in range(len(x)):
D[i,j]=a1*np.sqrt(((H[i,0]-x[j]))**2+(H[i,1]-y[j])**2)+a2*price[0,i]
D=D.T #转置
D=D.tolist() #转为列表格式
d2=[D[i].index(np.min(D[i])) for i in range(n)]
C = Counter(d2)
e=list(C.values())
err=sum(abs(E-e)) #期望差之和,即博弈对象
#博弈过程
J=[] #价格变化的差值
ER=[err] #E-e的变化差值
for k in range(1,100):
j=0
for i in range(25):
if e[i] < E[i] and price[0,i] >= min_price:
price[0,i] = price[0,i]-1
j=j+1
if e[i] > E[i] and price[0,i] <= max_price:
price[0,i] = price[0,i]+1
j=j+1
J.append(j)
DD=np.zeros((len(H),n)) #需求车辆到各换电站的需求比例
# price=200*np.ones((1,25))
for i in range(len(H)):
for j in range(len(x)):
DD[i,j]=a1*np.sqrt(((H[i,0]-x[j]))**2+(H[i,1]-y[j])**2)+a2*price[0,i]
DD=DD.T #转置
DD=DD.tolist() #转为列表格式
dd2=[DD[i].index(np.min(DD[i])) for i in range(n)]
C = Counter(dd2)
e=[C[i] for i in sorted(C.keys())]
err=sum(abs(E-e)) #期望差之和,即博弈对象
ER.append(err)
#绘制图
plt.plot(ER,'-o')
plt.title('E-e的差值变化')
# plt.set(gcf,'unit','normalized','position',[0.2,0.2,0.64,0.32])
plt.legend('E-e')
# plt.grid(ls=":",c='b',)#打开坐标网格
plt.show()
plt.plot(J,'r-o')
plt.title('价格的差值变化')
plt.xlabel('Iterations(t)')
plt.legend('sum of Price(t)-Price(t-1)')
# plt.grid(ls=":",c='b',)#打开坐标网格
plt.show()
plt.bar(x = range(1,26), # 指定条形图x轴的刻度值
height=price[0],
color = 'steelblue',
width = 0.8
)
plt.plot([1,26],[min_price,min_price],'g--')
plt.plot([1,26],[max_price,max_price],'r--')
plt.title('换电站的换电价格')
plt.ylabel('Price(¥)')
plt.axis([0,26,0,300])
# plt.grid(ls=":",c='b',)#打开坐标网格
plt.show
index = np.arange(1,26)
rects1 = plt.bar(index, e, 0.5, color='#0072BC')
rects2 = plt.bar(index + 0.5, E, 0.5, color='#ED1C24')
plt.axis([0,26,0,50])
plt.title('出租车的预期和实际数量')
plt.ylabel('E and e')
# plt.grid(ls=":",c='b',)#打开坐标网格
plt.xlabel('换电站')
plt.legend(['e','E'])
plt.show()
```




### 3.10.2 排队论
排队论,又称随机服务系统理论,是应用数学的一个分支,专门研究和解决拥挤现象问题。其研究内容主要涵盖以下三个方面:
1. 性态问题:主要研究各种排队系统的概率规律性,包括队列长度分布、顾客等待时间分布以及系统忙期分布等。性态问题包括瞬态和稳态两种情形。
2. 最优化问题:包括静态最优和动态最优。静态最优指的是最佳系统设计,即如何设计排队系统以最大化效率或最小化成本。而动态最优则涉及最佳运营策略,即在实际运行中如何调整服务机制以优化系统性能。
3. 统计推断问题:主要用于判断排队系统的类型,以便进行进一步分析和优化。
在排队系统中,顾客从顾客源(总体)出发,到达服务机构(服务台、服务员)前排队等候接受服务,服务完成后再离开。排队结构指的是队列的数量和排列方式,而排队规则和服务规则则说明了顾客按照何种规则和次序接受服务。

一个排队系统包括:
1. 在一定时间内顾客平均到达多少?
2. 按什么规律到达(输入过程服从什么分布)?
3. 进入系统的顾客按照什么规则排队?
4. 服务机构设置多少服务设施?排列形式?
5. 服务时间服从什么分布?
表1:表示相继到达间隔时间和服务时间的各种分布的符号
符号 含义
输入
顾客到达分布
输出
服务时间分布 M
(Markov) 负指数分布
Negative Exponential
D 确定型Deterministic
GI 一般相互独立分布
General Independent
Ek K阶爱尔朗分布
Erlang distribution
G 一般服务时间分布
General
服务规则 FCFS 先到先服务
LCFS 后到先服务
PR 带优先权服务
SIRO 随机服务
排队系统的要素:
* $X$: 相继到达的间隔时间的分布,一般为负指数分布
* $Y$: 服务时间的分布,一般为负指数分布或者确定性
* $Z$: 服务台的数目,1台或者多台
* $A$: 系统客量的限制,系统中是否存在顾客的最大数量限制
* $B$: 顾客源数目,顾客源是否有限
* $C$: 服务规则,先到先服务或者后到先服务等
| 数量指标 | 符号 | 含义 |
| ---- | --- | --- |
|队长 |$L_s$ |系统中的平均顾客数|
|等待队长的数量| $L_q$ |系统中处于等待的顾客的数量|
|平均逗留时间 |$W$| 顾客进入系统到离开系统的这段时间|
|平均等待时间| $W_a$| 顾客进入系统到接受服务的这段时间|
|平均到达率 |$\lambda$ |单位时间内平均到达的顾客数|
|平均服务率| $\mu$ |单位时间内受到服务的顾客平均数|
|单个服务台的服务强度| $p$ |每个服务台在单位时间内的服务强度|
|平均负荷 |$\rho$ |单个服务台在单位时间内的平均负荷|
智能仓库中配置多个搬运机器人,中心控制系统接收到订单后,经过分析拆解为相应的拣选任务,然后根据任务优先级,通过一定的分配算法,将任务分配给当前处于空闲状态的搬运机器人。这里,我们将订单看作顾客,搬运机器人看作服务台,不考虑系统对订单的处理及任务分配过程。那么,整个系统可以抽象为一个多服务台排队系统(M/M/C)。
对于仓库机器人数量分析问题,若只考虑订单到达和机器人搬运的过程,那么可以将该问题简化为排队论中的多服务台排队系统(M/M/N),前两个M表示顾客到达时间间隔和机器人服务时间均服从负值数分布,C表示为多个服务台,所以M/M/1即表示单服务台系统。基本的思路是:将拣选机器人看作服务台,因为仓库中是由多个搬运机器人组成的多机器人系统,所以是多个服务台;此外,假设顾客(即订单)到达时间间隔和机器人服务时间都服从负值数分布。通过以上简化,即可将仓库机器人数量需求分析问题简化为M/M/C问题。
排队论中有几个重要的指标:系统服务强度$\rho$,系统空闲概率$p_{0}$,系统平均排队等待的订单数$L_q$,系统中平均订单数(包括排队等待和正在接受服务的订单)$L_s$,系统中订单的平均等待时间$W_q$,系统中订单的平均逗留时间(包括等待时间和接受服务时间)$W_s$,上面几个指标的计算公式如下:
$$
\begin{align}
\rho &= \frac{\lambda}{C\mu}, \tag{3.10.16}\\
p_{0} &= \left[ \sum\limits_{k=1}^{C-1} \frac{1}{k!}\left( \frac{\lambda}{\mu} \right) + \frac{1}{C!(1-\rho)}\left( \frac{\lambda}{\mu} \right)^{C} \right]^{-1}, \tag{3.10.17}\\
L_{q} &= \frac{(C\rho)^{C}\rho}{C!(1-\rho)^{2}}\cdot p_{0}, \tag{3.10.18}\\
L_{s} &= L_{q} + \frac{\lambda}{\mu}, \tag{3.10.19}\\
W_{q} &= \frac{L_{q}}{\lambda}, \tag{3.10.20}\\
W_{s} &= \frac{L_{s}}{\lambda}. \tag{3.10.21}
\end{align}
$$
输入$300$,$12$,$20$,即达到率为$300$个/小时,服务率为$12$个/小时,机器人数量为$30$,执行代码后可得到结果:
```python
def factorials(x):
'''
利用递归的阶乘计算函数
:return:
'''
if x == 0 or x == 1:
return 1
else:
return x * factorials(x - 1)
class QueuingTheory(object):
def __init__(self, ar, sr, snum):
'''
排队论模型初始化
:param ar:顾客到达率
:param sr:机构服务率
:param snum:服务台数量
'''
self.ar = ar
self.sr = sr
self.snum = snum
self.ro = self.ar / self.sr
self.ros = self.ar / (self.sr * self.snum)
self.p0 = self.P0_Compute() #系统中所有机器人空闲的概率
self.cw = self.CW_Compute() #系统中订单排队的概率
self.lq = self.Lq_Compute() #系统中排队等待的平均订单数
self.ls = self.lq + self.ro #系统中的平均订单数
self.rw = (self.snum - self.ls) / self.snum #系统中单个机器人的平均空闲率
self.ws = self.ls / self.ar #系统中订单的平均等待时间
self.wq = self.lq / self.ar #系统中订单的平均排队时间
def P0_Compute(self):
'''
系统中所有机器人空闲的概率
:return:
'''
result = 0
ro, ros = self.ar / self.sr, self.ar / (self.sr * self.snum)
for k in range(self.snum):
result += ro ** k / factorials(k)
result += ro ** self.snum / factorials(self.snum) / (1 - ros)
return 1/result if (1/result)>0 else 0
def CW_Compute(self):
'''
订单排队的概率
:return:
'''
ro, ros = self.ar / self.sr, self.ar / (self.sr * self.snum)
return ro ** self.snum * self.p0 / factorials(self.snum) / (1 - ros)
def Lq_Compute(self):
'''
排队等待的平均订单数
:return:
'''
ros = self.ar / (self.sr * self.snum)
return self.cw * ros / (1- ros)
def main():
ar, sr, snum = list(map(float, input('请输入系统到达率,服务率和服务台数量').split(',')))
snum = int(snum)
myqueuing = QueuingTheory(ar, sr, snum)
print('系统中所有机器人空闲的概率为%6.3f'% myqueuing.p0)
print('系统中订单排队的概率为%6.3f' % myqueuing.cw)
print('系统中单个机器人的平均空闲率为%6.3f' % myqueuing.rw)
print('系统中排队等待的平均订单数为%6.3f' % myqueuing.lq)
print('系统中的平均订单数为%6.3f' % myqueuing.ls)
print('系统中订单的平均排队时间为%6.3f分钟' % (myqueuing.wq * 60))
print('系统中订单的平均等待时间为%6.3f分钟' % (myqueuing.ws * 60))
print('系统总成本为%6.3f元' % (1000*snum + myqueuing.lq*100))
if __name__ == '__main__':
main()
```
结果表明系统中平均同时存在 25 个订单,包括正在接受服务的和排队等待服务的订单。每个订单平均等待 5 分钟才能开始接受服务。最终系统的总成本为20000元。
## 3.11 多目标规划
一直到2.5节为止我们已经见到了各种各样的规划,当读者将这些内容全部读完并能有针对性地进行思考和扩展时,你已经具备了能够解一定难度规划题的能力。多目标规划实际上也并不难理解,就是说目标函数可能不止一个。譬如,当你进行投资选股的时候,并不会简单地考虑收益最大,还会考虑风险。在市场上,no risk, no gain,但这个风险你并不一定承担得起,所以你会综合考虑多个优化目标。有关投资选股的策略我们在后续章节中也会简单介绍一些策略,但现在我们的目的是考虑如何解决多目标规划。
从非线性规划的标准形式出发,我们定义一个二目标规划(其实多目标的策略和二目标也是一样的):
$$
\begin{align}
\text{minimize}~& f(x), g(x) \\[0.5em]
\text{s.t.}~
& Ax \leqslant b\\
& C(x) \leqslant 0\\
& A_{\text{eq}}x = b_{\text{eq}}\\
& C_{\text{eq}}(x) = 0\\
& l_{b} \leqslant x \leqslant u_{b}
\end{align} \tag{3.11.1}
$$
多目标规划最常见的思路就是去将多目标问题转化为单目标问题求解,那么就需要对二目标进行综合。要想把它们综合起来也很容易,我可以根据经验或者资料取一个合适的常数对其进行加权求和,像这样:
$$
\begin{align}
\text{minimize}~& f(x) + \lambda g(x) \\[0.5em]
\text{s.t.}~
& Ax \leqslant b\\
& C(x) \leqslant 0\\
& A_{\text{eq}}x = b_{\text{eq}}\\
& C_{\text{eq}}(x) = 0\\
& l_{b} \leqslant x \leqslant u_{b}
\end{align} \tag{3.11.2}
$$
通过这种手段把它变成一个单目标规划就好解决了。而常数的取值我们也可以测试不同的数值,在后续也可以探讨最优解与这个常数取值之间的关系(它往往被视作一种灵敏性分析)。
另一种常见的手段是取乘积或者取比值。比如如果我们想最小化风险R并最大化收益E,我们可以最大化这样一个目标函数:
$$\text{maximize}~ \frac{E(x)}{R(x)}, \tag{3.11.3}$$
后续章节也会看到这两种综合策略在投资选股问题中的不同。
另一种常见的方法叫*理想点法*,它基于这样一个事实:与最优解越近的点,其目标函数值往往也越接近最优值。所以在可行域内可以分别求两个目标的最优解,然后再在可行域内找点,让这个点到两个目标最优解的距离之和最小。可以看下面这个例子:
**例3.14** 某公司考虑试生产两种太阳能电池甲和乙。但生产这两种产品会引起空气污染,因此,有两个目标:极大化利润和极小化总的污染,已经每单位产品收益、污染排放量、机器能力(小时)装配能力(人时)和可用的原材料(单位)的限制如下表所示。假设市场需求无限制,两种产品的产量和至少为10,则该公司应如何安排一个生产周期的生产?
单位甲产品 单位乙产品 资源限量
设备工时 0.5 0.25 8
工人工时 0.2 0.2 4
原材料 1 5 72
利润 2 3
污染排放 1 2
在这个场景中,我们有两个目标:极大化利润和极小化污染。我们需要在给定的资源限制下,确定两种太阳能电池甲和乙的产量,使得这两个目标得到满足。
设甲产品的产量为 xA 单位,设乙产品的产量为xB单位。
目标函数:
* 极大化总利润:maxP = 2xA + 3xB)
* 极小化总污染:min E = xA + xB)
约束条件:
* 机器能力限制:0.5xA+ 0.2xB≤ 8
* 工人工时限制:0.2xA + 4xxB ≤ 4
* 原材料限制: xA + 5xB≤72
* 产量限制: xA,xB≥ 10
```python
import numpy as np
import cvxpy as cp
c1 = np.array([-2, -3])
c2 = np.array([1, 2])
a = np.array([[0.5, 0.25], [0.2, 0.2], [1, 5], [-1, -1]])
b = np.array([8, 4, 72, -10])
x = cp.Variable(2, pos=True)
# 1.线性加权法求解
obj = cp.Minimize(0.5*(c1+c2)@x)
con = [a@x <= b]
prob = cp.Problem(obj, con)
prob.solve(solver='GUROBI')
print('\n======1.线性加权法======\n')
print('解法一理想解:', x.value)
print('利润:', -c1@x.value)
print('污染排放:', c2@x.value)
# 2.理想点法求解
obj1 = cp.Minimize(c1@x)
prob1 = cp.Problem(obj1, con)
prob1.solve(solver='GUROBI')
v1 = prob1.value # 第一个目标函数的最优值
obj2 = cp.Minimize(c2@x)
prob2 = cp.Problem(obj2, con)
prob2.solve(solver='GUROBI')
v2 = prob2.value # 第二个目标函数的最优值
print('\n======2.理想点法======\n')
print('两个目标函数的最优值分别为:', v1, v2)
obj3 = cp.Minimize((c1@x-v1)**2+(c2@x-v2)**2)
prob3 = cp.Problem(obj3, con)
prob3.solve(solver='GUROBI') # GLPK_MI 解不了二次规划,只能用CVXOPT求解器
print('解法二的理想解:', x.value)
print('利润:', -c1@x.value)
print('污染排放:', c2@x.value)
# 3.序贯法求解
con.append(c1@x == v1)
prob4 = cp.Problem(obj2, con)
prob4.solve(solver='GUROBI')
x_3 = x.value # 提出最优解的值
print('\n======3.序贯法======\n')
print('解法三的理想解:', x_3)
print('利润:', -c1@x_3)
print('污染排放:', c2@x_3)
```
**例3.15** 求解多目标规划:

理想点法:
```python
from scipy.optimize import linprog
import numpy as np
c = [-3, 4] # 注意:c的顺序应与变量x的顺序对应,即c[0]对应x[0],c[1]对应x[1]
A = np.array([[2, 3], [2, 1]])
b = np.array([18, 10])
x_0_bounds = (0, None) # x_0的界限是[0, +∞)
x_1_bounds = (0, None) # x_1的界限是[0, +∞)
res = linprog(c, A_ub=A, b_ub=b, bounds=[x_0_bounds, x_1_bounds], method='highs')
if res.success:
print("找到最优解:")
print("x_0 =", res.x[0])
print("x_1 =", res.x[1])
print("目标函数值:", -res.fun)
else:
print("优化未成功:", res.message)
```
多目标规划还有序贯法等一系列方法可以求解,但求解得到的结果却未必是我们最想要的。因为在多目标规划的时候,目标之间不同的人有不同的权衡。比如投资当中,有人更希望收益更大,宁可冒一点风险;但有一些人小心驶得万年船,更看重风险。莎士比亚说,一千个读者就有一千个哈姆雷特,如何对目标做综合其实是一件富有主观性的工作。所以在多目标规划的问题上,我个人其实更倾向于用加权法做综合来解这类问题。
## 3.12 Monte Carlo模拟
Monte Carlo方法则是另一种求解规划时常用的方法。它基于一个事实:大量的重复试验下频率可以估计概率,也就是用大规模的候选解模拟出一个近似值逐步逼近精确解。理论上只要实验次数够多精度够细它可以无限逼近精确解。
我想各位上中学的时候还没忘记蒲丰投针估计圆周率的故事吧,如果不幸忘记了,也还记得撒黄豆估计圆周率的方法吧。在一个正方形中画一个内切圆,往正方形内撒一大把黄豆,通过数出圆里面的黄豆和正方形里面的黄豆之比可以估计圆周率的近似值。这一原理也被广泛用于求函数的定积分。图2.3是一个利用Monte Carlo方法求定积分的例子,通过统计方形中点的个数和曲线下方点的个数之比,就可以近似模拟定积分与方形面积之比。

图2.3 利用Monte Carlo方法求定积分的例子
Monte Carlo方法求线性规划的近似最优解我们可以看一个例子:
**例3.16** 求解下面的线性规划:
将Monte Carlo方法和这个规划都做成函数可以写成如下形式:
```python
from numpy import random as rd
N = 1000000
x_2 = rd.uniform(10, 20.1, N)
x_1 = x_2 + 10
x_3 = rd.uniform(-5, 16, N)
f = float('-inf')
for i in range(N):
if -x_1[i]+2*x_2[i]+2*x_3[i]>=0 and x_1[i]+2*x_2[i]+2*x_3[i]<=72:
result = x_1[i] * x_2[i] * x_3[i]
if result>f:
f = result
final_X = [x_1[i], x_2[i], x_3[i]]
print("""最终得出的最大目标函数值为:%.4f
最终自变量取值为:
x_1 = %.4f
x_2 = %.4f
x_3 = %.4f""" % (f, final_X[0], final_X[1], final_X[2]))
```
================================================
FILE: docs/CH4/第4章-复杂网络与图论模型.md
================================================
第4章 复杂网络与图论模型
> 内容:@若冰(马世拓)
>
> 审稿:@邢硕
>
> 排版&校对:@若冰
这一章我们主要介绍复杂网络与图论模型。图论模型不同于以前我们印象中的平面几何图,图论模型的边与点往往只是描述一种拓扑关系,所以并不能用传统平面几何的视角去定义复杂网络。复杂网络的研究领域很广,并且虽然复杂网络和图论二者研究对象相同,但它们探讨的实际上是一个对象的不同方面。我们更多的是做图论模型,会介绍几个经典的问题及其解决算法。本章主要涉及到的知识点有:
- 复杂网络的研究对象
- 最短路径问题
- 最小生成树问题
- 网络最大流问题
- TSP问题和VRP问题
> 注意:本章内容与前面的整数规划也经常会联合在一起考察,并且后面的群体智能算法也在图论中有典型应用。在TSP问题和VRP问题中我们就可能会用到后面第九章讲到的进化计算与群体智能算法。
## 4.1 复杂网络概念与理论
复杂网络与图论,听起来可能有点复杂,其实它们都是研究事物之间关系的好工具。你可以把“网络”想象成一群人和他们之间的朋友关系,或者电脑之间的连接线。而“图论”呢,就是用数学和图形的方式来研究这些关系。比如说,你想知道在一群朋友中,谁是最受欢迎的人,或者在一个复杂的电脑网络中,哪部分是最关键的。这时候,复杂网络与图论就能帮上大忙了。它们能帮你理清这些关系,找到重要的节点或者路径,让你更好地理解和利用这些网络。简而言之,复杂网络与图论就是帮助我们分析和理解复杂关系的好帮手。
### 4.1.1 复杂网络中的基本概念
复杂网络与图论通过节点和边的数学结构,自然地描述了实体间的关系和相互作用。它不仅能处理线性关系,还能捕捉非线性和复杂相互作用。例如,在社交网络分析中,图论可用来识别社区结构、评估影响力和推荐潜在联系。图论还能有效处理不确定性。利用概率图模型和随机过程,我们可以在图论框架下对不确定性进行建模和分析。这使得图论成为解决现实世界问题的理想工具,如网络可靠性和风险评估。

图论图并不侧重几何量与几何关系,更侧重节点之间逻辑层面的关联关系,是从欧几里得空间到非欧几里得空间的扩展。图论的研究最早起源于欧拉提出的哥尼斯堡七桥问题,也就是能否一笔画走完,自此对图形的研究不再只是单纯考虑其几何关系而是更多地考察拓扑特性。图论图实际上就是一个复杂网络,它同样具有节点、边,分为有向图和无向图。
在化学领域,图论发挥着至关重要的作用。想象一下,分子中的原子就像图中的节点,而原子之间的化学键则如同图中的边。图论可以帮助化学家们更直观地理解分子结构,例如通过构建分子的拓扑关系模型,分析分子内部的连接方式和稳定性。这就像我们用地图来理解和规划城市结构一样,图论帮助我们揭示分子内部的奥秘。
在社会科学领域,图论同样大显身手。社交网络,就像是一张巨大的关系网,每个人都是一个节点,人与人之间的联系就是边。图论可以帮助我们分析社交网络中的关系模式,比如谁是社交网络中的关键人物,信息的传播路径是怎样的。这就像我们用网络图来解析人际关系一样,图论帮助我们更好地理解和预测社会现象。
推荐系统,看似复杂,其实也可以从图论的角度进行研究。想象一下,每个物品或服务都是一个节点,而用户对物品的喜好或购买记录就是边。图论可以帮助我们找到物品之间的相似性,从而为用户推荐他们可能感兴趣的物品。这就像我们用图来找到相似的电影或音乐一样,图论让推荐系统更加智能和精准。
计算机网络,作为现代信息社会的基石,也离不开图论的建模。在网络中,每台计算机或设备都可以看作是一个节点,它们之间的连接就是边。图论可以帮助我们分析网络的拓扑结构,优化数据的传输路径,提高网络的稳定性和效率。这就像我们用图来规划交通网络一样,图论确保计算机网络能够高效、稳定地运行。
在图论中,图是由节点和边组成的。节点就像是地图上的城市或者地标,代表着某个具体的事物或对象。而边则像是连接城市的道路,代表着节点之间的关系或连接。你可以想象一个社交网络图,每个用户是一个节点,而用户之间的好友关系就是边。
无向图和有向图是图的两种基本类型。在无向图中,边没有方向,就像一条普通的道路,你可以从A地到B地,也可以从B地到A地。而在有向图中,边是有方向的,就像一条单行道,你只能按照箭头指示的方向行驶。例如,在交通网络中,单行道就是有向边的一个例子。
节点的度是图论中的一个重要概念,它表示与该节点相连的边的数量。在无向图中,节点的度就是与其相连的边数。而在有向图中,节点的度又分为入度和出度。入度是指指向该节点的边的数量,就像你收到多少条消息;出度则是从该节点指出的边的数量,就像你发出了多少条消息。通过这些度的概念,我们可以更好地理解节点在图中的重要性和角色。
欧拉回路是图论研究中最早的相关问题之一。如果图G(有向图或者无向图)中所有边一次仅且一次行遍所有顶点的通路称作欧拉通路;如果图G中所有边一次仅且一次行遍所有顶点的回路称作欧拉回路。
判断图是否存在欧拉回路的方法也很简单,对于有向图而言图连通,所有的顶点出度=入度;对于无向图而言,图连通,所有顶点都是偶数度。
欧拉回路是不重复遍历边,而哈密顿回路的要求更加严苛一些,需要不重复遍历节点。对于哈密顿回路而言,这是数学、拓扑学、计算机科学领域研究十分火热的一个话题。图G的一个回路,若它通过图的每一个节点一次,且仅一次,就是哈密顿回路,存在哈密顿回路的图就是哈密顿图。关于图存在哈密顿回路的充分必要条件,很不幸,目前学界还没有找到。
### 4.1.2 复杂网络中的统计指标
复杂网络学者在考虑网络的时候,往往只关心节点之间有没有边相连,至于节点到底在什么位置,以及具体的几何关系等都是他们不在意的。在这里,他们把网络不依赖于图具体的几何位置与几何形态的性质叫做网络的拓扑性质,相应的结构叫做网络的拓扑结构。那么,什么样的拓扑结构比较适合用来描述真实的系统呢?两百多年来,这个问题的研究经历了三个阶段。在最初的一百多年里,数学家们认为真实系统各因素之间的关系可以用一些规则的结构表示,如最近邻环网;到了二十世纪五十年代末,数学家们想出了一种新的构造网络的方法,在这种方法下,两个节点之间连边与否不再是确定的事情,而是根据一个概率决定,数学家把这样生成的网络叫做随机网络;直到最近几年,由于计算机数据处理和运算能力的飞速发展,科学家们发现大量的真实网络既不是规则网络,也不是随机网络,而是具有与前两者皆不同的统计特征的网络。这样的一些网络被科学家们叫做复杂网络(Complex Networks),对于它们的研究标志着第三阶段的到来。
下面我们介绍图论当中一些基本概念:
- 连通分支:连通分支是一个图论中的概念。在一张图中,有时候一些节点可以通过一些边相互连接,形成一个“团”。这些相互连接的节点和边就构成了一个连通分支。简单来说,连通分支就是图中所有能够相互到达的节点的集合。如果一个图里所有的节点都属于同一个连通分支,那么这个图就被称为连通图;反之,如果图中有多个连通分支,那么这个图就是非连通图。
- 聚类系数(Clustering Coefficient):聚类系数是图论中用来衡量一个图中节点聚集程度的指标。它计算的是图中每个节点的邻居节点之间实际存在的边数与可能存在的最大边数之间的比值。公式如下:
$$ \text{Clustering Coefficient of Node } i = \frac{\text{Number of Edges Among Neighbors of Node } i}{\text{Maximum Possible Number of Edges Among Neighbors of Node } i} $$
举个例子,假设一个节点有3个邻居节点,那么这3个邻居节点之间最多可能有3条边(每个节点与其他两个节点都相连)。如果这3个邻居节点之间实际上只有2条边,那么这个节点的聚类系数就是2/3。整个图的聚类系数通常是所有节点聚类系数的平均值。聚类系数越高,说明图中的节点越倾向于形成紧密的团簇。
复杂网络本质上只需要对点与点之间的连接关系做描述就可以了,比如一条边是从哪个点到哪个点这样的关系。如果读者朋友学过数据结构,应该知道一个网络图在数据结构中可以用邻接表、邻接矩阵、十字链表等形式表示。而在这些数据结构中,以邻接表和邻接矩阵最为重要。邻接表的每一行表示一个节点,每一列表示一条边,每一项表示以该节点为端点的边权值是多少;而邻接矩阵的每一行每一列都是一个节点,矩阵中的第(i,j)项就表示节点i与节点j之间是否有边连接,如果有,这个边的权重是多少。
- 度分布:大多数实际网络中的节点的度是满足一定的概率分布的。定义P(K)为网络中度为k的节点在整个网络中所占的比例。
- 规则网络:由于每个节点具有相同的度,所以其度分布集中在一个单一尖峰上,是一种Delta分布。
- 完全随机网络:度分布具有Poisson分布的形式,每一条边的出现概率是相等的,大多教节点的度是基本相同的,并接近于网络平均度k,远离峰值k,度分布则按指数形式急剧下降。把这类网络称为均匀网络
- 无标度网络: 具有幂指数形式的度分布。所谓无标度是指一个概率分布函数F(~)对于任意给定常数a存在常数b使得F(x)满足F(ax)=bF(x)。幂律分布是唯一满足无标度条件的概率分布函数。许多实际大规模无标度网络,其幂指数通常为2 0为一常数。
- 累计度分布:可以用累积度分布函数来描述度的分布情况,它与度分布的关系为
$$
P_k=\sum_{x=k}^{\inf} P(x)
$$
它表示度不小于k的节点的概率分布。
若度分布为幂律分布$P(k)=k^{-\gamma}$,则相应的累计度分布函数符合幂指数为$\gamma-1$的幂律分布
若度分布为指数分布,则相应的累积度分布函数符合同指数的指数分布
- 网络的直径和平均距离:网络中的两节点vi和vj之间经历边数最少的一条简单路径(经历的边各不相同),称为测地线。测地线的边数dij称为两节点vi和vj之间的距离 (或叫测地线距离)。1/dij称为节点和之间的效率,记为eij。通常效率用来度量节点间的信息传递速度。当和之间没有路径连通时,dij = 而Sj = 0,所以效率更适合度量非全连通网络.
- 网络的直径D定位为所有距离dij中的最大值
- 平均距离(特征路径长度)L定义为所有节点对之间距离的平均值,它描述了网络中节点间的平均分离程度,即网络有多小,计算公式为
$$
L=\frac{1}{N^2}\sum{i=1}^N \sum_{j=1}^N d_{ij}
$$
对于无向简单图来说,dij=dij且di=0,则上式可简化为
$$
L=\frac{2}{N(N+1)}\sum{i=1}^N \sum_{j=1+i}^N d_{ij}
$$
很多实际网络虽然节点数巨大,但平均距离却小得惊人,这就是所谓的小世界效应。
- 集聚系数:集聚系数用以捕获给定节点的邻居节点之间的连接程度。节点的集聚系数定义为:设节点vi和ki个节点直接连接,那么对于无向网络来说,这ki个节点间可能存在的最大边数为;ki(ki-1)/2,而实际存在的边数为M;,由此我们定义$C_i=2M_i/[k_i(k_i-1)]$为节点Vi的集聚系数。
对于有向网络来说,这k;个节点间可能存在的最大弧数为$k_i(k_i-1)$,此时v的集聚系数$C_i=M_i/[k_i(k_i-1)]$。将该集聚系数对整个网络作平均,可得网络的平均集聚系数为$\bar{C}=\frac{1}{N} \sum_{i=1}^N C_i$
显然,O≤C≤1。当C=0,所以节点都是孤立节点,没有边连接。当C =1时,网络为所有节点两两之间都有边连接的完全图。
- 网络密度
网络密度指的是一个网络中各节点之间联络的紧密程度。网络G的网络密度d(G)定义为
$$d(G)=2M/[N(N-1)]$$
式中,M为网络中实际拥有的连接数,N为网络节点数。
网络密度的取值范围为[0,1],当网络内部完全连通时,网络密度为1,而实际网络的密度通常远小于1,实际网络中能够发现的最大
密度值是0.5。
- 度中心性:度中心性分为节点度中心性和网络度中心性。前者指的是节点在其与之直接相连的邻居节点当中的中心程度,而后者则侧重节点在整个网络的中心程度,表征的是整个网络的集中或集权程度,即整个网络围绕一个节点或一组节点来组织运行的程度。节点V的度中心性Cp(v;)定义为
$$Cp(v₁)=ki/(N-1)$$
- 介数中心性:介数中心性分为节点介数中心性和网络介数中心性。节点V的介数中心性Cg(v;)定义为
$$CB(v₁)=2Bi/[(N-2)(N-1)]$$
与度中心性类似,可得到H=N-1(也是星型网络,中心节点的介数中心性为1,其它节点的介数中心性为0)。
- 接近度中心性:对于连通图来说,节点v的接近度中心性Cc(v;)定义为
$Cc(u)=(N-1)/E;=tj≠d;$
- 特征向量中心性:对于图的邻接矩阵进行特征值分解:
$Ax =λa$
## 4.2 图论算法:遍历,二分图与最小生成树
### 4.2.1 图的遍历算法
图的遍历,简单来说,就是走遍图中的所有节点,确保不遗漏任何一个。想象一下,你手里有一张地图,上面有很多城市,城市之间有道路相连。遍历这张地图,就是要你走遍所有的城市,并且每座城市只去一次。这就像是一个探险游戏,你要按照某种规则,一步步探索地图上的每一个角落。
图遍历有多种方法,比如深度优先遍历和广度优先遍历。深度优先遍历就像是探险家从某个城市出发,一直沿着一条路走,直到走到不能再走为止,然后再返回来换一条路继续走。而广度优先遍历则更像是探险家先走遍所有的邻居城市,然后再去更远的地方。这些遍历方法在计算机科学中非常有用,因为它可以帮助我们了解图的结构,找到图中的特定信息,比如两个节点之间的最短路径等。在实际生活中,图的遍历也被广泛应用于社交网络分析、地图导航、电路设计等领域。
刚刚说到,图的遍历有两种方法,即深度优先和广度优先。图的深度优先遍历是一种用于图的遍历的算法思想,其基本思想是从图的某一顶点出发,沿着图的边尽可能深地搜索图的分支,直到该分支的末端,然后回溯到前一个顶点,再继续搜索下一个分支。这种遍历方式类似于树的先序遍历,总是优先探索当前顶点的未访问过的邻居顶点,直到所有的邻居顶点都被访问过,才返回上一层顶点继续遍历。深度优先遍历可以帮助我们找到图中的连通分量、生成树等结构,是图论和计算机科学中的重要算法之一。
图的遍历是计算机领域中的一个重要概念,主要有两种方法:深度优先遍历和广度优先遍历。本文将重点介绍深度优先遍历的原理、应用和特点,以便新手能够更容易地理解这一算法。
深度优先遍历是一种从图的某一顶点出发,沿着图的边尽可能深入地搜索图的分支,直到该分支的末端,然后回溯到前一个顶点,再继续搜索下一个分支的算法。这种遍历方式与树的先序遍历类似,总是优先探索当前顶点的未访问过的邻居顶点。当所有邻居顶点都被访问过后,算法返回上一层顶点继续遍历。
深度优先遍历的过程可以用以下几个步骤来描述:
1. 选择一个起始顶点,将其标记为已访问。
2. 访问一个与当前顶点相邻的未访问过的顶点,将其标记为已访问,并将其作为新的当前顶点。
3. 如果当前顶点没有未访问过的相邻顶点,回溯到上一个顶点,并将该顶点的相邻顶点重新标记为未访问。
4. 重复步骤2和3,直到所有顶点都被访问过。
为了帮助你们更好地理解这一概念,让我们通过一个生动的例子来说明。想象一下,你正在一个迷宫中探险,深度优先遍历就像是你的探险策略。你从入口开始,选择一条路径走下去,直到你到达一个死胡同或迷宫的尽头。然后,你返回到上一个分叉点,尝试另一条路径。你不断重复这个过程,直到你探索了迷宫的每一个角落。
深度优先遍历的应用非常广泛。在计算机科学中,它可以用于解决路径搜索问题,如在互联网中寻找数据包从源到目的地的最短路径。在社会科学中,深度优先遍历可以帮助我们分析社交网络的结构,了解信息是如何在网络中传播的。在生物学中,它可以用来研究生态系统中的食物网,了解物种之间的相互依赖关系。
我们可以看到下面的例子,如图所示:

在数学建模中,理解和掌握图搜索算法是至关重要的。今天,我们将专注于深度优先搜索(DFS)和广度优先搜索(BFS)这两种基本的图遍历技术。这些方法在解决从迷宫导航到社交网络分析等各种问题中都有广泛应用。
在Python中,实现DFS的直观方式是使用递归。这种方法涉及定义一个递归函数,该函数接受一个顶点作为参数,并访问该顶点,然后对每个未访问的邻接顶点递归调用自身。这种方法不需要额外的库,代码简洁且易于理解。
在Python中,我们可以使用递归或栈来实现深度优先搜索。递归实现深度优先搜索是最直观的方式。在递归实现中,我们通常会定义一个递归函数,该函数接收一个顶点作为参数,并访问该顶点。然后,它会对该顶点的每个未访问过的邻居顶点调用自身。它的实现不需要使用任何库,可以这样写:
```python
def dfs_recursive(graph, start, visited=None):
if visited is None:
visited = set()
visited.add(start)
print(start)
for next_node in graph[start] - visited:
dfs_recursive(graph, next_node, visited)
return visited
```
使用基于邻接表存储的图进行测试,结果如下:
```python
graph = {
'A': {'B', 'C'},
'B': {'A', 'D', 'E'},
'C': {'A', 'F'},
'D': {'B'},
'E': {'B', 'F'},
'F': {'C', 'E'},
}
print(dfs_recursive(graph, 'A'))
```
所得到的遍历顺序为ACFEBD.
另一种实现深度优先搜索的方法是使用栈。在这种方法中,我们维护一个栈来保存待访问的顶点。我们首先将起始顶点压入栈中,然后进入循环,直到栈为空。在每次循环中,我们弹出栈顶的顶点,并访问它。然后,我们将该顶点的所有未访问过的邻居顶点压入栈中。基于栈的实现如下所示:
```python
def dfs_iterative(graph, start):
visited = set()
stack = [start]
while stack:
vertex = stack.pop()
if vertex not in visited:
visited.add(vertex)
print(vertex)
stack.extend(neighbor for neighbor in graph[vertex] if neighbor not in visited)
return visited
```
所得到的遍历顺序为ABDEFC.
在两种实现方式中,我们都使用了一个集合visited来跟踪已经访问过的顶点,以避免重复访问。在递归实现中,visited集合是通过参数传递的,而在迭代实现中,它是在函数内部维护的。这里我们可以看到,图的深度优先搜索方式并不唯一。无论是递归还是非递归,只要能正确遍历就是可以的。
图的广度优先遍历(Breadth-First Search, BFS)是一种用于图的遍历的算法,其基本思想是从图的某一顶点出发,先访问该顶点的所有未访问过的邻居顶点,然后再依次访问这些邻居顶点的未访问过的邻居顶点,如此类推,直到所有可达的顶点都被访问过。这种遍历方式类似于树的层次遍历,总是优先访问离起始顶点近的顶点。
我们可以看到下面的例子:

BFS从起始顶点开始,访问所有直接相邻的未访问顶点,然后对这些顶点的邻居重复此过程,直到图中所有顶点都被访问。BFS可以通过递归或队列来实现。使用队列的实现方式更加直观,我们可以将待访问的顶点放入队列中,并在每一步中处理队列的前端顶点。
在Python中我们可以通过递归或队列实现广度优先搜索。在广度优先搜索中,我们首先访问起始顶点,然后访问所有相邻的未访问过的顶点,然后对每个这样的顶点,再访问它们的相邻的未访问过的顶点,如此进行下去,直到所有顶点都被访问过。这种策略可以用两种常见的方式来实现:递归和队列。
队列用于存储待访问的顶点。当我们访问一个顶点时,我们将它的所有未访问过的邻居加入队列的末尾。然后,我们不断从队列的头部取出顶点进行访问,直到队列为空。以下是一个使用队列实现广度优先搜索的示例:
```python
def BFS(graph, s):
queue = []
queue.append(s)
seen = set()
seen.add(s)
while len(queue) > 0:
vertex = queue.pop(0)
nodes = graph[vertex]
for node in nodes:
if node not in seen:
queue.append(node)
seen.add(node)
print(vertex)
```
同样使用邻接表进行测试:
```python
graph = {
'a' : ['b', 'c'],
'b' : ['a', 'c', 'd'],
'c' : ['a','b', 'd','e'],
'd' : ['b' , 'c', 'e', 'f'],
'e' : ['c', 'd'],
'f' : ['d']
}
```
得到的遍历顺序为abcdef.
广度优先搜索通常使用队列来实现,但也可以使用递归来实现。递归实现的一个常见方法是使用层序遍历,也就是每层只访问一次。为此,我们需要一个额外的数据结构来存储每层的顶点。以下是一个使用递归实现广度优先搜索的示例:
```python
def bfs_recursive(graph, root):
nodes=list(graph.keys())
visited = set()
levels = [[root]] # 初始化层级列表,第一层只有一个根节点
next_level=[]
def process_level(level):
if not level:
return # 当前层级为空,返回上一层
for node in level:
if node not in visited:
visited.add(node)
print(node, end=" ")
# 将未访问的邻居加入下一层级的列表
next_level.extend(neighbour for neighbour in graph[node] if neighbour not in visited)
# 如果还有下一层级,递归处理
if next_level:
if set(nodes)==visited:
return
levels.append(next_level)
process_level(next_level)
# 递归处理每一层
while levels:
process_level(levels.pop(0))
bfs_recursive(graph, 'a') # 输出: A B C D E F
```
process_level函数会处理当前层级的节点,并将下一层级的节点添加到levels列表中。然后,如果levels列表不为空,它会继续处理下一层级。这样,递归调用就会按照广度优先的顺序处理所有节点,而不会导致无限递归。
networkx工具包进行图遍历时使用的是bfs_edges函数与dfs_edges函数。例如,我们看到下面的代码:
```python
import networkx as nx # 导入 NetworkX 工具包
G = nx.Graph() # 创建:空的 无向图
G.add_weighted_edges_from([(1,2,50),(1,3,60),(2,4,65),(2,5,40),(3,4,52),
(3,7,45),(4,5,50),(4,6,30),(4,7,42),(5,6,70)]) # 向图中添加多条赋权边: (node1,node2,weight)
list(nx.dfs_edges(G,1))
list(nx.bfs_edges(G,1))
```
它分别调用了dfs_edges和bfs_edges函数来展示从节点1开始的深度优先搜索(DFS)和广度优先搜索(BFS)的边遍历顺序。dfs_edges和bfs_edges用于生成DFS和BFS遍历的边序列,而dfs_tree和bfs_tree则用于构建DFS和BFS遍历的树状结构。这些函数对于理解和分析图的结构和性质非常有用。
### 4.2.2 二分图最大匹配
二分图又叫二部图,是图论中的一种特殊模型。假设S=(V,E)是一个无向图。如果顶点V可分割为两个互不相交的子集(A,B),并且图中的每条边(i,j)所关联的两个顶点i和j分别属于这两个不同的顶点集(i in A,j in B),就可以称图S为一个二分图。简单来说,就是顶点集V可分割为两个互不相交的子集,并且图中每条边依附的两个顶点都分属于这两个互不相交的子集,两个子集内的顶点不相邻。
在二分图S中,一个子图M如果构成其边集的任意两条边都不依附于同一个顶点,则M被称为一个匹配。这意味着在匹配中,每条边的两个端点分别属于二分图的两个不同集合,且任意两条边不会共享任何一个顶点。匹配是图论中一个重要的概念,用于研究图中节点之间的配对关系。

极大匹配是指在给定的二分图中,已经找不到更多的边可以添加到匹配中,使得每条边都满足匹配的条件。换句话说,极大匹配是一个无法再通过增加未匹配边来扩大的匹配。而最大匹配则是指在所有可能的极大匹配中,边数达到最大的一个。最大匹配是二分图中匹配边数的上限,它衡量了图中可以形成的最大规模的匹配。
如果一个匹配中包含了图中所有的顶点,即每个顶点都至少与匹配中的一条边相关联,那么这样的匹配被称为完全匹配或完备匹配。完全匹配意味着图中的每个节点都成功地与其他节点建立了配对关系。需要注意的是,完全匹配一定是极大匹配,因为已经没有任何未匹配的边可以加入。然而,并非所有的极大匹配都能达到完全匹配的状态,因为有可能存在一些顶点在极大匹配中没有被覆盖。
匹配、极大匹配、最大匹配和完全匹配是图论中逐渐递进的概念。它们用于描述和分析二分图中节点之间的配对关系,以及这种关系的规模和性质。通过寻找最大匹配和完全匹配,我们可以更好地理解图的结构和特性。二分图匹配的本质是一个指派问题,还记得我们在第3章中提到的吗,这类问题可以使用匈牙利算法解。
对于二分图的判断方法最常见的是染色法,就是对每一个点进行染色操作,只用黑白两种颜色,能不能使所有的点都染上了色且相邻两个点的颜色不同。如果能则是二分图。
KM算法,全称Kuhn-Munkres算法,也被称为匈牙利算法的一种扩展,主要用于解决二分图的最大权重匹配问题。在二分图的最大权重匹配问题中,给定一个二分图,其中图的边带有权重,目标是找到一种匹配方式,使得所有匹配边的权重之和最大。
KM算法的基本思想是通过给每个顶点一个标号(称为顶标),然后不断寻找增广路进行增广,直到找不到增广路为止。这里的增广路指的是一条从左侧未匹配点出发,到右侧未匹配点结束的路径,路径中的边交替出现未匹配边和已匹配边。
以下是KM算法的主要步骤:
1. 初始化:首先,给二分图的左侧每个顶点一个顶标,这个顶标通常初始化为与其相连的所有边中的最大权重;给右侧每个顶点一个顶标,这个顶标初始化为0。同时,初始化一个匹配矩阵,用于记录当前的匹配情况。
2. 寻找增广路:从左侧未匹配的顶点开始,尝试寻找增广路。在寻找过程中,对于每一条边,如果满足左侧顶点的顶标加上右侧顶点的顶标大于等于边的权重,那么这条边是可以走的。如果找到了一条增广路,那么进行增广操作,更新匹配矩阵,并尝试继续寻找增广路。
3. 调整顶标:如果找不到增广路,那么需要进行顶标调整。调整的原则是,对于在增广路中的左侧顶点,减小其顶标;对于不在增广路中的左侧顶点,增大其顶标。同样,对于在增广路中的右侧顶点,增大其顶标;对于不在增广路中的右侧顶点,保持其顶标不变。调整顶标的目的是为了让更多的边满足可以走的条件,从而可能找到新的增广路。
4. 重复步骤2和3:重复寻找增广路和调整顶标的步骤,直到找不到增广路为止。此时,当前的匹配就是二分图的最大权重匹配。
KM算法的时间复杂度主要取决于寻找增广路和调整顶标的次数,一般来说,其时间复杂度为O(n^3),其中n为二分图中顶点的数量。虽然KM算法的时间复杂度较高,但由于它能够解决带有权重的二分图匹配问题,因此在一些实际问题中仍然具有广泛的应用。
KM算法的实现如下:
```python
"""
KM算法
复杂度O(E*V*V)
"""
a = -float('inf')
# a = 5
graph = [
[4, 2, 6, a, a],
[2, 6, a, 6, 3],
[a, 3, 6, a, a],
[a, 8, 2, a, a],
[a, a, a, 3, 1]
]
label_left, label_right = [max(g) for g in graph], [0 for _ in graph]
S, T = {}, {}
visited_left = [False for _ in graph]
visited_right = [False for _ in graph]
slack_right = [float('inf') for _ in graph]
def find_path(i, visited_left, visited_right, slack_right):
visited_left[i] = True
for j, match_weight in enumerate(graph[i]):
if visited_right[j]:
continue
gap = label_left[i] + label_right[j] - match_weight
if gap == 0:
visited_right[j] = True
if j not in T or find_path(T[j], visited_left, visited_right, slack_right):
T[j] = i
S[i] = j
return True
else:
slack_right[j] = min(slack_right[j], gap)
return False
def KM():
m = len(graph)
for i in range(m):
# 重置辅助变量
slack_right = [float('inf') for _ in range(m)]
while True:
visited_left = [False for _ in graph]
visited_right = [False for _ in graph]
if find_path(i,visited_left,visited_right, slack_right):
break
d = float('inf')
for j, slack in enumerate(slack_right):
if not visited_right[j] and slack < d:
d = slack
for k in range(m):
if visited_left[k]:
label_left[k] -= d
if visited_right[k]:
label_right[k] += d
return S, T
KM()
print(S)
{0: 0, 1: 3, 2: 2, 3: 1, 4: 4}
```
### 4.2.3 最小生成树
树是一种抽象数据类型(ADT)或是实现这种抽象数据类型的数据结构,用来模拟具有树状结构性质的数据集合。在树结构中,每个节点有零个或多个子节点。根节点是树中的第一个节点,它没有父节点。除了根节点外,每个节点有且仅有一个父节点。树的深度是从根节点到最远叶子节点的最长路径上的节点数。
树、图和生成树之间存在密切关系。树是图的子集,其中任何两个节点之间恰好存在一条路径。换句话说,树是一个无环连通图。生成树则是图的一个子集,它包含图的所有节点且是一个树,即它也是一个无环连通图。因此,每个连通图都至少有一个生成树。生成树在许多算法中都有应用,如最小生成树算法,用于在网络中找到总权重最小的树形结构,使得所有节点都相互连通。
树的基本要素包括节点、边、权值、父节点、根节点和叶子节点。节点是树中的基本单位,可以看作是数据的存储单元。边是连接两个节点的路径,表示节点之间的关系。权值通常与边相关联,表示这条边的某种度量或代价,如距离或成本。在树中,每个节点通常有一个或多个父节点(除了根节点),根节点是树中没有父节点的节点,而叶子节点是没有子节点的节点。
最小生成树(Minimum Spanning Tree, MST)是图论中的一个概念,特指在一个连通的无向图中,包含所有顶点且边的权值之和最小的树。换句话说,最小生成树是原图的一个子集,构成了一棵树,并且这棵树的所有边的权值之和最小,同时保证了图中的所有顶点都被包含在内。最小生成树在计算机科学、网络优化等领域有着广泛的应用,常见的求解算法有Kruskal算法和Prim算法。
Prim算法从任意一个顶点开始,每次选择一个与当前顶点集最近的一个顶点,并将两顶点之间的边加入到树中。Prim算法在找当前最近顶点时使用到了贪婪算法。可在加权连通图里搜索最小生成树,由此算法搜索到的边子集所构成的树中,不但包括了连通图里的所有顶点,且其所有边的权值之和亦为最小。如图所示:

```python
import heapq
def prim(graph, start):
mst = {start: []} # 最小生成树,初始只包含起始节点
visited = {start} # 已访问的节点集合
edges = [(cost, start, to) for to, cost in graph[start].items()] # 起始节点的边加入堆中
heapq.heapify(edges) # 初始化堆
while edges:
cost, frm, to = heapq.heappop(edges) # 弹出权重最小的边
if to not in visited:
visited.add(to) # 标记节点为已访问
mst[to] = [] # 在MST中添加新节点
mst[frm].append((cost, to)) # 添加边到MST
mst[to].append((cost, frm)) # 由于是无向图,双向添加边
for to_next, cost2 in graph[to].items():
if to_next not in visited:
heapq.heappush(edges, (cost2, to, to_next)) # 添加相邻节点的边到堆中
return mst # 返回最小生成树
# 示例图的邻接表表示
graph = {
'A': {'B': 1, 'C': 4},
'B': {'A': 1, 'C': 2, 'D': 5},
'C': {'A': 4, 'B': 2, 'D': 1},
'D': {'B': 5, 'C': 1}
}
# 运行Prim算法
mst = prim(graph, 'A')
print("Minimum Spanning Tree:", mst)
Minimum Spanning Tree: {'A': [(1, 'B')], 'B': [(1, 'A'), (2, 'C')], 'C': [(2, 'B'), (1, 'D')], 'D': [(1, 'C')]}
```
Kruskal算法不同于Prim的从一点出发,它是一种全局的添加方法。将所有边按照权值的大小进行升序排序,然后从小到大一一判断,条件为:如果这个边不会与之前选择的所有边组成回路,就可以作为最小生成树的一部分;反之,舍去。直到具有 n 个顶点的连通网筛选出来 n-1 条边为止。筛选出来的边和所有的顶点构成此连通网的最小生成树。判断是否会产生回路的方法为:在初始状态下给每个顶点赋予不同的标记,对于遍历过程的每条边,其都有两个顶点,判断这两个顶点的标记是否一致,如果一致,说明它们本身就处在一棵树中,如果继续连接就会产生回路;如果不一致,说明它们之间还没有任何关系,可以连接。

```python
def find(parent, i):
if parent[i] != i:
parent[i] = find(parent, parent[i])
return parent[i]
def union(parent, rank, x, y):
root_x = find(parent, x)
root_y = find(parent, y)
if root_x != root_y:
if rank[root_x] > rank[root_y]:
parent[root_y] = root_x
else:
parent[root_x] = root_y
if rank[root_x] == rank[root_y]:
rank[root_y] += 1
def kruskal(graph):
edges = []
for u in graph:
for v, w in graph[u].items():
edges.append((w, u, v))
edges.sort() # Sort the edges by weight
mst = []
parent = {v: v for v in graph} # Initialize each node as its own set
rank = {v: 0 for v in graph} # Initialize rank for each set
for weight, u, v in edges:
if find(parent, u) != find(parent, v): # If the nodes are not connected
union(parent, rank, u, v) # Merge the sets
mst.append((u, v, weight)) # Add the edge to the MST
return mst
# Example graph represented as an adjacency list
graph = {
'A': {'B': 1, 'C': 4},
'B': {'A': 1, 'C': 2, 'D': 5},
'C': {'A': 4, 'B': 2, 'D': 1},
'D': {'B': 5, 'C': 1}
}
# Run the Kruskal algorithm
mst = kruskal(graph)
print("Edges in the Minimum Spanning Tree:", mst)
Edges in the Minimum Spanning Tree: [('A', 'B', 1), ('C', 'D', 1), ('B', 'C', 2)]
```
使用networkx实现最小生成树的代码如下:
```python
import matplotlib.pyplot as plt
import networkx as nx
# 创建带权边的列表
weighted_edges = [(i, j, weight) for i, j, weight in [
(1, 2, 50), (1, 3, 60), (2, 4, 65), (2, 5, 40), (3, 4, 52),
(3, 7, 45), (4, 5, 50), (4, 6, 30), (4, 7, 42), (5, 6, 70)
]]
# 创建无向图并添加边
def create_and_add_edges(G):
G.add_weighted_edges_from(weighted_edges)
return G
# 计算最小生成树
def calculate_mst(G):
mst_kruskal = nx.minimum_spanning_tree(G)
mst_edges_kruskal = list(nx.tree.minimum_spanning_edges(G, algorithm="kruskal"))
mst_prim = list(nx.tree.minimum_spanning_edges(G, algorithm="prim", data=False))
return mst_kruskal, mst_edges_kruskal, mst_prim
# 创建图
G = create_and_add_edges(nx.Graph())
# 计算最小生成树
mst_kruskal, mst_edges_kruskal, mst_prim = calculate_mst(G)
# 打印节点和边
print("Nodes of MST (Kruskal):", mst_kruskal.nodes)
print("Edges of MST (Kruskal):", mst_kruskal.edges)
print("Sorted Edges of MST (Kruskal):", sorted(mst_kruskal.edges))
print("MST Edges (Kruskal with weights):", mst_edges_kruskal)
print("MST Edges (Prim):", mst_prim)
# 指定顶点位置
pos = {
1: (2.5, 10), 2: (0, 5), 3: (7.5, 10), 4: (5, 5),
5: (2.5, 0), 6: (7.5, 0), 7: (10, 5)
}
# 绘制图形
nx.draw(G, pos, with_labels=True, alpha=0.8, node_color='lightblue', edge_color='gray')
labels = nx.get_edge_attributes(G, 'weight')
nx.draw_networkx_edge_labels(G, pos, edge_labels=labels, font_color='red', font_size=12)
nx.draw_networkx_edges(G, pos, edgelist=mst_kruskal.edges, edge_color='red', width=3, alpha=0.9)
# 显示图形
plt.axis('off') # 不显示坐标轴
plt.show()
```

## 4.3 图论算法:最短路径与最大流问题
### 4.3.1 最短路径问题
当我们谈论“最短路径问题”时,可以想象你正在规划一次旅行,希望从起点城市到达终点城市,并且希望整个旅程的距离或时间尽可能短。这就是图论中的最短路径问题,它涉及到在由节点(可以看作城市)和边(可以看作连接城市的道路)组成的网络中,找到从起点到终点的最短路线。解决这类问题的算法有很多,包括Floyd算法、迪杰斯特拉算法、Bellman-Ford算法、A*算法等。
Floyd算法是解决给定的加权图中顶点间的最短路径的一种算法,可以正确处理有向图或负权的最短路径问题,同时也被用于计算有向图的传递闭包。是一种类似于动态规划思想的算法,稠密图效果最佳,边权可正可负。只需要三次循环,但也恰恰是由于它属于循环结构所以Floyd算法适合做节点数量小而且稠密的图,能够一次输出所有节点对之间的最短路径。但请注意,这一方法只能在不存在负权环的情况下使用
下面是Floyd算法的基本执行流程:
1. **初始化**:
- 对于图中的每一个顶点对(i, j),如果顶点i和j之间存在一条直接边,则设置距离矩阵D[i][j]为该边的权重;如果不存在直接边,则设置D[i][j]为无穷大(表示这两个顶点之间不可达)。
- 对于每一个顶点i,设置D[i][i]为0,因为从一个顶点到其自身的距离总是0。
- 初始化一个路径矩阵P,用于记录最短路径中每个顶点的前驱顶点。在初始化时,如果D[i][j]不是无穷大,则P[i][j]为j;否则,P[i][j]可以设置为一个特殊值,表示没有前驱顶点。
2. **迭代过程**:
- 对于图中的每一个顶点k,执行以下步骤:
- 对于每一对顶点对(i, j),检查是否存在一条通过顶点k的路径,使得从i到j的距离比当前记录的距离更短。这可以通过比较D[i][k] + D[k][j]与D[i][j]来实现。
- 如果D[i][k] + D[k][j] < D[i][j],则更新D[i][j]为D[i][k] + D[k][j],并更新P[i][j]为k,表示在最短路径中,顶点i到顶点j是通过顶点k的。
3. **结果输出**:
- 在完成所有的迭代后,D矩阵中存储的就是图中所有顶点对之间的最短距离。
- 如果需要知道从顶点i到顶点j的最短路径上的所有顶点,可以从P矩阵中回溯。从P[i][j]开始,不断查找P[P[i][j]][j],直到找到起点i或找不到前驱顶点为止。
Floyd算法的时间复杂度为O(n^3),其中n是图中顶点的数量。虽然它的时间复杂度相对较高,但由于它能够处理带有负权重的图,并且能够找到所有顶点对之间的最短路径,因此在某些应用中仍然非常有用。
```python
# 弗洛伊德算法
def floyd():
n = len(graph)
for k in range(n):
for i in range(n):
for j in range(n):
if graph[i][k] + graph[k][j] < graph[i][j]:
graph[i][j] = graph[i][k] + graph[k][j]
parents[i][j] = parents[k][j] # 更新父结点
# 打印路径
def print_path(i, j):
if i != j:
print_path(i, parents[i][j])
print(j, end='-->')
我们用一个样例进行测试:
# Data [u, v, cost]
datas = [
[0, 1, 2],
[0, 2, 6],
[0, 3, 4],
[1, 2, 3],
[2, 0, 7],
[2, 3, 1],
[3, 0, 5],
[3, 2, 12],
]
n = 4
# 无穷大
inf = 9999999999
# 构图
graph = [[(lambda x: 0 if x[0] == x[1] else inf)([i, j]) for j in range(n)] for i in range(n)]
parents = [[i] * n for i in range(4)] # 关键地方,i-->j 的父结点初始化都为i
for u, v, c in datas:
graph[u][v] = c # 因为是有向图,边权只赋给graph[u][v]
#graph[v][u] = c # 如果是无向图,要加上这条。
floyd()
print('Costs:')
for row in graph:
for e in row:
print('∞' if e == inf else e, end='\t')
print()
print('\nPath:')
for i in range(n):
for j in range(n):
print('Path({}-->{}): '.format(i, j), end='')
print_path(i, j)
print(' cost:', graph[i][j])
```
得到结果如下图所示:

从上图中可以看到,Floyd算法可以把所有节点对之间的最短路径全部打印出来,是一种非常有效的方法。
Dijkstra算法是基于贪心思想实现的最短路径算法,首先把起点到所有点的距离存下来找最短的,然后松弛一次再找出最短的,所谓的松弛操作就是,遍历一遍看通过刚刚找到的距离最短的点作为中转站会不会更近,如果更近了就更新距离,这样把所有的点找遍之后就存下了起点到其他所有点的最短距离。Dijkstra算法是一种典型的单源最短路径算法,用于计算一个节点到其他所有节点的最短路径。主要特点是以起始点为中心向外层层扩展,直到扩展到终点为止。
Dijkstra算法的基本执行流程如下:
1. **初始化**:
- 设置一个数组`dist[]`,用于存储从起点到每个节点的最短距离。开始时,将起点的距离设为0,其余所有节点的距离设为无穷大(表示它们还没有被找到最短路径)。
- 创建一个集合`visited`,用于记录已经找到最短路径的节点。开始时,集合为空。
2. **选择未访问的节点中距离最短的节点**:
- 从所有未访问的节点中选择一个当前距离最短的节点。这通常通过遍历`dist[]`数组来实现,找到距离最小的节点。
3. **标记节点为已访问**:
- 将选中的节点标记为已访问,并加入`visited`集合中。
4. **更新邻居节点的距离**:
- 遍历选中节点的所有邻居节点。对于每个邻居节点,如果通过当前节点作为中间节点可以得到更短的距离,则更新`dist[]`数组中对应邻居节点的距离值。
5. **重复步骤2-4**:
- 重复执行步骤2至步骤4,直到所有节点都被访问过,即`visited`集合包含了所有的节点。
6. **结果输出**:
- 算法结束时,`dist[]`数组将包含从起点到每个节点的最短距离。
需要注意的是,Dijkstra算法不能处理带有负权重的边,因为负权重可能导致算法陷入无限循环或得到错误的结果。此外,为了更高效地执行步骤2(选择距离最短的节点),通常会使用一个优先队列(如最小堆)来存储未访问的节点及其当前距离,这样可以在O(log n)的时间内选择出距离最短的节点。
在实际应用中,Dijkstra算法常用于解决地图上的最短路径问题、网络路由选择等场景。下面是使用networkx中的最短路径接口调用迪杰斯特拉算法的案例demo
```python
import matplotlib.pyplot as plt # 导入 Matplotlib 工具包
import networkx as nx # 导入 NetworkX 工具包
G2 = nx.Graph() # 创建:空的 无向图
G2.add_weighted_edges_from([(1,2,2),(1,3,8),(1,4,1),
(2,3,6),(2,5,1),
(3,4,7),(3,5,5),(3,6,1),(3,7,2),
(4,7,9),
(5,6,3),(5,8,2),(5,9,9),
(6,7,4),(6,9,6),
(7,9,3),(7,10,1),
(8,9,7),(8,11,9),
(9,10,1),(9,11,2),
(10,11,4)]) # 向图中添加多条赋权边: (node1,node2,weight)
# 两个指定顶点之间的最短加权路径
minWPath_v1_v11 = nx.dijkstra_path(G2, source=1, target=11) # 顶点 0 到 顶点 3 的最短加权路径
print("顶点 v1 到 顶点 v11 的最短加权路径: ", minWPath_v1_v11)
# 两个指定顶点之间的最短加权路径的长度
lMinWPath_v1_v11 = nx.dijkstra_path_length(G2, source=1, target=11) #最短加权路径长度
print("顶点 v1 到 顶点 v11 的最短加权路径长度: ", lMinWPath_v1_v11)
pos = nx.spring_layout(G2) # 用 FR算法排列节点
nx.draw(G2, pos, with_labels=True, alpha=0.5)
labels = nx.get_edge_attributes(G2,'weight')
nx.draw_networkx_edge_labels(G2, pos, edge_labels = labels)
plt.show()
```
另一个demo如下:
```python
import pandas as pd
import matplotlib.pyplot as plt # 导入 Matplotlib 工具包
import networkx as nx # 导入 NetworkX 工具包
# 问题 1:城市间机票价格问题(司守奎,数学建模算法与应用,P41,例4.1)
# # 从Pandas数据格式(顶点邻接矩阵)创建 NetworkX 图
# # from_pandas_adjacency(df, create_using=None) # 邻接矩阵,n行*n列,矩阵数据表示权重
dfAdj = pd.DataFrame([[0, 50, 0, 40, 25, 10], # 0 表示不邻接,
[50, 0, 15, 20, 0, 25],
[0, 15, 0, 10, 20, 0],
[40, 20, 10, 0, 10, 25],
[25, 0, 20, 10, 0 ,55],
[10, 25, 0, 25, 55, 0]])
G1 = nx.from_pandas_adjacency(dfAdj) # 由 pandas 顶点邻接矩阵 创建 NetworkX 图
# 计算最短路径:注意最短路径与最短加权路径的不同
# 两个指定顶点之间的最短路径
minPath03 = nx.shortest_path(G1, source=0, target=3) # 顶点 0 到 顶点 3 的最短路径
lMinPath03 = nx.shortest_path_length(G1, source=0, target=3) #最短路径长度
print("顶点 0 到 3 的最短路径为:{},最短路径长度为:{}".format(minPath03, lMinPath03))
# 两个指定顶点之间的最短加权路径
minWPath03 = nx.bellman_ford_path(G1, source=0, target=3) # 顶点 0 到 顶点 3 的最短加权路径
# 两个指定顶点之间的最短加权路径的长度
lMinWPath03 = nx.bellman_ford_path_length(G1, source=0, target=3) #最短加权路径长度
print("顶点 0 到 3 的最短加权路径为:{},最短加权路径长度为:{}".format(minWPath03, lMinWPath03))
for i in range(1,6):
minWPath0 = nx.dijkstra_path(G1, source=0, target=i) # 顶点 0 到其它顶点的最短加权路径
lMinPath0 = nx.dijkstra_path_length(G1, source=0, target=i) #最短加权路径长度
print("城市 0 到 城市 {} 机票票价最低的路线为: {},票价总和为:{}".format(i, minWPath0, lMinPath0))
nx.draw(G1, with_labels=True, layout=nx.spring_layout(G1))
plt.show()
```
### 4.3.2 最大流问题
假设现在在几个城市之间铺设了图所示的水管网络,水管中水的流动只能是单向的。并且每条管道有流量限制,也就是说这是一幅带权有向图。现在假设自来水厂为节点1,最终目标地址为节点8。最大流问题就是试求水管网络上水流的最大流量,使得每条水管上的流量都不超过流量上限,并且自来水厂能够输送的水最多。

增广路径被定义为在残余网络上的一条从源点 s 到汇点 t 的简单路径,路径的残余流量为该边上的边 e' 容量的最小值,其实就是残余网络上增广的流值大于 0 的一条路径。设网络G,如果X是V的节点子集,Y是X的补集,即Y = V - X,且满足源点属于X,汇点属于Y。则称K=(X,Y)为网络G的割。最小割就是该网络中流量最小的割。
于是,数学上有最小割最大流定理:在一个网络流中,能够从源点到达汇点的最大流量等于如果从网络中移除就能够导致网络流中断的边的集合的最小容量和。即在任何网络中,最大流的值等于最小割的容量。
Ford-Fulkson的具体步骤如下:
- 初始化网络中所有边的容量,c继承该边的容量,c初始化为0,其中边即为回退边。初始化最大流为0。
- 在残留网络中找一条从源S到汇T的增广路p。如能找到,则转步骤3,;如不能找到,则转步骤5。
- 在增广路p中找到所谓的"瓶颈"边,即路径中容量最小的边,记录下这个值X,并且累加到最大流中,转步骤4。
- 将增广路中所有c减去X,所有c加上X,构成新的残留网络。转步骤2。
- 得到最大流,退出。
另一个常见的问题是,除了限制水管的流量,还会定义每条管道上面运输自来水需要花费的费用。这个价格等于费用乘以水管上的水流量,除了要控制最大流,还需要限制花费最小,故又称最小费用最大流问题。对于这一问题Ford-Fulkson法仍然奏效,只不过开始找的增广路径就是利用Dijkstra算法求出的最短路径。
我们来看下面这个例子怎么用最大流求解:

首先,找到从S到T的一条最大流量,由于总流量会存在木桶效应,路径中最小的流量决定了这条路上通过的总流量。我们把红线上的流量都-3以后添加反向边。

继续找最大的一条流量通路,同样进行增加反向边的操作。这时我们发现有两条反向边可以合并。

重复上述操作,此时终于产生了两个连通分量。

最后一次,完成。

最终得到的结果如下。

## 4.4 使用Networkx完成复杂网络建模
(本节参考自,特别鸣谢!)
- 创建一个图
创建一个没有边(edge)和节点(node)的空图
```python
import networkx as nx
G = nx.Graph()
```
根据定义,图形是节点(顶点)以及已识别的节点对(称为边,链接等)的集合。在 NetworkX 中,节点可以是任何可哈希(hashable)对象,例如,文本字符串、图像、XML对象、另一个图、自定义节点对象等。python 中的None不能作为节点。
- 节点
图可以以多种形式扩张。NetworkX包括许多图生成函数和工具,用于读取和写入多种格式的图。作为简单开始,可以每次添加一个节点:
```python
G.add_node(1)
```
或者从可迭代容器(iterable)(如列表)中添加多个节点
```python
G.add_nodes_from([2, 3])
```
你也可以同时添加包含节点属性的节点,如果你的容器以(node, node_attribute_dict)2元-元组的形式
```python
G.add_nodes_from([
(4, {"color": "red"}),
(5, {"color": "green"}),
])
```
一个图中的节点可以合并到另一个图
```python
H = nx.path_graph(10)
G.add_nodes_from(H)
```
现在图G中节点包括原H中的节点。相反,你也可以将整个图H作为图G中的一个节点
```python
G.add_node(H)
```
现在图G 将图H作为其中一个节点。这种灵活性非常强大,因为它允许图形组成的图形,文件组成的图形,函数组成的图形等等。值得考虑如何构建应用程序,以便节点是有用的实体。当然,如果您愿意,您始终可以在G中使用唯一标识符,并按标识符键记节点信息的单独字典。
- 边
图也可以以添加一条边的形式增长
```python
G.add_edge(1, 2)
e = (2, 3)
G.add_edge(*e) # unpack edge tuple*
```
通过接入边的列表增长
```python
G.add_edges_from([(1, 2), (1, 3)])
```
或者通过添加任何边的ebunch。ebunch 是边的元组的任何可迭代容器。边的元组可以是 2 元组节点,也可以是 3 元组:在 2 个节点后跟边的属性字典,如 (2, 3,{'weight':3.1415})。
从一个图中抽取边复制到另一个图:
```python
G.add_edges_from(H.edges)
```
一个更复杂的例子:
```python
G.add_edges_from([(1, 2), (1, 3)])
G.add_node(1)
G.add_edge(1, 2)
G.add_node("spam") # adds node "spam"
G.add_nodes_from("spam") # adds 4 nodes: 's', 'p', 'a', 'm'
G.add_edge(3, 'm')
```
此时,图G 包含 8 个节点和 3 条边
```python
>>> G.number_of_nodes()
8
>>> G.number_of_edges()
3
```
邻接报告(adjacency reporting)的顺序(例如,G.adj、G.successors、G.predecessors)是边添加的顺序。 然而,G.edges 的顺序是邻接的顺序,包括节点的顺序和每个节点的邻接。 请参见下面的示例:
```python
DG = nx.DiGraph()
DG.add_edge(2, 1) # adds the nodes in order 2, 1
DG.add_edge(1, 3)
DG.add_edge(2, 4)
DG.add_edge(1, 2)
assert list(DG.successors(2)) == [1, 4]
assert list(DG.edges) == [(2, 1), (2, 4), (1, 3), (1, 2)]
```
- 检查图的元素
我们可以检查节点和边。 四个基本图形属性便于报告:G.nodes、G.edges、G.adj 和 G.degree。 这些是图中节点、边、邻居(邻接)和节点度数的集合视图。 它们为图形结构提供了一个不断更新的只读视图。 它们也类似于 dict,因为您可以通过视图查找节点和边缘数据属性,并使用方法 .items()、.data() 迭代数据属性。 如果你想要一个特定的容器类型而不是一个视图,你可以指定一个。 这里我们使用列表,尽管集合、字典、元组和其他容器在其他情况下可能会更好。
```python
>>> list(G.nodes)
[1, 2, 3, 'spam', 's', 'p', 'a', 'm']
>>> list(G.edges)
[(1, 2), (1, 3), (3, 'm')]
>>> list(G.adj[1]) # or list(G.neighbors(1))
[2, 3]
>>> G.degree[1] # the number of edges incident to 1
2
```
可以指定使用 nbunch 报告来自所有节点子集的边缘和度数。 nbunch 是以下任何一种:None(表示所有节点);单个节点或节点的可迭代容器;其本身不是图中的节点。
```python
>>> G.edges([2, 'm'])
EdgeDataView([(2, 1), ('m', 3)])
>>> G.degree([2, 3])
DegreeView({2: 1, 3: 2})
```
- 从图中删除元素
可以以添加元素相同的风格山下湖边和节点。使用 Graph.remove_node(), Graph.remove_nodes_from(), Graph.remove_edge() 和 Graph.remove_edges_from(), 等方法
```python
>>> G.remove_node(2)
>>> G.remove_nodes_from("spam")
>>> list(G.nodes)
[1, 3, 'spam']
>>> G.remove_edge(1, 3)
```
- 使用图构造函数
图形对象不是只能增量构建 - 指定图形结构的数据可以直接传递给各种图形类的构造函数。 通过实例化其中一个图形类来创建图形结构时,您可以指定多种格式的数据。
```python
>>> G.add_edge(1, 2)
>>> H = nx.DiGraph(G) # create a DiGraph using the connections from G
>>> list(H.edges())[(1, 2), (2, 1)]
>>> edgelist = [(0, 1), (1, 2), (2, 3)]
>>> H = nx.Graph(edgelist) # create a graph from an edge list
>>> list(H.edges())[(0, 1), (1, 2), (2, 3)]
>>> adjacency_dict = {0: (1, 2), 1: (0, 2), 2: (0, 1)}
>>> H = nx.Graph(adjacency_dict) # create a Graph dict mapping nodes to nbrs
>>> list(H.edges())[(0, 1), (0, 2), (1, 2)]
```
- 访问边缘和邻居
除了视图 Graph.edges 和 Graph.adj 之外,还可以使用下标表示法访问边和邻居。
```python
>>> G = nx.Graph([(1, 2, {"color": "yellow"})])
>>> G[1] # same as G.adj[1]
AtlasView({2: {'color': 'yellow'}})
>>> G[1][2]
{'color': 'yellow'}
>>> G.edges[1, 2]
```
如果边缘已经存在,您可以使用下标表示法获取/设置边缘的属性。
```
>>> G.add_edge(1, 3)
>>> G[1][3]['color'] = "blue"
>>> G.edges[1, 2]['color'] = "red"
>>> G.edges[1, 2]
{'color': 'red'}
```
使用 G.adjacency() 或 G.adj.items() 可以快速检查所有(节点、邻接)对。 请注意,对于无向图,邻接迭代会看到每条边两次。
```python
FG = nx.Graph()
FG.add_weighted_edges_from([(1, 2, 0.125), (1, 3, 0.75), (2, 4, 1.2), (3, 4, 0.375)])
for n, nbrs in FG.adj.items():
for nbr, eattr in nbrs.items():
wt = eattr['weight']
if wt < 0.5: print(f"({n}, {nbr}, {wt:.3})")
使用edges 属性可以方便地访问所有边缘
for (u, v, wt) in FG.edges.data('weight'):
if wt < 0.5:
print(f"({u}, {v}, {wt:.3})")
```
- 向图、节点和边添加属性
诸如权重、标签、颜色或任何您喜欢的 Python 对象之类的属性都可以附加到图形、节点或边上。
每个图、节点和边都可以在关联的属性字典中保存键/值属性对(键必须是可散列的)。 默认情况下,这些是空的,但可以使用 add_edge、add_node 或直接操作名为 G.graph、G.nodes 和 G.edges 的属性字典来添加或更改属性。
创建新图形时分配图形属性
```python
>>> G = nx.Graph(day="Friday")
>>> G.graph
{'day': 'Friday'}
```
或者您可以稍后修改属性
```python
>>> G.graph['day'] = "Monday"
>>> G.graph
{'day': 'Monday'}
```
使用 add_node(), add_nodes_from(), or G.nodes 添加节点属性
```python
>>> G.add_node(1, time='5pm')
>>> G.add_nodes_from([3], time='2pm')
>>> G.nodes[1]
{'time': '5pm'}
>>> G.nodes[1]['room'] = 714
>>> G.nodes.data()
NodeDataView({1: {'time': '5pm', 'room': 714}, 3: {'time': '2pm'}})
```
请注意,将节点添加到 G.nodes 不会将其添加到图中,请使用 G.add_node() 添加新节点。 对于边缘也是如此。
使用 add_edge()、add_edges_from() 或下标表示法添加/更改边缘属性。
```python
G.add_edge(1, 2, weight=4.7 )
G.add_edges_from([(3, 4), (4, 5)], color='red')
G.add_edges_from([(1, 2, {'color': 'blue'}), (2, 3, {'weight': 8})])
G[1][2]['weight'] = 4.7
G.edges[3, 4]['weight'] = 4.2
```
特殊属性权重( weight)应该是数字,因为它被需要加权边缘的算法使用。
- 有向图
DiGraph 类提供了特定于有向边的附加方法和属性,例如,DiGraph.out_edges、DiGraph.in_degree、DiGraph.predecessors、DiGraph.successors 等。为了让算法轻松地使用这两个类,有向版本的 neighbors等效于 successors ,而 degree 报告 是 in_degree 和 out_degree 的总和,即使有时可能感觉不一致。
```python
>>> DG = nx.DiGraph()
>>> DG.add_weighted_edges_from([(1, 2, 0.5), (3, 1, 0.75)])
>>> DG.out_degree(1, weight='weight')
0.5
>>> DG.degree(1, weight='weight')
1.25
>>> list(DG.successors(1))
[2]
>>> list(DG.neighbors(1))
[2]
```
一些算法仅适用于有向图,而另一些算法对有向图没有很好的定义。 事实上,将有向图和无向图混为一谈的趋势是危险的。 如果您想将有向图视为无向的某些测量,您可能应该使用Graph.to_undirected()或使用H = nx.Graph(G)获得无向图版本
- 多图
NetworkX 提供了允许任意一对节点之间存在多条边的图类。 MultiGraph 和 MultiDiGraph 类允许您添加相同的边两次,可能使用不同的边数据。 这对于某些应用程序来说可能很强大,但许多算法在此类图上没有很好地定义。 在结果定义明确的地方,例如 MultiGraph.degree() 我们提供了函数。 否则,您应该以使测量明确定义的方式转换为标准图表。
```python
>>> MG = nx.MultiGraph()
>>> MG.add_weighted_edges_from([(1, 2, 0.5), (1, 2, 0.75), (2, 3, 0.5)])
>>> dict(MG.degree(weight='weight'))
{1: 1.25, 2: 1.75, 3: 0.5}
>>> GG = nx.Graph()
>>> for n, nbrs in MG.adjacency():
for nbr, edict in nbrs.items():
minvalue = min([d['weight'] for d in edict.values()])
GG.add_edge(n, nbr, weight = minvalue)
>>> nx.shortest_path(GG, 1, 3)
[1, 2, 3]
```
- 图生成器和图操作
除了逐节点或逐边构造图之外,它们还可以通过以下方式生成
1. 应用经典的图操作,例如:
---
| 方法 | 介绍 |
| --------------------------------- | ------------------------------------------------------------ |
| subgraph(G, nbunch) | Returns the subgraph induced on nodes in nbunch. |
| union(G, H[, rename, name]) | Return the union of graphs G and H. |
| disjoint_union(G, H) | Return the disjoint union of graphs G and H. |
| cartesian_product(G, H) | Returns the Cartesian product of G and H. |
| compose(G, H) | Returns a new graph of G composed with H. |
| complement(G) | Returns the graph complement of G. |
| create_empty_copy(G[, with_data]) | Returns a copy of the graph G with all of the edges removed. |
| to_undirected(graph) | Returns an undirected view of the graph graph. |
| to_directed(graph) | Returns a directed view of the graph graph. |
---
2. 对经典小图进行调用
---
| 方法 | 介绍 |
| ------------------------------------ | ------------------------------------------------- |
| petersen_graph([create_using]) | Returns the Petersen graph. |
| tutte_graph([create_using]) | Returns the Tutte graph. |
| sedgewick_maze_graph([create_using]) | Return a small maze with a cycle. |
| tetrahedral_graph([create_using]) | Returns the 3-regular Platonic Tetrahedral graph. |
---
3. 为经典图使用(constructive)生成器
---
| 方法 | 介绍 |
| ------------------------------------------------ | ------------------------------------------------------------ |
| complete_graph(n[, create_using]) | Return the complete graph K_n with n nodes. |
| complete_bipartite_graph(n1, n2[, create_using]) | Returns the complete bipartite graph K_{n_1,n_2}. |
| barbell_graph(m1, m2[, create_using]) | Returns the Barbell Graph: two complete graphs connected by a path. |
| lollipop_graph(m, n[, create_using]) | Returns the Lollipop Graph; K_m connected to P_n. |
---
像这样
```python
K_5 = nx.complete_graph(5)
K_3_5 = nx.complete_bipartite_graph(3, 5)
barbell = nx.barbell_graph(10, 10)
lollipop = nx.lollipop_graph(10, 20)
```
4. 使用随机图生成器,
---
| 方法 | 介绍 |
| ----------------------------------------- | ------------------------------------------------------------ |
| erdos_renyi_graph(n, p[, seed, directed]) | Returns a Gn,p random graph, also known as an Erdős-Rényi graph or a binomial graph. |
| watts_strogatz_graph(n, k, p[, seed]) | Returns a Watts–Strogatz small-world graph. |
| barabasi_albert_graph(n, m[, seed, ...]) | Returns a random graph using Barabási–Albert preferential attachment |
| random_lobster(n, p1, p2[, seed]) | Returns a random lobster graph. |
---
像这样
```python
er = nx.erdos_renyi_graph(100, 0.15)
ws = nx.watts_strogatz_graph(30, 3, 0.1)
ba = nx.barabasi_albert_graph(100, 5)
red = nx.random_lobster(100, 0.9, 0.9)
```
5. 使用常用图形格式读取存储在文件中的图形
NetworkX 支持许多流行的格式,例如边缘列表、邻接列表、GML、GraphML、pickle、LEDA 等。
```python
nx.write_gml(red, "path.to.file")
mygraph = nx.read_gml("path.to.file")
```
有关图形格式的详细信息,请参阅读和写图形;有关图形生成器函数,请参阅图形生成器
- 分析图
可以使用各种图论函数来分析图G 的结构,例如:
```python
>>> G = nx.Graph()
>>> G.add_edges_from([(1, 2), (1, 3)])
>>> G.add_node("spam") # adds node "spam"
>>> list(nx.connected_components(G))
[{1, 2, 3}, {'spam'}]
>>> sorted(d for n, d in G.degree())
[0, 1, 1, 2]
>>> nx.clustering(G)
{1: 0, 2: 0, 3: 0, 'spam': 0}
```
一些具有大输出的函数迭代 (node, value) 2 元组。 如果您愿意,这些很容易存储在 dict 结构中。
```python
>>> sp = dict(nx.all_pairs_shortest_path(G))
>>> sp[3]
{3: [3], 1: [3, 1], 2: [3, 1, 2]}
```
- 绘制图
NetworkX 主要不是一个图形绘图包,而是包含使用 Matplotlib 的基本绘图以及使用开源 Graphviz 软件包的接口。 这些是 networkx.drawing 模块的一部分,如果可能,将被导入。
首先导入 Matplotlib 的绘图接口(pylab 也可以)
```python
import matplotlib.pyplot as plt
要测试 nx_pylab 是否成功导入,请使用以下方法之一绘制图G
G = nx.petersen_graph()
subax1 = plt.subplot(121)
nx.draw(G, with_labels=True, font_weight='bold')
subax2 = plt.subplot(122)
nx.draw_shell(G, nlist=[range(5, 10), range(5)], with_labels=True, font_weight='bold')
```

交互式操作时上述图像会自动展示。 请注意如果您没有在交互模式下使用 matplotlib,您可能需要下面命令展示图形
```python
plt.show()
options = {
'node_color': 'black',
'node_size': 100,
'width': 3,
}
subax1 = plt.subplot(221)
nx.draw_random(G, **options)
subax2 = plt.subplot(222)
nx.draw_circular(G, **options)
subax3 = plt.subplot(223)
nx.draw_spectral(G, **options)
subax4 = plt.subplot(224)
nx.draw_shell(G, nlist=[range(5,10), range(5)], **options)
```
您可以通过 draw_networkx() 找到其他选项,并通过布局模块找到布局。 您可以通过 draw_shell() 使用多个 shell。
```python
G = nx.dodecahedral_graph()
shells = [[2, 3, 4, 5, 6], [8, 1, 0, 19, 18, 17, 16, 15, 14, 7], [9, 10, 11, 12, 13]]
nx.draw_shell(G, nlist=shells, **options)
```
要将图形保存到文件中,请使用,例如
```python
nx.draw(G)
plt.savefig("path.png")
```
此函数写入本地目录中的文件 path.png。 如果 Graphviz 和 PyGraphviz 或 pydot 在您的系统上可用,您还可以使用 networkx.drawing.nx_agraph.graphviz_layout 或 networkx.drawing.nx_pydot.graphviz_layout 来获取节点位置,或者以点格式编写图形以进行进一步处理。
有关其他详细信息,请参见绘图。
## 4.5 图论算法:TSP问题与VRP问题
TSP问题与VRP问题都是优化问题中的经典难题,它们在现实生活中有着重要的应用。TSP问题,也就是旅行商问题,可以简单理解为一个旅行家要访问多个城市,并且希望找到一条最短的路径来依次访问每个城市并最终回到出发地。这个问题看起来简单,但随着城市数量的增加,求解的难度会急剧上升。
而VRP问题,即车辆路径规划问题,则更加贴近我们的日常生活。它主要解决的是如何安排一组车辆,在满足各种约束条件(如货物需求、时间限制、车辆容量等)的前提下,以最小的成本或距离完成货物的配送任务。这就像是物流公司要规划送货路线,既要保证货物能准时送达,又要考虑成本效益。
这两个问题虽然看似不同,但它们的本质都是寻找最优解,即在满足一定条件的前提下,找到最符合目标要求的方案。无论是TSP问题还是VRP问题,都需要我们运用数学、计算机等学科知识,通过算法和模型来求解。在解决这些问题的过程中,我们不仅可以锻炼自己的逻辑思维和解决问题的能力,还可以为现实生活中的许多问题提供有效的解决方案。
TSP问题的背景源自一个古老的问题,即旅行商难题:假设有一个旅行商人要拜访n个城市,他必须选择所要走的路径,路径的限制是每个城市只能拜访一次,而且最后要回到原来出发的城市。路径的选择目标是要求得的路径路程为所有路径之中的最小值。
TSP问题是图论领域一个典型的NP难问题,目前进展迅速的想法是使用以进化计算与群体智能方法为代表的启发式方法求解精确解或者近似解。
利用动态规划解网络TSP的核心想法就是分支,当我从起点开始可以直达多个其他节点时,对于某一条边,添加到回路圈和不添加到回路圈会对子问题产生怎样的影响。如果添加这一条边,剩下的子问题最短回路会是多长;不添加的话子问题的最短回路又是多长呢?

车辆路径问题最早由Dantzig和Ramser于1959年首次提出,是运筹学中一个经典问题。VRP问题主要研究物流配送中的车辆路径规划问题,是当今物流行业中的基础问题。在VRP问题中,假设有一个供求关系系统,车辆从仓库取货,配送到若干个顾客处。车辆受到载重量的约束,需要组织适当的行车路线,在顾客的需求得到满足的基础上,使代价函数最小。
代价函数根据问题不同而不同,常见的有车辆总运行时间最小,车辆总运行路径最短等。基本的问题形式为:假设有N辆车,都从原点出发,每辆车访问一些点后回到原点,要求所有的点都要被访问到,求最短的车辆行驶距离或最少需要的车辆数。
在VRP问题中,假设有一个供求关系系统,车辆从仓库取货,配送到若干个顾客处。车辆受到载重量的约束,需要组织适当的行车路线,在顾客的需求得到满足的基础上,使代价函数最小。代价函数根据问题不同而不同,常见的有车辆总运行时间最小,车辆总运行路径最短等。
假设现在有多辆运载车运输医疗物资到医院,行驶能力、速度、续航等相同。定义j,k为需要接收物资的医院,e为车辆编号,C为城市的集合。Djk为从j到k的行驶距离,dj为医院j的缺口需求量,而车辆的最大载重量为Ye。可以列出如下优化模型:
$$
\begin{array}{c}
\min \sum\limits_{e \in E} {\sum\limits_{j \in C} {\sum\limits_{k \in C} {{D_{jk}}} } } {z_{ejk}}\\
s.t.\left\{ \begin{array}{l}
\sum\limits_{e \in E} {\sum\limits_{j \in C} {{z_{ejk}}} } = 1,\forall j,k \in C\backslash \{ 0\} \\
\sum\limits_{j \in C\backslash \{ 0\} } {\sum\limits_{k \in C\backslash \{ 0\} }^k {{d_k}{z_{ejk}}} } \le {Y_e},\forall i,e \in E\\
\sum\limits_{j \in C} {{z_{e0k}}} = 1,\forall e \in E\\
\sum\limits_{j \in C\backslash \{ 0\} } {{z_{ejk}}} - \sum\limits_{k \in C\backslash \{ 0\} }^k {{z_{ejk}}} = 0,\forall e \in E\\
\sum\limits_{j \in {C_i}} {{z_{ejk}}} = 1,\forall e \in E\\
{z_{ejk}} \in \{ 0,1\} ,\forall e \in E,\forall j,k \in C
\end{array} \right.
\end{array}
$$
本质上这一模型优化的是每辆车在各自回路上的路程和运载量乘积的和。用z表示复杂网络上节点j到节点k的路径上车辆e是否运送。本质上这一个成分是离散优化。然后,每一个医院都得有一辆车送;每一辆车送的量不能超载;每辆车最后必须返回配送中心。决策变量z是0-1变量,但是规模较大,所以与TSP类似,它也是用启发式算法解居多。
除此以外,还存在多仓库VRP等多种问题:
- 基本VRP

- 容量限制VRP

- 时间窗VRP

- 混合VRP: 允许不同类型的车辆存在,每种类型的车辆有不同的容量和成本,目标是最小化总成本。HVRP的主要特点在于它允许在同一个问题中考虑多种不同类型的车辆,这些车辆可以有不同的容量、速度、成本等属性。因此,在解决HVRP时,需要考虑以下几个方面:
- 车辆类型: 不同类型的车辆可能有不同的特点,如不同的容量限制、速度和成本。这些属性需要在问题中明确指定。
- 客户点需求: 每个客户点可能需要不同数量的货物,而不同类型的车辆可以承载不同数量的货物。因此,需要确保分配到每个客户点的车辆类型满足其需求。
- 成本和效率: 由于不同类型的车辆有不同的成本和速度,需要综合考虑成本和效率来优化路线分配。
- 路径优化: 在HVRP中,需要找到一种分配方案,使得每个客户点都得到服务,同时最小化总行驶距离或成本。涉及到路径规划和车辆调度的问题。
- 多目标VRP: 考虑多个冲突的目标,如最小化总行驶距离和最小化总成本。多目标车辆路径问题(Multi-Objective Vehicle Routing Problem,MOVRP)是车辆路径问题的一种变体,其主要特点是在优化过程中考虑多个目标函数,而不仅仅是单一的目标。MOVRP涉及在满足车辆容量和其他约束条件的前提下,同时优化多个不同的目标,如最小化总行驶距离、最小化总成本、最小化车辆数量等。MOVRP的主要挑战在于寻找一种解决方案,能够在多个不同目标之间找到一个平衡,并得到一组最优或非劣解(Pareto最优解)。这些解代表了在多个目标之间的权衡选择,没有一个解在所有目标上都优于其他解。
**例4.1** 无人机和车辆都可以一次运输多个地点,但在无人机电量耗尽之前二者必须在同一地点会合让无人机更换电池和重新装载物资。配送车辆和无人机合作完成所有地点应急物资配送任务,返回到出发地点,此时称为完成一次整体配送。
而在整体配送任务中配送时间是主要的研究因素。按照配送车辆和无人机从出发开始至全部返回到出发地点的时间来计算。在配送过程中,不考虑配送车辆及无人机装卸物资的时间,同时不考虑配送车辆和无人机在各个配送点的停留时间。各个节点的物资需求量如表4.1所示。图给出了问题的网络示意图。


1. 图中给出了所有地点的连接关系网络,仅考虑邻接矩阵中黑色的项。若目前所有应急物资集中在地点9,唯一的配送车辆的最大载重量为1000千克,采取仅使用配送车辆建立完成一次整体配送的数学模型,并给出最优方案。
2. 在问题一的基础上增加了无人机专用路线,如红色所示。应急物资仍然集中在第9个地点,配送车辆的最大载重量为 1000 千克,采取“配送车辆+无人机”的配送模式。建立完成一次整体配送的数学模型,并给出最优方案。
| **节点** | **1** | **2** | **3** | **4** | **5** | **6** | **7** |
| ---------- | ----- | ----- | ------ | ------ | ------ | ------ | ------ |
| **需求量** | 12 | 90 | 24 | 15 | 70 | 18 | 150 |
| **节点** | **8** | **9** | **10** | **11** | **12** | **13** | **14** |
| **需求量** | 50 | 30 | 168 | 36 | 44 | 42 | 13 |
我这里给出我的一些思考:
由于问题一中所有节点的需求量之和小于1000,故暂时不需要考虑负载问题。对于这一VRP问题,我们将其抽象为一个0-1规划问题求解。决策变量为节点i与节点j之间的路径是否经过,若经过则变量取值为1,不经过则变量取值为0。决策目标为使得总路径最短,那么,问题可以抽象为:
(4.2)
而对于无法直接到达的节点对,我们定义其距离为无穷大,这时对应的x必须取0。问题被抽象为一个0-1规划问题。可以使用0-1规划函数求解,但我更建议用遗传算法或模拟退火算法做这个问题,如果一头雾水可以参考第九章。解得结果路径如图所示:

利用遗传算法计算得到配送车经过的总路径为582km,配送车花费的最短总时长为11.64h,综合分析,模型求解时间仅需27s,在保证速度较快的同时我们也以枚举的方法列出了所有的可能,发现确实是最优解,说明使用启发式方法对于这一规模下的VRP问题求解是完备的,模型应用初步成功。
问题二在问题一的基础上增加了无人机,也新增了仅有无人机能行动的路径。如图5所示,虚线为仅有无人机能运行的路径。
经过路径增广以后我们通过计算问题一和问题二中节点的平均度数和聚类系数,发现问题二形成的增广网络在拓扑结构上更具有稳定性,节点度也更高,使得节点形成TSP形式下的回路是可行的。其中,节点5度数为7,节点9度数为7,节点8度数为5,节点6度数也为5,是需要注意的四个节点。
在问题一的基础上我们引入无人机的矩阵y,使得:

这里我们没有再选择路径为优化目标,而是换用时间为优化目标。因为总路径最短并不一定能保证时间最短。若对于某一段子路径,配送车和无人机都在节点i出发,在节点k会合,那么这一段的时间优化为:

在这一段子路径中,无人机和配送车除了起点和终点没有共有节点,需要使得它们的经过时间取更大的一方。而对于对应的路径,我们即取对应子路径u,v及其对应的0-1决策变量x,y,使得:

那么决策目标是所有子路径的最优时间求和。即:

其中N为所有子路径划分的会合点集。
而在问题中两个小于的约束条件无法保证每个节点都能运送到。为了保证这一点,我们引入两个14维的0-1向量,表示节点由无人机配送还是由车辆配送。这两个向量满足条件:

除此以外,向量P还需要满足负载条件,但我们已经知道车载负载是一定可以满足的,于是考虑无人机在每个子路径上的负载条件:

于是,我们建立起了一个较为完备的带约束的优化模型。对于这一模型,约束条件过多且有两个对象,所以在变量编码过程中需要重新进行约束,所需要的变量量也是原有的两倍以上。对于这一问题,使用遗传算法的求解将远远优于传统优化。
同样利用群智能算法可以解得结果如图所示:

得到总策略为:车辆的行进路程为315,无人机的行进路程为295。时间由于车辆运动时间更长一些,所以就着车子的来,就是4.3h最优。
================================================
FILE: docs/CH5/第五章-进化计算与群体智能.md
================================================
第5章 进化计算与群体智能
> 内容:@若冰(马世拓)
>
> 审稿:@刘旭
>
> 排版&校对:@何瑞杰
这一章我们主要介绍进化计算和群体智能算法中四个最常用的算法。传统的优化算法例如我们在第二章看到的分支定界、蒙特卡洛等方法比较适合于简单的、约束和变量不是那么多的优化。但当优化的变量非常之多,约束非常之多,目标函数形式非常之复杂时我们往往 是求不出最优解的。这时候我们通常使用智能优化算法去求近似解。这些算法由于很多从自然中生物行为规律受到启发故又名“仿生计算”。本章主要涉及到的知识点有:
- 遗传算法
- 蚁群算法
- 粒子群算法
- 模拟退火算法
## 5.1 遗传算法理论与实现
人类总是能够从自然界获取很多灵感。通过蝙蝠的回声定位,我们发明了雷达;通过鱼的游动,我们发明了潜艇;通过鲨鱼的皮肤,我们发明了潜水服……而遗传算法同样是基于生物原理得到灵感。这个灵感,来自于孟德尔遗传定律和达尔文自然选择学说。
### 5.1.1 遗传算法
这一章的标题是进化计算与群体智能算法,如果你在其他的资料上看到了“智能优化算法”或者“元启发优化算法”,其实它们说的是一类东西。只是我为什么称之为进化计算与群体智能呢?因为它包含的算法种类真的非常之多,多到令人无法想象。当你看到它们的名字,你会感叹原来自然界为我们提供了如此之多的灵感,诸如蚁群算法,人工鱼群算法,萤火虫群算法,蜂群算法,狼群算法,哈里斯鹰算法……
我将一些常见的智能优化算法按照如图 9.1 所示的方式进行归类汇总。值得注意的是,本章讲解的四个算法是最常用的算法,其它的一些算法虽然灵感上很创新,看起来很炫但实质效果却并没有得到太大的改善。或许运算速度有可能快一些,但从整体来看并没有比本章的这四个算法好多少。

图9.1 一些智能算法的分类
遗传算法是 J.H.Holland 在 1975 年提出,模拟达尔文的遗传选择和自然淘汰的进化过程。这一算法被誉为智能优化算法“根源中的根源”。它被广泛应用于大规模的优化问题,例如非线性规划,离散优化,TSP 问题,VRP 问题,车间调度问题等。
孟德尔在他的遗传学说当中揭示了遗传过程中染色体的一些变化过程:复制,交叉,突变等。而微观的遗传物质的变化影响到了种群在自然界的发展,因为生物的发展与进化主要的过程就是三个:遗传,变异和选择。只有适应环境的竞争力强的生物才能存活下来,不适应者就会消亡。而遗传算法就是借鉴了这一点,通过遗传和变异生成一批候选解,然后在逐代进化的过程中一步步逼近最优解。这里补充几个概念定义:
- 染色体:遗传物质的主要载体,是多个遗传因子的集合。
- 基因:遗传操作的最小单元,以一定排列方式构成染色体。
- 个体:染色体带有特征的实体。
- 种群:多个个体组成群体,进化之初的原始群体被称为初始种群。
- 适应度:用于评价个体适应环境程度的函数值。
- 编码:二进制或十进制去表示研究问题的过程。
- 解码:将二进制或十进制还原为原始问题的过程。
- 选择:以一定概率从种群中选择若干个体的过程,选择的基准方法有很多,常见的有适应度比例法、期望值法、轮盘赌法等。
- 交叉:将两个染色体换组的操作,又称重组。
- 变异:遗传因子按一定概率变化的操作。
遗传算法借鉴了生物学的概念,首先需要对问题进行编码,通常是将函数编码为二进制代码以后,随机产生初始种群作为初始解。随后是遗传算法的核心操作之一——选择,通常选择首先要计算出个体的适应度,根据适应度不同来采取不同选择方法进行选择,常用方法有适应度比例法、期望值法、排位次法、轮盘赌法等。
在自然界中,基因的突变与染色体的交叉组合是常见现象,这里也需要在选择以后按照一定的概率发生突变和组合。不断重复上述操作直到收敛,得到的解即最优。遗传算法基本思想如图 9.2 所示:

图9.2 遗传算法的基本思想
> 注意:其实遗传算法说起来这么复杂,实际上思想本质上还是一个搜索。从一堆可行解里面搜索最优解,没有方向漫无目的的检索叫暴力搜索,有方向的才叫启发式搜索。遗传算法的方向就是进化。
### 5.1.2 遗传算法的实现
我们以一个二元函数的寻优为例介绍如何实现遗传算法。
**例 9.1** 求下面这个函数的极值:
$$F(x, y) = 100(y - x^2)^2 + (1 - x)^2 \tag{5.1.1}$$
首先,我们定义函数并给出绘图方法:
```python
def F(x, y):
return 100.0 * (y - x ** 2.0) ** 2.0 + (1 - x) ** 2.0 # 以香蕉函数为例
def plot_3d(ax):
X = np.linspace(*X_BOUND, 100)
Y = np.linspace(*Y_BOUND, 100)
X, Y = np.meshgrid(X, Y)
Z = F(X, Y)
ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=cm.coolwarm)
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z')
plt.pause(3)
plt.show()
```
函数图像如图所示:

图9.3 函数图像
执行遗传算法的第一步是进行编码并初始化种群,随后评估种群适应度。而评估适应度的过程中需要对编码后的算子进行解码,因此,给出解码方法和适应度评估函数:
```python
def get_fitness(pop):
x, y = translateDNA(pop)
pred = F(x, y)
return pred
# return pred - np.min(pred)+1e-3 # 求最大值时的适应度
# return np.max(pred) - pred + 1e-3 # 求最小值时的适应度,通过这一步 fitness 的范围为[0, np.max(pred)-np.min(pred)]
def translateDNA(pop):
# pop 表示种群矩阵,一行表示一个二进制编码表示的 DNA,矩阵的行数为种群数目
x_pop = pop[:, 0:DNA_SIZE] # 前 DNA_SIZE 位表示 X
y_pop = pop[:, DNA_SIZE:] # 后 DNA_SIZE 位表示 Y
x = x_pop.dot(2 ** np.arange(DNA_SIZE)[::-1]) / float(2 ** DNA_SIZE - 1) * (X_BOUND[1] - X_BOUND[0]) + X_BOUND[0]
y = y_pop.dot(2 ** np.arange(DNA_SIZE)[::-1]) / float(2 ** DNA_SIZE - 1) * (Y_BOUND[1] - Y_BOUND[0]) + Y_BOUND[0]
return x, y
```
在迭代过程中,需要不断进行交叉变异等操作。这里给出变异操作的代码:
```python
def mutation(child, MUTATION_RATE=0.003):
if np.random.rand() < MUTATION_RATE: # 以 MUTATION_RATE 的概率进行变异
mutate_point = np.random.randint(0, DNA_SIZE) # 随机产生一个实数,代表要变异基因的位置
child[mutate_point] = child[mutate_point] ^ 1 # 将变异点的二进制为反转
```
交叉操作的代码如下
```python
def crossover_and_mutation(pop, CROSSOVER_RATE=0.8):
new_pop = []
for father in pop: # 遍历种群中的每一个个体,将该个体作为父亲
child = father # 孩子先得到父亲的全部基因(这里我把一串二进制串的那些 0,1 称为基因)
if np.random.rand() < CROSSOVER_RATE: # 产生子代时不是必然发生交叉,而是以一定的概率发生交叉
mother = pop[np.random.randint(POP_SIZE)] # 再种群中选择另一个个体,并将该个体作为母亲
cross_points = np.random.randint(low=0, high=DNA_SIZE * 2) # 随机产生交叉的点
child[cross_points:] = mother[cross_points:] # 孩子得到位于交叉点后的母亲的基因
mutation(child) # 每个后代有一定的机率发生变异
new_pop.append(child)
return new_pop
```
最终,会对种群进行自然选择,留下适应度高的部分。自然选择的代码形如:
```python
def select(pop, fitness):
# nature selection wrt pop's fitness
idx = np.random.choice(np.arange(POP_SIZE), size=POP_SIZE, replace=True,
p=(fitness) / (fitness.sum()))
return pop[idx]
```
完整代码如下:
```python
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import cm
from mpl_toolkits.mplot3d import Axes3D
DNA_SIZE = 24
POP_SIZE = 80
CROSSOVER_RATE = 0.6
MUTATION_RATE = 0.01
N_GENERATIONS = 100
X_BOUND = [-2.048, 2.048]
Y_BOUND = [-2.048, 2.048]
def F(x, y):
return 100.0 * (y - x ** 2.0) ** 2.0 + (1 - x) ** 2.0 # 以香蕉函数为例
def plot_3d(ax):
X = np.linspace(*X_BOUND, 100)
Y = np.linspace(*Y_BOUND, 100)
X, Y = np.meshgrid(X, Y)
Z = F(X, Y)
ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=cm.coolwarm)
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z')
plt.pause(3)
plt.show()
def get_fitness(pop):
x, y = translateDNA(pop)
pred = F(x, y)
return pred
# return pred - np.min(pred)+1e-3 # 求最大值时的适应度
# return np.max(pred) - pred + 1e-3 # 求最小值时的适应度,通过这一步fitness的范围为[0, np.max(pred)-np.min(pred)]
def translateDNA(pop): # pop表示种群矩阵,一行表示一个二进制编码表示的DNA,矩阵的行数为种群数目
x_pop = pop[:, 0:DNA_SIZE] # 前DNA_SIZE位表示X
y_pop = pop[:, DNA_SIZE:] # 后DNA_SIZE位表示Y
x = x_pop.dot(2 ** np.arange(DNA_SIZE)[::-1]) / float(2 ** DNA_SIZE - 1) * (X_BOUND[1] - X_BOUND[0]) + X_BOUND[0]
y = y_pop.dot(2 ** np.arange(DNA_SIZE)[::-1]) / float(2 ** DNA_SIZE - 1) * (Y_BOUND[1] - Y_BOUND[0]) + Y_BOUND[0]
return x, y
def crossover_and_mutation(pop, CROSSOVER_RATE=0.8):
new_pop = []
for father in pop: # 遍历种群中的每一个个体,将该个体作为父亲
child = father # 孩子先得到父亲的全部基因(这里我把一串二进制串的那些0,1称为基因)
if np.random.rand() < CROSSOVER_RATE: # 产生子代时不是必然发生交叉,而是以一定的概率发生交叉
mother = pop[np.random.randint(POP_SIZE)] # 再种群中选择另一个个体,并将该个体作为母亲
cross_points = np.random.randint(low=0, high=DNA_SIZE * 2) # 随机产生交叉的点
child[cross_points:] = mother[cross_points:] # 孩子得到位于交叉点后的母亲的基因
mutation(child) # 每个后代有一定的机率发生变异
new_pop.append(child)
return new_pop
def mutation(child, MUTATION_RATE=0.003):
if np.random.rand() < MUTATION_RATE: # 以MUTATION_RATE的概率进行变异
mutate_point = np.random.randint(0, DNA_SIZE) # 随机产生一个实数,代表要变异基因的位置
child[mutate_point] = child[mutate_point] ^ 1 # 将变异点的二进制为反转
def select(pop, fitness): # nature selection wrt pop's fitness
idx = np.random.choice(np.arange(POP_SIZE), size=POP_SIZE, replace=True,
p=(fitness) / (fitness.sum()))
return pop[idx]
def print_info(pop):
fitness = get_fitness(pop)
max_fitness_index = np.argmax(fitness)
print("max_fitness:", fitness[max_fitness_index])
x, y = translateDNA(pop)
print("最优的基因型:", pop[max_fitness_index])
print("(x, y):", (x[max_fitness_index], y[max_fitness_index]))
print(F(x[max_fitness_index], y[max_fitness_index]))
if __name__ == "__main__":
fig = plt.figure()
ax = Axes3D(fig)
plot_3d(ax)
pop = np.random.randint(2, size=(POP_SIZE, DNA_SIZE * 2)) # matrix (POP_SIZE, DNA_SIZE)
for _ in range(N_GENERATIONS): # 迭代N代
x, y = translateDNA(pop)
if 'sca' in locals():
sca.remove()
sca = ax.scatter(x, y, F(x, y), c='black', marker='o')
plt.show()
plt.pause(0.1)
pop = np.array(crossover_and_mutation(pop, CROSSOVER_RATE))
fitness = get_fitness(pop)
pop = select(pop, fitness) # 选择生成新的种群
print_info(pop)
plot_3d(ax)
```
最终解得极值点出现在 $(x, y) = (2.04287866180412, -1.9751059526864263)$,极值为 $3781.442624151466$。
事实上,在第三章中我们其实也见到过,Python 的 scikit-opt 库也可以实现遗传算法。这里我们以两个案例介绍 sko 中遗传算法的使用。
**例 9.2** 求下面这个函数的极值:
$$ F(x, y) = x^2 + y^2 + \sin(x) + (1 - 0.001)x^2 \tag{5.1.2}$$
代码如下:
```python
import numpy as np
from sko.GA import GA
def schaffer(p):
'''This function has plenty of local minimum, with strong shocks
global minimum at (0,0) with value 0'''
x1, x2 = p
x = np.square(x1) + np.square(x2)
return 0.5 + (np.square(np.sin(x)) - 0.5) / np.square(1 + 0.001 * x)
ga = GA(func=schaffer, n_dim=2, size_pop=50, max_iter=800, prob_mut=0.001, lb=[-1, -1], ub=[1, 1], precision=1e-7)
best_x, best_y = ga.run()
print('best_x:', best_x, '\n', 'best_y:', best_y)
```
最终搜索到的最优解为 $[0,0]$。在迭代过程中的损失函数曲线也可以进行绘制:
```python
import pandas as pd
import matplotlib.pyplot as plt
Y_history = pd.DataFrame(ga.all_history_Y)
fig, ax = plt.subplots(2, 1)
ax[0].plot(Y_history.index, Y_history.values, '.', color='red')
Y_history.min(axis=1).cummin().plot(kind='line')
plt.show()
```
所得到的适应度函数随迭代轮次的变化曲线如图所示:

图9.4 遗传算法的迭代曲线
**例 9.3** 利用遗传算法解 TSP 问题
我们可以先创建数据点的横纵坐标,并定义目标函数为回路的距离之和:
```python
import numpy as np
from scipy import spatial
import matplotlib.pyplot as plt
num_points = 50
points_coordinate = np.random.rand(num_points, 2) # generate coordinate of points
distance_matrix = spatial.distance.cdist(points_coordinate, points_coordinate, metric='euclidean')
def cal_total_distance(routine):
'''The objective function. input routine, return total distance.
cal_total_distance(np.arange(num_points))'''
num_points, = routine.shape
return sum([distance_matrix[routine[i % num_points], routine[(i + 1) % num_points]] for i in range(num_points)])
```
在 sko 中,有专门用于解决 TSP 问题的接口 `GA_TSP` 来通过遗传算法解决 TSP 问题。例如,我们看到下面的代码:
```python
from sko.GA import GA_TSP
ga_tsp = GA_TSP(func=cal_total_distance, n_dim=num_points, size_pop=50, max_iter=500, prob_mut=1)
best_points, best_distance = ga_tsp.run()
fig, ax = plt.subplots(1, 2)
best_points_ = np.concatenate([best_points, [best_points[0]]])
best_points_coordinate = points_coordinate[best_points_, :]
ax[0].plot(best_points_coordinate[:, 0], best_points_coordinate[:, 1], 'o-r')
ax[1].plot(ga_tsp.generation_best_Y)
plt.show()
```
最终得到的 TSP 回路路径与适应度函数变化曲线如图所示:

图9.5 使用遗传算法解TSP问题
**例 9.4** 使用遗传算法进行数据拟合
我们随机生成一组数据点:
```python
x_true = np.linspace(-1.2, 1.2, 30)
y_true = x_true ** 3 - x_true + 0.4 * np.random.rand(30)
plt.plot(x_true, y_true, 'o')
```
我们使用 sko 库中的遗传算法(GA) 进行拟合:
```python
def f_fun(x, a, b, c, d):
return a * x ** 3 + b * x ** 2 + c * x + d
def obj_fun(p):
a, b, c, d = p
residuals = np.square(f_fun(x_true, a, b, c, d) - y_true).sum()
return residuals
nga = GA(func=obj_fun, n_dim=4, size_pop=100, max_iter=500, lb=[-2] * 4, ub=[2] * 4)
best_params, residuals = ga.run()
print('best_x:', best_params, '\n', 'best_y:', residuals)
y_predict = f_fun(x_true, *best_params)
fig, ax = plt.subplots()
ax.plot(x_true, y_true, 'o')
ax.plot(x_true, y_predict, '-')
plt.show()
# best_x: [ 0.93360083 -0.0612649 -0.98437051 0.27416942] best_y: [0.2066883]
```

图9.6 遗传算法解数据拟合问题
## 5.2 粒子群算法理论与实现
### 5.3.1 粒子群算法
不知各位是否会注意到,鸟群例如大雁在飞行的时候它们的飞行方向除了受到环境的影响,还会受到其他大雁的影响,从而使群体中每一只大雁的飞行轨迹都整齐划一。而当一只鸟飞离鸟群去寻找栖息地的时候,它不仅要考虑自身运动方向和周围环境,还会从其他优秀的个体的飞行轨迹去模仿学习经验(当然它自己也可能被其它鸟模仿)。这一过程揭示了鸟群运动过程中的两类重要的知识:自我智慧和群体智慧。
现在假设一群鸟在一块有食物的区域内,它们都瞎了都不知道食物在哪里,但知道当前位置与食物的距离。最简单的方法就是搜寻目前离食物最近的鸟的区域。那我现在把这个区域看做是函数的搜索空间,每个鸟被抽象为一个粒子(物理意义上的质点),每个粒子有一个适应度和速度描述飞行方向和距离。粒子通过分析当前最优粒子在解空间中的运动过程去搜索最优解。设微粒群体规模为 $N$,其中每个微粒在 $D$ 维空间中的坐标位置可表示为 $X_{i}=(x_{i,1},x_{i,2},…,x_{i,D})$,微粒 i 的速度定义为每次迭代中微粒移动的距离,表示为 $V_i=(v_{i,1},v_{i,2},…,v_{i,D})$,$P_i$ 表示微粒 $i$ 所经历的最好位置,$P_g$ 为群体中所有微粒所经过的最好位置,则微粒 $i$ 在第 $d$ 维子空间中的飞行速度 $v_{i,d}$ 根据下式进行调整:
$$ v_{i,d}^{t+1} = w \cdot v_{i,d}^{t} + c_1 \cdot \text{Rand}() \cdot (p_{i,d}^t - x_{i,d}^{t}) + c_2 \cdot \text{Rand}() \cdot (p_{g,d}^t - x_{i,d}^{t}) \tag{5.2.1}$$
在这个过程中,每次运动的时间间隔被视作单位 $1$,那么速度实际上也可以用于描述下一个时间间隔的移动方向和移动距离。
$$ x_{i,d}^{t+1} = x_{i,d}^{t} + v_{i,d}^{t+1} \tag{5.2.2}$$
第一项为微粒先前速度乘一个权值进行加速,表示微粒对当前自身速度状态的信任,依据自身的速度进行惯性运动,因此称这个权值为惯性权值。第二项为微粒当前位置与自身最优位置之间的距离,为认知部分,表示微粒本身的思考,即微粒的运动来源于自己经验的部分。第三项为微粒当前位置与群体最优位置之间的距离,为社会部分,表示微粒间的信息共享与相互合作,即微粒的运动中来源于群体中其他微粒经验的部分。
粒子群算法基本流程:
1. 初始化:随机初始化每一微粒的位置和速度。
2. 评估:依据适应度函数计算每个微粒的适应度值,以作为判断每一微粒之好坏。
3. 寻找个体最优解:找出每一微粒到目前为止的搜寻过程中最佳解,这个最佳解称为 Pbest。
4. 寻找群体最优解:找出所有微粒到目前为止所搜寻到的整体最佳解,此最佳解称之为 Gbest。
5. 更新每一微粒的速度与位置。
6. 回到步骤 2 继续执行,直到获得一个令人满意的结果或符合终止条件为止。
粒子群算法的工作流程如图 9.7 所示:

图9.7 粒子群算法的计算过程
### 5.2.2 粒子群算法的实现
**例 9.5** 求下面这个函数的极值:
$$F(x, y) = 3\cos(x y) + x + y^2 \tag{9.2.3}
$$
我们可以先通过它的图像来观察它的性质:
```python
import numpy as np
import matplotlib.pyplot as plt
X = np.arange(-4 ,4 ,0.01)
Y = np.arange(-4 ,4 ,0.01)
x, y = np.meshgrid(X ,Y)
Z = 3*np.cos(x * y) + x + y**2
# 作图
fig = plt.figure(figsize=(10,15))
ax3 = plt.axes(projection = "3d")
ax3.plot_surface(x,y,Z ,cmap = "rainbow")
plt.show()
```
得到图像如图9.8 所示:

图9.8 测试函数的图像
从图中可以看到函数有多个极值点,我们使用粒子群算法找到函数的全局最优点。对上述过程进行复现的完整代码如下:
```python
import numpy as np
# 初始化种群,群体规模,每个粒子的速度和规模
N = 100 # 种群数目
D = 2 # 维度
T = 200 # 最大迭代次数
c1 = c2 = 1.5 # 个体学习因子与群体学习因子
w_max = 0.8 # 权重系数最大值
w_min = 0.4 # 权重系数最小值
x_max = 4 # 每个维度最大取值范围,如果每个维度不一样,那么可以写一个数组,下面代码依次需要改变
x_min = -4 # 同上
v_max = 1 # 每个维度粒子的最大速度
v_min = -1 # 每个维度粒子的最小速度
# 定义适应度函数
def func(x):
return 3 * np.cos(x[0] * x[1]) + x[0] + x[1] ** 2
# 初始化种群个体
x = np.random.rand(N, D) * (x_max - x_min) + x_min # 初始化每个粒子的位置
v = np.random.rand(N, D) * (v_max - v_min) + v_min # 初始化每个粒子的速度
# 初始化个体最优位置和最优值
p = x # 用来存储每一个粒子的历史最优位置
p_best = np.ones((N, 1)) # 每行存储的是最优值
for i in range(N): # 初始化每个粒子的最优值,此时就是把位置带进去,把适应度值计算出来
p_best[i] = func(x[i, :])
# 初始化全局最优位置和全局最优值
g_best = 100 #设置真的全局最优值
gb = np.ones(T) # 用于记录每一次迭代的全局最优值
x_best = np.ones(D) # 用于存储最优粒子的取值
# 按照公式依次迭代直到满足精度或者迭代次数
for i in range(T):
for j in range(N):
# 个更新个体最优值和全局最优值
if p_best[j] > func(x[j,:]):
p_best[j] = func(x[j,:])
p[j,:] = x[j,:].copy()
# p_best[j] = func(x[j,:]) if func(x[j,:]) < p_best[j] else p_best[j]
# 更新全局最优值
if g_best > p_best[j]:
g_best = p_best[j]
x_best = x[j,:].copy() # 一定要加 copy,否则后面 x[j,:]更新也会将 x_best 更新
# 计算动态惯性权重
w = w_max - (w_max - w_min) * i / T
# 更新位置和速度
v[j, :] = w * v[j, :] + c1 * np.random.rand(1) * (p[j, :] - x[j, :]) + c2 * np.random.rand(1) * (x_best - x[j, :])
x[j, :] = x[j, :] + v[j, :]
# 边界条件处理
for ii in range(D):
if (v[j, ii] > v_max) or (v[j, ii] < v_min):
v[j, ii] = v_min + np.random.rand(1) * (v_max - v_min)
if (x[j, ii] > x_max) or (x[j, ii] < x_min):
x[j, ii] = x_min + np.random.rand(1) * (x_max - x_min)
# 记录历代全局最优值
gb[i] = g_best
print("最优值为", gb[T - 1],"最优位置为",x_best)
plt.plot(range(T),gb)
plt.xlabel("迭代次数")
plt.ylabel("适应度值")
plt.title("适应度进化曲线")
plt.show()
```
可以得到适应度的进化曲线如图9.9 所示:

图9.9 适应度随迭代次数的变化
最终得到的搜索结果为最优值为 $-6.4063965702604575$ 最优位置为 $[-3.99999512 -0.74624737]$
同样的,在 sko 中有 `PSO` 方法提供了粒子群算法的接口。例如,下面两个例子都可以使用 `PSO` 接口进行求解。
**例 9.6** 求下面这个函数的极值:
$$ F(x) = x_1^2 + (x_2 - 0.05)^2 + x_3^2, \quad x_{3} \geqslant 0.05 \tag{5.2.4}$$
使用 `sko.PSO` 提供的 `PSO` 方法解决这个问题的代码如下:
```python
from sko.PSO import PSO
def demo_func(x):
x1, x2, x3 = x
return x1 ** 2 + (x2 - 0.05) ** 2 + x3 ** 2
pso = PSO(func=demo_func, dim=3, pop=40, max_iter=150, lb=[0, -1, 0.5], ub=[1, 1, 1], w=0.8, c1=0.5, c2=0.5)
pso.run()
print('best_x is ', pso.gbest_x, 'best_y is', pso.gbest_y)
plt.plot(pso.gbest_y_hist)
plt.show()
# best_x is [0. 0.05 0.5 ] best_y is [0.25]
```

图9.10 适应度的变化
**例 9.7** 利用粒子群算法解 TSP 问题
与遗传算法类似,粒子群算法也提供了针对 TSP 问题的接口。完整代码如下:
```python
from sko.PSO import PSO_TSP
from scipy import spatial
distance_matrix = spatial.distance.cdist(points_coordinate, points_coordinate, metric='euclidean')
def cal_total_distance(routine):
'''The objective function. input routine, return total distance.
cal_total_distance(np.arange(num_points))'''
num_points, = routine.shape
return sum([distance_matrix[routine[i % num_points], routine[(i + 1) % num_points]] for i in range(num_points)])
pso_tsp = PSO_TSP(func=cal_total_distance, n_dim=num_points, size_pop=200, max_iter=800, w=0.8, c1=0.1, c2=0.1)
best_points, best_distance = pso_tsp.run()
print('best_distance', best_distance)
fig, ax = plt.subplots(1, 2)
best_points_ = np.concatenate([best_points, [best_points[0]]])
best_points_coordinate = points_coordinate[best_points_, :]
ax[0].plot(best_points_coordinate[:, 0], best_points_coordinate[:, 1], 'o-r')
ax[1].plot(pso_tsp.gbest_y_hist)
plt.show()
```
最优距离为 $4.5485$,得到的结果如图所示:

图9.11 使用粒子群算法解TSP问题
## 5.3 蚁群算法理论与实现
### 5.2.1 蚁群算法
蚁群算法(Ant colony algorithm)是 20 世纪 90 年代初意大利学者 M.Dorigo,V.Maniezzo,A.Colorni 等从生物进化的机制中受到启发,通过模拟自然界蚂蚁搜索路径的行为提出来的一种新型的模拟进化算法。蚂蚁在运动过程中,能够在它所经过的路径上留下一种称之为外激素(pheromone)的物质进行信息传递,而且蚂蚁在运动过程中能够感知这种物质,并以此指导自己的运动方向,因此由大量蚂蚁组成的蚁群集体行为便表现出一种信息正反馈现象:某一路径上走过的蚂蚁越多,则后来者选择该路径的概率就越大。最优路径上的激素浓度越来越大,而其它的路径上激素浓度却会随着时间的流逝而消减。最终整个蚁群会找出最优路径。
蚁群算法的规则如下:
- 初始化:为每条边上的初始信息素和蚂蚁进行赋值。
- 如果满足算法外循环的停止规则则停止计算并输出最优解;否则蚂蚁们统统从起点出发,将走过的路径添加到集合中。
- 对每一只蚂蚁,按照信息素浓度分配各个路径的概率,并选择路径同时留下信息素。
分配规则如下:$$ p_{i,j} = \frac{\tau_{i,j}^{\alpha} \cdot \eta_{i,j}^{\beta}}{\sum_{k=0}^{n-1} \tau_{ik}^{\alpha} \cdot \eta_{ik}^{\beta}} \tag{5.2.5}$$
其中,$\tau_{i,j}$ 是从节点 $i$ 到节点 $j$ 的信息素浓度,$\eta_{i,j}$ 是启发式因子,通常是距离的倒数,$\alpha$ 和 $\beta$ 是参数。
- 按照一定规则对最短路径上的信息素增强,其他路径上的信息素进行挥发。定义最短路径为 $W$,挥发的规则形如:$$ \tau_{i,j} \leftarrow (1 - \rho) \tau_{i,j} + \Delta \tau_{i,j} \tag{5.2.6}$$
其中,$\rho$ 是挥发率,$\Delta \tau_{i,j}$ 是路径 $i$ 到 $j$ 上新增的信息素量。
> 注意:蚁群算法的过程中边上信息素的一些状态和蚂蚁的行进信息可以用一个表格(数组)存储起来,这个表叫禁忌表。
蚁群算法的流程如图9.12 所示:

图9.12 蚁群算法的流程图
### 5.2.2 蚁群算法的实现
我们可以用面向对象的方式提供一种蚁群算法的实现:
```python
import numpy as np
import matplotlib.pyplot as plt
class ACO:
def __init__(self, parameters):
# 初始化
self.NGEN = parameters[0] # 迭代的代数
self.pop_size = parameters[1] # 种群大小
self.var_num = len(parameters[2]) # 变量个数
self.bound = [] # 变量的约束范围
self.bound.append(parameters[2])
self.bound.append(parameters[3])
self.pop_x = np.zeros((self.pop_size, self.var_num)) # 所有蚂蚁的位置
self.g_best = np.zeros((1, self.var_num)) # 全局蚂蚁最优的位置
# 初始化第 0 代初始全局最优解
temp = -1
for i in range(self.pop_size):
for j in range(self.var_num):
self.pop_x[i][j] = np.random.uniform(self.bound[0][j], self.bound[1][j])
fit = self.fitness(self.pop_x[i])
if fit > temp:
self.g_best = self.pop_x[i]
temp = fit
def fitness(self, ind_var):
""“个体适应值计算""“
x1 = ind_var[0]
x2 = ind_var[1]
x3 = ind_var[2]
y = 4*x1 ** 2 + 2*x2 + x3 ** 3
return y
def update_operator(self, gen, t, t_max):
""“更新算子:根据概率更新下一时刻的位置""“
rou = 0.8 # 信息素挥发系数
Q = 1 # 信息释放总量
lamda = 1 / gen
pi = np.zeros(self.pop_size)
for i in range(self.pop_size):
for j in range(self.var_num):
pi[i] = (t_max - t[i]) / t_max
# 更新位置
if pi[i] < np.random.uniform(0, 1):
self.pop_x[i][j] = self.pop_x[i][j] + np.random.uniform(-1, 1) * lamda
else:
self.pop_x[i][j] = self.pop_x[i][j] + np.random.uniform(-1, 1) * (
self.bound[1][j] - self.bound[0][j]) / 2
# 越界保护
if self.pop_x[i][j] < self.bound[0][j]:
self.pop_x[i][j] = self.bound[0][j]
if self.pop_x[i][j] > self.bound[1][j]:
self.pop_x[i][j] = self.bound[1][j]
# 更新 t 值
t[i] = (1 - rou) * t[i] + Q * self.fitness(self.pop_x[i])
# 更新全局最优值
for i in range(self.pop_size):
if self.fitness(self.pop_x[i]) > self.fitness(self.g_best):
self.g_best = self.pop_x[i].copy()
t_max = np.max(t)
return t_max, t
def main(self):
popobj = []
best = np.zeros((1, self.var_num))[0]
for gen in range(1, self.NGEN + 1):
if gen == 1:
tmax, t = self.update_operator(gen, np.array(list(map(self.fitness, self.pop_x))),
np.max(np.array(list(map(self.fitness, self.pop_x)))))
else:
tmax, t = self.update_operator(gen, t, tmax)
print('############ Generation {} ############'.format(str(gen)))
print(self.g_best)
print(self.fitness(self.g_best))
if self.fitness(self.g_best) > self.fitness(best):
best = self.g_best.copy()
popobj.append(self.fitness(best))
print('最好的位置:{}'.format(best))
print('最大的函数值:{}'.format(self.fitness(best)))
print("---- End of (successful) Searching ----")
plt.figure()
plt.title("Figure1")
plt.xlabel("iterators", size=14)
plt.ylabel("fitness", size=14)
t = [t for t in range(1, self.NGEN + 1)]
plt.plot(t, popobj, color='b', linewidth=2)
plt.show()
if __name__ == '__main__':
NGEN = 100
popsize = 50
low = [1, 1, 1]
up = [30, 30, 30]
parameters = [NGEN, popsize, low, up]
aco = ACO(parameters)
aco.main()
```
如果使用 sko 工具,`sko.ACA` 也提供了蚁群算法的接口。蚁群算法在解决 TSP 问题中有着重要作用,例如,使用下面的代码利用蚁群算法解决 TSP 问题:
```python
import numpy as np
from scipy import spatial
import matplotlib.pyplot as plt
num_points = 50
points_coordinate = np.random.rand(num_points, 2) # generate coordinate of points
distance_matrix = spatial.distance.cdist(points_coordinate, points_coordinate, metric='euclidean')
def cal_total_distance(routine):
'''The objective function. input routine, return total distance.
cal_total_distance(np.arange(num_points))'''
num_points, = routine.shape
return sum([distance_matrix[routine[i % num_points], routine[(i + 1) % num_points]] for i in range(num_points)])
from sko.ACA import ACA_TSP
aca = ACA_TSP(func=cal_total_distance, n_dim=num_points,
size_pop=50, max_iter=200,
distance_matrix=distance_matrix)
best_points, best_distance = aca.run()
print(best_points)
print(best_distance)
fig, ax = plt.subplots(1, 2)
best_points_ = np.concatenate([best_points, [best_points[0]]])
best_points_coordinate = points_coordinate[best_points_, :]
ax[0].plot(best_points_coordinate[:, 0], best_points_coordinate[:, 1], 'o-r')
ax[1].plot(aca.generation_best_Y)
plt.show()
```

图9.13 使用蚁群算法解TSP问题
## 5.4 模拟退火算法理论与实现
模拟退火算法由 Kirkpatrick 等提出,能有效的解决局部最优解问题。它不同于前面基于生物的进化和群体智能,它是基于物理学定律提出的方法。
### 5.4.1 模拟退火算法
模拟退火算法(Simulated Annealing, SA)的思想借鉴于固体的退火原理,当固体的温度很高的时候,内能比较大,固体的内部粒子处于快速无序运动,当温度慢慢降低的过程中,固体的内能减小,粒子的慢慢趋于有序,最终,当固体处于常温时,内能达到最小,此时,粒子最为稳定。模拟退火算法便是基于这样的原理设计而成。
模拟退火算法来源于晶体冷却的过程,如果固体不处于最低能量状态,给固体加热再冷却,随着温度缓慢下降,固体中的原子按照一定形状排列,形成高密度、低能量的有规则晶体,对应于算法中的全局最优解。而如果温度下降过快,可能导致原子缺少足够的时间排列成晶体的结构,结果产生了具有较高能量的非晶体,这就是局部最优解。因此就可以根据退火的过程,给其在增加一点能量,然后再冷却,如果增加能量,跳出了局部最优解,本次退火就是成功的。
模拟退火算法包含两个部分即 **Metropolis 准则**和**退火过程**。Metropolis 准则以概率来接受新状态,而不是使用完全确定的规则,称为 Metropolis 准则,计算量较低。从某一个解到新解本质上是衡量其能量变化,若能量向递减的方向跃迁则接受这一次迭代,若能量反而增大,并不是一定拒绝而是以一定的采样概率接受。这一概率值满足 Metropolis 定义:
$$ P = \exp\left(-\frac{E_{new} - E_{old}}{T}\right) \tag{5.4.1}$$
直接使用 Metropolis 算法可能会导致寻优速度太慢,以至于无法实际使用,为了确保在有限的时间收敛,必须设定控制算法收敛的参数,在上面的公式中,可以调节的参数就是 $T$,$T$ 如果过大,就会导致退火太快,达到局部最优值就会结束迭代,如果取值较小,则计算时间会增加,实际应用中采用退火温度表,在退火初期采用较大的 $T$ 值,随着退火的进行,逐步降低。
模拟退火的过程如图 9.14 所示:

图9.14 模拟退火算法流程图
> 注意:速度上模拟退火和粒子群都很快,但模拟退火略快一些,比遗传更快,蚁群的速度是最慢的。但粒子群求解大规模函数极值的时候容易碰到边界陷入的情况。模拟退火则相对比较稳定一些。
### 5.4.2 模拟退火算法的实现
**例 9.8** 求下面这个函数的极值:
$$ y = x^3 - 60x^2 - 4x + 6 \tag{5.4.2}$$
使用 python 对模拟退火算法进行编程的代码如下:
```python
import numpy as np
import math
def aimFunction(x):
y = x ** 3 - 60 * x ** 2 - 4 * x + 6
return y
x = [i / 10 for i in range(1000)]
y = [0 for i in range(1000)]
for i in range(1000):
y[i] = aimFunction(x[i])
plt.plot(x, y)
plt.show()
T = 1000 # initiate temperature
Tmin = 10 # minimum value of temperature
x = np.random.uniform(low=0, high=100) # initiate x
k = 50 # times of internal circulation
y = 0 # initiate result
t = 0 # time
while T >= Tmin:
for i in range(k):
# calculate y
y = aimFunction(x)
# generate a new x in the neighboorhood of x by transform function
xNew = x + np.random.uniform(low=-0.055, high=0.055) * T
if (0 <= xNew and xNew <= 100):
yNew = aimFunction(xNew)
if yNew - y < 0:
x = xNew
else:
# metropolis principle
p = math.exp(-(yNew - y) / T)
r = np.random.uniform(low=0, high=1)
if r < p:
x = xNew
t += 1
T = 1000 / (1 + t) #降温函数,也可使用 T=0.9T
print(x, aimFunction(x))
# 39.78060332087924 -32150.24487975278
```
**例 9.9** 使用模拟退火算法解决 TSP 问题
使用 sko 中提供的模拟退火算法接口解一个 TSP 问题的代码如下:
```python
from sko.SA import SA_TSP
nsa_tsp = SA_TSP(func=cal_total_distance, x0=range(num_points), T_max=800, T_min=1, L=1000)
best_points, best_distance = sa_tsp.run()
print(best_points, best_distance, cal_total_distance(best_points))
fig, ax = plt.subplots(1, 2)
best_points_ = np.concatenate([best_points, [best_points[0]]])
best_points_coordinate = points_coordinate[best_points_, :]
ax[0].plot(sa_tsp.best_y_history)
ax[0].set_xlabel("Iteration")
ax[0].set_ylabel("Distance")
ax[1].plot(best_points_coordinate[:, 0], best_points_coordinate[:, 1], marker='o', markerfacecolor='b', color='c', linestyle='-')
ax[1].set_xlabel("Longitude")
ax[1].set_ylabel("Latitude")
plt.show()
```
得到结果如图所示:

图9.15 使用模拟退火算法解TSP问题
## 5.5 使用 scikit-opt 实现智能优化算法
### 5.5.1 智能优化算法分类
智能优化算法受到人类智能、生物群体社会性或自然现象规律的启发,主要包括以下几种类型:
- **进化类算法**:
- *遗传算法*:模仿自然界生物进化机制。
- *差分进化算法*:通过群体个体间的合作与竞争来优化搜索。
- *免疫算法*:模拟生物免疫系统学习和认知功能。
- **群体智能算法**:
- *蚁群算法*:模拟蚂蚁集体寻径行为。
- *粒子群算法*:模拟鸟群和鱼群群体行为。
除了以上常见的算法外,还有许多其他群体智能优化算法,例如:萤火虫算法、布谷鸟算法、蝙蝠算法、狼群算法、烟花算法、合同网协议算法等等。
- *模拟退火算法*:源于固体物质退火过程。
- *禁忌搜索算法*:模拟人类智力记忆过程。
- *神经网络算法*:模拟动物神经网络行为特征。
### 5.5.2 Scikit-opt 使用方法简介
Scikit-opt 封装了 7 种启发式算法,分别是差分进化算法、遗传算法、粒子群算法、模拟退火算法、蚁群算法、鱼群算法和免疫优化算法。
在探索智能优化算法之前,首先我们需要先安装 scikit-opt 库。
```bash
pip install scikit-opt
```
接下来我们来学习如何利用 Scikit-opt 库实现上述的七种算法,下面是一些简单的案例:
#### 1. 差分进化算法
**例 9.10** 解下面的优化问题:
$$
\begin{align}
\text{minimize}~&f(x_{1}, x_{2}, x_{3}) = x_{1}^{2} + x_{2}^{2} + x_{3}^{2}\\
\text{s.t.}~&x_{1}x_{2} \geqslant 1\\
&x_{1}x_{2} \leqslant 5\\
&x_{2} + x_{3} = 1\\
&0 \leqslant x_{1}, x_{2}, x_{3} \leqslant 5
\end{align} \tag{5.5.1}
$$
```python
from sko.DE import DE
de = DE(func=obj_func, n_dim=3, size_pop=50, max_iter=800, lb=[0, 0, 0], ub=[5, 5, 5],
constraint_eq=constraint_eq, constraint_ueq=constraint_ueq)
best_x, best_y = de.run()
print('best_x:', best_x, '\n', 'best_y:', best_y)
```
#### 2. 遗传算法
**例 9.11** 解下面的优化问题:
$$
\text{minimize}~f(x_{1}, x_{2}, x_{3}) = x_{1}^{2} + (x_{2} - 0.05)^{2} + x_{3}^{2} \tag{5.5.2}
$$
```python
from sko.GA import GA
def schaffer(p):
'''
这个函数有很多局部最小值,具有强烈的震荡
全局最小值在 (0,0) 处,值为 0
'''
x1, x2 = p
x = np.square(x1) + np.square(x2)
return 0.5 + (np.square(np.sin(x)) - 0.5) / np.square(1 + 0.001 * x)
ga = GA(func=schaffer, n_dim=2, size_pop=50, max_iter=800, prob_mut=0.001, lb=[-1, -1], ub=[1, 1], precision=1e-7)
best_x, best_y = ga.run()
print('best_x:', best_x, '\n', 'best_y:', best_y)
import pandas as pd
import matplotlib.pyplot as plt
Y_history = pd.DataFrame(ga.all_history_Y)
fig, ax = plt.subplots(2, 1)
ax[0].plot(Y_history.index, Y_history.values, '.', color='red')
Y_history.min(axis=1).cummin().plot(kind='line')
plt.show()
```

图9.16 优化结果
#### 3. 粒子群算法
**例 9.12** 解下面的优化问题:
$$
\text{minimize}~f(x_{1}, x_{2}) = 0.5 + \frac{\sin^{2}\Big(x_{1}^{2} + x_{2}^{2}\Big) - 0.5}{\Big(1 + 0.001(x_{1}^{2} + x_{2}^{2})\Big)^{2}} \tag{5.5.3}
$$
```python
def demo_func(x):
x1, x2, x3 = x
return x1 ** 2 + (x2 - 0.05) ** 2 + x3 ** 2
from sko.PSO import PSO
pso = PSO(func=demo_func, n_dim=3, pop=40, max_iter=150, lb=[0, -1, 0.5], ub=[1, 1, 1], w=0.8, c1=0.5, c2=0.5)
pso.run()
print('best_x is ', pso.gbest_x, 'best_y is', pso.gbest_y)
import matplotlib.pyplot as plt
plt.plot(pso.gbest_y_hist)
plt.show()
```

图9.17 优化结果
#### 4. 模拟退火算法
**例 9.13** 解下面的优化问题:
```python
from sko.SA import SA
sa = SA(func=demo_func, x0=[1, 1, 1], T_max=1, T_min=1e-9, L=300, max_stay_counter=150)
best_x, best_y = sa.run()
print('best_x:', best_x, 'best_y', best_y)
```
> scikit-opt 还提供了三种模拟退火流派: Fast, Boltzmann 和 Cauchy.
#### 5.蚁群算法
**例 9.14** 解决TSP问题:
```python
from sko.ACA import ACA_TSP
aca = ACA_TSP(func=cal_total_distance, n_dim=num_points,
size_pop=50, max_iter=200,
distance_matrix=distance_matrix)
best_x, best_y = aca.run()
```

图9.18 优化结果
#### 6. 免疫优化算法
**例 9.15** 解决TSP问题:
```python
from sko.IA import IA_TSP
ia_tsp = IA_TSP(func=cal_total_distance, n_dim=num_points, size_pop=500, max_iter=800,
prob_mut=0.2,
T=0.7, alpha=0.95)
best_points, best_distance = ia_tsp.run()
print('best routine:', best_points, 'best_distance:', best_distance)
```

图9.19 优化结果
#### 7. 人工鱼群算法
**例 9.16** 解下面的优化问题:
$$
\text{minimize}~f(x_{1}, x_{2}) = \frac{1}{x_{1}^{2}} + x_{1}^{2} + \frac{1}{x_{2}^{2}} + x_{2}^{2} \tag{5.5.4}
$$
```python
from sko.AFSA import AFSA
def func(x):
x1, x2 = x
return 1 / x1 ** 2 + x1 ** 2 + 1 / x2 ** 2 + x2 ** 2
afsa = AFSA(func, n_dim=2, size_pop=50, max_iter=300,
max_try_num=100, step=0.5, visual=0.3,
q=0.98, delta=0.5)
best_x, best_y = afsa.run()
print(best_x, best_y)
```
================================================
FILE: docs/CH6/第六章-数据处理与拟合模型.md
================================================
第6章 数据处理与拟合模型
> 内容:@若冰(马世拓)
>
> 审稿:@萌弟
>
> 排版&校对:@若冰
从这一章开始我们就在数据的海洋中遨游了。在这一章中我们将介绍数学建模中的数据处理以及常见的数据模型。从数据中挖掘有用的价值,这个学科叫做数据挖掘,也是数学建模中必考的问题,也是各大数学建模竞赛中考察的重难点。它主要针对的就是从杂乱无章的数据到验证结论的全过程。本章主要涉及到的知识点有:
- 数据与大数据
- Python数据预处理
- 常见的统计分析模型
- 随机过程与随机模拟
- 数据可视化
> 注意:本章内容涉及到基础的概率论与数理统计理论,如果对这部分内容不熟悉,可以参考相关概率论与数理统计的相关书籍,如果您想对此基础做入门学习,可以参考:。
## 6.1 数据与大数据
数据的概念我想很多读者可能会或多或少的有一些认知误区。因为我想当我提到“数据”二字的时候,多数读者脑子里面的第一印象就是一张excel表格。可能学过数据库系统原理的朋友脑子里也可能浮现出SQL文件,然后开始想它的主码这些东西。但事实上,“数据”的概念远远大于一张excel表格。
### 6.1.1 什么是数?什么是数据?
古希腊的先哲毕达哥拉斯说“万物皆数”,我私以为这句话说的是没错的。因为纵观整个自然科学,物理学中充满了数据,化学中充满了数据,计算机科学本身也有数据,并且即使是社会学、经济学乃至新闻领域,都充满了量化研究的影子。但他们的数据就是简单的做表格吗?恕我直言,仅仅是做Excel表格这种高中生能干的活,大学为什么要开设大数据的专业和相关研究?
因为万物皆数,很多你想象不到的东西都可以转化为数据。
你或许无法想象,有一天唐诗宋词也会被称作“数据”吧?你或许无法想象,许嵩的一首歌也会被称作“数据”吧?你或许也无法想象,有关你女朋友的自拍也被称作为“数据”吧?但事实上,这些都是广义的“数据”。因为数据的目的,是为了描述与传递信息;而信息的载体是多种多样的,人类能够感知与认知的信息,计算机同样有办法处理。
而我们把这些信息的不同形式称之为“模态”,包括:
- 数值类数据,例如结构化的excel表格和SQL文件。
- 文本类数据,例如新闻报道、微博评论、餐饮点评等文字。
- 图像类数据,以一定尺寸的黑白或彩色图像在计算机内存储。
- 音频类数据,例如音乐、电话录音等。
- 信号类数据,例如地震波的波形、电磁波信号、脑电信号等。
这些形式下的数据都可以用来数学建模。文本变为数据,只需要做词频统计、TF-IDF、词嵌入等操作就可以“数化”;图像变为数据,因为它在计算机内本身就是以RGB三个通道的大矩阵在存储每个像素点的颜色信息;音频和信号类数据则更为简单,它本身可以视作一个波,用信号与系统的概念和方法也可以对其“数化”。
而不同模态的数据往往能够联合对同一个事物去做描述,例如对于狗狗而言,既有它的图片,又有它的叫声。能够同时利用描述同一事物的不同模态数据去进行联合建模的模型,我们称之为多模态模型。
> 注意:视频数据为什么没有单列呢?因为视频本身也是由一帧帧图像在时间轴上堆叠才形成的数据。如果加上声音就更完美了。所以本质上视频数据是图像和音频的融合。
### 6.1.2 数据与大数据
可能很多人认为:8000条数据啊!我人都要算麻了!这还不叫大数据吗?但说实话,这个量真不能算。真正的大数据可能要以TB甚至PB、ZB来衡量。当然对于我们平时来说,能够过G的数据体量基本都不小了,但在G以内的数据说实话都不能算太大。
我为什么在这里这么强调“大数据”的“大”究竟是什么水平?难道是为了显得我看起来老练而见多识广吗?绝非如此。心里对“大数据”的“大”有一个基本的概念,不仅能够让我们对“大数据科学”保持谦卑,更重要的是知道面对不同体量的数据如何快速下手找准最合适的建模方法。
靠山吃山,靠水吃水。小数据有小数据的方法,大数据有大数据的方法。拿着线性回归的那一套去拟合大数据,往往效果不会很好;拿着神经网络去学小数据,学到的东西往往没有意义(我们会笑称:你是想去研究few-shot learning吗)。面对不同的数据,能够使用最合适的方法最为重要。而判断此方法是否合适,一个根本的衡量就是数据的体量。
### 6.1.3 数据科学的研究对象
Drew Conway在2010年阐释“数据科学”的时候称:“数据科学是统计学、计算机科学和领域知识的交叉学科”(如图4.1.1(a))。其实数学建模亦是如此,它需要数学基础,也需要计算机基础,但在解决实际工程问题的时候需要特定的工程背景。此三者缺一不可。但我看到很多学校讲“数据科学”,只是纯粹的让学生学习机器学习尤其是有监督学习,甚至自然语言处理和计算机视觉这些课程都是选修课。每当我看到这样的课程大纲和安排的时候我就在想:什么时候数据科学变成了一门这么狭隘的科学了?
在我的印象当中,数据科学应该包含如图所示的研究方面:

笔者看来,大数据科学研究的不仅仅是数据分析,它还包括了:
- 数据的获取和存储:包括爬虫、软件定义存储、硬件存储有关背景知识等。
- 数据的处理:包括分布式计算、并行计算、数据流等知识,以及Hadoop、Spark等大数据框架。
- 数据的分析:包括统计学、数据挖掘与机器学习、计算机视觉、自然语言处理等内容,重在挖掘数据中的模式与知识。
- 数据的管理:现代数据库系统及其架构等内容。
- 数据的应用:数据可视化、数据相关软件的开发、报表分析以及如何将数据挖掘得到的结果还原为实际问题的解决方案。
> 注意:这一节我们主要是为了阐明一些概念,但在后面数据处理的方法中我们主要还是以excel表格为例展开介绍数据的处理方法。图像、文本等非常规数据我们在第10章会简单介绍。另外补充一个小点,csv、tsv等格式的数据可以用记事本、excel、WPS等打开,以前在碰到一些学校的学生的时候他们甚至不知道怎么打开csv,这里特地提示一下。
## 6.2 数据的预处理
上一节基本都是一些概念性的东西,这一节我们开始讨论讨论实操。关于数据的预处理,是有一些基本操作方法的,这一节的主要目的就是总结归纳其中的操作方法。
### 6.2.1 为什么需要数据预处理
在现实生活问题中,我们得到的原始数据往往非常混乱、不全面,模型往往无法从中有效识别并提取信息。数据和特征决定了效果的上限,而模型和算法只是逼近这个上限而已,在采集完数据后,建模的首要步骤以及主要步骤便是数据预处理。
在介绍数据预处理之前,我们需要引入一些概念来加强我们对数据的理解。以2021年华数杯C题的部分数据为例,在WPS中打开它如图:

图中展示了前29行数据。最上面一行我们称之为表头,表头的每一格我们称之为字段。每个字段描述了这一列数据表示的意义。而这个表格的体量则是它有多少行多少列,如果列数超过了行数的1/2就可以说是有些稀疏了,如果列数是行数的3倍那它就是严重稀疏的数据。这个数据的每一列称其为一个属性,有多少个属性又可以称之有多少维。
> 注意:稀疏还有一种定义,就是表格里面很多都是空白的或者全是0。
属性有离散属性和连续属性之分。例如在图中,“品牌类型”这个属性只有{1,2,3}三个有限的取值,我们称之为离散。当然,这个取值也可以不是数字,比如{汽车,火车,飞机}等。但例如属性“a1”,它的取值是一个连续的浮点数,这种属性我们称之为连续属性。连续属性和离散属性的处理方法是截然不同的。“目标客户编号”提供了每一行数据的标识信息,可以根据这个编号对数据进行检索、排序等操作,我们称之为“主码”,也可以理解为id。
### 6.2.2 使用pandas处理数据的基础
一份数据可能并没有理想中那么规则,经常会出现空缺、重复和一些异常记录,那么如何识别并处理这些问题就非常关键了。对于重复数据直接将其删除即可。但缺失数据的处理有一些技巧。我们主要是观察缺失率,如果存在缺失的数据项(数表一行称作一个数据项)占比较少(大概5%以内)这个时候如果问题允许可以把行删掉。如果缺失率稍微高一点(5%-20%)左右就可以使用填充、插值的方法去处理,有关插值的方法我们会在下一节探讨,而填充方法包括常数填充、均值填充等方法;如果缺失率还高一些(20%-40%)那么就需要用预测方法例如机器学习去填充缺失数据了;但如果一行数据有50%以上都是缺失的,如果条件允许,我们可以把这一列都删掉(当然凡事都有例外,见机行事)。下面我们来做一些小实验,学习如何使用python处理数据:
(1)Python创建一个数据框DataFrame:
import pandas as pd
import numpy as np
data = {'animal': ['cat', 'cat', 'snake', 'dog', 'dog', 'cat', 'snake', 'cat', 'dog', 'dog'],
'age': [2.5, 3, 0.5, np.nan, 5, 2, 4.5, np.nan, 7, 3],
'visits': [1, 3, 2, 3, 2, 3, 1, 1, 2, 1],
'priority': ['yes', 'yes', 'no', 'yes', 'no', 'no', 'no', 'yes', 'no', 'no']}
labels = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
df = pd.DataFrame(data)
(2)显示该 DataFrame 及其数据相关的基本信息:
df.describe()
(3)返回DataFrame df 的前5列数据:
df.head(5)
(4)从 DataFrame `df` 选择标签列为 `animal` 和 `age` 的列
df[['animal', 'age']]
(5)在 `[3, 4, 8]` 行中,列为 `['animal', 'age']` 的数据
df.loc[[3, 4, 8], ['animal', 'age']]
(6)选择列为`visits`中等于3的行
df.loc[df['visits']==3, :]
(7)选择 `age` 为缺失值的行
df.loc[df['age'].isna(), :]
(8)选择 `animal` 是cat且`age` 小于 3 的行
df.loc[(df['animal'] == 'cat') & (df['age'] < 3), :]
(9)选择 `age` 在 2 到 4 之间的数据(包含边界值)
df.loc[(df['age']>=2)&(df['age']<=4), :]
(10)将 'f' 行的 `age` 改为 1.5
df.index = labels
df.loc[['f'], ['age']] = 1.5
(11)对 `visits` 列的数据求和
df['visits'].sum()
(12)计算每种 `animal` `age` 的平均值
df.groupby(['animal'])['age'].mean()
6.2.3 使用pandas处理数据的进阶
6.2.2节的内容主要介绍了一些pandas dataframe的基础语法与一些简单的案例,但仅仅使用简单处理数据api无法快速处理一些特定的任务,如:数据去重、填补缺失值等等。下面,我们还是以一个简单的demo,说明pandas如何处理这些在数学建模中常见的任务。
```python
(1)创建pandas dataframe
df = pd.DataFrame({'From_To': ['LoNDon_paris', 'MAdrid_miLAN', 'londON_StockhOlm',
'Budapest_PaRis', 'Brussels_londOn'],
'FlightNumber': [10045, np.nan, 10065, np.nan, 10085],
'RecentDelays': [[23, 47], [], [24, 43, 87], [13], [67, 32]],
'Airline': ['KLM(!)', ' (12)', '(British Airways. )',
'12. Air France', '"Swiss Air"']})
df
(2)FlightNumber列中有某些缺失值,缺失值常用nan表示,请在该列中添加10055与10075填充该缺失值。
df['FlightNumber'] = df['FlightNumber'].interpolate().astype(int)
(3)由于列From_To 代表从地点A到地点B,因此可以将这列拆分成两列,并赋予为列From与To。
temp = df['From_To'].str.split("_", expand=True)
temp.columns = ['From', 'To']
(4)将列From和To转化成只有首字母大写的形式。
temp['From'] = temp['From'].str.capitalize()
temp['To'] = temp['To'].str.capitalize()
(5)将列From_To从df中去除,并把列From和To添加到df中
df.drop('From_To', axis=1, inplace=True)
df[['From', 'To']] = temp
(6)清除列中的特殊字符,只留下航空公司的名字。
df['Airline'] = df['Airline'].str.extract(r'([a-zA-Z\s]+)', expand=False).str.strip()
(7)在 RecentDelays 列中,值已作为列表输入到 DataFrame 中。我们希望每个第一个值在它自己的列中,每个第二个值在它自己的列中,依此类推。如果没有第 N 个值,则该值应为 NaN。将 Series 列表展开为名为 的 DataFrame delays,重命名列delay_1,delay_2等等,并将不需要的 RecentDelays 列替换df为delays。
delays = df['RecentDelays'].apply(pd.Series)
delays.columns = ['delay_%s' % i for i in range(1, len(delays.columns)+1)]
df = df.drop('RecentDelays', axis=1).join(delays, how='left')
(8)将delay_i列的控制nan都填为自身的平均值。
for i in range(1, 4):
df[f'delay_{i}'] = df[f'delay_{i}'].fillna(np.mean(df[f'delay_{i}']))
(9)在df中增加一行,值与FlightNumber=10085的行保持一致。
df = df._append(df.loc[df['FlightNumber'] == 10085, :], ignore_index=True)
(10)对df进行去重,由于df添加了一行的值与FlightNumber=10085的行一样的行,因此去重时需要去掉。
df = df.drop_duplicates()
```
### 6.2.3 数据的规约
数据为何需要规约?因为实际应用中数据的分布可能是有偏的,量纲影响和数值差异可能会比较大。规约是为了形成对数据的更高效表示,学习到更好的模型。它会保留数据的原始特征,但对极端值、异常值等会比较敏感。这里我们就介绍两个比较典型的规约方式:min-max规约和Z-score规约。
min-max规约的表达式形如:
$$
{x_{new}} = \frac{{x - \min (x)}}{{\max (x) - \min (x)}} \tag{6.2.1}
$$
这一操作的目的是为了消除量纲影响,所有的属性都被规约到[0,1]的范围内,数据的偏差不会那么大。但是如果出现异常值,比如非常大的数值,那么这个数据的分布是有偏的。为了对数据的分布进行规约,还会使用到另一个常用的方法就是Z-score规约:
$$
{x_{new}} = \frac{{x - \bar x}}{{std(x)}} \tag{6.2.2}
$$
本质上,一列数据减去其均值再除以标准差,如果这一列数据近似服从正态分布,这个过程就是化为标准正态分布的过程。Z-score规约和min-max规约往往不是二者取其一,有时候两个可以组合起来用。除此以外,还有很多的规约方法,但是这两种最为常用。若使用Python实现起来也非常简单,只需要:
## 6.3 常见的统计分析模型
说到大数据的分析思路,学过概率论与数理统计这门课的同学都会脱口而出——统计分析。统计分析的很多内容与机器学习的相关内容大差不差,但是侧重点不一样。统计分析通常在做“为什么”的任务,机器学习通常在做“预测未来”的任务。这里将介绍几种常用的统计分析的方法,由于统计分析的原理涉及大量数学知识,对这部分知识的原理感兴趣的话,欢迎查看:
另外,在第九章中,我们会对这些统计方法进行更精细的讲解。
### 6.3.1 回归分析与分类分析
回归分析与分类分析都是一种基于统计模型的统计分析方法。它们都研究因变量(被解释变量)与自变量(解释变量)之间存在的潜在关系,并通过统计模型的形式将这些潜在关系进行显式的表达。不同的是,回归分析中因变量是连续变量,如工资、销售额;而分类分析中因变量是属性变量,如判断邮件“是or否”为垃圾邮件。下面,使用几个简单的例子,说明回归分析与分类分析在数学建模中的应用。
(1)回归分析:在学生刚入大学时,大学通常会举办一个大学入学考试,该成绩虽然没什么用,但是能让刚入学的学生保持持续学习的警示。现在,我们想分析大学成绩GPA是否与入学成绩以及高考成绩有关?
```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
import seaborn as sns
from IPython.display import display
import statsmodels.api as sm
# 加载数据
gpa1=pd.read_stata('./data/gpa1.dta')
# 在数据集中提取自变量(ACT为入学考试成绩、hsGPA为高考成绩)
X1=gpa1.ACT
X2=gpa1[['ACT','hsGPA']]
# 提取因变量
y=gpa1.colGPA
# 为自变量增添截距项
X1=sm.add_constant(X1)
X2=sm.add_constant(X2)
display(X2)
# 拟合两个模型
gpa_lm1=sm.OLS(y,X1).fit()
gpa_lm2=sm.OLS(y,X2).fit()
# 输出两个模型的系数与对应p值
p1=pd.DataFrame(gpa_lm1.pvalues,columns=['pvalue'])
c1=pd.DataFrame(gpa_lm1.params,columns=['params'])
p2=pd.DataFrame(gpa_lm2.pvalues,columns=['pvalue'])
c2=pd.DataFrame(gpa_lm2.params,columns=['params'])
display(c1.join(p1,how='right'))
display(c2.join(p2,how='right'))
```

从模型I可以看到,ACT变量的pvalue为0.01<0.05,说明大学入学考试成绩ACT能显著影响大学GPA;但从模型II可以看到,高考成绩haGPA的pvalue为0.000005远小于0.05,说明高考成绩haGPA显著影响大学成绩GPA,但大学入学考试成绩ACT的pvalue为0.38大于0.05,不能说明大学入学考试成绩ACT显著影响大学成绩GPA。为什么会出现这样矛盾的结论呢?原因有可能是:在没有加入高考成绩haGPA时,大学入学考试成绩ACT确实能显著影响大学成绩GPA,但是加入高考成绩haGPA后,有可能高考成绩haGPA高的同学大学入学考试也高分,这两者的相关性较高,显得“大学入学考试成绩ACT并不是那么重要”了。
从模型II的系数params数值可以看到,当高考成绩haGPA显著影响大学成绩GPA,params为0.45代表每1单位的高考成绩的增加将导致大学GPA平均增加0.45个单位。
(2)分类分析:ST是我国股市特有的一项保护投资者利于的决策,当上市公司因财务状况不佳导致投资者难以预测其前景时,交易所会标记该公司股票为ST,并进行一系列限制措施。我们想研究被ST的公司其背后的因素,并尝试通过利用公司的财务指标提前预测某上市公司在未来是否会被ST,是一件很有意义的举措。而在这项任务中,因变量就是公司是否会被ST。该例中自变量是一些财务指标,如ARA、ASSET等,它们具体的意义在此不做赘述。
```python
# 加载基础包
import pandas as pd
import numpy as np
import statsmodels.api as sm
from scipy import stats
# 读取数据
ST=pd.read_csv('./data/ST.csv')
ST.head()
st_logit=sm.formula.logit('ST~ARA+ASSET+ATO+ROA+GROWTH+LEV+SHARE',data=ST).fit()
print(st_logit.summary())
```

其中pvalue为第四列标记为P(>| z |)的数值。与回归分析累死,一般认为,p值小于0.05的自变量是显著的,也就是说,有统计学证据表明该变量会影响因变量为1的概率(即顾客购买杂志)。
### 6.3.2 假设检验
假设检验是统计分析中重要的一环,它贯穿着统计分析的所有环节。在数学建模中,我们既能通过假设检验对数据进行探索性的信息挖掘,以给予我们选择模型充分且客观的依据;又能在建模完成后,通过特定的假设检验验证模型的有效性。因此,储备基本的假设检验知识,学会根据数据特点、任务需求选择相对应的假设检验十分重要。
为了便于大家理解,我们用一个例子开始对这个话题的讨论:必胜中学初三年级在三天前开展了一次数学考试,校长给级长下达了命令:全级均分必须不低于110,否则就要扣级长工资。今天考试成绩出来了,但是由于年级人数太多,完成全级考试情况的统计还需要两天的时间。急性子的级长很想早点知道这次考试的情况,万幸的是一班已经完成了成绩统计,级长便想以一班的成绩,推测出全级的考试情况。已知:一班的成绩均值为,样本标准差s=4,人数n=25,且根据以往的经验,年级的考试成绩呈现正态分布。那么,级长可以认为年级均分不低于110吗?
现在简单分析一下问题。我们将整个年级的数学成绩作为一个总体,在这个前提下,可以将一班的数学成绩视作在这个总体中抽取出的一个样本。级长想知道的是一班这个样本的均值是否达到110吗?显然不是,因为样本均值108.2是已知的;级长想知道的是,能否通过一班这个样本推测出年级这一总体的均值是否达到110。
换言之,我们需要对“年级总体均分不低于110”这样的命题做出“是”或“否”的回答,这类问题就被称之为假设检验问题。对假设检验问题进行数理上的检验并给予回答,这个过程就是假设检验。
总结一下:这种根据样本信息与已知信息,对一个描述总体性质的命题进行“是或否”的检验与回答,就是假设检验的本质。即:假设检验验证的不是样本本身的性质,而是样本所在总体的性质。
假设检验大体上可分为两种:参数假设检验与非参数假设检验。若假设是关于总体的一个参数或者参数的集合,则该假设检验就是参数假设检验,刚刚的例子的假设是关于总体均值的,均值是参数,因此这是一个参数假设检验;若假设不能用一个参数集合来表示,则该假设检验就是非参数假设检验,一个典型就是正态性检验。
(1)正态性检验:于参数检验比非参数检验更灵敏,因此一旦数据是正态分布的,我们应该使用参数检验,此时对数据进行正态性检验就非常有必要了。在这里,我们提供了三种方法对帮助大家判断数据的正态性:可视化判断-正态分布概率图;Shapiro-Wilk检验;D'Agostino's K-squared检验。
```python
# 生成1000个服从正态分布的数据
data_norm = stats.norm.rvs(loc=10, scale=10, size=1000) # rvs(loc,scale,size):生成服从指定分布的随机数,loc:期望;scale:标准差;size:数据个数
# 生成1000个服从卡方分布的数据
data_chi=stats.chi2.rvs(2,3,size=1000)
# 画出两个概率图
fig=plt.figure(figsize=(12,6))
ax1=fig.add_subplot(1,2,1)
plot1=stats.probplot(data_norm,plot=ax1) # 正态数据
ax2=fig.add_subplot(1,2,2)
plot2=stats.probplot(data_chi,plot=ax2) # 卡方分布数据
```

可以看到,正态性数据在正态分布概率图中十分接近直线y=x,而卡方分布数据则几乎完全偏离了该直线。可见,概率图可视化确实可以起到初步判断数据正态性的作用。实际应用中,由于数据的复杂性,仅使用一种方法判断正态性有可能产生一定的误差,因此我们通常同时使用多种方法进行判断。如果不同方法得出的结论不同,此时就需要仔细观察数据的特征,寻找结果不一致的原因。如:若Shapiro-Wilk test显著(非正态),D'Agostino's K-squared test不显著(正态),则有可能是因为样本量较大,或者样本中存在重复值现象,如果事实确实如此,那么我们就应该采纳D'Agostino's K-squared test的结论而非Shapiro-Wilk test的结论。(在这里,我们假设数据服从正态分布,p<0.05则拒绝假设,换个说法:p<0.05数据不服从正态分布,如果p>=0.05说明没有证据说明数据不服从正态分布)
```python
# 接下来,我们在python中定义一个函数,将概率图、Shapiro-Wilk test、D'Agostino's K-squared test结合在一起。
data_small = stats.norm.rvs(0, 1, size=30) # 小样本正态性数据集
data_large = stats.norm.rvs(0, 1, size=6000) # 大样本正态性数据集
# 定义一个正态性检验函数,它可以输出:
## 正态概率图
## 小样本Shapiro-Wilk检验的p值
## 大样本D'Agostino's K-squared检验的p值
from statsmodels.stats.diagnostic import lilliefors
from typing import List
def check_normality(data: np.ndarray, show_flag: bool=True) -> List[float]:
"""
输入参数
----------
data : numpy数组或者pandas.Series
show_flag : 是否显示概率图
Returns
-------
两种检验的p值;概率图
"""
if show_flag:
_ = stats.probplot(data, plot=plt)
plt.show()
pVals = pd.Series(dtype='float64')
# D'Agostino's K-squared test
_, pVals['Omnibus'] = stats.normaltest(data)
# Shapiro-Wilk test
_, pVals['Shapiro-Wilk'] = stats.shapiro(data)
print(f'数据量为{len(data)}的数据集正态性假设检验的结果 : ----------------')
print(pVals)
check_normality(data_small,show_flag=True)
```

```python
数据量为30的数据集正态性假设检验的结果 : ----------------
Omnibus 0.703004
Shapiro-Wilk 0.898077
dtype: float64
check_normality(data_large,show_flag=False) # 当样本量大于5000,会出现警告
数据量为6000的数据集正态性假设检验的结果 : ----------------
Omnibus 0.980014
Shapiro-Wilk 0.992427
dtype: float64
e:\anaconda\envs\ml\lib\site-packages\scipy\stats\morestats.py:1760: UserWarning: p-value may not be accurate for N > 5000.
warnings.warn("p-value may not be accurate for N > 5000.")
```
(2)单组样本均值假定的检验:必胜中学里,陈老师班结束了一次英语考试。由于班级人数较多,短时间内很难完成批改与统计,陈老师又很想知道此次班级平均分与级长定的班级均分137的目标是否有显著区别,于是他随机抽取了已经改好的10名同学的英语成绩:
136,136,134,136,131,133,142,145,137,140
问:陈老师可以认为此次班级平均分与级长定的班级均分137的目标没有显著区别吗?
很明显,这就是一个典型的单组样本均值假定的检验,比较的是这个样本(10个同学的英语成绩)所代表的总体均值(班级英语成绩均值)是否与参考值137相等。那么对于这类问题,我们有两种检验可以使用:单样本t检验与wilcoxon检验。
data=np.array([136,136,134,136,131,133,142,145,137,140])
data
# 定义一个单组样本均值检验函数,使它可以同时输出t检验与wilcoxon符号秩和检验的p值
def check_mean(data,checkvalue,significance=0.05,alternative='two-sided'):
'''
输入参数
----------
data : numpy数组或者pandas.Series
checkvalue : 想要比较的均值
significance : 显著性水平
alternative : 检验类型,这取决于我们备择假设的符号:two-sided为双侧检验、greater为右侧检验、less为左侧检验
输出
-------
在两种检验下的p值
在显著性水平下是否拒绝原假设
'''
pVal=pd.Series(dtype='float64')
# 正态性数据检验-t检验
_, pVal['t-test'] = stats.ttest_1samp(data, checkvalue,alternative=alternative)
print('t-test------------------------')
if pVal['t-test'] < significance:
print(('目标值{0:4.2f}在显著性水平{1:}下不等于样本均值(p={2:5.3f}).'.format(checkvalue,significance,pVal['t-test'])))
else:
print(('目标值{0:4.2f}在显著性水平{1:}下无法拒绝等于样本均值的假设.(p={2:5.3f})'.format(checkvalue,significance,pVal['t-test'])))
# 非正态性数据检验-wilcoxon检验
_, pVal['wilcoxon'] = stats.wilcoxon(data-checkvalue,alternative=alternative)
print('wilcoxon------------------------')
if pVal['wilcoxon'] < significance:
print(('目标值{0:4.2f}在显著性水平{1:}下不等于样本均值(p={2:5.3f}).'.format(checkvalue,significance,pVal['wilcoxon'])))
else:
print(('目标值{0:4.2f}在显著性水平{1:}下无法拒绝等于样本均值的假设.(p={2:5.3f})'.format(checkvalue,significance,pVal['wilcoxon'])))
return pVal
check_mean(data,137,0.05)
t-test------------------------
目标值137.00在显著性水平0.05下无法拒绝等于样本均值的假设.(p=1.000)
wilcoxon------------------------
目标值137.00在显著性水平0.05下无法拒绝等于样本均值的假设.(p=0.812)
/Users/leo/opt/anaconda3/envs/DataAnalysis_env/lib/python3.10/site-packages/scipy/stats/_morestats.py:3337: UserWarning: Exact p-value calculation does not work if there are zeros. Switching to normal approximation.
warnings.warn("Exact p-value calculation does not work if there are "
/Users/leo/opt/anaconda3/envs/DataAnalysis_env/lib/python3.10/site-packages/scipy/stats/_morestats.py:3351: UserWarning: Sample size too small for normal approximation.
warnings.warn("Sample size too small for normal approximation.")
t-test 1.000000
wilcoxon 0.811892
dtype: float64
(2)两组样本的均值相等性检验:
在进行两组之间的均值比较之前,我们需要进行一个重要的判断:这两组样本之间是否独立呢?这个问题的答案将决定着我们使用何种类型的检验。
为了让大家对这里的“独立”有较好的了解,我们看看下面的例子。陈老师在年级有一个竞争对手:王老师。王老师在他们班级在同一时间也举行了一次英语考试,且两个班用的是同一份卷子,因此两个班的英语成绩就具有比较的意义了。和陈老师一样,王老师也来不及批改和统计他们班级的英语成绩,不过他手头上也有12份已经改好的试卷,成绩分别为:
134,136,135,145,147,140,142,137,139,140,141,135
问:我们可以认为两个班级的均分是相等的吗?
这是一个非常典型的双独立样本的均值检验。值得注意的是,这里的独立指的是抽样意义上的独立,而不是统计意义的独立,什么意思呢?即我们只需要保证这两个样本在选取的时候是“现实上”的互不影响就可以了。至于两者在数值上是否独立(通过独立性检验判断的独立性)我们并不关心。对于抽样意义上的独立的解释,各种教材众说纷纭,在这里我们选取一种容易理解的说法:两个样本中,一个样本中的受试不能影响另一个样本中的受试。
在本例中,两个班级的授课老师不同,因此两个班学生的英语学习不会受到同一个老师的影响;两个班级考试同时进行,意味着不存在时间差让先考完的同学给后考完的同学通风报信(进而影响受试)。因此,我们可以认为这是两个独立的样本。
事实上,两个样本是否在抽样意义上独立,是没有固定答案的,因为在很多情况下,我们既不能保证两个样本间完全独立,也很难判断出两者是否存在相关性。我们只能在抽样的时候使用更加科学的抽样方法,尽可能地避免样本间的相互影响。
若两个样本的总体都服从正态分布,那么我们可以使用双样本t检验。如果不服从正态分布,则可以使用Mannwhitneyu秩和检验,Mannwhitneyu秩和检验是一种非参数检验。
```python
# 定义一个单组样本均值检验函数,使它可以同时输出t检验与mannwhitneyu检验的p值
def unpaired_data(group1:np.ndarray,group2:np.ndarray,significance=0.05,alternative='two-sided'):
"""
输入参数
----------
group1/2 : 用于比较的两组数据
significance : 显著性水平
alternative : 检验类型,这取决于我们备择假设的符号:two-sided为双侧检验、greater为右侧检验、less为左侧检验
输出
-------
在两种检验下的p值
在显著性水平下是否拒绝原假设
"""
pVal=pd.Series(dtype='float64')
# 先进行两组数据的方差齐性检验
_,pVal['levene']=stats.levene(group1,group2)
# t检验-若数据服从正态分布
if pVal['levene']>> STOP stats <<< ---
# 两组样本均值的散点图可视化
print('------------------------------------')
print('两组样本均值的散点图可视化')
plt.plot(group1, 'bx', label='group1')
plt.plot(group2, 'ro', label='group2')
plt.legend(loc=0)
plt.show()
return pVal
# 陈老师班
group1=data
# 王老师班
group2=np.array([134,136,135,145,147,140,142,137,139,140,141,135])
unpaired_data(group1,group2)
```
在显著性水平0.05下,不能拒绝两组样本方差相等的假设(p=0.8277),因此需要使用方差相等的t检验
```python
------------------------------------
t检验p值:0.221
Mann-Whitney检验p值:0.260
------------------------------------
```
两组样本均值的散点图可视化

```python
levene 0.827727
t-test 0.221138
mannwhitneyu 0.259739
dtype: float64
```
两个检验都表明,两个班级的平均分没有显著差异。
在进行两组间均值比较的时候,有一种特殊情况——两个样本“故意”不独立。这种情况多出现两个样本分别为同一个受试个体不同时间的受试结果,这两个样本是“成对”的,是彼此紧密相连的。对这样两个样本进行均值比较检验,就是成对检验。
为了让大家更好地理解“成对”的概念,我们再把王老师的故事讲长一点。得知了王老师班的考试均值与自己班的没有显著差异,陈老师非常生气:“我堂堂优秀教师,居然不能和隔壁老王拉开差距,岂有此理!”,于是陈老师开始了为期一周的魔鬼训练,并在一个星期后又进行了一次全班的测验。陈老师**依旧抽取了上次十位同学的成绩**,依次为:
139,141,137,136,135,132,141,148,145,139
问:这次班级均分与上次是否存在显著差异呢?
显然,两个样本分别为相同的同学前后两次的考试成绩,是非常典型的成对数据,因此我们可以使用成对检验。成对检验也分为两种:若总体服从正态分布,则使用成对t检验;若总体不服从正态分布,则使用成对wilcoxon秩和检验。
```python
def paired_data(group1:np.ndarray,group2:np.ndarray,significance,alternative='two-sided'):
"""
输入参数
----------
group1/2 : 用于比较的两组数据,注意,两组数据的样本顺序必须相同
significance : 显著性水平
alternative : 检验类型,这取决于我们备择假设的符号:two-sided为双侧检验、greater为右侧检验、less为左侧检验
输出
-------
在两种检验下的p值
在显著性水平下是否拒绝原假设
"""
pVal=pd.Series(dtype='float64')
# 配对t检验-样本服从正态分布
_, pVal['t-test'] = stats.ttest_1samp(post - pre, 0,alternative=alternative)
print('t-test------------------------')
if pVal['t-test'] < significance:
print(('在显著性水平{0:}下,两组配对样本的均值不相等(p={1:5.3f}).'.format(significance,pVal['t-test'])))
else:
print(('在显著性水平{0:}下无法拒绝等于样本均值的假设.(p={1:5.3f})'.format(significance,pVal['t-test'])))
# wilcoxon秩和检验
_, pVal['wilcoxon'] = stats.wilcoxon(group1,group2, mode='approx',alternative=alternative)
print('wilcoxon------------------------')
if pVal['wilcoxon'] < significance:
print(('在显著性水平{0:}下,两组配对样本的均值不相等(p={1:5.3f}).'.format(significance,pVal['wilcoxon'])))
else:
print(('在显著性水平{0:}下无法拒绝等于样本均值的假设.(p={1:5.3f})'.format(significance,pVal['wilcoxon'])))
return pVal
# 第一次测验
pre=data
# 第二次测验
post=np.array([139,141,137,136,135,132,141,148,145,139])
paired_data(pre,post,0.05)
```
```python
t-test------------------------
在显著性水平0.05下,两组配对样本的均值不相等(p=0.039).
wilcoxon------------------------
在显著性水平0.05下,两组配对样本的均值不相等(p=0.049).
/Users/leo/opt/anaconda3/envs/DataAnalysis_env/lib/python3.10/site-packages/scipy/stats/_morestats.py:3351: UserWarning: Sample size too small for normal approximation.
warnings.warn("Sample size too small for normal approximation.")
t-test 0.039370
wilcoxon 0.048997
dtype: float64
```
两种检验都显示两次测验的均分在显著性水平0.05下有显著差异,根据双边检验的p值是某侧单边检验两倍的结论,我们可以推测出第二次测验的均分均值显著地高于第一次测验均分,恭喜陈老师,赢!
(3)方差分析-多组样本间的均值相等性检验
既然我们检验的是不同总体的均值是否相等,那么观察各样本的样本均值的“差异程度”一定是非常自然且合理的想法,如果各样本的均值差异很大,那么它们的总体均值也有很大可能存在差异。
样本间均值的“差异程度”是一个很好的评判指标,但这并不足够,还有一个不起眼的指标也十分重要:各样本的样本内差异程度。在相同的样本间差异程度下,样本内差异程度越大,各总体间均值存在差异的可能性就越小,为什么呢?简单来说,就是样本内差异程度越大,“偶然性”越大,我们越难以判断两个不相等的均值是否真的不相等。
举个例子:小红的考试均分是91,小刚的考试均分是89。我们假设一个非常极端的情况:他们的标准差都是0,即小红每次考试都是91,小刚每次考试都是89,那么我们似乎可以很容易地判断出,两个人的均分确实存在明显的差异;但是,如果他们的标准差都很大,高达6(方差就是36),即他们的成绩都很不稳定,这次小红考79、小刚考93,下次小红考94、小刚考70。在高达36的方差下,2分的均值差似乎没有什么说服力了。
因此,我们需要综合这两个评判指标。最简单的方法就是两者相除,即样本间均值的“差异程度”除以样本内差异程度。这就是方差分析最根本的思想。
尽管在大样本下,非正态性数据的方差分析也是稳健的,但是在小样本下,对非正态性数据做方差分析还是可能存在误差。此时,我们可以使用kruskalwallis检验。该检验也是一种非参数检验,关于该检验的原理,我们就不再学习了。大家只需要知道它的应用场景既可。
案例:对altman_910.txt数据集进行方差分析。该数据记录了3组心脏搭桥病人给予不同水平的一氧化氮通气下,他们的红细胞内叶酸水平。注:三组样本都是正态性样本。
```python
data = np.genfromtxt('./data/altman_910.txt', delimiter=',')
group1 = data[data[:,1]==1,0]
group2 = data[data[:,1]==2,0]
group3 = data[data[:,1]==3,0]
group1
from typing import Tuple
def anova_oneway() -> Tuple[float, float]:
pVal=pd.Series(dtype='float64')
# 先做方差齐性检验
_,pVal['levene'] = stats.levene(group1, group2, group3)
if pVal['levene']<0.05: #这里假设显著性水平为0.05
print('警告: 方差齐性检验的p值小于0.05: p={},方差分析结果在小样本下可能不准确'.format(pVal['levene']))
print('-------------------------------')
# 单因素方差分析-假设样本服从正态分布
_, pVal['anova_oneway_normal'] = stats.f_oneway(group1, group2, group3) # 在这里输入待分析的数据
print('若样本服从正态分布,单因素方差分析的p值为{}'.format(pVal['anova_oneway_normal']))
if pVal['anova_oneway_normal'] < 0.05:
print('检验在0.05的显著性水平下显著,多组样本中至少存在一组样本均值与其它样本的均值不相等。')
print('---------------------------------')
# 单因素方差分析-假设样本不服从正态分布
_, pVal['anova_oneway_notnormal'] = stats.mstats.kruskalwallis(group1, group2, group3) # 在这里输入待分析的数据
print('若样本不服从正态分布,单因素方差分析的p值为{}'.format(pVal['anova_oneway_notnormal']))
if pVal['anova_oneway_notnormal'] < 0.05:
print('检验在0.05的显著性水平下显著,多组样本中至少存在一组样本均值与其它样本的均值不相等。')
return pVal
anova_oneway()
```
```python
警告: 方差齐性检验的p值小于0.05: p=0.045846812634186246,方差分析结果在小样本下可能不准确
-------------------------------
若样本服从正态分布,单因素方差分析的p值为0.043589334959178244
检验在0.05的显著性水平下显著,多组样本中至少存在一组样本均值与其它样本的均值不相等。
---------------------------------
若样本不服从正态分布,单因素方差分析的p值为0.12336326887166982
levene 0.045847
anova_oneway_normal 0.043589
anova_oneway_notnormal 0.123363
dtype: float64
```
### 6.3.2 随机过程与随机模拟
随机过程的世界是构建在概率论的世界上的,它拥有比概率论更为广阔的视野。那么,随机过程相比于概率论做了哪些深化的研究呢?首先,概率论仅仅研究的是在某一个时点,某个随机变量(随机向量)的分布情况,如:2022年07月03日这天的某个股票价格在开盘前是一个随机变量,因为在开盘前这个价格可以是的任意值,但是每个价格的概率是有差别的。而随机过程描述的是一段时间内随机变量的分布的变化,如:2022年07月03日这天的某个股票价格在开盘前是一个随机变量,而2022年07月04日这天的某个股票价格在开盘前也是一个随机变量,这两个随机变量按道理不会差别太大(也有可能差别很大),如果差别太大了估计就是暴跌或者暴涨了,我们说这种随机变量的分布按照时间变化的性质可以使用随机过程来描述。因此,总结一下:随机过程就是在随机变量的基础上加入了时间维度(值得注意的是,时间维度不是随机变量,只是普通变量),随机过程与概率论有很强的关联性,又有不同。(由于随机过程的相关原理需要大量数学理论,如果想系统了解相关理论,欢迎访问)
我们用一个案例,带大家使用随机过程来对现实世界进行模拟分析与预测。在本次案例中,我们主要掌握如何使用简单的simpy进行仿真的流程,simpy的具体语法将在后续课程给大家详细解答,这里只需要直观感受下仿真的流程即可。
**案例1**:为了改善道路的路面情况(道路经常维修,坑坑洼洼),因此想统计一天中有多少车辆经过,因为每天的车辆数都是随机的,一般来说有两种技术解决这个问题:
(1) 在道路附近安装一个计数器或安排一个技术人员,在一段长时间的天数(如365天)每天24h统计通过道路的车辆数。
(2) 使用仿真技术大致模拟下道路口的场景,得出一个近似可用的仿真统计指标。
由于方案(1)需要花费大量的人力物力以及需要花费大量的调研时间,虽然能得出准确的结果,但是有时候在工程应用中并不允许。因此,我们选择方案(2),我们通过一周的简单调查,得到每天的每个小时平均车辆数:[30, 20, 10, 6, 8, 20, 40, 100, 250, 200, 100, 65, 100, 120, 100, 120, 200, 220, 240, 180, 150, 100, 50, 40],通过利用平均车辆数进行仿真。
```python
# 模拟仿真研究该道路口一天平均有多少车经过
import simpy
class Road_Crossing:
def __init__(self, env):
self.road_crossing_container = simpy.Container(env, capacity = 1e8, init = 0)
def come_across(env, road_crossing, lmd):
while True:
body_time = np.random.exponential(1.0/(lmd/60)) # 经过指数分布的时间后,泊松过程记录数+1
yield env.timeout(body_time) # 经过body_time个时间
yield road_crossing.road_crossing_container.put(1)
hours = 24 # 一天24h
minutes = 60 # 一个小时60min
days = 3 # 模拟3天
lmd_ls = [30, 20, 10, 6, 8, 20, 40, 100, 250, 200, 100, 65, 100, 120, 100, 120, 200, 220, 240, 180, 150, 100, 50, 40] # 每隔小时平均通过车辆数
car_sum = [] # 存储每一天的通过路口的车辆数之和
print('仿真开始:')
for day in range(days):
day_car_sum = 0 # 记录每天的通过车辆数之和
for hour, lmd in enumerate(lmd_ls):
env = simpy.Environment()
road_crossing = Road_Crossing(env)
come_across_process = env.process(come_across(env, road_crossing, lmd))
env.run(until = 60) # 每次仿真60min
if hour % 4 == 0:
print("第"+str(day+1)+"天,第"+str(hour+1)+"时的车辆数:", road_crossing.road_crossing_container.level)
day_car_sum += road_crossing.road_crossing_container.level
car_sum.append(day_car_sum)
print("每天通过交通路口的的车辆数之和为:", car_sum)
```
```
仿真开始:
第1天,第1时的营业额: 80
第1天,第5时的营业额: 44
第1天,第9时的营业额: 996
第1天,第13时的营业额: 989
第1天,第17时的营业额: 582
第1天,第21时的营业额: 582
第2天,第1时的营业额: 81
第2天,第5时的营业额: 65
第2天,第9时的营业额: 833
第2天,第13时的营业额: 917
第2天,第17时的营业额: 496
第2天,第21时的营业额: 585
第3天,第1时的营业额: 85
第3天,第5时的营业额: 89
第3天,第9时的营业额: 888
第3天,第13时的营业额: 1048
第3天,第17时的营业额: 554
第3天,第21时的营业额: 734
每天商店的的营业额之和为: [11491, 11014, 11572]
```
**案例2**:现在,我们来仿真“每天的商店营业额”这个复合泊松过程吧。首先,我们假设每个小时进入商店的平均人数为:[10, 5, 3, 6, 8, 10, 20, 40, 100, 80, 40, 50, 100, 120, 30, 30, 60, 80, 100, 150, 70, 20, 20, 10],每位顾客的平均花费为:10元(大约一份早餐吧),请问每天商店的营业额是多少?
```python
# 模拟仿真研究该商店一天的营业额
import simpy
class Store_Money:
def __init__(self, env):
self.store_money_container = simpy.Container(env, capacity = 1e8, init = 0)
def buy(env, store_money, lmd, avg_money):
while True:
body_time = np.random.exponential(1.0/(lmd/60)) # 经过指数分布的时间后,泊松过程记录数+1
yield env.timeout(body_time)
money = np.random.poisson(lam=avg_money)
yield store_money.store_money_container.put(money)
hours = 24 # 一天24h
minutes = 60 # 一个小时60min
days = 3 # 模拟3天
avg_money = 10
lmd_ls = [10, 5, 3, 6, 8, 10, 20, 40, 100, 80, 40, 50, 100, 120, 30, 30, 60, 80, 100, 150, 70, 20, 20, 10] # 每个小时平均进入商店的人数
money_sum = [] # 存储每一天的商店营业额总和
print('仿真开始:')
for day in range(days):
day_money_sum = 0 # 记录每天的营业额之和
for hour, lmd in enumerate(lmd_ls):
env = simpy.Environment()
store_money = Store_Money(env)
store_money_process = env.process(buy(env, store_money, lmd, avg_money))
env.run(until = 60) # 每次仿真60min
if hour % 4 == 0:
print("第"+str(day+1)+"天,第"+str(hour+1)+"时的营业额:", store_money.store_money_container.level)
day_money_sum += store_money.store_money_container.level
money_sum.append(day_money_sum)
print("每天商店的的营业额之和为:", money_sum)
```
```
仿真开始:
第1天,第1时的营业额: 121
第1天,第5时的营业额: 92
第1天,第9时的营业额: 926
第1天,第13时的营业额: 1036
第1天,第17时的营业额: 714
第1天,第21时的营业额: 754
第2天,第1时的营业额: 127
第2天,第5时的营业额: 123
第2天,第9时的营业额: 925
第2天,第13时的营业额: 1036
第2天,第17时的营业额: 567
第2天,第21时的营业额: 782
第3天,第1时的营业额: 148
第3天,第5时的营业额: 53
第3天,第9时的营业额: 1057
第3天,第13时的营业额: 1212
第3天,第17时的营业额: 605
第3天,第21时的营业额: 572
每天商店的的营业额之和为: [11394, 12285, 12223]
```
**案例3**:艾滋病发展过程分为四个阶段(状态),急性感染期(状态 1)、无症状期(状态 2), 艾滋病前期(状态 3), 典型艾滋病期(状态 4)。艾滋病发展过程基本上是一个不可逆的过程,即:状态1 -> 状态2 -> 状态3 -> 状态4。现在收集某地600例艾滋病防控数据,得到以下表格:

现在,我们希望计算若一个人此时是无症状期(状态2)在10次转移之后,这个人的各状态的概率是多少?
```python
# 研究无症状期病人在10期转移后的状态分布
def get_years_dist(p0, P, N):
P1 = P
for i in range(N):
P1 = np.matmul(P1, P)
return np.matmul(p0, P1)
p0 = np.array([0, 1, 0, 0])
P = np.array([
[10.0/80, 62.0/80, 5.0/80, 3.0/80],
[0, 140.0/290, 93.0/290, 57.0/290],
[0, 0, 180.0/220, 40.0/220],
[0, 0, 0, 1]
])
N = 10
print(str(N)+"期转移后,状态分布为:", np.round(get_years_dist(p0, P, N), 4))
```
```
10期转移后,状态分布为: [0.000e+00 3.000e-04 1.048e-01 8.948e-01]
```
## 6.4 数据可视化
使用文字表达结论往往显得过于单调与枯燥,所谓“一图抵千言”,适当使用图表会增加分析结论的有趣性与可读性,这里有一个关键词叫:数据可视化。
首先,我们先来探讨下“可视化”与“数据可视化”的联系与区别。可视化和数据可视化其实都旨在使用图表去表达观点,而数据可视化更加强调使用可视化的图表去表达数据中的信息,如数据分析中的结论。可视化与数据可视化的差别其实类似于:时尚杂志封面与学术杂志封面的区别,一个注重视觉的观感,另一个在注重观感的同时表达数据结论;
其次,数据可视化在一般情况下可以分为:在分析过程中的数据可视化与分析结果表达中的数据可视化。分析过程中的数据可视化强调辅助分析,即图表不是很在意观感如何,只要自己能看懂,自己从图表中得到有用的结论并辅助分析的流程其实就可以了。而分析结果表达中的数据可视化强调在阅读数据分析结论的人能更好地知道分析结论是什么,因此相对于分析过程中的数据可视化来说更加美观、表达的信息更全面;(在这里,我们主要探讨分析结果表达中的数据可视化)
最后,什么才能称得上是一个好的数据可视化图表呢?
- 图表展示的信息全面且无歧义
- 图表表达的信息越多、越全面越好
- 通俗易懂,不能太专业
下面,我们用一个例子来看看什么才算是一个好的数据可视化图表:
```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline
plt.rcParams['font.sans-serif']=['SimHei','Songti SC','STFangsong']
plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号
import seaborn as sns
df = pd.DataFrame({
'variable': ['gender', 'gender', 'age', 'age', 'age', 'income', 'income', 'income', 'income'],
'category': ['Female', 'Male', '1-24', '25-54', '55+', 'Lo', 'Lo-Med', 'Med', 'High'],
'value': [60, 40, 50, 30, 20, 10, 25, 25, 40],
})
df['variable'] = pd.Categorical(df['variable'], categories=['gender', 'age', 'income'])
df['category'] = pd.Categorical(df['category'], categories=df['category'])
# 堆叠柱状图
from plotnine import *
(
ggplot(df, aes(x='variable', y='value', fill='category'))+
geom_col()
)
```

```python
# 柱状图
from plotnine import *
(
ggplot(df, aes(x='variable', y='value', fill='category'))+
geom_col(stat='identity', position='dodge')
)
```

大家现在可以先忽略可视化的代码部分,着重观察两个图表在表达信息上的差异。以上两个图其实都是表示:在gender、age和income上,不同类别的占比,但是第一个图明显比第二个图表达的信息更多,因为第一个图能表达每个类别的大致占比情况,而第二个图只能比较不同类别下的数量情况。
### 6.4.1 Python三大数据可视化工具库的简介
#### (1)Matplotlib:
Matplotlib正如其名,脱胎于著名的建模软件Matlab,因此它的设计与Matlab非常相似,提供了一整套和Matlab相似的命令API,适合交互式制图,还可以将它作为绘图控件,嵌入其它应用程序中。同时,Matplotlib是Python数据可视化工具库的开山鼻祖。
Matplotlib是一个面向对象的绘图工具库,pyplot是Matplotlib最常用的一个绘图接口,调用方法如下:
```python
import matplotlib.pyplot as plt
```
在Matplotlib中,我们可以想像自己手里拿着一支画笔🖌️,每一句代码都是往纸上添加一个绘图特征,下面我们以最简单的方式绘制散点图为例:
- 创建一个图形对象,并设置图形对象的大小(可以想象成在白纸中添加一个图,并设置图的大小):plt.figure(figsize=(6,4))
- 在纸上的坐标系中绘制散点:plt.scatter(x=x, y=y)
- 设置x轴的标签label:plt.xlabel('x')
- 设置y轴标签的label:plt.ylabel('y')
- 设置图表的标题:plt.title('y = sin(x)')
- 展示图标:plt.show()
举个例子:
```python
# 创建数据
x = np.linspace(-2*np.pi, 2*np.pi, 100)
y = np.sin(x)
import matplotlib.pyplot as plt
plt.figure(figsize=(8,6))
plt.scatter(x=x, y=y)
plt.xlabel('x')
plt.ylabel('y')
plt.title('y = sin(x)')
plt.show()
```

在上面的例子中,我们听过Matplotlib绘制了最简单的散点图,但是以上的方法没有体现Matplotlib的“面向对象”的特性。下面,我们使用一个例子体会Matplotlib的面向对象绘图的特性:
【例子】绘制y = sin(x) 和 y=cos(x)的散点图:
创建第一个绘图对象
在纸上的坐标系中绘制散点:plt.scatter(x=x, y=sin(x))
设置x轴的标签label:plt.xlabel('x')
设置y轴标签的label:plt.ylabel('y')
设置图表的标题:plt.title('y = sin(x)')
创建第二个绘图对象:
在纸上的坐标系中绘制散点:plt.scatter(x=x, y=cos(x))
设置x轴的标签label:plt.xlabel('x')
设置y轴标签的label:plt.ylabel('y')
设置图表的标题:plt.title('y = cos(x)')
```python
# 准备数据
x = np.linspace(-2*np.pi, 2*np.pi, 100)
y1 = np.sin(x)
y2 = np.cos(x)
# 绘制第一个图:
fig1 = plt.figure(figsize=(6,4), num='first')
fig1.suptitle('y = sin(x)')
plt.scatter(x=x, y=y1)
plt.xlabel('x')
plt.ylabel('y')
# 绘制第二个图:
fig2 = plt.figure(figsize=(6,4), num='second')
fig2.suptitle('y = cos(x)')
plt.scatter(x=x, y=y2)
plt.xlabel('x')
plt.ylabel('y')
plt.show()
```


现在,大家应该能体会到“Matplotlib的每一句代码都是往纸上添加一个绘图特征”这句话的含义了吧。现在,我们来看看这样的Matplotlib有什么优点与缺点。优点是非常简单易懂,而且能绘制复杂图表;缺点也是十分明显的,如果绘制复杂图表的时候一步一步地绘制,代码量还是十分巨大的。Seaborn是在Matplotlib的基础上的再次封装,是对Matplotlib绘制统计图表的简化。下面,我们一起看看Seaborn的基本绘图逻辑。
#### (2)Seaborn:
Seaborn主要用于统计分析绘图的,它是基于Matplotlib进行了更高级的API封装。Seaborn比matplotlib更加易用,尤其在统计图表的绘制上,因为它避免了matplotlib中多种参数的设置。Seaborn与matplotlib关系,可以把Seaborn视为matplotlib的补充。
下面,我们使用一个简单的例子,来看看使用Seaborn绘图与使用Matplotlib绘图之间的代码有什么不一样:
```python
# 准备数据
x = np.linspace(-10, 10, 100)
y = 2 * x + 1 + np.random.randn(100)
df = pd.DataFrame({'x':x, 'y':y})
# 准备数据
x = np.linspace(-10, 10, 100)
y = 2 * x + 1 + np.random.randn(100)
df = pd.DataFrame({'x':x, 'y':y})
# 使用Seaborn绘制带有拟合直线效果的散点图
sns.lmplot("x","y",data=df)
```


可以看到,Seaborn把数据拟合等统计属性高度集成在绘图函数中,绘图的功能还是构筑在Matplotlib之上。因此,Seaborn相当于是完善了统计图表的Matplotlib工具库,二者应该是相辅相成的。因此,在实际的可视化中,我们往往一起使用Matplotlib和Seaborn,两者的结合应该属于Python的数据可视化的一大流派吧。
#### (3)Plotnine:
gplot2奠定了R语言数据可视化在R语言数据科学的统治地位,R语言的数据可视化是大一统的,提到R语言数据可视化首先想到的就是ggplot2。数据可视化一直是Python的短板,即使有Matplotlib、Seaborn等数据可视化包,也无法与R语言的ggplot2相媲美,原因在于当绘制复杂图表时,Matplotlib和Seaborn由于“每一句代码都是往纸上添加一个绘图特征”的特性而需要大量代码语句。Plotnine可以说是ggplot2在Python上的移植版,使用的基本语法与R语言ggplot2语法“一模一样”,使得Python的数据可视化能力大幅度提升,为什么ggplot2和Plotnine会更适合数据可视化呢?原因可以类似于PhotoShop绘图和PPT绘图的区别,与PPT一笔一画的绘图方式不同的是,PhotoShop绘图采用了“图层”的概念,每一句代码都是相当于往图像中添加一个图层,一个图层就是一类绘图动作,这无疑给数据可视化工作大大减负,同时更符合绘图者的认知。
下面,我们通过一个案例来看看Plotnine的图层概念以及Plotnine的基本绘图逻辑:
```python
from plotnine import * # 讲Plotnine所有模块引入
from plotnine.data import * # 引入PLotnine自带数据集
mpg.head()
```
mpg数据集记录了美国1999年和2008年部分汽车的制造厂商,型号,类别,驱动程序和耗油量。
manufacturer 生产厂家
model 型号类型
year 生产年份
cty 和 hwy分别记录城市和高速公路驾驶耗油量
cyl 气缸数
displ 表示发动机排量
drv 表示驱动系统:前轮驱动、(f),后轮驱动®和四轮驱动(4)
class 表示车辆类型,如双座汽车,suv,小型汽车
fl (fuel type),燃料类型
```python
# 绘制汽车在不同驱动系统下,发动机排量与耗油量的关系
p1 = (
ggplot(mpg, aes(x='displ', y='hwy', color='drv')) # 设置数据映射图层,数据集使用mpg,x数据使用mpg['displ'],y数据使用mpg['hwy'],颜色映射使用mog['drv']
+ geom_point() # 绘制散点图图层
+ geom_smooth(method='lm') # 绘制平滑线图层
+ labs(x='displacement', y='horsepower') # 绘制x、y标签图层
)
print(p1) # 展示p1图像
```

从上面的案例,我们可以看到Plotnine的绘图逻辑是:一句话一个图层。因此,在Plotnine中少量的代码就能画图非常漂亮的图表,而且可以画出很多很复杂的图表,就类似于PhotoShp能轻松画出十分复杂的图但是PPT需要大量时间也不一定能达到同样的效果。
那什么时候选择Matplotlib、Seaborn还是Plotnine?Plotnine具有ggplot2的图层特性,但是由于开发时间较晚,目前这个工具包还有一些缺陷,其中最大的缺陷就是:没有实现除了直角坐标以外的坐标体系,如:极坐标。因此,Plotnine无法绘制类似于饼图、环图等图表。为了解决这个问题,在绘制直角坐标系的图表时,我们可以使用Plotnine进行绘制,当涉及极坐标图表时,我们使用Matplotlib和Seaborn进行绘制。有趣的是,Matplotlib具有ggplot风格,可以通过设置ggplot风格绘制具有ggplot风格的图表。
```python
plt.style.use("ggplot") #风格使用ggplot
```
但是值得注意的是,这里所说的绘制ggplot风格,是看起来像ggplot表格,但是实际上Matplotlib还是不具备图层风格。
### 6.4.2 基本图标Quick Start
(1)类别型图表:类别型图表一般表现为:X类别下Y数值之间的比较,因此类别型图表往往包括:X为类别型数据、Y为数值型数据。类别型图表常常有:柱状图、横向柱状图(条形图)、堆叠柱状图、极坐标的柱状图、词云、雷达图、桑基图等等。
```python
## Matplotlib绘制单系列柱状图:不同城市的房价对比
data = pd.DataFrame({'city':['深圳', '上海', '北京', '广州', '成都'], 'house_price(w)':[3.5, 4.0, 4.2, 2.1, 1.5]})
fig = plt.figure(figsize=(10,6))
ax1 = fig.add_axes([0.15,0.15,0.7,0.7]) # [left, bottom, width, height], 它表示添加到画布中的矩形区域的左下角坐标(x, y),以及宽度和高度
plt.bar(data['city'], data['house_price(w)'], width=0.6, align='center', orientation='vertical', label='城市')
"""
x 表示x坐标,数据类型为int或float类型,也可以为str
height 表示柱状图的高度,也就是y坐标值,数据类型为int或float类型
width 表示柱状图的宽度,取值在0~1之间,默认为0.8
bottom 柱状图的起始位置,也就是y轴的起始坐标
align 柱状图的中心位置,"center","lege"边缘
color 柱状图颜色
edgecolor 边框颜色
linewidth 边框宽度
tick_label 下标标签
log 柱状图y周使用科学计算方法,bool类型
orientation 柱状图是竖直还是水平,竖直:"vertical",水平条:"horizontal"
"""
plt.title("不同城市的房价对比图") # 在axes1设置标题
plt.xlabel("城市") # 在axes1中设置x标签
plt.ylabel("房价/w") # 在axes1中设置y标签
plt.grid(b=True, which='both') # 在axes1中设置设置网格线
for i in range(len(data)):
plt.text(i-0.05, data.iloc[i,]['house_price(w)']+0.01, data.iloc[i,]['house_price(w)'],fontsize=13) # 添加数据注释
plt.legend()
plt.show()
```

```python
## Plotnine绘制单系列柱状图:不同城市的房价对比
data = pd.DataFrame({'city':['深圳', '上海', '北京', '广州', '成都'], 'house_price(w)':[3.5, 4.0, 4.2, 2.1, 1.5]})
p_single_bar = (
ggplot(data, aes(x='city', y='house_price(w)', fill='city', label='house_price(w)'))+
geom_bar(stat='identity')+
labs(x="城市", y="房价(w)", title="不同城市的房价对比图")+
geom_text(nudge_y=0.08)+
theme(text = element_text(family = "Songti SC"))
)
print(p_single_bar)
```

```python
## Matplotlib绘制多系列柱状图:不同城市在不同年份的房价对比
data = pd.DataFrame({
'城市':['深圳', '上海', '北京', '广州', '成都', '深圳', '上海', '北京', '广州', '成都'],
'年份':[2021,2021,2021,2021,2021,2022,2022,2022,2022,2022],
'房价(w)':[3.5, 4.0, 4.2, 2.1, 1.5, 4.0, 4.2, 4.3, 1.6, 1.9]
})
fig = plt.figure(figsize=(10,6))
ax1 = fig.add_axes([0.15,0.15,0.7,0.7]) # [left, bottom, width, height], 它表示添加到画布中的矩形区域的左下角坐标(x, y),以及宽度和高度
plt.bar(
np.arange(len(np.unique(data['城市'])))-0.15,
data.loc[data['年份']==2021,'房价(w)'],
width=0.3,
align='center',
orientation='vertical',
label='年份:2021'
)
plt.bar(
np.arange(len(np.unique(data['城市'])))+0.15,
data.loc[data['年份']==2022,'房价(w)'],
width=0.3,
align='center',
orientation='vertical',
label='年份:2022'
)
plt.title("不同城市的房价对比图") # 在axes1设置标题
plt.xlabel("城市") # 在axes1中设置x标签
plt.ylabel("房价/w") # 在axes1中设置y标签
plt.xticks(np.arange(len(np.unique(data['城市']))), np.array(['深圳', '上海', '北京', '广州', '成都']))
plt.grid(b=True, which='both') # 在axes1中设置设置网格线
data_2021 = data.loc[data['年份']==2021,:]
for i in range(len(data_2021)):
plt.text(i-0.15-0.05, data_2021.iloc[i,2]+0.05, data_2021.iloc[i,2],fontsize=13) # 添加数据注释
data_2022 = data.loc[data['年份']==2022,:]
for i in range(len(data_2022)):
plt.text(i+0.15-0.05, data_2022.iloc[i,2]+0.05, data_2022.iloc[i,2],fontsize=13) # 添加数据注释
plt.legend()
plt.show()
```

```python
## Plotnine绘制多系列柱状图:不同城市在不同年份的房价对比
data = pd.DataFrame({
'城市':['深圳', '上海', '北京', '广州', '成都', '深圳', '上海', '北京', '广州', '成都'],
'年份':[2021,2021,2021,2021,2021,2022,2022,2022,2022,2022],
'房价(w)':[3.5, 4.0, 4.2, 2.1, 1.5, 4.0, 4.2, 4.3, 1.6, 1.9]
})
data['年份'] = pd.Categorical(data['年份'], ordered=True, categories=data['年份'].unique())
p_mult_bar = (
ggplot(data, aes(x='城市', y='房价(w)', fill='年份'))+
geom_bar(stat='identity',width=0.6, position='dodge')+
scale_fill_manual(values = ["#f6e8c3", "#5ab4ac"])+
labs(x="城市", y="房价(w)", title="不同城市的房价对比图")+
geom_text(aes(label='房价(w)'), position = position_dodge2(width = 0.6, preserve = 'single'))+
theme(text = element_text(family = "Songti SC"))
)
print(p_mult_bar)
```

```python
## Matplotlib绘制堆叠柱状图:不同城市在不同年份的房价对比
data = pd.DataFrame({
'城市':['深圳', '上海', '北京', '广州', '成都', '深圳', '上海', '北京', '广州', '成都'],
'年份':[2021,2021,2021,2021,2021,2022,2022,2022,2022,2022],
'房价(w)':[3.5, 4.0, 4.2, 2.1, 1.5, 4.0, 4.2, 4.3, 1.6, 1.9]
})
tmp=data.set_index(['城市','年份'])['房价(w)'].unstack()
data=tmp.rename_axis(columns=None).reset_index()
data.columns = ['城市','2021房价','2022房价']
print(data)
plt.figure(figsize=(10,6))
plt.bar(
data['城市'],
data['2021房价'],
width=0.6,
align='center',
orientation='vertical',
label='年份:2021'
)
plt.bar(
data['城市'],
data['2022房价'],
width=0.6,
align='center',
orientation='vertical',
bottom=data['2021房价'],
label='年份:2022'
)
plt.title("不同城市2121-2022年房价对比图") # 在axes1设置标题
plt.xlabel("城市") # 在axes1中设置x标签
plt.ylabel("房价/w") # 在axes1中设置y标签
plt.legend()
plt.show()
```

```python
## Matplotlib绘制百分比柱状图:不同城市在不同年份的房价对比
## Matplotlib绘制堆叠柱状图:不同城市在不同年份的房价对比
data = pd.DataFrame({
'城市':['深圳', '上海', '北京', '广州', '成都', '深圳', '上海', '北京', '广州', '成都'],
'年份':[2021,2021,2021,2021,2021,2022,2022,2022,2022,2022],
'房价(w)':[3.5, 4.0, 4.2, 2.1, 1.5, 4.0, 4.2, 4.3, 1.6, 1.9]
})
tmp=data.set_index(['城市','年份'])['房价(w)'].unstack()
data=tmp.rename_axis(columns=None).reset_index()
data.columns = ['城市','2021房价','2022房价']
print(data)
plt.figure(figsize=(10,6))
plt.bar(
data['城市'],
data['2021房价']/(data['2021房价']+data['2022房价']),
width=0.4,
align='center',
orientation='vertical',
label='年份:2021'
)
plt.bar(
data['城市'],
data['2022房价']/(data['2021房价']+data['2022房价']),
width=0.4,
align='center',
orientation='vertical',
bottom=data['2021房价']/(data['2021房价']+data['2022房价']),
label='年份:2022'
)
plt.title("不同城市2121-2022年房价对比图") # 设置标题
plt.xlabel("城市") # 在axes1中设置x标签
plt.ylabel("房价/w") # 在axes1中设置y标签
plt.legend()
plt.show()
```

```python
# 使用Matplotlib绘制雷达图:英雄联盟几位英雄的能力对比
data = pd.DataFrame({
'属性': ['血量', '攻击力', '攻速', '物抗', '魔抗'],
'艾希':[3, 7, 8, 2, 2],
'诺手':[8, 6, 3, 6, 6]
})
plt.figure(figsize=(8,8))
theta = np.linspace(0, 2*np.pi, len(data), endpoint=False) # 每个坐标点的位置
theta = np.append(theta, theta[0]) # 让数据封闭
aixi = np.append(data['艾希'].values,data['艾希'][0]) #让数据封闭
nuoshou = np.append(data['诺手'].values,data['诺手'][0]) # 让数据封闭
shuxing = np.append(data['属性'].values,data['属性'][0]) # 让数据封闭
plt.polar(theta, aixi, 'ro-', lw=2, label='艾希') # 画出雷达图的点和线
plt.fill(theta, aixi, facecolor='r', alpha=0.5) # 填充
plt.polar(theta, nuoshou, 'bo-', lw=2, label='诺手') # 画出雷达图的点和线
plt.fill(theta, nuoshou, facecolor='b', alpha=0.5) # 填充
plt.thetagrids(theta/(2*np.pi)*360, shuxing) # 为每个轴添加标签
plt.ylim(0,10)
plt.legend()
plt.show()
```

(2)关系型图表:关系型图表一般表现为:X数值与Y数值之间的关系,如:是否是线性关系、是否有正向相关关系等等。一般来说,关系可以分为:数值型关系、层次型关系和网络型关系。
```python
# 使用Matplotlib和四个图说明相关关系:
x = np.random.randn(100)*10
y1 = np.random.randn(100)*10
y2 = 2 * x + 1 + np.random.randn(100)
y3 = -2 * x + 1 + np.random.randn(100)
y4 = x**2 + 1 + np.random.randn(100)
plt.figure(figsize=(12, 12))
plt.subplot(2,2,1) #创建两行两列的子图,并绘制第一个子图
plt.scatter(x, y1, c='dodgerblue', marker=".", s=50)
plt.xlabel("x")
plt.ylabel("y1")
plt.title("y1与x不存在关联关系")
plt.subplot(2,2,2) #创建两行两列的子图,并绘制第二个子图
plt.scatter(x, y2, c='tomato', marker="o", s=10)
plt.xlabel("x")
plt.ylabel("y2")
plt.title("y2与x存在关联关系")
plt.subplot(2,2,3) #创建两行两列的子图,并绘制第三个子图
plt.scatter(x, y3, c='magenta', marker="o", s=10)
plt.xlabel("x")
plt.ylabel("y3")
plt.title("y3与x存在关联关系")
plt.subplot(2,2,4) #创建两行两列的子图,并绘制第四个子图
plt.scatter(x, y4, c='deeppink', marker="s", s=10)
plt.xlabel("x")
plt.ylabel("y3")
plt.title("y4与x存在关联关系")
plt.show()
```

```python
# 使用Plotnine和四个图说明相关关系:
x = np.random.randn(100)*10
y1 = np.random.randn(100)*10
y2 = 10 * x + 1 + np.random.randn(100)
y3 = -10 * x + 1 + np.random.randn(100)
y4 = x**2 + 1 + np.random.randn(100)
df = pd.DataFrame({
'x': np.concatenate([x,x,x,x]),
'y': np.concatenate([y1, y2, y3, y4]),
'class': ['y1']*100 + ['y2']*100 + ['y3']*100 + ['y4']*100
})
p1 = (
ggplot(df)+
geom_point(aes(x='x', y='y', fill='class', shape='class'), color='black', size=2)+
scale_fill_manual(values=('#00AFBB', '#FC4E07', '#00589F', '#F68F00'))+
theme(text = element_text(family = "Songti SC"))
)
print(p1)
```

```python
# 使用Matplotlib绘制具备趋势线的散点图
from sklearn.linear_model import LinearRegression #线性回归等参数回归
import statsmodels.api as sm
from sklearn.preprocessing import PolynomialFeatures # 构造多项式
x = np.linspace(-10, 10, 100)
y = np.square(x) + np.random.randn(100)*100
x_poly2 = PolynomialFeatures(degree=2).fit_transform(x.reshape(-1, 1))
y_linear_pred = LinearRegression().fit(x.reshape(-1, 1), y).predict(x.reshape(-1, 1))
y_poly_pred = LinearRegression().fit(x_poly2, y).predict(x_poly2)
y_exp_pred = LinearRegression().fit(np.exp(x).reshape(-1, 1), y).predict(np.exp(x).reshape(-1, 1))
y_loess_pred = sm.nonparametric.lowess(x, y, frac=2/3)[:, 1]
plt.figure(figsize=(8, 8))
plt.subplot(2,2,1)
plt.scatter(x, y, c='tomato', marker="o", s=10)
plt.plot(x, y_linear_pred, c='dodgerblue')
plt.xlabel("x")
plt.ylabel("y")
plt.title("带线性趋势线的散点图")
plt.subplot(2,2,2)
plt.scatter(x, y, c='tomato', marker="o", s=10)
plt.plot(x, y_poly_pred, c='dodgerblue')
plt.xlabel("x")
plt.ylabel("y")
plt.title("带二次趋势线的散点图")
plt.subplot(2,2,3)
plt.scatter(x, y, c='tomato', marker="o", s=10)
plt.plot(x, y_exp_pred, c='dodgerblue')
plt.xlabel("x")
plt.ylabel("y")
plt.title("带指数趋势线的散点图")
plt.subplot(2,2,4)
plt.scatter(x, y, c='tomato', marker="o", s=10)
plt.plot(x, y_loess_pred, c='dodgerblue')
plt.xlabel("x")
plt.ylabel("y")
plt.title("带 loess平滑线的散点图")
plt.show()
```

```python
# 使用Matplotlib绘制聚类散点图
from sklearn.datasets import load_iris #家在鸢尾花数据集
iris = load_iris()
X = iris.data
label = iris.target
feature = iris.feature_names
df = pd.DataFrame(X, columns=feature)
df['label'] = label
label_unique = np.unique(df['label']).tolist()
plt.figure(figsize=(10, 6))
for i in label_unique:
df_label = df.loc[df['label'] == i, :]
plt.scatter(x=df_label['sepal length (cm)'], y=df_label['sepal width (cm)'], s=20, label=i)
plt.xlabel('sepal length (cm)')
plt.ylabel('sepal width (cm)')
plt.title('sepal width (cm)~sepal length (cm)')
plt.legend()
plt.show()
```

```python
# 使用plotnine绘制相关系数矩阵图:
from plotnine.data import mtcars
corr_mat = np.round(mtcars.corr(), 1).reset_index() #计算相关系数矩阵
df = pd.melt(corr_mat, id_vars='index', var_name='variable', value_name='corr_xy') #将矩阵宽数据变成长数据
df['abs_corr'] = np.abs(df['corr_xy'])
p1 = (
ggplot(df, aes(x='index', y='variable', fill='corr_xy', size='abs_corr'))+
geom_point(shape='o', color='black')+
scale_size_area(max_size=11, guide=False)+
scale_fill_cmap(name='RdYIBu_r')+
coord_equal()+
labs(x="Variable", y="Variable")+
theme(dpi=100, figure_size=(4.5,4.55))
)
p2 = (
ggplot(df, aes(x='index', y='variable', fill='corr_xy', size='abs_corr'))+
geom_point(shape='s', color='black')+
scale_size_area(max_size=10, guide=False)+
scale_fill_cmap(name='RdYIBu_r')+
coord_equal()+
labs(x="Variable", y="Variable")+
theme(dpi=100, figure_size=(4.5,4.55))
)
p3 = (
ggplot(df, aes(x='index', y='variable', fill='corr_xy', label='corr_xy'))+
geom_tile(color='black')+
geom_text(size=8, color='white')+
scale_fill_cmap(name='RdYIBu_r')+
coord_equal()+
labs(x="Variable", y="Variable")+
theme(dpi=100, figure_size=(4.5,4.55))
)
print([p1, p2, p3])
```



```python
# 使用Matplotlib/Seaborn绘制相关系数矩阵图
uniform_data = np.random.rand(10, 12)
sns.heatmap(uniform_data)
```

(3)分布型图表:所谓数据的分布,其实就是数据在哪里比较密集,那里比较稀疏,描述数据的密集或者稀疏情况实际上可以用频率或者概率。
```python
# 使用matplotlib绘制直方图:
plt.figure(figsize=(8, 6))
plt.hist(mtcars['mpg'], bins=20, alpha=0.85)
plt.xlabel("mpg")
plt.ylabel("count")
plt.show()
```

```python
# 使用matplotlib绘制箱线图
import seaborn as sns
from plotnine.data import mtcars
data = mtcars
data['carb'] = data['carb'].astype('category')
plt.figure(figsize=(8, 6))
sns.boxenplot(x='carb', y='mpg', data=mtcars, linewidth=0.2, palette=sns.husl_palette(6, s=0.9, l=0.65, h=0.0417))
plt.show()
```

```python
# 使用Matplotlib绘制饼状图:
from matplotlib import cm, colors
df = pd.DataFrame({
'己方': ['寒冰', '布隆', '发条', '盲僧', '青钢影'],
'敌方': ['女警', '拉克丝', '辛德拉', '赵信', '剑姬'],
'己方输出': [26000, 5000, 23000, 4396, 21000],
'敌方输出': [25000, 12000, 21000, 10000, 18000]
})
df_our = df[['己方', '己方输出']].sort_values(by='己方输出', ascending=False).reset_index()
df_other = df[['敌方', '敌方输出']].sort_values(by='敌方输出', ascending=False).reset_index()
color_list = [cm.Set3(i) for i in range(len(df))]
plt.figure(figsize=(16, 10))
plt.subplot(1,2,1)
plt.pie(df_our['己方输出'].values, startangle=90, shadow=True, colors=color_list, labels=df_our['己方'].tolist(), explode=(0,0,0,0,0.3), autopct='%.2f%%')
plt.subplot(1,2,2)
plt.pie(df_other['敌方输出'].values, startangle=90, shadow=True, colors=color_list, labels=df_other['敌方'].tolist(), explode=(0,0,0,0,0.3), autopct='%.2f%%')
plt.show()
```

```python
# 使用Matplotlib绘制环状图:
from matplotlib import cm, colors
df = pd.DataFrame({
'己方': ['寒冰', '布隆', '发条', '盲僧', '青钢影'],
'敌方': ['女警', '拉克丝', '辛德拉', '赵信', '剑姬'],
'己方输出': [26000, 5000, 23000, 4396, 21000],
'敌方输出': [25000, 12000, 21000, 10000, 18000]
})
df_our = df[['己方', '己方输出']].sort_values(by='己方输出', ascending=False).reset_index()
df_other = df[['敌方', '敌方输出']].sort_values(by='敌方输出', ascending=False).reset_index()
color_list = [cm.Set3(i) for i in range(len(df))]
wedgeprops = {'width':0.3, 'edgecolor':'black', 'linewidth':3}
plt.figure(figsize=(16, 10))
plt.subplot(1,2,1)
plt.pie(df_our['己方输出'].values, startangle=90, shadow=True, colors=color_list, wedgeprops=wedgeprops, labels=df_our['己方'].tolist(), explode=(0,0,0,0,0.3), autopct='%.2f%%')
plt.text(0, 0, '己方' , ha='center', va='center', fontsize=30)
plt.subplot(1,2,2)
plt.pie(df_other['敌方输出'].values, startangle=90, shadow=True, colors=color_list, wedgeprops=wedgeprops, labels=df_other['敌方'].tolist(), explode=(0,0,0,0,0.3), autopct='%.2f%%')
plt.text(0, 0, '敌方' , ha='center', va='center', fontsize=30)
plt.show()
```

(4)时间序列型图表:
```python
# 使用Plotnine绘制时间序列线图:
df = pd.read_csv(
'./data/AirPassengers.csv'
) # 航空数据1949-1960
df['date'] = pd.to_datetime(df['date'])
p1 = (
ggplot(df, aes(x='date', y='value'))+
geom_line(size=1, color='red')+
scale_x_date(date_labels="%Y", date_breaks="1 year")+
xlab('date')+
ylab('value')
)
print(p1)
```

```python
# 使用Matplotlib绘制时间序列折线图
plt.figure(figsize=(8,6))
plt.plot(df['date'], df['value'], color='red')
plt.xlabel("date")
plt.ylabel("value")
plt.show()
```

```python
# Plotnine绘制多系列折线图
date_list = pd.date_range('2022-01-01', '2022-03-31').astype('str').tolist() * 2
value_list = np.random.randn(len(date_list))
Class = [1] * (len(date_list) // 2) + [2] * (len(date_list) // 2)
data = pd.DataFrame({
'date_list': date_list,
'value_list': value_list,
'Class': Class
})
data['date_list'] = pd.to_datetime(data['date_list'])
p1 = (
ggplot(data, aes(x='date_list', y='value_list', group='factor(Class)', color='factor(Class)'))+
geom_line(size=1)+
scale_x_date(date_labels="%D")+
xlab('date')+
ylab('value')
)
print(p1)
```

```python
# Matplotlib 绘制多系列折线图
date_list = pd.date_range('2022-01-01', '2022-03-31').astype('str').tolist()
value_list1 = np.random.randn(len(date_list))
value_list2 = np.random.randn(len(date_list))
data = pd.DataFrame({
'date_list': date_list,
'value_list1': value_list1,
'value_list2': value_list2
})
data['date_list'] = pd.to_datetime(data['date_list'])
plt.figure(figsize=(8, 6))
plt.plot(data['date_list'], data['value_list1'], color='red', alpha=0.86, label='value1')
plt.plot(data['date_list'], data['value_list2'], color='blue', alpha=0.86, label='value2')
plt.legend()
plt.xlabel('date')
plt.ylabel('value')
plt.show()
```

## 6.5 插值模型
插值方法实际上除了是用于处理缺失值一个很好的方法,也是做数据填充的很好的方法。比如,我获取了按日的数据,需要按小时对数据进行填充,这个时候就可以用插值去做填充任务。这里介绍几种比较常见的插值方法。
### 6.5.1 线性插值法
线性插值对两个点中的解析式是按照线性方程来建模。比如我们得到的原始数据列{y}和数据的下标{x},这里数据下标x可能并不是固定频率的连续取值而是和y一样存在缺失的。给定了数据点(xk,yk)和(xk+1,yk+1),需要对两个点之间构造直线进行填充。很显然,根据直线的点斜式方程,这个直线解析式为:
$$
{L_1}(x) = {y_k} + \frac{{{y_{k + 1}} - {y_k}}}{{{x_{k + 1}} - {x_k}}}(x - {x_k}) \tag{6.5.1}
$$
按照这个方程形式对空缺的(x,y)处进行填充就可以完成插值过程。
```python
import numpy as np
#数据准备
X = np.arange(-2*np.pi, 2*np.pi, 1) # 定义样本点X,从-pi到pi每次间隔1
Y = np.sin(X) # 定义样本点Y,形成sin函数
new_x = np.arange(-2*np.pi, 2*np.pi, 0.1) # 定义插值点
# 进行样条插值
import scipy.interpolate as spi
# 进行一阶样条插值
ipo1 = spi.splrep(X, Y, k=1) # 样本点导入,生成参数
iy1 = spi.splev(new_x, ipo1) # 根据观测点和样条参数,生成插值
```
### 6.4.2 三次样条插值
三次样条插值是一种非常自然的插值方法。它将两个数据点之间的填充模式设置为三次多项式。它假设在数据点(xk,yk)和(xk+1,yk+1)之间的三次式叫做Ik,那么这一组三次式需要满足条件:
$$
{a_i}x_i^3 + {b_i}x_i^2 + {c_i}{x_i} + {d_i} = {a_{i + 1}}x_{i + 1}^3 + {b_{i + 1}}x_{i + 1}^2 + {c_{i + 1}}{x_{i + 1}} + {d_{i + 1}} \tag{6.5.2}
$$
$$
3{a_i}x_i^2 + 2{b_i}{x_i} + {c_i} = 3{a_{i + 1}}x_{i + 1}^2 + 2{b_{i + 1}}{x_{i + 1}} + {c_{i + 1}} \tag{6.5.3}
$$
$$
6{a_i}{x_i} + 2{b_i} = 6{a_{i + 1}}{x_{i + 1}} + 2{b_{i + 1}} \tag{6.5.4}
$$
通过解方程的形式可以解出每一只三次式。而简而言之,也就是说某个数据点前后两条三次函数不仅在当前的函数值相等,一次导数值和二次导数值也要保持相等。
```python
# 进行三次样条拟合
ipo3 = spi.splrep(X, Y, k=3) # 样本点导入,生成参数
iy3 = spi.splev(new_x, ipo3) # 根据观测点和样条参数,生成插值
```
### 6.4.3 拉格朗日插值
对于一组数据{y}和下标{x},定义n个拉格朗日插值基函数:
$$
{l_k}(x) = \prod\limits_{i = 0,i \ne k}^n {\frac{{x - {x_i}}}{{{x_k} - {x_i}}}} \tag{6.5.5}
$$
这本质上是一个分式,当x=xk时lk(x)=1,这一操作实现了离散数据的连续化。按照对应下标的函数值加权求和可以得到整体的拉格朗日插值函数:
$$
L(x) = \sum\limits_{k = 0}^n {{y_k}{l_k}(x)} \tag{6.5.6}
$$
但很不幸,Python没有为我们提供拉格朗日插值函数的方法。不过好在实现并不算太困难,我们可以自己写一个:
```python
def lagrange(x0,y0,x):
y=[]
for k in range(len(x)):
s=0
for i in range(len(y0)):
t=y0[i]
for j in range(len(y0)):
if i!=j:
t*=(x[k]-x0[j])/(x0[i]-x0[j])
s+=t
y.append(s)
return y
```
通过一个简单的样例进行测试:
```python
from scipy.interpolate import interp1d
x0=[1,2,3,4,5]
y0=[1.6,1.8,3.2,5.9,6.8]
x=np.arange(1,5,1/30)
f1=interp1d(x0,y0,'linear')
y1=f1(x)
f2=interp1d(x0,y0,'cubic')
y2=f2(x)
y3=lagrange(x0,y0,x)
plt.plot(x0,y0,'r*')
plt.plot(x,y1,'b-',x,y2,'y-',x,y3,'r-')
plt.show()
```

================================================
FILE: docs/CH7/第7章-权重生成与评价模型.md
================================================
第7章 权重生成与评价模型
> 内容:@若冰(马世拓)
>
> 审稿:@牧小熊(聂雄伟)
>
> 排版&校对:@牧小熊(聂雄伟)
这一章我们主要介绍评价模型。评价类模型是初次接触数学建模竞赛的同学非常喜爱的一类题型,因为代码量小,原理通俗易懂。这类问题主要是用以解决对于一个目标不同方案之间比较或不同影响因素之间比较的问题。本章主要涉及到的知识点有:
- 层次分析法
- 熵权法
- TOPSIS分析法
- 模糊评价法
- CRITIC方法
- 主成分分析法
- 因子分析法
- 数据包络法
> 注意:本章内容相对来说较为简单,比起代码实现,对结果的解读更为重要。
## 7.1 层次分析法
> 层次分析法是美国运筹学家匹茨堡大学教授萨蒂于20世纪70年代初提出的一种评价策略。这种策略虽然带有一定主观性,但非常奏效,也是在社会科学研究中经常使用的一类方法。
### 7.1.1 层次分析法的原理
首先,层次分析法的流程分五步走:
1.选择指标,构建层次模型。
2.对目标层到准则层之间和准则层到方案层之间构建比较矩阵。
3.对每个比较矩阵计算CR值检验是否通过CR检验,如果没有通过检验需要调整比较矩阵。
4.求出每个矩阵最大的特征值对应的归一化权重向量。
5.根据不同矩阵的归一化权向量计算出不同方案的得分进行比较。
下面我们就对其中的每个步骤进行详细的分析。
首先是选择指标。在一些问题当中,如果它明确了需要做一个评价类问题,有可能会给出一些评价的指标准则,但很多情况下它并不会给你明确的指标。这需要你查阅资料或者自己去构建。事实上当我们需要用层次分析法解决问题的时候所评价的准则量绝非空穴来风凭空杜撰,而是在经过大量文献考证或社会调查后选取的。如果不去查阅文献的话可能就得发放调查问卷或者使用德尔菲法去征求专家意见。所幸,这些指标都是可以通过查找以往文献得到的。指标按照一定的层级结构组织起来就构成了一个指标体系。
层次分析法的层次从何而来?注意,在层次分析法中一个非常重要的操作就是构造层次模型图。从结构上看,层次分析法将模型大致分为目标层、准则层和方案层。目标层是你的评价目标,准则层是你的评价指标体系,方案层是多个对比方案。注意,方案层是一定要有多个对象进行比较的,因为层次分析法是基于比较的方法。

图7.1 使用层次分析法选择空调
两个相邻的层次之间是需要构建成对比较矩阵的。比方说在上图,我们在目标层和准则层之间就需要构建第一层比较矩阵,这个矩阵的大小是行列均为4。矩阵的每一项表示因素i和因素j的相对重要程度。由于对角线上元素都是自己和自己做比较,所以对角线上元素为1。另外,还有一条重要性质:
$$
{a\mathop{{}}\nolimits_{{ij}}a\mathop{{}}\nolimits_{{ji}}=1}
$$
关于这个矩阵的每一项取值多少,若因素*i*比因素*j*重要,为了描述重要程度,我们用1~9中间的整数描述,如表5.2所示。
**表5.2 重要性程度取值**
| **取值** | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| ---------------- | -------- | ------------ | ------------ | ------------ | ------------ | ------------ | ------------ | ------------ | ------------ |
| **相对重要程度** | 同等重要 | 介于1、3之间 | 相对重要一些 | 介于3、5之间 | 相对比较重要 | 介于5、7之间 | 相比明显重要 | 介于7、9之间 | 相比非常重要 |
表5.2中描述的重要性是因素*i*比因素*j*重要的情况下描述的。如果是因素*j*比因素*i*重要,由式(5.2)得到的“相对不重要程度”那就用1~9的倒数描述即可。这个比较矩阵每一项的确定具有较强的主观性,因为究竟二者重要程度是比较重要还是非常重要也是不同的人可能有不同的理解,但总体来讲是奏效的。
注意:通常来说,对重要性的取值都是取奇数,也就是1、3、5、7、9,偶数是当你不太确定,也就是认为介于两个奇数程度之间时再取偶数。
除了准则层外,方案层也需要构建成对比较矩阵。但不同的是,假如有m个准则n个样本,需要构建的矩阵数量为m,矩阵大小为(n,n)。每个成对比较矩阵是需要我们自己手动确定的。在人工商定了这些成对比较矩阵后,接下来的操作是对每个成对比较矩阵进行一致性检验。在前面我们已经知道如何对矩阵进行特征值分解,那么对于成对比较矩阵可以很容易地计算出它最大的特征值及其对应的特征向量。那么,定义CI值:
$$
{CI=\frac{{ \lambda \mathop{{}}\nolimits_{{max}}-n}}{{n-1}}}
$$
这里*n*取4的话很容易计算出CI值为0.0145。而除了CI,还有一个RI值(随机一致性指标),在不同的*n*的取值下RI值也不同。这个值是通过大量随机实验得到的统计规律,数值可以查表获得,将RI表列在表5.4中。
**表5.4 RI*取值**
| n | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| ------ | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| **RI** | 0 | 0 | 0.52 | 0.89 | 1.12 | 1.26 | 1.32 |
| **n** | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| **RI** | 1.41 | 1.46 | 1.49 | 1.52 | 1.54 | 1.56 | 1.58 |
得到RI和CI后,计算CI和RI的比值也就是CR。查表计算可以得到这个矩阵的CR值为0.0161。通常来说,当CR值超过0.1时,就可以认为这个矩阵是不合理的,需要被修改、被调整。这里由于没有超过这个阈值,所以可以认为这个比较矩阵通过了一致性检验。于是,剩下的过程便可以如法炮制,计算出准则层到方案层的4个矩阵了。
得到一致性检验结果后,还需要对最大特征值对应的特征向量进行归一化得到权重向量。归一化的方法为将特征向量除以该向量所有元素之和:
$$
{x\mathop{{}}\nolimits_{{i}}=\frac{{x\mathop{{}}\nolimits_{{i}}}}{{{\mathop{ \sum }\limits_{{i-1}}^{{n}}{x\mathop{{}}\nolimits_{{i}}}}}}}
$$
最终可以得到每个指标的权重,以及每个样本在不同指标上的归一化得分。通过加权折算就可以得到评价的最终分数。
### 7.1.2 层次分析法的案例
上面的纯文字表述可能还是会有些抽象,没关系,我们这里有两个不错的案例。
**例7.1** 某日从三条河流的基站处抽检水样,得到了水质的四项检测指标如表5.1所示。请根据提供数据对三条河流的水质进行评价。其中,DO代表水中溶解氧含量,越大越好;CODMn表示水中高锰酸盐指数,NH3-N表示氨氮含量,这两项指标越小越好;pH值没有量纲,在6~9区间内较为合适。
**例7.1的数据**
| **地点名称** | **pH\*** | **DO** | **CODMn** | **NH3-N** |
| -------------- | -------- | ------ | --------- | --------- |
| 四川攀枝花龙洞 | 7.94 | 9.47 | 1.63 | 0.077 |
| 重庆朱沱 | 8.15 | 9.00 | 1.4 | 0.417 |
| 湖北宜昌南津关 | 8.06 | 8.45 | 2.83 | 0.203 |
首先,我们需要对上面的数据分析:该评价问题一共有三个样本,四个评价指标。不同的评价指标还不太一样,有的越大越好有的越小越好。这里既然他们给出来了评价指标,就不再另外查找文献了。我们构建层次模型图:

接下来的操作就是对目标层到准则层构建一个大小为4的方阵,准则层到方案层构建4个大小为3的方阵。我们先来计算一下这个目标层到准则层,至于准则层到方案层的矩阵都是如法炮制的过程。例如,创建了这么一个矩阵:
| **变量** | **pH\*** | **DO** | **CODMn** | **NH3-N** |
| --------- | -------- | ------ | --------- | --------- |
| **pH\*** | 1 | 1/5 | 1/3 | 1 |
| **DO** | 5 | 1 | 3 | 5 |
| **CODMn** | 3 | 1/3 | 1 | 3 |
| **NH3-N** | 1 | 1/5 | 1/3 | 1 |
对这个矩阵做特征值分解的代码如下:
```python
import numpy as np
# 构建矩阵
A=np.array([[1,1/5,1/3,1],
[5,1,3,5],
[3,1/3,1,3],
[1,1/5,1/3,1]])
#获得指标个数
m=len(A)
n=len(A[0])
RI=[0,0,0.58,0.90,1.12,1.24,1.32,1.41,1.45,1.49,1.51]
#求判断矩阵的特征值和特征向量,V为特征值,D为特征向量
V,D=np.linalg.eig(A)
list1=list(V)
#求矩阵的最大特征值
B=np.max(list1)
index=list1.index(B)
C=d[:,index]
```
很容易地,我们定位到了最大的特征值与特征向量。进而我们计算CI和CR:
```python
CI=(B-n)/(n-1)
CR=CI/RI[n]
if CR<0.10:
print("CI=",CI)
print("CR=",CR)
print("对比矩阵A通过一致性检验,各向量权重向量Q为:")
C_sum=np.sum(C)
Q=C/C_sum
print(Q)
else:
print("对比矩阵A未通过一致性检验,需对对比矩阵A重新构造")
```
结果输出如下:

很多同学可能会问一个问题,说:这里分解出来的权重向量为什么是个复数。注意,python进行矩阵分解的时候是在复数域内进行分解,所得到的向量也是复数向量。虚部为0的情况下想要单独分析实部,通过Q.real即可达成取实部的效果。
计算出目标层到准则层的1个权重向量和4个准则层到方案层的权重向量以后,可以列出表5.5将权重向量进行排布。
**例7.1中4个权重向量的排布**
| **地点名称** | **pH\*** | **DO** | **CODMn** | **NH3-N** | **得分** |
| -------------- | -------- | ------ | --------- | --------- | -------- |
| | 0.0955 | 0.5596 | 0.2495 | 0.0955 | |
| 四川攀枝花龙洞 | 0.4166 | 0.5396 | 0.2970 | 0.6370 | 0.4767 |
| 重庆朱沱 | 0.3275 | 0.2970 | 0.5396 | 0.1047 | 0.3421 |
| 湖北宜昌南津关 | 0.2599 | 0.1634 | 0.1634 | 0.2583 | 0.1817 |
将准则层到方案层得到的7个成对比较矩阵对应的权重向量排列为一个矩阵,矩阵的每一行表示对应的方案,矩阵的每一列代表评价准则。将这一方案权重矩阵与目标层到准则层的权重向量进行数量积,得到的分数就是最终的评分。最终得到的一个结论是:在评价过程中水中溶解氧含量与钴金属含量占评价体系比重最大,而四川攀枝花龙洞的水质虽然含钴元素比另外两个更高,但由于溶解氧更多,NH3-N的含量更小,水体不显富营养化。就整体而言,四川攀枝花龙洞得分高于重庆朱沱和湖北宜昌南津关。
将一个成对比较矩阵的AHP过程封装为函数,完整函数如下。
```python
def AHP(A):
m=len(A) #获取指标个数
n=len(A[0])
RI=[0, 0, 0.58, 0.90, 1.12, 1.24, 1.32, 1.41, 1.45, 1.49, 1.51]
R= np.linalg.matrix_rank(A) #求判断矩阵的秩
V,D=np.linalg.eig(A) #求判断矩阵的特征值和特征向量,V特征值,D特征向量;
list1 = list(V)
B= np.max(list1) #最大特征值
index = list1.index(B)
C = D[:, index] #对应特征向量
CI=(B-n)/(n-1) #计算一致性检验指标CI
CR=CI/RI[n]
if CR<0.10:
print("CI=", CI.real)
print("CR=", CR.real)
print('对比矩阵A通过一致性检验,各向量权重向量Q为:')
sum=np.sum(C)
Q=C/sum #特征向量标准化
print(Q.real) # 输出权重向量
return Q.real
else:
print("对比矩阵A未通过一致性检验,需对对比矩阵A重新构造")
return 0
```
**例7.2** 第二个例子是有关于物种入侵的。现在想要比较三种草类植物(Dandelions, Hogweed, Scotch Broom)是否在美国构成物种入侵。从A1:生态因素、A2:有益因素、A3:入侵因素三个角度考虑。A1包括五个二级指标:P1:生长速度、P2:竞争能力、P3:生态位占用、P4:生态系统适应性、P5:扩散能力,A2包括三个二级指标:P6:食用价值、P7:药用价值、P8:经济价值,A3包括两个二级指标:P9:扩散历史、P10:入侵地区。请通过层次分析法对三种植物的入侵能力进行综合评价。
这个问题的难点在于,三个一级指标A下面又设置了十个二级指标P。我们首先把层级结构图画出来:

接下来我们分析需要构建的矩阵数量。首先,目标层到一级指标层有一个3*3的矩阵;A1到下属的五个二级指标有一个5*5的矩阵,A2就有一个3*3的矩阵,A3有一个2*2的矩阵。十个二级指标对应三个比较对象,也就出现了10个3*3的矩阵。通过调用前面的AHP函数就可以构造层级分析表。
```python
#目标层到第一准则层
A0=np.array([[1,3,5],
[1/3,1,3],
[1/5,1/3,1]])
print('目标到第一准则层')
v0=AHP(A0)
#第一准则层到第二准则层
A11 = np.array([[1,5,3,4,2],
[1/5,1,1/2,1,1/3],
[1/3,2,1,2,1/2],
[1/4,1,1/2,1,1/3],
[1/2,3,2,3,1]])
A21 = np.array([[1,3,4],
[1/3,1,2],
[1/4,1/2,1]])
A31 = np.array([[1,3],
[1/3,1]])
print('生态因素')
v11=AHP(A11)
print('有益因素')
v21=AHP(A21)
print('入侵因素')
v31=AHP(A31)
```
这几个矩阵都是可以通过检验的。剩下十个矩阵就可以如法炮制。最终权重列表如下:
| 顶层设计 | 一级变量 | 顶层权重 | 二级变量 | 二级权重 | 最终权重 |
| -------- | -------- | -------- | -------------- | -------- | -------- |
| 评价体系 | 有益因素 | 0.258 | 食用价值 | 0.625 | 0.16125 |
| 评价体系 | 有益因素 | 0.258 | 药用价值 | 0.238 | 0.061404 |
| 评价体系 | 有益因素 | 0.258 | 经济价值 | 0.136 | 0.035088 |
| 评价体系 | 生态因素 | 0.637 | 生长速度 | 0.427 | 0.271999 |
| 评价体系 | 生态因素 | 0.637 | 竞争能力 | 0.082 | 0.052234 |
| 评价体系 | 生态因素 | 0.637 | 生态位占用 | 0.151 | 0.096187 |
| 评价体系 | 生态因素 | 0.637 | 生态系统适应性 | 0.087 | 0.055419 |
| 评价体系 | 生态因素 | 0.637 | 扩散能力 | 0.254 | 0.161798 |
| 评价体系 | 入侵因素 | 0.105 | 扩散历史 | 0.75 | 0.07875 |
| 评价体系 | 入侵因素 | 0.105 | 入侵地区 | 0.25 | 0.02625 |
这样就得到了十个二级指标的最终权重。接下来只需要求解三个样本在每个二级指标上的归一化得分就可以计算出最终评分系数了。这里可能有人会问,n=2的时候RI=0,它能作为分母吗?事实上,当n=2的时候,无论成对比较矩阵长什么样,它最大的特征值都会等于2(有兴趣的同学可以证明一下,不难),CI也就等于0。在CI和RI都为0的时候,我们定义n=2下的成对比较矩阵都能通过一致性检验。最后得到三种植物的分数分别为Dandelions=0.310, Hogweed=0.283, Scotch Broom=0.406。显然,第三种的入侵系数是最高的。
## 7.2 熵权分析法
熵权法是一种客观赋权方法,基于信息论的理论基础,根据各指标的数据的分散程度,利用信息熵计算并修正得到各指标的熵权,较为客观。相对而言这种数据驱动的方法就回避了上面主观性因素造成的重复修正的影响。
### 7.2.1 熵权分析法的原理
在上一节例7.1中,我们发现一个现象:衡量好坏的标准因指标而异。由于指标的多样性,有些指标越大越好,如成绩;有些越小越好,如房贷;有些在特定区间过高或过低都不佳,如血压;还有些存在最优值,偏差越大越差。若模型未经数据预处理直接计算,可能出现问题。因此,第一步是指标正向化。它涉及将原本的负向指标转为正向指标,例如将死亡率转为生存率、故障率转为可靠度。通过正向化,目标或结果的达成情况更直观,提高指标的可解释性和可操作性。
- 对于极大型指标:指标越大越好,不需要正向化。只需要通过min-max规约或Z-score规约进行规约即可。
- 对于极小型指标:此类指标越小越好。它的正向化方式比较简单,可以取相反数;如果指标全部为正数,也可以取其倒数。
- 对于区间型指标:它的规约方法遵循下面的式子。
$$
{x\mathop{{}}\nolimits_{{new}}={ \left\{ {\begin{array}{*{20}{l}}{1-\frac{{a-x}}{{M}},x < a}\\{1,a \le x \le b}\\{1-\frac{{x-b}}{{M}},x > b}\end{array}}\right. }}
$$
- 对于中值型指标:它的正向化操作为
$$
{x\mathop{{}}\nolimits_{{new}}=1-\frac{{{ \left| {x-x\mathop{{}}\nolimits_{{best}}} \right| }}}{{max \left( { \left| {x-x\mathop{{}}\nolimits_{{best}}} \right| } \right) }}}
$$
在进行指标正向化以后,所有的指标全部被变换为越大越好。而为了进一步消除量纲这些的影响,需要进一步进行min-max规约化或z-score规约化消除量纲差异。
熵权法的主要计算步骤如下。
(1)构建*m*个事物*n*个评价指标的判断矩阵$R(i=1,2,3,…,n;j=1,2,…,m)$。
(2)将判断矩阵进行归一化处理,得到新的归一化判断矩阵$B$。
$$
{B=\frac{{X-min \left( X \right) }}{{max \left( X \left) -min \left( X \right) \right. \right. }}}
$$
(3)熵权法可利用信息熵计算出各指标的权重,从而为多指标评价提供依据。根据信息论中对熵的定义,熵值$e$的计算如下。
$$
{e\mathop{{}}\nolimits_{{j}}=\frac{{{\mathop{ \sum }\limits_{{i=1}}^{{n}}{p\mathop{{}}\nolimits_{{ij}}lnp\mathop{{}}\nolimits_{{ij}}}}}}{{lnn}}}
$$
其中$p$为离散属性中每个类取值的占比。通过式(5.8)的熵值,可以评价不同指标的离散程度,一般情况下,信息熵越小,离散程度越大,因子对综合评价的权重就越大。
(4)计算权重系数,式子(5.9)中代表对于某一个属性$j$,第$i$类占样本的比例。$n$为属性$j$的取值数量。所以,权重系数$w$定义为
$$
{w\mathop{{}}\nolimits_{{j}}=\frac{{1-e\mathop{{}}\nolimits_{{j}}}}{{{\mathop{ \sum }\limits_{{i=1}}^{{m}}{ \left( 1-e\mathop{{}}\nolimits_{{j}} \right) }}}}}
$$
> 注意:熵权法是一个数据驱动过程,一定要保证有一定数据量并且做了正向化。
### 7.2.2 熵权分析法的案例
**例7.3** 在例7.1的基础上采集到了更多基站的数据,现在需要对指标进行熵值赋权。新的数据表格如表所示。
例7.3的新数据
| **地点名称** | **pH\*** | **DO** | **CODMn** | **NH3-N** | **鱼类密度** | **垃圾密度** |
| ---------------- | -------- | ------ | --------- | --------- | ------------ | ------------ |
| 四川攀枝花龙洞 | 7.94 | 9.47 | 1.63 | 0.08 | 6.78 | 4.94 |
| 重庆朱沱 | 8.15 | 9.00 | 1.40 | 0.42 | 5.27 | 4.47 |
| 湖北宜昌南津关 | 8.06 | 8.45 | 2.83 | 0.20 | 2.50 | 7.03 |
| 湖南岳阳城陵矶 | 8.05 | 9.16 | 3.33 | 0.29 | 5.65 | 5.26 |
| 江西九江河西水厂 | 7.60 | 7.93 | 2.07 | 0.13 | 5.26 | 6.39 |
| 安徽安庆皖河口 | 7.39 | 7.12 | 2.23 | 0.20 | 6.21 | 7.50 |
| 江苏南京林山 | 7.74 | 7.29 | 1.77 | 0.06 | 6.44 | 3.93 |
| 四川乐山岷江大桥 | 7.38 | 6.51 | 3.63 | 0.41 | 3.17 | 5.75 |
| 四川宜宾凉姜沟 | 8.32 | 8.47 | 1.60 | 0.14 | 3.32 | 6.29 |
| 四川泸州沱江二桥 | 7.69 | 8.50 | 2.73 | 0.28 | 7.25 | 8.21 |
| 湖北丹江口胡家岭 | 8.15 | 9.88 | 2.00 | 0.08 | 7.22 | 3.82 |
| 湖南长沙新港 | 6.88 | 7.59 | 1.77 | 0.92 | 2.94 | 8.03 |
| 湖南岳阳岳阳楼 | 8.00 | 8.15 | 4.87 | 0.33 | 4.68 | 5.01 |
| 湖北武汉宗关 | 7.94 | 7.48 | 3.30 | 0.13 | 5.81 | 4.87 |
| 江西南昌滁槎 | 8.01 | 7.76 | 2.67 | 6.36 | 4.52 | 3.22 |
| 江西九江蛤蟆石 | 7.91 | 7.93 | 5.47 | 0.21 | 7.48 | 2.40 |
| 江苏扬州三江营 | 8.04 | 8.34 | 3.87 | 0.20 | 3.73 | 4.05 |
首先,读取数据并对指标进行正向化。首先,pH这一列虽然是区间型指标,但是可以用pH=7作为最优值将其看作中间值型指标处理,与7的偏差越大则得分越小。DO和鱼类密度是极大型指标,剩下三个是极小型指标,所以用倒数的方法正向化。
```python
import pandas as pd
newdata=pd.read_excel("test.xlsx",sheet_name='Sheet5')
newdata['pH*']=1-abs(newdata['pH*']-7)
newdata['CODMn']=1/newdata['CODMn']
newdata['NH3-N']=1/newdata['NH3-N']
newdata['垃圾密度']=1/newdata['垃圾密度']
newdata=newdata.set_index('地点名称').dropna()
```
将熵权法的代码复现如下所示:
```python
def entropyWeight(data):
data = np.array(data)
# 归一化
P = data / data.sum(axis=0)
# 计算熵值
E = np.nansum(-P * np.log(P) / np.log(len(data)), axis=0)
# 计算权系数
return (1 - E) / (1 - E).sum()
```
通过调用函数,可以得到权重分别为
```
[0.15547967, 0.01030048, 0.13105116, 0.50152061, 0.09189262,0.10975546]
```
## 7.3 TOPSIS分析法
TOPSIS评价法是有限方案多目标决策分析中常用的一种科学方法,其基本思想为,对原始决策方案进行归一化,然后找出最优方案和最劣方案,对每一个决策计算其到最优方案和最劣方案的欧几里得距离,然后再计算相似度。若方案与最优方案相似度越高则越优先。
### 7.3.1 一般的TOPSIS分析法
中国有句古话,叫做“近朱者赤近墨者黑”。我们要比较各个方案的好坏,但是每个方案都有很多不同的指标,比如经济、环境、社会等等。如果我们直接比较这些指标,可能会很复杂,而且也容易受到不同指标单位和量纲的影响。层次分析法里面我们用成对比较矩阵主观地消除量纲影响,熵权法里面我们用熵值和正向化消除了量纲影响,还有什么办法嘞?在TOPSIS分析法中,我们通过计算每个方案离理想解和负理想解的距离来判断优劣。理想解是最佳方案,各项指标最优;负理想解是最差方案,各项指标最差。这种方法就像“近朱者赤近墨者黑”,离最好的方案越近,表现就越优秀,离最差的方案越远,表现就越差。简化多个指标问题为距离问题,如图所示。
对于距离,有多种方式可以衡量它。包括最常用的欧几里得距离、曼哈顿距离、余弦距离等多种计算方式,它们的计算方法如下:
$$
${Euclidean \left( x,y \left) =\sqrt{{{\mathop{ \sum }\limits_{{i=1}}^{{n}}{ \left( x\mathop{{}}\nolimits_{{i}}-y\mathop{{}}\nolimits_{{i}} \left) \mathop{{}}\nolimits^{{2}}\right. \right. }}}}\right. \right. }$
$$
$$
{Manhaton \left( x,y \left) ={\mathop{ \sum }\limits_{{i=1}}^{{n}}{{ \left| {x\mathop{{}}\nolimits_{{i}}-y\mathop{{}}\nolimits_{{i}}} \right| }}}\right. \right. }
$$
$$
{cos \left( x,y \left) =\frac{{x\mathop{{}}\nolimits^{{T}}y}}{{{ \left| {x} \right| }{ \left| {y} \right| }}}\right. \right. }
$$
有了上面的计算方法TOPSIS法的基本流程如下:
(1)对原始数据进行指标正向化和归一化操作得到矩阵$Z$
(2)确定正理想解${Z\mathop{{}}\nolimits^{{+}}}$和负理想解${Z\mathop{{}}\nolimits^{{-}}}$
$$
{Z\mathop{{}}\nolimits^{{+}}= \left\{ \mathop{{max\text{ }}}\limits_{{j\mathop{{}}\nolimits^{{ \in J}}}}z\mathop{{}}\nolimits_{{ij}} \right\} }
$$
$$
{Z\mathop{{}}\nolimits^{{-}}= \left\{ \mathop{{min\text{ }}}\limits_{{j\mathop{{}}\nolimits^{{ \in J}}}}z\mathop{{}}\nolimits_{{ij}} \right\} }
$$
(3)计算各评价对象$i(i=1,2,3…,402)$到正理想解和负理想解的欧几里得距离${D\mathop{{}}\nolimits_{{i}}}$:
$$
{D\mathop{{}}\nolimits_{{+}}^{{i}}=\sqrt{{{\mathop{ \sum }\limits_{{j=1}}^{{n}}{ \left( z\mathop{{}}\nolimits_{{ij}}-Z\mathop{{}}\nolimits_{{+}}^{{j}} \left) \mathop{{}}\nolimits^{{2}}\right. \right. }}}}}
$$
$$
{D\mathop{{}}\nolimits_{{-}}^{{i}}=\sqrt{{{\mathop{ \sum }\limits_{{j=1}}^{{n}}{ \left( z\mathop{{}}\nolimits_{{ij}}-Z\mathop{{}}\nolimits_{{-}}^{{j}} \left) \mathop{{}}\nolimits^{{2}}\right. \right. }}}}}
$$
(4)计算各评价对象的相似度${W\mathop{{}}\nolimits_{{i}}}$:
$$
{W\mathop{{}}\nolimits_{{i}}=\frac{{D\mathop{{}}\nolimits_{{-}}^{{i}}}}{{D\mathop{{}}\nolimits_{-}^{{i}}+D\mathop{{}}\nolimits_{{+}}^{{i}}}}}
$$
可以看到,相似度是与负理想解和两个理想解距离之和的比值,若占比越大,则说明离负理想解越远,越优先选择。
(5)根据${W\mathop{{}}\nolimits_{{i}}}$大小排序可得到结果。
TOPSIS分析的流程图如下:
### 7.3.2 改进的TOPSIS分析法
在经典TOPSIS方法中,计算欧几里得距离时,不同指标的差的平方会被直接相加。然而,考虑到不同指标在评价体系中的重要性可能存在差异,因此在计算距离时应对各个指标赋予相应的权重。权重的确定可以通过熵权法或层次分析法来实现。通常在解决TOPSIS问题时,我们会处理大量的数据,因此,权重可以通过数据驱动的熵权法来获得。
如果给每个指标赋权重,权重向量为w的话,距离的计算方式应该被改为:
$$
{d \left( x,y \left) =\sqrt{{{\mathop{ \sum }\limits_{{i=1}}^{{n}}{w\mathop{{}}\nolimits_{{i}} \left( x\mathop{{}}\nolimits_{{i}}-y\mathop{{}}\nolimits_{{i}} \left) \mathop{{}}\nolimits^{{2}}\right. \right. }}}}\right. \right. }
$$
这样,可以在距离计算中考虑权重影响,更重要的权重会被赋予更多的权重,在距离计算中起到更大的作用。
### 7.3.3 TOPSIS分析法的案例
仍然以例7.3为例,实现TOPSIS代码如下:
```python
import numpy as np
import pandas as pd
#TOPSIS方法函数
def TOPSIS(A1,w):
Z=np.array(A1)
#计算正、负理想解
Zmax=np.ones([1,A1.shape[1]],float)
Zmin=np.ones([1,A1.shape[1]],float)
for j in range(A1.shape[1]):
if j==1:
Zmax[0,j]=min(Z[:,j])
Zmin[0,j]=max(Z[:,j])
else:
Zmax[0,j]=max(Z[:,j])
Zmin[0,j]=min(Z[:,j])
#计算各个方案的相对贴近度C
C=[]
for i in range(A1.shape[0]):
Smax=np.sqrt(np.sum(w*np.square(Z[i,:]-Zmax[0,:])))
Smin=np.sqrt(np.sum(w*np.square(Z[i,:]-Zmin[0,:])))
C.append(Smin/(Smax+Smin))
C=pd.DataFrame(C)
return C
```
输入归一化后的数据和权重系数就可以获得评价结果。利用上一节里面讲到的归一化方法进行数据处理并通过熵权法获得权重,调用代码可以得到最终结果。得分最高的是江苏南京林山,为0.657;得分最低的是湖北宜昌南津关,得分仅0.232。
可以看到,TOPSIS方法与层次分析法和熵权法都不同,它不是一个构造权重的方法,而是根据权重去进行得分折算的方法。这为我们分析评价类模型提供了一个新思路。
## 7.4 CRITIC方法
CRITIC权重法是一种基于数据波动性的客观赋权法。其思想在于两项指标,分别是波动性(对比强度)和 冲突性(相关性)指标。对比强度使用标准差进行表示,如果数据标准差越大说明波动越大,权重会越高; 冲突性使用相关系数进行表示,如果指标之间的相关系数值越大,说明冲突性越小,那么其权重也就越低。权重计算时,对比强度与冲突性指标相乘,并且进行归一化处理,即得到最终的权重。CRITIC权重法适用于数据稳定性可视作一种信息,并且分析的指标或因素之间有着一定的关联关系的数据。
### 7.4.1 CRITIC分析法的原理
在数据分析中,我们经常需要评估多个指标的好坏,但这些指标的标准往往不统一。这时,我们可以使用CRITIC法来统一标准。这种方法的基本原理是综合考虑了对比强度和指标的变异程度,从而为每个指标赋予一个客观的权重。这种方法之所以是正确的,是因为它不仅考虑了指标之间的相对重要性,还考虑了指标本身的波动性,使得权重更加客观和准确。
> 注意:CRITIC方法和熵权法一样都属于数据驱动的方法类型,需要数据量支持。
假设有一个*n*个对象*m*项指标的数表,CRITIC法按照如下的操作步骤进行。
(1)对指标进行无量纲化和正向化处理。第6章提到,min-max规约能够进行很好的无量纲化处理,如果这个指标是越大越好,那么规约方法形如:
$$
{x\mathop{{}}\nolimits_{{new}}=\frac{{x-min \left( x \right) }}{{max \left( x \left) -min \left( x \right) \right. \right. }}}
$$
而如果指标是越小越好,那么规约方法形如:
$$
{x\mathop{{}}\nolimits_{{new}}=\frac{{max \left( x \left) -x\right. \right. }}{{max \left( x \left) -min \left( x \right) \right. \right. }}}
$$
对于区间型和中值型指标,则按照在TOPSIS分析中讲到的指标正向化处理。
(2)计算指标变异性。本质上就是计算每个指标在所有样本中的标准差${S\mathop{{}}\nolimits_{{j}}}$。标准差表示指标在样本中的差异波动情况,若标准差越大,则它的区分度越明显,信息强度也越高,越应该给它分配更多权重。
(3)计算指标冲突性,定义为:
$$
{R\mathop{{}}\nolimits_{{j}}={\mathop{ \sum }\limits_{{i=1}}^{{m}}{ \left( 1-r\mathop{{}}\nolimits_{{ij}} \right) }}}
$$
其中${r\mathop{{}}\nolimits_{{ij}}}$表示指标$i$和指标$j$之间的相关系数。相关系数的概念我们在中学阶段应该学习过,其实中学学到的相关系数严格意义上应该叫皮尔逊相关系数。其定义为:
$$
{r_{ij}} = \frac{{\sum\limits_{i = 1}^n {{x_i}{y_i}} - n\bar x\bar y}}{{\sqrt {\sum\limits_{i = 1}^n {x_i^2} - n{{\bar x}^2}} \sqrt {\sum\limits_{i = 1}^n {y_i^2} - n{{\bar y}^2}} }}
$$
(4)获取信息量,其中信息量的定义方法为指标变异性和冲突性的乘积:
$$
{C\mathop{{}}\nolimits_{{j}}=\mathop{{S}}\nolimits_{{j}}{\mathop{ \sum }\limits_{{i=1}}^{{m}}{ \left( 1-r\mathop{{}}\nolimits_{{ij}} \right) }}}
$$
(5)归一化得到指标权重,再用权重去乘归一化的数据矩阵可以得到每个对象的评分,并根据评分进行对象的评价、排序。归一化的过程形如:
$$
{w\mathop{{}}\nolimits_{{j}}=\frac{{C\mathop{{}}\nolimits_{{j}}}}{{{\mathop{ \sum }\limits_{{i=1}}^{{m}}{C\mathop{{}}\nolimits_{{i}}}}}}}
$$
CRITIC方法和熵权法都是用于确定评价指标权重的综合评价方法,并且都是基于数据去驱动,通过计算一列数据的信息量来归一化得到权重的。不同的是,CRITIC方法综合考虑了标准差和相关系数来确定权重,而熵权法只考虑了信息熵。CRITIC方法适用于具有明显客观标准的数据,而熵权法更适用于具有主观判断的数据。
在计算权重的时候有一点CRITIC方法考虑的很好,就是考虑到本列数据与其他列的相关性,这是熵权法不能考虑到的。为何要利用相关系数和标准差来表示信息量?相关系数可以反映两个评价指标之间的线性相关程度,如果两个指标高度相关,说明它们之间的信息存在重复,因此权重应该较低。标准差则反映了数据中的变异程度,即评价指标的离散程度。标准差越大,说明该指标的变异程度越大,因此越重要。与信息熵相比,CRITIC方法的优点在于能够更准确地确定评价指标的权重,因为它不仅考虑了信息熵,还考虑了评价指标之间的相关性。然而,如果数据中存在异常值或离群点,可能会对计算结果产生较大影响。
### 7.4.2 CRITIC分析法的案例
以例7.3中的数据为例展示CRITIC方法。上面的计算方法是非常纯粹的,封装为函数:
```python
def CRITIC(df):
std_d=np.std(df,axis=1)
mean_d=np.mean(df,axis=1)
cor_d=np.corrcoef(df)
# 也可以使用df.corr()
w_j=(1-cor_d).sum(0) *std_d
print(w_j)
w=(mean_d/(1-mean_d)*w_j)/sum(mean_d/(1-mean_d)*w_j)
print(w)
##############################
经过计算,结果为:
pH* 0.180598
DO 0.199151
CODMn 0.185891
NH3-N 0.097907
鱼类密度 0.248081
垃圾密度 0.088372
```
## 7.5 模糊综合评价法
> 模糊综合评价法是一种基于模糊数学的综合评价方法。该综合评价法根据模糊数学的隶属度理论把定性评价转化为定量评价,即用模糊数学对受到多种因素制约的事物或对象做出一个总体的评价。它具有结果清晰,系统性强的特点,能较好地解决模糊的、难以量化的问题,适合各种非确定性问题的解决。
### 7.5.1 模糊综合分析法的原理
说到模糊,就不能不提到模糊数学。数学从分支上来说,有输入输出均为确定的确定性数学(包括代数、几何、分析、拓扑等门类),也有输入输出为随机数的随机性数学(包括概率论、数理统计、随机过程等门类)。处在确定与随机中间地带的数学还没有被彻底研究,其中就包括了模糊数学和灰色数学。灰色数学中的两个经典模型我们会在下一章学习,模糊数学中的模糊综合评价法就是本节所想要重点讲述的。
什么是模糊?轻、重、热、冷、厚、薄、快、慢、大、小、高、低、长、短、贵、贱……这些形容词其实都是模糊概念。例如冷热,40度的水温可能女孩子洗澡觉得刚刚好,但是男生洗澡就会觉得有些烫。不同的人对冷热的标准也不一样。水温冷,多低的水温才算冷?它是没有一个绝对的数字去量化的。事实上,有很多东西都无法用数字量化。模糊概念也就是从属于该概念到不属于该概念之间无明显分界线,外延不清楚。模糊概念导致模糊现象。在客观世界中,存在着大量的模糊现象。模糊数学就是用数学方法研究模糊现象。模糊综合评价方法是借助模糊数学的隶属度理论把定性评价转化为定量评价,即对受到多种因素制约的事物或对象做出一个总体的评价。
模糊综合评价法的基本思想就是用属于程度代替属于或不属于,从而刻画“中介状态”。首先确定被评价对象的因素(指标)集合评价(等级)集;再分别确定各个因素的权重及它们的隶属度矢量,获得模糊评判矩阵;最后把模糊评判矩阵与因素的权矢量进行模糊运算并进行归一化,得到模糊综合评价结果。
为了方便,我们先引入模糊集和隶属度的概念。设$U={u1,u2,…,um}$为刻画被评价对象的m种评价因素(评价指标)。其中$m$是评价因素的个数,有具体的指标体系所决定。为便于权重分配和评议,可以按评价因素的属性将评价因素分成若干类,把每一类都视为单一评价因素,并称之为第一级评价因素.第一级评价因素可以设置下属的第二级评价因素,第二级评价因素又可以设置下属的第三级评价因素,依此类推,即$U=U1∪U2∪…∪Us.$(有限不交并),其中$Ui={ui1,ui2,…,uim},Ui∩Uj=Φ$,任意$i≠j,i,j=1,2,…,s$。我们称${Ui}$是$U$的一个划分(或剖分),$Ui$称为类(或块)。
设$V={v1,v2,…,vn}$,是评价者对被评价对象可能做出的各种总的评价结果组成的评语等级的集合。其中:$vj$代表第$j$个评价结果,$j=1,2,…,n.$ $n$为总的评价结果数.一般划分为3~5个等级。设$A=(a1,a2,…,am)$为权重(权数)分配模糊矢量,其中ai表示第i个因素的权重,要求$0«ai,Σai=1$。A反映了各因素的重要程度.在进行模糊综合评价时,权重对最终的评价结果会产生很大的影响,不同的权重有时会得到完全不同的结论。
而隶属度又是什么意思呢?实际上就是一个评分,一项指标对每个程度都分别进行评分。你可以理解为这项指标被预判为某个程度的概率大小,它是一个0到1之间的数。在实际的调查研究中,更多的是用德尔菲法也就是向多名相关领域专家发放问卷征求他们的意见以获得模糊评价,或是采取文献分析法从现有文献中找到模糊隶属度。但在短期内如果想要取得模糊隶属度,还可以根据具体的指标去推算,方法和TOPSIS中讲到的指标正向化一样。例如,我们如果获得了某种溶液的酸碱度PH值,它可能是越偏酸性越好,也可能是越偏碱性越好,也可能是在某个酸碱范围内最好。此时的隶属度计算也有不同的方式。
常见隶属度计算的模式如表5.7所示。
表5.7 常见隶属度的计算模式

对每个指标去计算隶属度可以得到一个隶属度矩阵,再将隶属度矩阵与指标权重相乘能够得到总的隶属度向量。最后,根据不同的隶属度给出评分即可获得对象的总体评分。例如,对于李克特五级量表{很差,差,中等,好,很好},分别给出{100,80,60,30,0}五个等级的评分,与最终隶属度向量数乘可以得到最终评分值。
模糊综合评价最大的特点就是它虽然是一种主观评价方法,但并不需要像层次分析法一样有多个对象比较。即使是单个对象,模糊综合评价法也是奏效的。它事实上是一个矩阵乘法过程:
在这个式子中,$w$为不同指标的权重向量,需要通过其他的综合评价方法或人工手动给出。$A$是模糊隶属度矩阵,可以是构造隶属度,也可以是专家打分,表示该对象在指标层上属于每个评语的隶属度。$p$则是每个评语对应的分数。最后通过分数折算,这个矩阵乘法公式就可以得到样本最后的评分。
### 7.5.2 模糊综合分析法的案例
**例7.4** 假设以企业组织和管理水平评价为例,用模糊综合评价方法给出定量评价。这是专家(或其他统计方式)对评价打分表投票表决结果统计数据,简单的说就是对需要评价的因素(指标)给出主管或客观的“优、良、一般、较差、非常差”评价。
有关数据如下图所示:
这个地方模糊隶属度矩阵已经有了,所以我们要确定权向量w和得分向量p。得分向量可以自己定义,例如$p=[1,0.8,0.6,0.4,0.2]$。权重向量构建可以参考层次分析法,大家可以构建层次模型图,然后通过AHP得到权重。这里我给出目标层到第一准则层和第一准则层到第二准则层的成对比较矩阵方案。
```python
# 准则重要性矩阵
criteria = np.array([[1, 7, 5, 7, 5],
[1 / 7, 1, 2, 3, 3],
[1 / 5, 1 / 2, 1, 2, 3],
[1 / 7, 1 / 3, 1 / 2, 1, 3],
[1 / 5, 1 / 3, 1 / 3, 1 / 3, 1]])
# 对每个准则,方案优劣排序
b1 = np.array([[1, 5], [1 / 5, 1]])
b2 = np.array([[1, 2, 5], [1 / 2, 1, 2], [1 / 5, 1 / 2, 1]])
b3 = np.array([[1, 5, 6, 8], [1 / 5, 1 ,2, 7], [1 / 6, 1 / 2, 1 ,4],[1 / 8, 1 / 7, 1 / 4, 1]])
b4 = np.array([[1, 3, 4], [1 / 3, 1, 1], [1 / 4, 1, 1]])
b5 = np.array([[1, 4, 5, 5], [1 / 4, 1, 2, 4], [1 /5 , 1 / 2, 1, 2], [1 / 5,1 /4,1 / 2, 1]])
#模糊综合评价法(FCE),输入准则权重、因素权重
def fuzzy_eval(criteria, eigen):
#量化评语(优秀、 良好、 一般、 较差、 非常差)
score = [1,0.8,0.6,0.4,0.2]
df = pd.read_excel('FCE.xlsx')
print('单因素模糊综合评价:{}\n'.format(df))
#把单因素评价数据,拆解到5个准则中
v1 = df.iloc[0:2,:].values
v2 = df.iloc[2:5,:].values
v3 = df.iloc[5:9,:].values
v4 = df.iloc[9:12,:].values
v5 = df.iloc[12:16,:].values
vv = [v1,v2,v3,v4,v5]
val = []
num = len(eigen)
for i in range(num):
v = np.dot(np.array(eigen[i]),vv[i])
print('准则{} , 矩阵积为:{}'.format(i+1,v))
val.append(v)
# 目标层
obj = np.dot(criteria, np.array(val))
print('目标层模糊综合评价:{}\n'.format(obj))
#综合评分
eval = np.dot(np.array(obj),np.array(score).T)
print('综合评价:{}'.format(eval*100))
criteria, eigen=weight()
fuzzy_eval(criteria, eigen)
```
这里weight()返回的是每个指标的最终权重,大家自己很容易实现。最后转化为百分制评分下的模糊综合评价分数为69.76。
## 7.6 秩和比分析法
> 秩和比法,是我国统计学家田凤调教授于1988年提出的一种综合评价方法,是利用秩和比(RSR, Rank-sum ratio)进行统计分析的一种方法。它不仅适用于四格表资料的综合评价,也适用于n行m列资料的综合评价,同时也适用于计量资料和分类资料的综合评价。
> 推荐链接:
RSR法包括以下几个步骤:
- 对效益型指标进行从小到大的排序,并计算每个指标的秩次。
- 对成本型指标进行从大到小的排序,并计算每个指标的秩次。
- 计算每个指标的秩和比(RSR),作为无量纲的统计量。
- 基于秩和比进行统计分析,研究其分布情况。
- 根据RSR值对评价对象进行直接排序或分档排序,以评估其综合表现。
**RSR法本质**
秩和比综合评价法基本原理是在一个n行m列,通过秩的转换,获得无量纲统计量RSR;然后运用参数统计分析的概念与方法、研究RSR的分布;以RSR值对评价对象的优劣进行分档排序,从而对评价对象做出综合评价。
优点:是非参数统计分析,对指标的选择无特殊要求,适于各种评价对象;由于计算用的数值是秩次,可以消除异常值的干扰,它融合了参数分析的方法,结果比单纯采用非参数法更为精确,既可以直接排序,又可以分档排序,使用范围广泛。
缺点:是排序的主要依据是利用原始数据的秩次,最终算得的RSR值反映的是综合秩次的差距,而与原始数据的顺位间的差距程度大小无关,这样在指标转化为秩次是会失去一些原始数据的信息,如原始数据的大小差别等。
当RSR值实际说不满足正态分布时,分档归类的结果与实际情况会有偏差,且只能回答分级程度是否有差别,不能进一步回答具体的差别情况。
### 7.6.1 秩和比分析法原理
**整次秩和比法**
将 n 个评价对象的 m 个评价指标排列成 n 行 m 列的原始数据表。编出每个指标各评价对象的秩,其中效益型指标从小到大编秩,成本型指标从大到小编秩,同一指标数据相同者编平均秩。得到秩矩阵,记为${R= \left( R\mathop{{}}\nolimits_{{ij}} \left) \mathop{{}}\nolimits_{{m \times n}}\right. \right. }$
> 编秩即对数据排序,其顺序号作为秩
**非整次秩和比法**
此方法用类似于线性插值的方式对指标值进行编秩,以改进 RSR 法编秩方法的不足,所编秩次与原指标值之间存在定量的线性对应关系,从而克服了 RSR 法秩次化时易损失**原指标值定量信息**的缺点。
对于正向指标:
$$
{R\mathop{{}}\nolimits_{{ij}}=1+ \left( n-1 \left) \frac{{x\mathop{{}}\nolimits_{{ij}}-min \left( x\mathop{{}}\nolimits_{{1j}},x\mathop{{}}\nolimits_{{2j}},...,x\mathop{{}}\nolimits_{{nj}} \right) }}{{max \left( x\mathop{{}}\nolimits_{{1j}},x\mathop{{}}\nolimits_{{2j}},...,x\mathop{{}}\nolimits_{{nj}} \left) -min \left( x\mathop{{}}\nolimits_{{1j}},x\mathop{{}}\nolimits_{{2j}},...x\mathop{{}}\nolimits_{{nj}} \right) \right. \right. }}\right. \right. }
$$
对于负向指标:
$$
{R\mathop{{}}\nolimits_{{ij}}=1+ \left( n-1 \left) \frac{{max \left( x\mathop{{}}\nolimits_{{1j}},x\mathop{{}}\nolimits_{{2j}},...,x\mathop{{}}\nolimits_{{nj}} \left) -x\mathop{{}}\nolimits_{{ij}}\right. \right. }}{{max \left( x\mathop{{}}\nolimits_{{1j}},x\mathop{{}}\nolimits_{{2j}},...,x\mathop{{}}\nolimits_{{nj}} \left) -min \left( x\mathop{{}}\nolimits_{{1j}},x\mathop{{}}\nolimits_{{2j}},...x\mathop{{}}\nolimits_{{nj}} \right) \right. \right. }}\right. \right. }
$$
非整次秩和比法是对一般秩和比法的扩展:希望在考虑部分模糊指标时,也能正常对待可量化的指标,此外将线性模型改为指数、对数等其他情况,也能做更多扩展。
**计算秩和比并排序**
整次秩和比法中,只考虑元素的相对大小,不考虑具体值,计算秩和:
$$
{RSR\mathop{{}}\nolimits_{{i}}=\frac{{1}}{{n}}{\mathop{ \sum }\limits_{{j=1}}^{{m}}{w\mathop{{}}\nolimits_{{j}}R\mathop{{}}\nolimits_{{ij}}}}}
$$
其中${w\mathop{{}}\nolimits_{{j}}}$为第$j$个评价指标的权重,${{\mathop{ \sum }\nolimits_{{m}}^{{j=1}}{w\mathop{{}}\nolimits_{{j}}}}=1}$
当指标权重相同时,${w\mathop{{}}\nolimits_{{j}}=\frac{{1}}{{m}}}$,此时秩和可以表示为
$$
{RSR\mathop{{}}\nolimits_{{i}}=\frac{{1}}{{mn}}{\mathop{ \sum }\limits_{{j=1}}^{{m}}{R\mathop{{}}\nolimits_{{ij}}}}}
$$
> 如果只考虑排序问题, 到这里的时候就可以知道结果了, 以下步骤是为了能从正态分布角度对数据进行分层
**确定RSR的分布**
RSR 的分布是指用概率单位$Probit$表达的值特定的累计频率 。 $Probit$模型是一种广义的线性模型,服从正态分布。其转换方法为:
**Step 1** 编制RSR频数分布表,列出各组频数 $f$,计算各组累计频数${ \sum {f}}$
**Step 2** 确定各组 RSR 的秩次范围及平均秩次
**Step 3** 计算累计频率 ${{R} \overline /n \times 100\text{%}}$ ,最后一项记为${1-\frac{{1}}{{4n}}}$ 进行修正
**Step 4** 将累计频率换算为概率单位 $Probit$ , $Probit$为累计频率对应的标准正态离差 ${ \mu }$加5。
**计算回归方程**
以累积频率所对应的概率单位 ${Probit\mathop{{}}\nolimits_{{i}}}$ 为自变量,以 RSR 值为因变量,计算直线回归方程,即:
$$
{RSR=a+b \times Probit}
$$
回归方程检验:对该回归方程,需要进行检验。回归方程的检验往往围绕以下几点展开:
- 残差独立性检验:Durbin-Watson 检验
- 方差齐性检验(异方差性):Breusch-Pagan 检验和 White 检验
- 回归系数$b$的有效性检验: $t$检验法和置信区间检验法
- 拟合优度检验:(自由度校正)决定系数、Pearson相关系数、Spearman秩相关系数、交叉验证法等
此处建议进行回归系数$b$的$t$检验及拟合优度的 Pearson 相关系数检验即可
**计算校正RSR值,并进行分档排序**
按照回归方程推算所对应的RSR估计值对评价对象进行分档排序,分档数由研究者根据实际情况决定。
> 这一部分的目的是将数据按照秩的各种情况, 映射到正态分布曲线上, 结合正态分布的相关划分方法进行分档

### 7.6.2 秩和比分析法案例
**例7.5**

```python
import pandas as pd
import numpy as np
import statsmodels.api as sm
from scipy.stats import norm
def rsr(data, weight=None, threshold=None, full_rank=True):
Result = pd.DataFrame()
n, m = data.shape
# 对原始数据编秩
if full_rank:
for i, X in enumerate(data.columns):
Result[f'X{str(i + 1)}:{X}'] = data.iloc[:, i]
Result[f'R{str(i + 1)}:{X}'] = data.iloc[:, i].rank(method="dense")
else:
for i, X in enumerate(data.columns):
Result[f'X{str(i + 1)}:{X}'] = data.iloc[:, i]
Result[f'R{str(i + 1)}:{X}'] = 1 + (n - 1) * (data.iloc[:, i].max() - data.iloc[:, i]) / (data.iloc[:, i].max() - data.iloc[:, i].min())
# 计算秩和比
weight = 1 / m if weight is None else np.array(weight) / sum(weight)
Result['RSR'] = (Result.iloc[:, 1::2] * weight).sum(axis=1) / n
Result['RSR_Rank'] = Result['RSR'].rank(ascending=False)
# 绘制 RSR 分布表
RSR = Result['RSR']
RSR_RANK_DICT = dict(zip(RSR.values, RSR.rank().values))
Distribution = pd.DataFrame(index=sorted(RSR.unique()))
Distribution['f'] = RSR.value_counts().sort_index()
Distribution['Σ f'] = Distribution['f'].cumsum()
Distribution[r'\bar{R} f'] = [RSR_RANK_DICT[i] for i in Distribution.index]
Distribution[r'\bar{R}/n*100%'] = Distribution[r'\bar{R} f'] / n
Distribution.iat[-1, -1] = 1 - 1 / (4 * n)
Distribution['Probit'] = 5 - norm.isf(Distribution.iloc[:, -1])
# 计算回归方差并进行回归分析
r0 = np.polyfit(Distribution['Probit'], Distribution.index, deg=1)
print(sm.OLS(Distribution.index, sm.add_constant(Distribution['Probit'])).fit().summary())
if r0[1] > 0:
print(f"\n回归直线方程为:y = {r0[0]} Probit + {r0[1]}")
else:
print(f"\n回归直线方程为:y = {r0[0]} Probit - {abs(r0[1])}")
# 代入回归方程并分档排序
Result['Probit'] = Result['RSR'].apply(lambda item: Distribution.at[item, 'Probit'])
Result['RSR Regression'] = np.polyval(r0, Result['Probit'])
threshold = np.polyval(r0, [2, 4, 6, 8]) if threshold is None else np.polyval(r0, threshold)
Result['Level'] = pd.cut(Result['RSR Regression'], threshold, labels=range(len(threshold) - 1, 0, -1))
return Result, Distribution
def rsrAnalysis(data, file_name=None, **kwargs):
Result, Distribution = rsr(data, **kwargs)
file_name = 'RSR 分析结果报告.xlsx' if file_name is None else file_name + '.xlsx'
Excel_Writer = pd.ExcelWriter(file_name)
Result.to_excel(Excel_Writer, '综合评价结果')
Result.sort_values(by='Level', ascending=False).to_excel(Excel_Writer, '分档排序结果')
Distribution.to_excel(Excel_Writer, 'RSR分布表')
Excel_Writer.save()
return Result, Distribution
data = pd.DataFrame({'产前检查率': [99.54, 96.52, 99.36, 92.83, 91.71, 95.35, 96.09, 99.27, 94.76, 84.80],
'孕妇死亡率': [60.27, 59.67, 43.91, 58.99, 35.40, 44.71, 49.81, 31.69, 22.91, 81.49],
'围产儿死亡率': [16.15, 20.10, 15.60, 17.04, 15.01, 13.93, 17.43, 13.89, 19.87, 23.63]},
index=list('ABCDEFGHIJ'), columns=['产前检查率', '孕妇死亡率', '围产儿死亡率'])
data["孕妇死亡率"] = 1 / data["孕妇死亡率"]
data["围产儿死亡率"] = 1 / data["围产儿死亡率"]
rsr(data)
```

## 7.7 主成分分析法
> 有时候问题提供的变量是过于精细化的变量,我们想对这些变量去抽象出更高一级的变量去描述数据,同时还能够保持数据的信息尽可能少地丢失,那么这个时候我们就需要用到主成分分析法。
### 7.7.1 主成分分析法的原理
在CRITIC方法一节中我们看到了一个事实:数据列之间是存在关联的。例如,在分析数据时,有一列数据是用kg表示的重量,一列数据是用g表示的重量,两列数据的信息是一样的,所以完全可以去掉其中一列。这是个极限情况,但是事实上数据中确实可能存在相关性很高的几列,原本10列的数据集,实际上用3列就有可能表示了。如果能够通过删减或变换的方式减少列数,那么处理的难度可就大大减小了。这种操作被称为降维。
另外,即使不去减少列数,削弱相关性也是很重要的操作。例如,我们看下面这张图:
通过变换将其修正到正交基下的椭圆,后续的处理就比斜着的椭圆更好处理了。所以,哪怕变量个数不会减少,削弱相关性的操作仍然是很重要的。
主成分分析的主要目的是希望用较少的变量去解释原来资料中的大部分变异,将原始数据中许多相关性较高的变量转化成彼此相互独立或不相关的变量。通常是选出比原始变量个数少,能解释大部分资料中的变异的几个新变量(也就是主成分)。因此,我们可以知道主成分分析的一般目的是:(1)数据的降维;(2)主成分的解释。
主成分分析包括以下几个步骤.
(1)数据的去中心化:对数据表$X$中每个属性减去这一列的均值。这样做的目的在于消除数据平均水平对它的影响。
$$
\bar x = ({x_1} - {\bar x_1},{x_2} - {\bar x_2},...,{x_n} - {\bar x_n})
$$
(2)求协方差矩阵:注意这里需要除(n-1)。
$$
C = \frac{1}{{n - 1}}{\bar X^T}\bar X
$$
(3)对协方差矩阵进行特征值分解。
$$
{C=Q{ \sum {Q\mathop{{}}\nolimits^{{T}}}}}
$$
(4)特征值排序:挑选更大的*k*个特征值,将特征向量组成矩阵$P$。
(5)进行线性变换$F=PX$,从而得到主成分分析后的矩阵。矩阵中的每一列称作一个主成分,选择的特征值经过归一化就变成了权重。
> 注意:实际过程中PCA的底层做矩阵分解时使用奇异值分解(SVD)更多一些,这里用特征值分解更加容易理解。
### 7.7.2 主成分分析法的案例
主成分分析在数据处理和评价中有很大应用。首先,我们通过鸢尾花数据集的例子来看到它是如何减少数据列数的。鸢尾花数据集是一个在机器学习和统计学领域广泛使用的经典数据集。它包含了150个样本,每个样本有4个特征:萼片长度、萼片宽度、花瓣长度和花瓣宽度,这四个特征都是以厘米为单位。这些特征用于描述鸢尾花的外观。数据集中的样本分为三类,每类50个样本,分别代表山鸢尾、变色鸢尾和维吉尼亚鸢尾三种不同的鸢尾属植物。这个数据集在第9章会被再一次搬上舞台,这里主要是测试主成分分析法。
通过sklearn.datasets的接口导入数据,可以发现其中有四列自变量,再使用sklearn.decomposition提供的PCA函数进行主成分分析即可。代码形如:
```python
import matplotlib.pyplot as plt
import sklearn.decomposition as dp
from sklearn.datasets import load_iris
x,y=load_iris(return_X_y=True) #加载数据,x表示数据集中的属性数据,y表示数据标签
pca=dp.PCA(n_components=0.99) #加载pca算法,设置降维后主成分数目为2
reduced_x=pca.fit_transform(x,y) #对原始数据进行降维,保存在reduced_x中
red_x,red_y=[],[]
blue_x,blue_y=[],[]
green_x,green_y=[],[]
for i in range(len(reduced_x)): #按鸢尾花的类别将降维后的数据点保存在不同的表表中
if y[i]==0:
red_x.append(reduced_x[i][0])
red_y.append(reduced_x[i][1])
elif y[i]==1:
blue_x.append(reduced_x[i][0])
blue_y.append(reduced_x[i][1])
else:
green_x.append(reduced_x[i][0])
green_y.append(reduced_x[i][1])
plt.scatter(red_x,red_y,c='r',marker='x')
plt.scatter(blue_x,blue_y,c='b',marker='D')
plt.scatter(green_x,green_y,c='g',marker='.')
plt.show()
```
这里参数中PCA的参数设置有两种方法:如果n_components为整数,说明希望取到几个主成分;如果为小数,则说明希望保留多少的信息量,也就是取前几个特征值它们的和能够超过参数给定的阈值。
主成分分析法如何应用于评价呢?其实非常简单,将所有的主成分乘上对应的权重就可以得到最终的评分了,也就是:
$$
{y={\mathop{ \sum }\limits_{{i=1}}^{{n}}{w\mathop{{}}\nolimits_{{i}}F\mathop{{}}\nolimits_{{i}}}}}
$$
我们回到例7.3,如果想使用主成分分析法对数据进行综合评价,可以怎么做呢?首先,在经过数据的正向化和归一化以后,可以手动实现PCA如下:
```python
def pca(X,n_components):
X=np.array(X)
X=X-np.mean(X)
n=len(X)
A=np.dot(X.T,X)/(n-1)
V,D=np.linalg.eig(A)
idx = (-V).argsort(axis=None)[:n_components]
P=D[idx]
F=np.dot(X,P.T)
return V[idx]/sum(V),F
```
在使用同样的正向化和归一化手段以后,可以给出对应的主成分评分。值得注意的是,主成分评分可能会出现负数,但并不影响。得分越高则说明评价结果越好,越低甚至为负数则评价结果越差。
## 7.8 因子分析法
> 主成分分析本身是以线性加权的方式去抽象出新变量,难以对变量背后的东西进行解释。而为了从表象的数据中发现更深层的原因则需要用到因子分析。
### 7.8.1 因子分析法的原理
因子分析和主成分分析虽然都是用于评价模型的方法,但二者有很大的不同:
- 原理不同:主成分分析是利用降维(线性变换)的思想,每个主成分都是原始变量的线性组合,使得主成分比原始变量具有某些更优越的性能,从而达到简化系统结构,抓住问题实质的目的。而因子分析更倾向于从数据出发,描述原始变量的相关关系,将原始变量进行分解。
- 线性表示方向不同:主成分分析中是把主成分表示成各变量的线性组合,而因子分析是把变量表示成各公因子的线性组合。说白了,一个是组合,一个是分解。
- 假设条件不同:因子分析需要一些假设。因子分析的假设包括:各个共同因子之间不相关,特殊因子之间也不相关,共同因子和特殊因子之间也不相关。
- 主成分分析的主成分的数量是一定的,一般有几个变量就有几个主成分(只是主成分所解释的信息量不等),实际应用时会根据帕累托图提取前几个主要的主成分。而因子分析的因子个数需要分析者指定,指定的因子数量不同而结果也不同。
- 应用范围不同:在实际的应用过程中,主成分分析常被用作达到目的的中间手段,而非完全的一种分析方法,提取出来的主成分无法清晰的解释其代表的含义。而因子分析就是一种完全的分析方法,可确切的得出公共因子。
在进行因子分析之前,需要先进行巴雷特检验或KMO检验。巴雷特特球形检验(Barlett's Test)是一种统计方法,用于检验多个变量之间是否存在相关性。它的基本思想是,如果多个变量之间彼此独立,那么它们的方差应该与它们的相关系数矩阵的行列式值成正比。如果实际观察到的行列式值与预期的行列式值相差很大,那么可以认为这些变量之间存在相关性。通过比较实际观察到的行列式值与预期的行列式值,我们可以决定是否拒绝零假设,即这些变量是独立的。简单来说,巴雷特特球形检验的作用就是帮助我们判断多个变量之间是否存在相关性,从而决定是否适合进行因子分析。如果得到的统计概率小于0.05,那么它是适合做因子分析的。
KMO检验用于评估一组数据是否适合进行因子分析。它的基本作用是检测数据是否符合因子分析的基本假设,即变量之间应该呈现出一定程度的相关性。KMO检验的基本思想是通过比较变量之间的简单相关系数和偏相关系数来进行评估。简单相关系数描述了两个变量之间的直接关系,而偏相关系数则描述了在控制其他变量影响后,两个变量之间的净关系。KMO检验通过计算这些相关系数的平方和来比较这两种关系,以确定数据是否适合进行因子分析。KMO统计量的取值范围在0-1之间。当KMO值越接近1时,表示变量间的相关性越强,原有变量越适合作因子分析;当KMO值越接近0时,表示变量间的相关性越弱,原有变量越不适合作因子分析。在实际分析中,KMO统计量在0.7以上时效果比较好;当KMO统计量在0.5以下时,则不适合应用因子分析法,可能需要重新设计变量结构或者采用其他统计分析方法。
如果能够通过这两个检验中的一个,就可以开始做因子分析了。它的基本流程如下:
- 首先,我们要选出一组变量来进行因子分析。选择的方法有两种:定性和定量。如果原始变量之间的相关性不好,那它们就很难被分解成几个公共因子。所以,原始变量之间应该有较强的相关性。
- 接着,我们要计算这些选定的原始变量的相关系数矩阵。这个矩阵能告诉我们各个变量之间的关系是什么样的。这一步特别重要,因为如果变量之间没什么关系,那把它们分解成几个因子就没什么意义了。这个相关系数矩阵也是我们进行因子分析的基础。
- 然后,我们要从这些原始变量中提取出公共因子。具体要提取几个,需要我们来做决定。这个决定可以基于我们的先验知识或者实验假设。不过,通常我们会看提取的因子的累计方差贡献率是多少。一般来说,累计的方差贡献率达到70%或以上,就算是满足了要求。分解的形式如下所示:
???
- 之后,我们要对提取出来的公共因子进行旋转。这样做的目的是为了让因子的意义更明确,更容易理解。
- 最后,我们要计算出因子的得分。这些得分可以在后续的研究中使用,比如在因子回归模型中。这样,我们就能更好地理解这些变量的关系,并找出影响结果的关键因素。
因子载荷矩阵是因子分析中的核心概念之一,它描述了变量与因子之间的关系。因子载荷是第i个变量与第j个公共因子的相关系数,反映了第i个变量和第j个公共因子之间的重要性。绝对值越大,表示相关性的密切程度越高。因子载荷矩阵中各列元素的平方和成为对所有的变量的方差贡献和,衡量了各个公共因子的相对重要性。因子载荷矩阵是可逆的,因此可以用于将原始变量表示为公共因子和特殊因子的线性组合。这使得我们可以利用公共因子解释原始数据的结构和模式,并对其进行解释和分析。因子载荷矩阵在因子分析中具有重要的作用,它不仅用于确定公共因子和特殊因子的数量,还可以用于估计公共因子和特殊因子的系数。在实际中,可以使用主成分分析法等方法估计因子载荷矩阵。
为什么需要进行因子旋转?假设我们有一个市场调研数据集,其中包括了多个产品特性和消费者对产品的评价。通过因子分析,我们希望找出影响消费者评价的公共因子。初始的因子载荷矩阵可能显示出一些不太直观的结果,例如某些产品特性与公共因子之间的关系不太明显。这时,通过因子旋转,我们可以对原始因子进行转换,使得因子载荷矩阵中的因子载荷的绝对值更加接近于1或0。这样,我们可以更清楚地看出哪些产品特性与公共因子有强烈的关联,哪些特性的影响较小。因子旋转的本质就是做一个正交变换,让因子载荷阵的结构得到简化。常见的因子旋转方法包括方差最大法等。
最终得到的因子得分往往比主成分分析更加具有可解释性。它在人文社会科学的问题中有着非常重要的应用。前面学习的一系列方法例如层次分析法、熵权法等把评价的重点放在了指标权重上,TOPSIS分析法等把重点放在了得分折算上。但因子分析走出了第三条路径:通过构造因子,将多个变量进行抽象构造出指标体系(可以理解为,数据中给出的的二级指标,而通过因子分析可以给出一级指标以及指标对应关系)。良好的可解释性就意味着它可以深度地和一些人文社会科学理论融合起来,并具有广阔的后续应用空间。在第9章中我们会再一次提到因子分析的扩展方法:结构方程。
### 7.8.2 因子分析法的案例
因子分析分为探索型因子分析和验证型因子分析。探索性因子分析是从数据出发,寻找能获得最大解释性的因子数量;而验证型因子分析则是研究之前已经提出了理论假设,现在需要用实验数据佐证。Python可以使用factor_analyzer实现:
**例7.5** 我们从国家统计局获取了自2005—2012年间各类学校的生师比(学生数量与教师数量的比值),数据如表5.9所示,试对数据进行因子分析并进行一定解释。
| **年份** | **小学生师比** | **初中生师比** | **普通高中生师比** | **职业高中生师比** | **本科院校生师比** | **专科院校生师比** |
| -------- | -------------- | -------------- | ------------------ | ------------------ | ------------------ | ------------------ |
| 2005年 | 19.98 | 18.65 | 18.65 | 19.1 | 17.44 | 13.15 |
| 2006年 | 19.43 | 17.8 | 18.54 | 20.62 | 17.75 | 14.78 |
| 2007年 | 19.17 | 17.15 | 18.13 | 22.16 | 17.61 | 18.26 |
| 2008年 | 18.82 | 16.52 | 17.48 | 23.5 | 17.31 | 17.2 |
| 2009年 | 18.38 | 16.07 | 16.78 | 23.47 | 17.21 | 17.27 |
| 2010年 | 17.88 | 15.47 | 16.3 | 23.65 | 17.23 | 17.35 |
| 2011年 | 17.7 | 14.98 | 15.99 | 23.66 | 17.38 | 17.21 |
| 2012年 | 17.71 | 14.38 | 15.77 | 21.59 | 17.48 | 17.28 |
这是一个非常典型的社会科学问题。我们这里提出了假说:问题可以被分解为三个因子。但首先,我们需要对数据进行预处理:
```python
import pandas as pd
import numpy as np
data=pd.read_csv("查询数据.csv",encoding='gbk')
data=data.interpolate('linear')
data=data.fillna(method='bfill')
newdata=data[['小学生师比(教师人数=1)', '初中生师比(教师人数=1)',
'普通高中生师比(教师人数=1)', '职业高中生师比(教师人数=1)', '普通中专生师比(教师人数=1)',
'普通高校生师比(教师人数=1)', '本科院校生师比(教师人数=1)', '专科院校生师比(教师人数=1)', '教育经费(万元)',
'国家财政性教育经费(万元)', '国家财政预算内教育经费(万元)', '各类学校教育经费社会捐赠经费(万元)',
'各类学校教育经费学杂费(万元)']]
```
通过插值与填充,我们对数据中的空缺进行了处理。下面对数据进行巴雷特球形检验和KMO检验:
```python
from factor_analyzer import FactorAnalyzer
import pandas as pd
import seaborn as sns
from matplotlib import pyplot as plt
import numpy as np
import math
from scipy.stats import bartlett
plt.rcParams['font.sans-serif'] = ['SimHei'] #显示中文
plt.rcParams['axes.unicode_minus']=False #用来正常显示负号
plt.style.use("ggplot")
n_factors=3#因子数量
cols=['小学生师比(教师人数=1)', '初中生师比(教师人数=1)',
'普通高中生师比(教师人数=1)', '职业高中生师比(教师人数=1)', '普通中专生师比(教师人数=1)',
'普通高校生师比(教师人数=1)', '本科院校生师比(教师人数=1)', '专科院校生师比(教师人数=1)']
#用检验是否进行
df=newdata[cols]
corr=list(df.corr().to_numpy())
print(bartlett(*corr)) #巴雷特方法
def kmo(dataset_corr): #KMO方法
corr_inv = np.linalg.inv(dataset_corr) #求逆
nrow_inv_corr, ncol_inv_corr = dataset_corr.shape
A = np.ones((nrow_inv_corr,ncol_inv_corr))
for i in range(nrow_inv_corr):
for j in range(i,ncol_inv_corr,1):
A[i,j] = -(corr_inv[i,j])/(math.sqrt(corr_inv[i,i]*corr_inv[j,j]))
A[j,i] = A[i,j]
dataset_corr = np.asarray(dataset_corr)
kmo_num = np.sum(np.square(dataset_corr)) - np.sum(np.square(np.diagonal(A)))
kmo_denom = kmo_num + np.sum(np.square(A)) - np.sum(np.square(np.diagonal(A)))
kmo_value = kmo_num / kmo_denom
return kmo_value
print(kmo(newdata[cols].corr().to_numpy()))
```
结果显示,KMO值为0.73可以进行因子分析。随后调用分析器并给出载荷矩阵:
```python
#开始计算
fa = FactorAnalyzer(n_factors=n_factors,method='principal',rotation="varimax")
fa.fit(newdata[cols])
communalities= fa.get_communalities()#共性因子方差
loadings=fa.loadings_#成分矩阵,可以看出特征的归属因子
#画图
plt.figure(figsize=(9,6),dpi=800)
ax = sns.heatmap(loadings, annot=True, cmap="BuPu")
ax.set_xticklabels(['基础教育','职业教育','高等教育'],rotation=0)
ax.set_yticklabels(cols,rotation=0)
plt.title('Factor Analysis')
plt.show()
factor_variance = fa.get_factor_variance()#贡献率
fa_score = fa.transform(newdata[cols])#因子得分
#综合得分
complex_score=np.zeros([fa_score.shape[0],])
for i in range(n_factors):
complex_score+=fa_score[:,i]*factor_variance[1][i]#综合得分s
```
给出的载荷矩阵如下:
我们可以看到,三列因子的累计解释率能到0.9以上,说明非常好。而这三列根据颜色的对应关系,可以看到第一列对应了小学、初中、高中,我们可以命名为基础教育;第二列对应职高、中专、大专,可以命名为职业教育;第三列对应普通高校和本科,可以命名为高等教育。可解释性确实是非常强,并且这样我们就构成了一个指标体系的对应关系。
## 7.9 数据包络分析法
绩效评估是评估组织或个人如何以较少的资源获得较多的产出结果的多属性评估,也称之为成本效益分析。数据包络分析是 A.Charnes, W.W.Copper 和 E.Rhodes 在 1978 年提出的评价多指标输入输出,衡量系统有效性的方法。将属性划分为投入项、产出项(成本型、效益型指标),不预先设定权重,只关心总产出与总投入,以其比率作为相对效率。
### 7.9.1 CCR数据包络模型
数据包络分析其实是一系列方法的总称,它指的就是通过分析成本指标和收益指标给出效率评价的一系列方法。数据包络分析包括CCR, BBC等多种方法。首先我们介绍CCR方法。假设现在有m个投入型指标X,s个产出型指标Y,n个比较对象,数据包络的目的就是评价这些对象的“性价比”,也就是投入少一些、产出多一些。
与先前的一些评价类模型一样,数据包络中的CCR模型其目的也是获得权重。只不过,分别对产出型指标和投入型指标构造权重向量$u$和$v$,而定义投入产出比,问题的本质是解一个规划模型。对于每一个样本而言,有:
$$
{max\frac{{u\mathop{{}}\nolimits^{{T}}Y}}{{v\mathop{{}}\nolimits^{{T}}X}}}
$$
$$
{s.t.{ \left\{ {\begin{array}{*{20}{l}}{\frac{{u\mathop{{}}\nolimits^{{T}}y\mathop{{}}\nolimits_{{i}}}}{{v\mathop{{}}\nolimits^{{T}}x\mathop{{}}\nolimits_{{i}}}} \le 1}\\{u,v\text{ } \ge 0}\end{array}}\right. }}
$$
这一规划的目的是让投入产出比尽可能大,因为分母表示投入,分子表示产出,这个值越大,表示这一项的“性价比”越高。但投入产出比根据常理判断,不可能超过100%,所以加上了不能超过100%的约束。
这显然是一个非线性规划模型,但可以转化为一个线性规划问题求解。事实上,希望一个分式越大,只需分子越大的同时分母越小即可。如果不能同时做到这两点,也可以通过限制的方法让分母为一个定值。因此,对于样本k,可以有这样一个形式来改写:
$$
{s.t.{ \left\{ {\begin{array}{*{20}{l}}{{\mathop{ \sum }\limits_{{i=1}}^{{n}}{ \lambda \mathop{{}}\nolimits_{{i}}x\mathop{{}}\nolimits_{{ij}} \le OE\mathop{{}}\nolimits_{{k}}∙x\mathop{{}}\nolimits_{{kj}}\text{ }\text{ }\text{ } \forall j=1,2, \cdots ,m\mathop{{}}\nolimits_{{1}}}}}\\{{\mathop{ \sum }\limits_{{i=1}}^{{n}}{ \lambda \mathop{{}}\nolimits_{{i}}}}y\mathop{{}}\nolimits_{{ij}} \ge y\mathop{{}}\nolimits_{{kj}}\text{ } \forall j=1,2, \cdots ,m\mathop{{}}\nolimits_{{2}}\text{ }\text{ }\text{ }\text{ }}\\{ \lambda \mathop{{}}\nolimits_{{i}} \ge 0,\text{ }i=1,2,{ \cdots ,}n}\end{array}}\right. }}
$$
在不同的生产规模下,规模报酬将会随之改变。生产规模小时,投入产出比会随着规模增加而迅速提升,称为规模报酬递增;当生产达到高峰期时,产出与规模成正比而达到最适生产规模,称为规模报酬固定;当生产规模过于庞大时,产出减缓,则称为规模报酬递减,也就是投入增加时,产出增加的比例会少于投入增加的比例。分析递增与递减是通过lambda的和发现的,当lambda的和为1的时候规模报酬固定;大于1则递增,小于1则递减。
### 7.9.2 BBC数据包络模型
BBC模式是对CCR的改进。BCC模型是从产出的角度探讨效率,即在相同的投入水准下,比较产出资源的达成情况,这种模式称为“投入导向模式”。所得到的是“技术效益”,DEA=1称为“技术有效”。实质上,就是投入可持续增加,研究投入和产出的变化关系。
BCC模型的基本形式如下
$$
{s.t.{ \left\{ {\begin{array}{*{20}{l}}{{\mathop{ \sum }\limits_{{j=1}}^{{n}}{ \lambda \mathop{{}}\nolimits_{{j}}y\mathop{{}}\nolimits_{{ij}}}} \le \theta \mathop{{}}\nolimits_{{i}}x\mathop{{}}\nolimits_{{i}}}\\{{\mathop{ \sum }\limits_{{j=1}}^{{n}}{ \lambda \mathop{{}}\nolimits_{{i}}}}y\mathop{{}}\nolimits_{{ij}} \ge \theta \mathop{{}}\nolimits_{{i}}y\mathop{{}}\nolimits_{{i}}\text{ }\text{ }\text{ }\text{ }}\\{{\mathop{ \sum }\limits_{{j=1}}^{{n}}{ \lambda \mathop{{}}\nolimits_{{j}}}}=1}\end{array}}\right. }}
$$
这个问题的本质也是在求解一个规划问题,主要的目的是求解*λ*和***θ\***。但BCC模型应用相对较少,能够掌握CCR模型的解决方法即可。
### 7.9.3 数据包络模型的实现
这里我们实现数据包络模型中的CCR方法,并以面向对象的方法进行封装:
```python
import numpy as np
from scipy.optimize import fmin_slsqp
class DEA(object):
def __init__(self, inputs, outputs):
# supplied data
self.inputs = inputs
self.outputs = outputs
# parameters
self.n = inputs.shape[0]
self.m = inputs.shape[1]
self.r = outputs.shape[1]
# iterators
self.unit_ = range(self.n)
self.input_ = range(self.m)
self.output_ = range(self.r)
# result arrays
self.output_w = np.zeros((self.r, 1), dtype=np.float) # output weights
self.input_w = np.zeros((self.m, 1), dtype=np.float) # input weights
self.lambdas = np.zeros((self.n, 1), dtype=np.float) # unit efficiencies
self.efficiency = np.zeros_like(self.lambdas) # thetas
def __efficiency(self, unit):
# compute efficiency
denominator = np.dot(self.inputs, self.input_w)
numerator = np.dot(self.outputs, self.output_w)
return (numerator/denominator)[unit]
def __target(self, x, unit):
in_w, out_w, lambdas = x[:self.m], x[self.m:(self.m+self.r)], x[(self.m+self.r):] # unroll the weights
denominator = np.dot(self.inputs[unit], in_w)
numerator = np.dot(self.outputs[unit], out_w)
return numerator/denominator
def __constraints(self, x, unit):
in_w, out_w, lambdas = x[:self.m], x[self.m:(self.m+self.r)], x[(self.m+self.r):] # unroll the weights
constr = [] # init the constraint array
# for each input, lambdas with inputs
for input in self.input_:
t = self.__target(x, unit)
lhs = np.dot(self.inputs[:, input], lambdas)
cons = t*self.inputs[unit, input] - lhs
constr.append(cons)
# for each output, lambdas with outputs
for output in self.output_:
lhs = np.dot(self.outputs[:, output], lambdas)
cons = lhs - self.outputs[unit, output]
constr.append(cons)
# for each unit
for u in self.unit_:
constr.append(lambdas[u])
return np.array(constr)
def __optimize(self):
d0 = self.m + self.r + self.n
# iterate over units
for unit in self.unit_:
# weights
x0 = np.random.rand(d0) - 0.5
x0 = fmin_slsqp(self.__target, x0, f_ieqcons=self.__constraints, args=(unit,),disp=False)
# unroll weights
self.input_w, self.output_w, self.lambdas = x0[:self.m], x0[self.m:(self.m+self.r)], x0[(self.m+self.r):]
self.efficiency[unit] = self.__efficiency(unit)
def fit(self):
self.__optimize() # optimize
return self.efficiency
```
在这里我们给出一系列测试用例,若第1到4列为投入指标,第5到11列为产出指标,测试每个对象的投入产出效率如下:
```python
# 定义输入和输出数据
data = np.array([[39414, 2823, 34877, 44562, 2036, 603, 322, 934936, 929914, 1492, 29811],
[54934, 1911, 52242, 35262, 3862, 908, 396, 1075563, 1030664, 1780, 29811],
[96442, 2743, 88737, 303221, 4307, 1596, 694, 1104835, 1010146, 1936, 32678],
[107079, 3036, 98513, 478883, 3956, 2530, 1089, 909220, 862077, 2160, 36063],
[124359, 3326, 116897, 378318, 4102, 2669, 1179, 1117851, 1123109, 2349, 38951],
[140167, 3900, 130355, 261203, 4180, 3538, 1991, 1116429, 1100510, 2446, 40324],
[161523, 3989, 153722, 444755, 4309, 3727, 1593, 878466, 880226, 2637, 43211],
[177681, 4669, 167161, 422267, 4630, 6629, 1867, 1048053, 1003952, 2904, 47116],
[124969, 4416, 111415, 286399, 3829, 5665, 2591, 1142395, 1112661, 3092, 49406]])
X=data[:,0:4]
y=data[:,5:11]
dea = DEA(X,y)
rs = dea.fit()
print(rs)
```
从结果上来看,这些样本中有多个样本的投入产出比能达到1,说明它们的效率是比较高的。而对于没有达到1的样本,则需要进一步分析它们的规模报酬是哪一种类型。
## 7.10 评价模型总结
> 在最后的一节里面我不打算讲什么新的东西了,我这里就对前面的一些方法进行一个总结和阐释,希望能帮助大家对评价类模型有一个更深刻的理解与体会。
### 7.10.1 指标体系的构建
评价类模型指使用比较系统的、规范的方法对于多个指标、多个因素、多个维度、多个个体同时进行评价的方法。不仅仅是一种方法,是一系列方法的总称,即: 对多指标进行一系列有效方法的总称。综合评价是针对研究的对象,建立一个进行测评的指标体系,利用一定的方法或模型,对搜集的资料进行分析,对被评价的事物作出定量化的总体判断。
从前面这些评价类模型的性质来看,评价类模型的核心就是三个:指标体系,权重计算,评分规则。一个好的指标体系是计算权重和评分的基础。如果没有一个好的指标体系,我们就无法对问题做出客观全面准确的评价,也无法使用前面讲述的评价类模型。
构建好的指标体系需要遵循以下几个步骤:
- 明确目标与目的:首先需要明确指标体系的目标和目的,以确保所选择的指标与所需评估的目标紧密相关。这涉及到确定要评估哪些方面的表现、期望达到的水平以及衡量这些评分的标准。
- 收集数据和信息:为了准确地度量和评估,需要收集相关数据和信息。这些数据可以来自问题本身,也可以查找文献。在数据收集过程中,需要注意数据的可靠性和完整性,以确保指标的可信度和有效性。
- 选择合适的指标:选择合适的关键指标对于构建有效的指标体系至关重要。指标应该具有可操作性、关联性和可靠性。可操作性是指指标应该易于测量和计算;关联性是指指标之间应该有密切的相关性或因果关系;可靠性是指指标应该是稳定和可重复的。
- 建立权重和优先级:为了更好地反映实际情况,需要为每个指标分配一定的权重和优先级。权重可以根据不同因素的重要性而定,比如财务指标可能比非财务指标更重要。而优先级的设定则需要根据组织的战略目标和重点关注领域来确定。
- 反馈和沟通:评价模型只是第一步,更重要的是要将结果进行阐述与分析,把它好理论结合起来。同时,在实际应用中不断优化指标体系,使其更加贴近实际需求。
### 7.10.2 两大核心:权重生成与得分评价
权重和评分是评价类模型计算中最重要的两个部分。前面的一些方法已经给大家展示了权重的计算方法例如基于熵值、相关系数与方差、成对比较矩阵等,也有计算方法例如加权求和、计算距离、模糊隶属度等。
针对具体问题选择合适的评价类模型,需要考虑以下几个因素:
- 问题类型和数据类型:不同的评价类模型适用于不同的问题类型和数据类型。例如,层次分析法适用于定性与定量相结合的问题,模糊综合评价法适用于模糊性较强的问题,主成分分析法和因子分析法适用于高维度的数据降维问题。因此,需要根据问题的性质和数据类型选择合适的评价类模型。
- 数据量和分析需求:不同评价类模型对数据量和数据分析需求也不同。例如,熵权法需要大量的数据才能准确地计算出各个指标的权重,而因子分析法则需要较少的样本量来分析数据。因此,需要根据具体的数据量和数据分析需求选择合适的评价类模型。
- 算法复杂度和可操作性:不同的评价类模型算法复杂度不同,所需的计算资源和操作难度也不同。例如,主成分分析法需要较为复杂的数学推导和计算,而熵权法则相对简单易懂。因此,需要根据具体的应用场景和计算资源选择合适的评价类模型。
- 指标数量和权重分配:在选择评价类模型时,需要考虑指标数量和权重分配的问题。如果指标数量较多或者权重分配比较复杂,需要选择能够处理这些问题的评价类模型。例如,熵权法则可以通过熵值来判断各个指标的离散程度,从而确定各个指标的权重。
- 主观与客观因素:不同的评价类模型在处理主观与客观因素时有所不同。例如,层次分析法和模糊综合评价法可以较好地处理主观因素,而熵权法和CRITIC法则更加注重客观因素的考虑。因此,在选择评价类模型时,需要根据具体的问题和实际情况选择能够处理主观与客观因素的评价类模型。
================================================
FILE: docs/CH8/第8章-时间序列.md
================================================
第8章 时间序列与投资模型
> 内容:@若冰(马世拓)
>
> 审稿:@牧小熊(聂雄伟)
>
> 排版&校对:@牧小熊(聂雄伟)
这一章主要介绍时间序列与投资模型。时间序列,顾名思义也就是按照时间排下来的序列,例如股票等。如果是短期的序列比方说只有十几条的,那么我们按照之前讲的回归那一套来做也未尝不可;但如果序列长度比较长,有几千条上万条,并且似乎一下子看不出什么规律,我们就需要引入新的方法去建模。另外,时间序列分析能够相对精准预测股市,但怎么买取决于一个优化策略。本章主要涉及到的知识点有:
- 时间序列的基本概念
- 移动平均法与指数平滑法
- ARIMA系列模型
- GARCH系列模型
- 灰色系统模型
- 组合投资中的基本策略
> 注意:第4章当中我为何强调数据体量的作用?在这个地方就体现得很好。在不同的数据体量下我们也应该选用不同的建模方法才能取得更好的建模效果。
## 8.1 时间序列的基本概念
时间序列,顾名思义就是有时间性的序列。它本质上和第四章讲到的数据也并没有太大的出入,但这种数据一个典型的特征就是有一个时间列作为索引。这个时间表示的是一个先后关系,可以以日为单位,可以以小时为单位,可以以分钟或秒为单位,这些都可以,并且它的应用范畴也很广。
### 8.1.1 时间序列的典型应用
典型的时间序列有哪些呢?天气预报中每一天的天气按照时间构成了一个序列,这属于离散的时间序列,我们通常用马尔可夫模型建模;股票当中某只股票每日的开盘价、收盘价也在变化,也认为它是一个时间序列。
时间序列的建模主要包括参数学习和预测两个方面。预测比较直观容易理解,比如对天气预报而言,我可以基于历史天气预报未来24小时内的天气情况,并且用序列建模方法可以预测得比较精准;对股票而言,我可以基于其一个月内的股价预测其接下来一周的股价变化。但参数的学习则是通过模型参数分析这个序列的特征,从而基于“领域知识”分析序列特点挖掘出一些有意思的点子。
> 注意:时间序列的预测也分为长期预测与短期预测,通常预测周期和预测精度是一对冲突概念,如果想要精准预测那么预测周期不能够太长,长期的预测只能做趋势预测,因为时间序列的预测时间太长就难以考虑环境变化与突发事件对序列的影响,所做的预测也就没有意义。
### 8.1.2 时间序列的描述与分解
在一个时间序列数据中,可以想象你的面前有一份表格:这个表格里面的内容是一条河流中水质的各种指标随着时间的变化,第一列是按日计的时间,第二列是PH,第三列是溶解氧,第四列是水体中重金属含量,第五列是水体中细菌含量。由于数据显示出随时间变化的特性,这是一个时间序列数据。每一行是在某一天的情况,我们称一行为一个“截面”,这些截面在时间轴上拼接起来构成了一个面板。
另外,定义平稳型时间序列。平稳性时间序列的定义为一个序列的均值、方差和协方差不会随着时间变化。这一定义包括三个方面的意义:第一,序列的趋势线是一条水平线;第二,序列不会某一段波动很大某一段波动很小;第三,序列不会某一段分布密集某一段分布稀疏。这是平稳序列的定义。
一个时间序列Y通常由长期趋势,季节变动,循环波动,不规则波动几部分组成:
- 长期趋势T指现象在较长时期内持续发展变化的一种趋向或状态,通常表现为一条光滑曲线趋势线。
- 季节波动S是由于季节的变化引起的现象发展水平的规则变动,通常可以表现为周期相对短一些的周期曲线。
- 循环波动I指在某段时间内,不具严格规则的周期性连续变动,通常表现为周期更长的周期曲线。
- 不规则波动C也可以叫噪声指由于众多偶然因素对时间序列造成的影响。
分解模型又分为加法模型和乘法模型。加法指的是时间序分的组成是相互独立的,四个成分都有相同的量纲。乘法模型输出部分和趋势项有相同的量纲,季节项和循环项是比例数,不规则变动项为独立随机变量序列,服从正态分布。基本分解形如:
$$
\begin{array}{c}
Y[t] = T[t] + S[t] + C[t] + I[T]\\
Y[t] = T[t]*S[t]*C[t]*I[T]
\end{array}
$$
当然,也可以把加法和乘法的分解模式进行组合。
```python
df=pd.read_csv("Bitcoin.csv")
y=df.Bitcoin
df.Date=pd.to_datetime(df.Date)
df=df.set_index(df['Date'],drop=True)
# 绘制时间序列图
plt.figure(figsize=(12, 4))
plt.plot(df.Bitcoin, label='Bitcoin')
plt.title('Bitcoin Time Series')
plt.xlabel('Date')
plt.ylabel('Bitcoin')
plt.legend()
plt.show()
```

\# 季节性分析
```python
res = sm.tsa.seasonal_decompose(df.Bitcoin, model='additive')
res.plot()
plt.suptitle('Seasonal Decomposition')
plt.show()
```

## 7.2 移动平均法与指数平滑法
这一讲介绍移动平均法和指数平滑法。这两种方法对于大趋势的建模是很有用的,也可以用于短期的趋势外推。在股票的K线图中,也经常可以看到移动平均法的影子,而指数平滑法则是它的一种扩展。
### 8.2.1 移动平均法
移动平均法是用一组最近的实际数据值来预测未来时间序列的一种常用方法。移动平均法适用于短期预测。当序列需求既不快速增长也不快速下降,且不存在季节性因素时,移动平均法能有效地消除预测中的随机波动,是非常有用的。移动平均法根据预测时使用的各元素的权重不同,可以分为:简单移动平均和加权移动平均。
移动平均法是一种简单平滑预测技术,它的基本思想是:根据时间序列资料、逐项推移,依次计算包含一定项数的序列平均值,以反映长期趋势的方法。因此,当时间序列的数值由于受周期变动和随机波动的影响,起伏较大,不易显示出事件的发展趋势时,使用移动平均法可以消除这些因素的影响,显示出事件的发展方向与趋势(即趋势线),然后依趋势线分析预测序列的长期趋势。
若预测目标的基本在某一个水平上下浮动,趋势线是一条水平线而非斜线更非曲线时,可以用一次移动平均方法建立预测模型。一次移动平均方法的递推公式:
$$
{M\mathop{{}}\nolimits_{{ \left( 1 \right) }}^{{t}}=M\mathop{{}}\nolimits_{{1}}^{{t-1}}+\frac{{y\mathop{{}}\nolimits_{{t}}+y\mathop{{}}\nolimits_{{t-1}}+L+y\mathop{{}}\nolimits_{{t-N+1}}}}{{N}}}
$$
$$
{M\mathop{{}}\nolimits_{{ \left( 1 \right) }}^{{0}}=\frac{{1}}{{N}}{\mathop{ \sum }\limits_{{i=1}}^{{N}}{y\mathop{{}}\nolimits_{{i}}}}}
$$
如果预测目标类似于一个线性模型(也就是趋势线是一条一次函数)会使用二次移动平均。二次移动平均方法的递推公式形如:
$$
{M\mathop{{}}\nolimits_{{ \left( 2 \right) }}^{{t}}=M\mathop{{}}\nolimits_{{ \left( 2 \right) }}^{{t}}+\frac{{M\mathop{{}}\nolimits_{{ \left( 1 \right) }}^{{t}}+M\mathop{{}}\nolimits_{{ \left( 1 \right) }}^{{t-1}}+K+M\mathop{{}}\nolimits_{{ \left( 1 \right) }}^{{t-N+1}}}}{{N}}}
$$
预测标准误差为:
$$
{S=\sqrt{{\frac{{{\mathop{ \sum }\limits_{{t=N+1}}^{{T}}{ \left( \mathop{{y}}\limits^{ᨈ}\mathop{{}}\nolimits_{{t}}-y\mathop{{}}\nolimits_{{t}} \left) \mathop{{}}\nolimits^{{2}}\right. \right. }}}}{{T-N}}}}}
$$
如果预测目标的基本趋势呈周期加线性,我们可以趋势移动平均法。形如:
$$
{\mathop{{y}}\limits^{︵}\mathop{{}}\nolimits_{{T+m}}=a\mathop{{}}\nolimits_{{T}}+b\mathop{{}}\nolimits_{{T}}m,m=1,2...}
$$
$$
{a\mathop{{}}\nolimits_{{T}}=2M\mathop{{}}\nolimits_{{ \left( 1 \right) }}^{{T}}-M\mathop{{}}\nolimits_{{ \left( 2 \right) }}^{{T}},b\mathop{{}}\nolimits_{{T}}=\frac{{2}}{{N-1}} \left( M\mathop{{}}\nolimits_{{ \left( 1 \right) }}^{{T}}-M\mathop{{}}\nolimits_{{ \left( 2 \right) }}^{{T}} \right) }
$$
> 注意:时间序列中如果出现明显的直线型或曲线型趋势,需要先把这个趋势成分分离出来以后才方便分析。无论是上面提到的二次移动平均还是趋势移动平均都是为了对这个直线型大趋势或曲线型大趋势做拟合,将其分离出来以后剩下的序列才更接近平稳。平稳序列的分析永远比非平稳的序列分析方便。
基于上面的过程,我们可以看一个例子。
```python
import numpy as np
y=np.array(y)
def MoveAverage(y,N):
Mt=[y[0]]*N
for i in range(N+1,len(y)+2):
M=y[i-N-1:i-1].mean()
Mt.append(M)
return Mt
yt3=MoveAverage(y, 30)
yt5=MoveAverage(y, 80)
import matplotlib.pyplot as plt
plt.plot(y,label='y')
plt.plot(yt3,label='yt30')
plt.plot(yt5,label='yt80')
plt.legend()
plt.show()
```

### 8.2.2 指数平滑法
在做时序预测时,一个显然的思路是:认为离着预测点越近的点,作用越大。将权重按照指数级进行衰减,这就是指数平滑法的基本思想。这一思路显然是好理解的,毕竟在股市预测中,五年十年前的市场数据对现在意义不大,因为那个时候的宏观经济形势和现在也不同,相对而言,最近两个月的市场数据显得更为重要,在模型中这一部分数据理应被分配更大的权重。
指数平滑法有几种不同形式:一次指数平滑法针对没有趋势和季节性的序列,二次指数平滑法针对有趋势但没有季节性的序列,三次指数平滑法针对有趋势也有季节性的序列。
一次指数平滑的递推公式为:
$$
{S\mathop{{}}\nolimits_{{ \left( 1 \right) }}^{{t}}= \alpha y\mathop{{}}\nolimits_{{t}}+ \left( 1- \alpha \left) S\mathop{{}}\nolimits_{{ \left( 1 \right) }}^{{t-1}}\right. \right. }
$$
从这一递推公式出发进行化简,可以得到:
$$
{S\mathop{{}}\nolimits_{{ \left( 1 \right) }}^{{t}}= \alpha {\mathop{ \sum }\limits_{{j=0}}^{{ \infty }}{ \left( 1- \alpha \left) \mathop{{}}\nolimits^{{j}}y\mathop{{}}\nolimits_{{t-j}}\right. \right. }}}
$$
这里α表示修正幅度大小。通过对修正幅度的调节可以实现一次指数平滑。现在我们从一阶指数平滑到三阶指数平滑,因为更具有一般性。定义三个累计序列:
$$
{S_t^{(1)} = \alpha {y_t} + (1 - \alpha )S_{t - 1}^{(1)}}\\
{S_t^{(2)} = \alpha S_t^{(1)} + (1 - \alpha )S_{t - 1}^{(2)}}\\
{S_t^{(3)} = \alpha S_t^{(2)} + (1 - \alpha )S_{t - 1}^{(3)}}
$$
那么,三次指数平滑的模型被定义为:
$$
{\mathop{{y}}\limits^{ᨈ}\mathop{{}}\nolimits_{{t+m}}=a\mathop{{}}\nolimits_{{t}}+b\mathop{{}}\nolimits_{{t}}m+c\mathop{{}}\nolimits_{{t}}m\mathop{{}}\nolimits^{{2}}}
$$
$$
$$
注意:时间序列中如果应用移动平均,预测序列的数据量会少一个窗口长度;而应用指数平滑法的时候,趋势线的长度和原始序列的长度是对齐的。
同样针对上面的例子,可以用下面的代码实现不同修正幅度下的指数平滑:
```python
import numpy as np
import pandas as pd
def ExpMove(y,a):
n=len(y)
M=np.zeros(n)
#M[0]=(y[0]+y[1])/2
M[0]=y[0]
for i in range(1,len(y)):
M[i]=a*y[i-1]+(1-a)*M[i-1]
return M
yt1=ExpMove(y,0.2)
yt2=ExpMove(y,0.5)
yt3=ExpMove(y,0.8)
s1=np.sqrt(((y-yt1)**2).mean())
s2=np.sqrt(((y-yt2)**2).mean())
s3=np.sqrt(((y-yt3)**2).mean())
d=pd.DataFrame(np.c_[y,yt1,yt2,yt3])
f=pd.ExcelWriter('exp_smooth_example.xlsx')
d.to_excel(f)
f.close()
d.plot()
plt.show()
print(d)
```

图7.3 不同修正幅度下的指数平滑
从图7.3中可以发现,当修正幅度逐渐增大的情况下新的平滑序列越接近原始序列。与前面移动平均一样,为了对原始序列进行平滑分析其大体的趋势线可以不断调节修正幅度。
## 8.3 ARIMA系列模型
积土成山,风雨兴焉;积水成渊,蛟龙生焉。ARIMA模型实际上是由多个模型组合而来,而最起初的模型也都是针对平稳时间序列而言的。在一开始,我们会重新回温一下平稳时间序列和白噪声的概念,然后介绍如何判断一个序列是平稳时间序列的检验方法,再来介绍模型的演化和组合。
注意:如果原始序列非平稳但差分以后平稳也是可以变换使用这些模型的。
### 8.3.1 AR模型
AR模型模型,全称为自回归模型自回归模型(Autoregressive model),是统计上一种处理时间序列的方法,用同一变数例如x的之前各期,亦即${x\mathop{{}}\nolimits_{{1}}}$至${x\mathop{{}}\nolimits_{{t-1}}}$来预测本期${x\mathop{{}}\nolimits_{{t}}}$的表现,并假设它们为一线性关系。该模型描述了当前值与历史值之间的相关关系,用变量自身的历史数据对当前数据进行预测。在实际运用中,AR模型必须满足弱平稳性的要求,且必须具有自相关性,自相关系数小于0.5则不适用。
对于p阶自回归模型(AR),其递推公式形如:
$$
{\mathop{{y}}\limits^{︵}\mathop{{}}\nolimits_{{t}}=v+{\mathop{ \sum }\limits_{{i=1}}^{{q}}{ \beta \mathop{{}}\nolimits_{{i}}ℰ\mathop{{}}\nolimits_{{t-i}}+ℰ\mathop{{}}\nolimits_{{t}}}}}
$$
这个方程的形式本质上是自己与自己的历史做回归。比如,如果这个自回归模型为三项(也可以记为AR(3)的形式),那么就是以当天序列值为因变量,前面三天的序列值为自变量构建回归模型。在构建回归方程的过程中还会引入一个白噪声项,也就是取值服从标准正态分布的一个随机序列。
> 注意:时间序列中自回归的思想在后面也很有用。本质上自己和自己的历史去做回归也不一定局限在线性的模型形式,也可以用多项式去做一个广义的回归,还可以用支持向量机等构建一个机器学习模型。
### 8.3.2 MA模型
MA模型模型,全称移动平均模型移动平均模型(moving average model),是一种用于分析时间序列数据的统计模型。该模型的主要特点是当前的输出(或时间序列值)被视为过去白噪声误差的加权和。12
在MA模型中,通常包括一个常数项,用于表示时间序列的平均水平。这个模型假设时间序列的数据是平稳的,即它们的均值和方差保持不变,并且每个时间点的数据都是独立的。MA模型在信号处理信号处理、谱估计谱估计、金融分析金融分析和时间序列预测时间序列预测等领域有广泛应用。
MA模型的一个重要特征是它的自协方差函数自协方差函数和自相关系数自相关系数表现出特定的模式。自协方差函数在某个滞后阶数后趋于零,表现出q阶截尾的特性,而自相关系数则表现出q阶截尾的特性。这与自回归(AR)模型形成对比,后者的自相关系数表现出拖尾的特性。
在实际应用中,MA模型常与其他模型结合使用,如自回归滑动平均(ARMA)模型和自回归移动平均(ARIMA)模型,以适应更复杂的时间序列分析需求。
对于q阶移动平均模型(MA),其递推公式形如:
$$
{\mathop{{y}}\limits^{︵}\mathop{{}}\nolimits_{{t}}=v+{\mathop{ \sum }\limits_{{i=1}}^{{q}}{ \beta \mathop{{}}\nolimits_{{i}}ℰ\mathop{{}}\nolimits_{{t-1}}+ℰ\mathop{{}}\nolimits_{{t}}}}}
$$
> 需要注意的是,移动平均模型与自回归模型同样需要序列平稳作为先决条件。
### 8.3.3 ARMA模型和ARIMA模型
ARIMA模型是统计模型中最常见的一种用来进行时间序列预测的模型,只需要考虑内生变量而无需考虑其他外生变量,但要求序列是平稳序列或者差分后是平稳序列。ARIMA模型包含3个部分,即自回归(AR)、差分(I)和移动平均(MA)三个部分。对其每一个部分,都有其递推公式定义。当差分阶数为0的时候模型退化为ARMA模型。
①自回归模型:
对于p阶自回归模型(AR),其递推公式形如:
$$
{\mathop{{y}}\limits^{︵}\mathop{{}}\nolimits_{{t}}={\mathop{ \sum }\limits_{{i=1}}^{{p}}{ \alpha \mathop{{}}\nolimits_{{i}}y\mathop{{}}\nolimits_{{t-i}}+ℰ\mathop{{}}\nolimits_{{t}}+ \mu }}}
$$
②移动平均模型:
对于q阶移动平均模型(MA),其递推公式形如:
$$
{\mathop{{y}}\limits^{︵}\mathop{{}}\nolimits_{{t}}=v+{\mathop{ \sum }\limits_{{i=1}}^{{q}}{ \beta \mathop{{}}\nolimits_{{i}}ℰ\mathop{{}}\nolimits_{{t-i}}+ℰ\mathop{{}}\nolimits_{{t}}}}}
$$
③差分模型:
$$
{ \nabla \mathop{{}}\nolimits^{{ \left( d \right) }}y\mathop{{}}\nolimits_{{t}}= \nabla \mathop{{}}\nolimits^{{ \left( d-1 \right) }}y\mathop{{}}\nolimits_{{t}}- \nabla \mathop{{}}\nolimits^{{ \left( d-1 \right) }}y\mathop{{}}\nolimits_{{t-1}}}
$$
$$
{ \nabla \mathop{{}}\nolimits^{{ \left( 1 \right) }}y\mathop{{}}\nolimits_{{t}}=y\mathop{{}}\nolimits_{{t}}-y\mathop{{}}\nolimits_{{t-1}}}
$$
> 注意:时间序列可能自身不平稳,但是差分一次以后可能就平稳了。差分一次可能还不平稳,但差分两次就平稳了。所以定义了差分模型。但一般来讲ARIMA模型的差分次数不应该超过两次,超过两次的话应该考虑建模方法的问题了。
即由自回归模型阶数p、差分阶数d和移动平均阶数q就可以确定ARIMA模型的基本形式,我们将其简记为ARIMA(p,d,q)
$$
{ \nabla \mathop{{}}\nolimits^{{ \left( d \right) }}\mathop{{y}}\limits^{︵}\mathop{{}}\nolimits_{{t}}={\mathop{ \sum }\limits_{{i=1}}^{{p}}{ \alpha \mathop{{}}\nolimits_{{i}} \nabla \mathop{{}}\nolimits^{{ \left( d \right) }}y\mathop{{}}\nolimits_{{t-i}}+{\mathop{ \sum }\limits_{{i=1}}^{{q}}{ \beta \mathop{{}}\nolimits_{{i}}ℰ\mathop{{}}\nolimits_{{t-i}}+ℰ\mathop{{}}\nolimits_{{t}}}}}}}
$$
对于模型最优参数选择,我们使用AIC准则法(也叫赤池信息准则)分析。AIC准则由H. Akaike提出,主要用于时间序列模型的定阶,而AIC统计量的定义如下所示[13],其中L表示模型的最大似然函数:
$$
{AIC=2 \left( p+q+d \left) -2lnL\right. \right. }
$$
当两个模型之间存在较大差异时,差异主要体现在似然函数项,当似然函数差异不显著时,模型复杂度则起主要作用,从而参数个数少的模型是较好的选择。一般而言,当模型复杂度提高时,似然函数L也会增大,从而使AIC变小,但是参数过多时,根据奥卡姆剃刀原则,模型过于复杂从而AIC增大容易造成过拟合现象。AIC不仅要提高似然项,而且引入了惩罚项,使模型参数尽可能少,有助于降低过拟合的可能性。
另一个常用的准则叫贝叶斯信息准则。贝叶斯决策理论是主观贝叶斯派归纳理论的重要组成部分。是在不完全情报下,对部分未知的状态用主观概率估计,然后用贝叶斯公式对发生概率进行修正,最后再利用期望值和修正概率做出最优决策。它的公式为:
$$
{BIC= \left( p+q+d \left) ln\text{ }n-2lnL\right. \right. }
$$
实际上,相对于AIC准则,BIC准则可能更加常用一些。
## 8.5 灰色系统模型
> 灰色系统是指系统数据有一些是未知,有一些是已知。而灰色预测就是对含有已知和未知信息的系统进行预测,寻找数据变动规律,再建立相应的微分方程模型,来对事物发展进行预测。值得一提的是,灰色理论的创始人就是华科以前还叫华中理工大学那会的邓聚龙教授哦。
### 8.5.1 灰色预测模型
我们先从最基本的GM(1,1)模型说起。若已知数据列${x\mathop{{}}\nolimits^{{ \left( 0 \right) }}}$,进行一次累加生成新的数列。我们需要针对已知数据${x\mathop{{}}\nolimits^{{ \left( 0 \right) }}= \left( x\mathop{{}}\nolimits_{{ \left( 0 \right) }}^{{t}} \left( 1 \left) ,x\mathop{{}}\nolimits_{{ \left( 0 \right) }}^{{t}} \left( 2 \left) ,...,x\mathop{{}}\nolimits_{{ \left( 0 \right) }}^{{t}} \left( n \left) \right) \right. \right. \right. \right. \right. \right. }$进行累加,通过累加生成一阶累加序列:
$$
x\mathop{{}}\nolimits_{{ \left( 1 \right) }}^{{t}} \left( n \left) ={\mathop{ \sum }\limits_{{i=1}}^{{n}}{x\mathop{{}}\nolimits_{{ \left( 0 \right) }}^{{t}} \left( i \right) }}\right. \right.
$$
对序列均值化可以得到:
$$
{z\mathop{{}}\nolimits^{{ \left( 1 \right) }} \left( k \left) =\frac{{x\mathop{{}}\nolimits^{{ \left( 1 \right) }} \left( k \left) +x\mathop{{}}\nolimits^{{ \left( 1 \right) }} \left( k-1 \right) \right. \right. }}{{2}}\right. \right. }
$$
虽然是个离散的差分模型,我们当它连续,建立灰微分方程:
$$
{x\mathop{{}}\nolimits^{{ \left( 0 \right) }} \left( k \left) +az\mathop{{}}\nolimits^{{ \left( 1 \right) }} \left( k \left) =b,k=2,3,...m\right. \right. \right. \right. }
$$
以及对应的白化微分方程:
$$
{\frac{{dx\mathop{{}}\nolimits^{{ \left( 1 \right) }} \left( t \right) }}{{dt}}+az\mathop{{}}\nolimits^{{ \left( 1 \right) }} \left( t \left) =b,k=2,3,...,m\right. \right. }
$$
实际上这两个方程是等价的,通过求解白化微分方程并使用最小二乘法去拟合参数,可以得到方程的解为:
$$
{u= \left[ a,b \left] \mathop{{}}\nolimits^{{T}}\right. \right. }
$$
$$
{Y= \left[ x\mathop{{}}\nolimits^{{ \left( 0 \right) }} \left( 2 \left) ,x\mathop{{}}\nolimits^{{ \left( 0 \right) }} \left( 3 \left) ,...,x \left( 0 \left) \left( n \left) \left] \mathop{{}}\nolimits^{{T}}\right. \right. \right. \right. \right. \right. \right. \right. \right. \right. }
$$
$$
{B= \left[ \begin{array}{*{20}{l}}{-z\mathop{{}}\nolimits^{{ \left( 1 \right) }} \left( 2 \left) \text{ }\text{ }\text{ }1\right. \right. }\\{-z\mathop{{}}\nolimits^{{ \left( 1 \right) }} \left( 3 \left) \text{ }\text{ }\text{ }1\right. \right. }\\{M\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }M}\\{-z\mathop{{}}\nolimits^{{ \left( 1 \right) }} \left( n \left) \text{ }\text{ }\text{ }1\right. \right. }\end{array} \right] }
$$
> 注意:这一操作读者朋友可以自行在草稿纸上推导一下。
利用最小二乘法的矩阵模式,求解这个模型,可以得到方程
$$
{x\mathop{{}}\nolimits^{{ \left( 1 \right) }} \left( k+1 \left) = \left( x\mathop{{}}\nolimits^{{ \left( 0 \right) }} \left( 1 \left) -\frac{{b}}{{a}} \left) exp \left( -ak \left) +\frac{{b}}{{a}}\right. \right. \right. \right. \right. \right. \right. \right. }
$$
然后向后差分就可以得到原数据的预测值。
另一个更一般的模型是$GM(2,1)$模型。对于原始序列,得到一次累加序列$x1$和一次差分序列$x0'$,然后我们可以得到:
$$
{{x\mathop{{}}\nolimits^{{\text{'} \left( 0 \right) }} \left( k \left) +a\mathop{{}}\nolimits_{{1}}x\right. \right. }\mathop{{}}\nolimits^{{ \left( 0 \right) }} \left( k \left) +a\mathop{{}}\nolimits_{{2}}z\mathop{{}}\nolimits^{{ \left( 1 \right) }} \left( k \left) =b\right. \right. \right. \right. }
$$
这就是GM(2,1)的灰色方程。白化微分方程为:
$$
{\frac{{d\mathop{{}}\nolimits^{{2}}x\mathop{{}}\nolimits^{{ \left( 1 \right) }}}}{{dt\mathop{{}}\nolimits^{{2}}}}+a\mathop{{}}\nolimits_{{1}}\frac{{dx\mathop{{}}\nolimits^{{ \left( 1 \right) }}}}{{dt}}+a\mathop{{}}\nolimits_{{2}}x\mathop{{}}\nolimits^{{ \left( 1 \right) }}=b}
$$
同样去解这个方程即可。但求解过程较为复杂,所以我们以GM(1,1)为例实现灰色预测模型的建模:
```python
import numpy as np
import math
import matplotlib.pyplot as plt
history_data = [724.57,746.62,778.27,800.8,827.75,871.1,912.37,954.28,995.01,1037.2]
def GM11(history_data,forcast_steps):
n = len(history_data) # 确定历史数据体量
X0 = np.array(history_data) # 向量化
# 级比检验的部分可以自行补充
lambda0=np.zeros(n-1)
for i in range(n-1):
if history_data[i]:
lambda0[i]=history_data[i+1]/history_data[i]
if lambda0[i]np.exp(2/n+2):
print("GM11模型失效")
return -1
#累加生成
history_data_agg = [sum(history_data[0:i+1]) for i in range(n)]
X1 = np.array(history_data_agg)
#计算数据矩阵B和数据向量Y
B = np.zeros([n-1,2])
Y = np.zeros([n-1,1])
for i in range(0,n-1):
B[i][0] = -0.5*(X1[i] + X1[i+1])
B[i][1] = 1
Y[i][0] = X0[i+1]
#计算GM(1,1)微分方程的参数a和b
A = np.linalg.inv(B.T.dot(B)).dot(B.T).dot(Y)
a = A[0][0]
b = A[1][0]
#建立灰色预测模型
XX0 = np.zeros(n)
XX0[0] = X0[0]
for i in range(1,n):
XX0[i] = (X0[0] - b/a)*(1-math.exp(a))*math.exp(-a*(i))
#模型精度的后验差检验
e=sum(X0-XX0)/n
#求历史数据平均值
aver=sum(X0)/n
#求历史数据方差
s12=sum((X0-aver)**2)/n
#求残差方差
s22=sum(((X0-XX0)-e)**2)/n
#求后验差比值
C = s22 / s12
#求小误差概率
cobt = 0
for i in range(0,n):
if abs((X0[i] - XX0[i]) - e) < 0.6754*math.sqrt(s12):
cobt = cobt+1
else:
cobt = cobt
P = cobt / n
f = np.zeros(forcast_steps)
if (C < 0.35 and P > 0.95):
#预测精度为一级
print('往后各年预测值为:')
for i in range(0,forcast_steps):
f[i] = (X0[0] - b/a)*(1-math.exp(a))*math.exp(-a*(i+n))
print(f)
else:
print('灰色预测法不适用')
return f
f=GM11(history_data,20)
plt.plot(range(11,31),f)
plt.plot(range(1,11),history_data)
plt.show()
```

从图7.7可以看到,预测值相对于真实值的波动更加光滑。但从短期序列的预测情况而言,灰色预测方法的拟合程度还是相对比较高的。
> 注意:灰色预测一般适合小中期的序列预测,并且适合有指数上升趋势的序列波动。
### 7.5.2 灰色关联模型
灰色关联分析方法,是根据因素之间发展趋势的相似或相异程度,亦即“灰色关联度”,作为衡量因素间关联程度的一种方法。其思想很简单,确定参考列和比较列以后需要对数列进行无量纲化处理,然后计算灰色关联系数。这里我们使用均值处理法,即每个属性的数据除以对应均值:
$$
x( i) =\frac{{x(i)}}{\bar x(i)}
$$
灰色关联系数的定义如下:
$$
{ \zeta \mathop{{}}\nolimits_{{i}} \left( k \left) =\frac{{\mathop{{{min}}}\limits_{{s}}\mathop{{min}}\limits_{{t}}{ \left| {x\mathop{{}}\nolimits_{{0}} \left( t \left) -x\mathop{{}}\nolimits_{{s}} \left( t \right) \right. \right. } \right| }+ \rho \mathop{{{max}}}\limits_{{s}}\mathop{{max}}\limits_{{t}}{ \left| {x\mathop{{}}\nolimits_{{0}} \left( t \left) -x\mathop{{}}\nolimits_{{s}} \left( t \right) \right. \right. } \right| }}}{{{ \left| {x\mathop{{}}\nolimits_{{0}} \left( t \left) -x\mathop{{}}\nolimits_{{s}} \left( t \right) \right. \right. } \right| }+ \rho \mathop{{max}}\limits_{{s}}\mathop{{max}}\limits_{{t}}{ \left| {x\mathop{{}}\nolimits_{{0}} \left( t \left) -x\mathop{{}}\nolimits_{{s}} \left( t \right) \right. \right. } \right| }}}\right. \right. }
$$
其中ρ不超过0.5643时分辨力最好,这里为了简洁,可以取之为0.5。灰色关联度为关联系数在样本上的平均值,计算出每个属性的灰色关联度以后就可以进行分析。
**例7.1** 对表7.1中的属性进行灰色关联分析,分析x4-x7与x1之间的相关关系。
表7.1 例7.1使用数据
| **年份** | **x1** | **x2** | **x3** | **x4** | **x5** | **x6** | **x7** |
| -------- | ------ | ------ | ------ | ------ | ------ | ------ | ------ |
| 2007 | 22578 | 27569 | 4987 | 2567.7 | 267.98 | 1.5429 | 1.172 |
| 2008 | 5698 | 29484 | 5048 | 3131 | 348.51 | 1.8546 | 1.2514 |
| 2009 | 27896 | 31589 | 5129 | 3858.2 | 429.1 | 2.0369 | 1.0254 |
| 2010 | 29540 | 34894 | 5569 | 4417.7 | 541.29 | 2.2589 | 1.189 |
| 2011 | 31058 | 36478 | 5783 | 5158.1 | 647.25 | 2.4276 | 1.4213 |
| 2012 | 35980 | 38695 | 6045 | 6150.1 | 736.45 | 2.5678 | 1.5304 |
| 2013 | 39483 | 40746 | 6259 | 7002.8 | 850 | 2.8546 | 1.7421 |
可以写出以下代码:
```python
#导入相关库
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# 解决图标题中文乱码问题
import matplotlib as mpl
mpl.rcParams['font.sans-serif'] = ['SimHei'] # 指定默认字体
mpl.rcParams['axes.unicode_minus'] = False # 解决保存图像是负号'-'显示为方块的问题
#导入数据
data=pd.read_excel('huiseguanlian.xlsx')
# print(data)
#提取变量名 x1 -- x7
label_need=data.keys()[1:]
# print(label_need)
#提取上面变量名下的数据
data1=data[label_need].values
print(data1)
[m,n]=data1.shape #得到行数和列数
data2=data1.astype('float')
data3=data2
ymin=0
ymax=1
for j in range(0,n):
d_max=max(data2[:,j])
d_min=min(data2[:,j])
data3[:,j]=(ymax-ymin)*(data2[:,j]-d_min)/(d_max-d_min)+ymin
print(data3)
# 绘制 x1,x4,x5,x6,x7 的折线图
t=range(2007,2014)
plt.plot(t,data3[:,0],'*-',c='red')
for i in range(4):
plt.plot(t,data3[:,2+i],'.-')
plt.xlabel('year')
plt.legend(['x1','x4','x5','x6','x7'])
plt.title('灰色关联分析')
plt.show()
# 得到其他列和参考列相等的绝对值
for i in range(3,7):
data3[:,i]=np.abs(data3[:,i]-data3[:,0])
#得到绝对值矩阵的全局最大值和最小值
data4=data3[:,3:7]
d_max=np.max(data2)
d_min=np.min(data2)
a=0.5 #定义分辨系数
# 计算灰色关联矩阵
data4=(d_min+a*d_max)/(data4+a*d_max)
xishu=np.mean(data4, axis=0)
print(' x4,x5,x6,x7 与 x1之间的灰色关联度分别为:')
print(xishu)
```

图7.8为不同的属性与x1之间的灰色关联分析图。属性x4-x7与x1之间的灰色关联系数分别为0.95294652 0.92674346 0.9004367 0.80079348,相比于后面会讲到的皮尔逊相关系数,灰色关联分析更适合时间序列的关联分析探究。
## 7.6 组合投资问题的一些策略
> 股票序列能够相对精准地进行预测,而预测以后我们又能做什么呢?成功预测以后的结果对我们有什么价值吗?当然有。这个价值就可以让我们基于价格变化去合理安排组合投资策略,使得投资的收益最大。
### 7.6.1 股票序列与投资组合
为什么需要组合投资?如果你玩过股票(当然,我是不碰的)就会知道一个道理:“永远不要把鸡蛋放在同一个篮子里”。学会分散你的资产配置很重要,你会把你的资产分一些到不动产,分一些到古玩字画,分一些到黄金,分一些到石油,再分一些去买股票……这样某一方面亏了其他方面可以帮忙赚回来。几十年前有这样一批赌徒,他们将自己的全部身家都压在了澳门或者拉斯维加斯的赌场身上,最后一夜之间倾家荡产,这样的例子我见过,读者即使没见过看电视应该也看到过。学会分散你的资产会使你的投资不至于亏空的那么难看。
假设你现在持有的本金为单位1,在市场上进行投资选股你需要做的事情有两个:第一,从一大片股票当中选择你眼里的潜力股去投资;第二,为你的每支股票要投多少确定一个比例。你的目标很简单,是希望在下次套现的时候股票能够赚麻,所以你的投资策略与你的周期有关。比如你买股票一周以后就套现,或者一年以后再观察,是两种不同的策略。从上帝视角来看,这一问题理应使用动态规划去建模;但从现实情况出发,没人能够知道股票一周以后涨多少或者跌多少,你只能基于时间序列方法进行预测。预测出来以后怎么做呢?靠的是投资组合策略。
> 注意:这几个投资方法是在实战中最基础的方法,但风险预测同样是投资过程中重要的一环。风险并不一定是随时间的一个常量哟!
现在我以2022年美赛C题为例,现在就考虑黄金和比特币两种产品做组合投资。
### 7.6.2 马科维兹均值方差模型
马科维茨均值-方差理论被广泛用于解决最优投资组合选择问题。该理论主要通过研究各资产的预期收益、方差和协方差来确定最优投资组合。这也是第一次将数理统计方法引入投资组合理论。
马科维兹理论认为,股票的风险和收益可以通过一支股票时间序列的统计特性来描述。其中,风险可以由股票的方差或VaR来进行描述,但本质上还是通过方差来描述一支股票的风险。风险越大,赔本或者盈利的幅度也就越大,这也就像赌石中的“一刀豪宅一刀命根”。而收益则通过期望来衡量,也就是收益的平均水平越大、越稳定于一个较高的平均水平我们越开心,因为稳赚不赔。但是在计算收益的时候要注意,是套现的时候手持股票的价格与当时投资的时候投入成本的一个差额。
基于马科维兹均值方差模型,我们进行如下建模:
对于风险函数:
$$
{D \left( w\mathop{{}}\nolimits_{{1}}r\mathop{{}}\nolimits_{{b}}+w\mathop{{}}\nolimits_{{2}}r\mathop{{}}\nolimits_{{g}} \left) =w\mathop{{}}\nolimits^{{T}}{ \sum {w= \left[ \begin{array}{*{20}{l}}{ \sigma \mathop{{}}\nolimits_{{2}}^{{1}}}&{ \sigma \mathop{{}}\nolimits_{{12}}}\\{ \sigma \mathop{{}}\nolimits_{{12}}}&{ \sigma \mathop{{}}\nolimits_{{2}}^{{2}}}\end{array} \right] }}\right. \right. }
$$
其中,${ \sigma \mathop{{}}\nolimits_{{2}}^{{1}}\text{ } \sigma \mathop{{}}\nolimits_{{2}}^{{2}}}$分别代表波动率,${ \sigma \mathop{{}}\nolimits_{{2}}^{{12}}\text{ }}$代表协方差,
$$
{ \sigma \mathop{{}}\nolimits_{{12}}= \rho \sigma \mathop{{}}\nolimits_{{1}}\text{ } \sigma \mathop{{}}\nolimits_{{2}}}
$$
对于收益函数,若考虑在第二天就将比特币和黄金全部套现,那么套现折算减去当日购买价格和交易额,所得即为预期收益:
$$
{E \left( w\mathop{{}}\nolimits_{{1}}r\mathop{{}}\nolimits_{{b}}+w\mathop{{}}\nolimits_{{2}}r\mathop{{}}\nolimits_{{g}} \left) = \left( 1- \alpha \mathop{{}}\nolimits_{{1}} \left) \left( e\mathop{{}}\nolimits^{{r\mathop{{}}\nolimits_{{b}} \left( t \right) }}-1 \left) w\mathop{{}}\nolimits_{{1}}+ \left( 1- \alpha \mathop{{}}\nolimits_{{2}} \left) \left( e\mathop{{}}\nolimits^{{r\mathop{{}}\nolimits_{{g}} \left( t \right) }}-1 \left) w\mathop{{}}\nolimits_{{2}}- \left( \alpha \mathop{{}}\nolimits_{{1}}w\mathop{{}}\nolimits_{{1}}+ \alpha \mathop{{}}\nolimits_{{2}}w\mathop{{}}\nolimits_{{2}} \right) \right. \right. \right. \right. \right. \right. \right. \right. \right. \right. }
$$
那么模型形式为:
$$
{\mathop{{min}}\limits_{{w}}D \left( w,r \right) }
$$
$$
{\mathop{{max}}\limits_{{w}}E \left( w,r \right) }
$$
$$
{s.t.{ \left\{ {\begin{array}{*{20}{l}}{w\mathop{{}}\nolimits_{{1}}+w\mathop{{}}\nolimits_{{w}} \le 1}\\{-B \le w\mathop{{}}\nolimits_{{1}} \le 1}\\{-G \le w\mathop{{}}\nolimits_{{2}} \le 1}\end{array}}\right. }}
$$
这是一个多目标规划问题。为使问题简化,我们引入乘子:
$$
{\mathop{{min}}\limits_{{w}}f= \tau D \left( w,r \left) -E \left( w,r \right) \right. \right. }
$$
$$
{s.t.{ \left\{ {\begin{array}{*{20}{l}}{w\mathop{{}}\nolimits_{{1}}+w\mathop{{}}\nolimits_{{w}} \le 1}\\{-B \le w\mathop{{}}\nolimits_{{1}} \le 1}\\{-G \le w\mathop{{}}\nolimits_{{2}} \le 1}\end{array}}\right. }}
$$
对这一问题进行求解即可得到对应的策略w。
### 7.6.3 最大夏普比率模型
1990年诺贝尔经济学奖得主威廉·夏普,认为当投资者建立一个风险投资组合,他们至少应该需要投资回报达到团队220718116页25无风险投资,或更多,在此基础上,他提出了夏普比率。夏普比率是可以同时考虑回报和风险的三个经典指标之一。夏普比率的目的是计算一个投资组合每单位总风险将产生多少超额回报。如果夏普比率为正,则表示基金的回报率高于波动风险;如果为负,则表示基金的操作风险大于回报率。夏普比率越高,投资组合就越好。
夏普比率是一种综合考虑风险和收益的指标,它被定义为:
$$
{Sharp \left( w,r \left) =\frac{{E \left( w,r \left) -r\mathop{{}}\nolimits_{{f}}\right. \right. }}{{\sqrt{{D \left( w,r \right) }}}}\right. \right. }
$$
其中,${r\mathop{{}}\nolimits_{{f}}}$为无风险利率,通常取0.04作为市场估计值。此时优化问题变为:
$$
{\mathop{{min}}\limits_{{w}}-Sharp \left( w,r \right) }
$$
$$
{s.t.{ \left\{ {\begin{array}{*{20}{l}}{w\mathop{{}}\nolimits_{{1}}+w\mathop{{}}\nolimits_{{w}} \le 1}\\{-B \le w\mathop{{}}\nolimits_{{1}} \le 1}\\{-G \le w\mathop{{}}\nolimits_{{2}} \le 1}\end{array}}\right. }}
$$
可以看到本质上这个问题还是把风险和收益二者进行了一个综合。此时求解问题是一个非有理函数,可通过数值方法得到其最优解。
### 7.6.4 风险平价模型
风险平价是由爱德华·钱博士在2005年提出的。风险平价是一种资产配置哲学,它为投资组合中的不同资产分配相等的风险权重。风险平价的本质实际上是假设各种资产的夏普比率在长期内往往是一致的,以找到投资组合的长期夏普比率的最大化。
对于比特币和黄金的风险贡献率,可以分别计算为:
$$
{ \left\{ {\begin{array}{*{20}{l}}{P\mathop{{}}\nolimits_{{1}}=1-\frac{{w\mathop{{}}\nolimits_{{2}}^{{2}} \sigma \mathop{{}}\nolimits_{{2}}^{{2}}}}{{D \left( w,r \right) }}}\\{P\mathop{{}}\nolimits_{{2}}=1-\frac{{w\mathop{{}}\nolimits_{{2}}^{{1}} \sigma \mathop{{}}\nolimits_{{2}}^{{1}}}}{{D \left( w,r \right) }}}\end{array}}\right. }
$$
为使两个序列风险尽可能一致,我们构造这样一个规划模型:
$$
{\mathop{{min}}\limits_{{w}}f= \left( P\mathop{{}}\nolimits_{{1}}-P\mathop{{}}\nolimits_{{2}} \left) \mathop{{}}\nolimits^{{2}}\right. \right. }
$$
$$
{s.t.{ \left\{ {\begin{array}{*{20}{l}}{w\mathop{{}}\nolimits_{{1}}+w\mathop{{}}\nolimits_{{w}} \le 1}\\{-B \le w\mathop{{}}\nolimits_{{1}} \le 1}\\{-G \le w\mathop{{}}\nolimits_{{2}} \le 1}\end{array}}\right. }}
$$
最优的组合投资策略也就只需要对这个规划进行求解就可以了。
> 注意:这些规划本质也是可以用MATLAB去计算的,读者朋友可以结合2022MCM的C题试一试这些方法的MATLAB解。
## 7.7 马尔可夫模型
最后一节,我们针对离散时间序列讲讲马尔可夫模型。但马尔可夫模型也可以用于做连续数据的预测。
### 8.7.1 一些马尔可夫模型的概念
一个马尔科夫链是离散时间的随机过程,系统的下一个状态仅仅依赖当前的所处状态,与在它之前发生的事情无关。写成表达式就是:
$$
{P \left( X\mathop{{}}\nolimits_{{t+1}} \left| X\mathop{{}}\nolimits_{{t}},X\mathop{{}}\nolimits_{{t-1}},...,X\mathop{{}}\nolimits_{{t-k}} \left) =P \left( X\mathop{{}}\nolimits_{{t+1}} \left| X\mathop{{}}\nolimits_{{t}} \right) \right. \right. \right. \right. }
$$
马氏定理是指对于一个非周期马尔科夫链有状态转移矩阵P,有:
$$
{\mathop{{lim}}\limits_{{n \to \infty }}P\mathop{{}}\nolimits^{{n}}={ \left[ {\begin{array}{*{20}{l}}{ \pi \left( 1 \right) }&{ \pi \left( 2 \right) }&{L}&{ \pi \left( j \right) }\\{ \pi \left( 1 \right) }&{ \pi \left( 2 \right) }&{L}&{ \pi \left( j \right) }\\{M}&{M}&{L}&{M}\\{ \pi \left( 1 \right) }&{ \pi \left( 2 \right) }&{L}&{ \pi \left( j \right) }\end{array}\begin{array}{*{20}{l}}{L}\\{L}\\{L}\\{L}\end{array}} \right] }}
$$
$$
{ \pi \left( j \left) ={\mathop{ \sum }\limits_{{i=0}}^{{ \infty }}{ \pi \left( i \left) P\mathop{{}}\nolimits_{{ij}}\right. \right. }}\right. \right. }
$$
$$
{\mathop{ \sum }\limits_{{i=0}}^{{ \infty }}{ \pi \mathop{{}}\nolimits_{{i}}=1}}
$$
而细致平稳定理是说:若对于非周期马尔可夫链,
$$
{ \pi \left( j \left) ={\mathop{ \sum }\limits_{{i=0}}^{{ \infty }}{ \pi \left( i \left) P\mathop{{}}\nolimits_{{ij}}\right. \right. }}\right. \right. }
$$
我们就说${ \pi \mathop{{}}\nolimits_{{i}} \left( x \right) }$是马尔可夫链的平稳分布。上式被称为细致平稳条件。这两个定理是马尔可夫模型中最根本的两个定理。
马尔可夫随机场就是概率无向图模型,它是一个可以用无向图表示联合概率分布。假设有一个联合概率分布P(Y),其中Y代表一组随机变量,该联合概率分布可以由无向图来表示,图中的每一个节点表示的是Y中的一个随机变量,图中的每条边表示的两个随机变量之间的概率依赖关系,那么这个联合概率分布P(Y)怎么样才能构成一个马尔可夫随机场呢?答案是:联合概率分布P(Y)满足成对马尔可夫性、局部马尔可夫性和全局马尔可夫性这三个中的任意一个。
隐马尔可夫模型通常用一个五元组${ \lambda = \left( N,M, \pi ,A,B \right) }$定义:
- 模型的状态数${q\mathop{{}}\nolimits_{{t}} \in { \left\{ {S\mathop{{}}\nolimits_{{1}},S\mathop{{}}\nolimits_{{2}},...,S\mathop{{}}\nolimits_{{N}}} \right\} }}$。
- 模型观测值数${O\mathop{{}}\nolimits_{{t}} \in { \left\{ {V\mathop{{}}\nolimits_{{1}},V\mathop{{}}\nolimits_{{2}},...,V\mathop{{}}\nolimits_{{M}}} \right\} }}$。
- 状态转移概率${a\mathop{{}}\nolimits_{{ij}}=P \left( q\mathop{{}}\nolimits_{{t+1}}=S\mathop{{}}\nolimits_{{j}} \left| q\mathop{{}}\nolimits_{{t}}=S\mathop{{}}\nolimits_{{i}} \right) \right. }$。
- 观察概率${b\mathop{{}}\nolimits_{{jk}}=P \left( O\mathop{{}}\nolimits_{{k}}=V\mathop{{}}\nolimits_{{k}} \left| q\mathop{{}}\nolimits_{{k}}=S\mathop{{}}\nolimits_{{j}} \right) \right. }$。
- 初始状态概率${ \pi = \left( \pi \mathop{{}}\nolimits_{{1}}, \pi \mathop{{}}\nolimits_{{2}},..., \pi \mathop{{}}\nolimits_{{N}} \left) , \pi \mathop{{}}\nolimits_{{i}}=P \left( q\mathop{{}}\nolimits_{{i}}=S\mathop{{}}\nolimits_{{i}} \right) \right. \right. }$。
在隐马尔可夫模型中存在这样几个经典问题:
- 已知模型参数计算某个序列的概率:使用前向后向算法。
- 已知参数寻找最可能产生某一序列的隐含状态:使用维特比算法。
- 已知输出寻找最可能的状态转移与概率:使用鲍姆-韦尔奇算法
### 8.7.2 马尔可夫模型的实现
隐马尔可夫模型(Hidden Markov Model, HMM)是一个强大的工具,用于模拟具有隐藏状态的时间序列数据。HMM广泛应用于多个领域,如语音识别、自然语言处理和生物信息学等。在处理HMM时,主要集中于三个经典问题:评估问题、解码问题和学习问题。三个问题构成了使用隐马尔可夫模型时的基础框架,使得HMM不仅能够用于模拟复杂的时间序列数据,还能够从数据中学习和预测。
#### 1、评估问题
在隐马尔可夫模型(Hidden Markov Model, HMM)的应用中,评估问题是指确定一个给定的观测序列在特定HMM参数下的概率。简而言之,就是评估一个模型生成某个观测序列的可能性有多大。模型评估问题通常使用前向算法解决。前向算法是一个动态规划算法,它通过累积“前向概率”来计算给定观测序列的概率。前向概率定义为在时间点`t`观察到序列的前`t`个观测,并且系统处于状态`i`的概率。算法的核心是递推公式,它利用前一时刻的前向概率来计算当前时刻的前向概率。
```python
import numpy as np
# 定义模型参数
states = {'Rainy': 0, 'Sunny': 1}
observations = ['walk', 'shop', 'clean']
start_probability = np.array([0.6, 0.4])
transition_probability = np.array([[0.7, 0.3], [0.4, 0.6]])
emission_probability = np.array([[0.1, 0.4, 0.5], [0.6, 0.3, 0.1]])
# 观测序列,用索引表示
obs_seq = [0, 1, 2] # 对应于 'walk', 'shop', 'clean'
# 初始化前向概率矩阵
alpha = np.zeros((len(obs_seq), len(states)))
# 初始化
alpha[0, :] = start_probability * emission_probability[:, obs_seq[0]]
# 递推计算
for t in range(1, len(obs_seq)):
for j in range(len(states)):
alpha[t, j] = np.dot(alpha[t-1, :], transition_probability[:, j]) * emission_probability[j, obs_seq[t]]
# 序列的总概率为最后一步的概率之和
total_prob = np.sum(alpha[-1, :])
print("Forward Probability Matrix:")
print(alpha)
print("\nTotal Probability of Observations:", total_prob)
```
#### 2、解码问题
在隐马尔可夫模型(Hidden Markov Model, HMM)中,解码问题是指给定一个观测序列和模型参数,找出最有可能产生这些观测的隐状态序列。这个问题的核心是如何从已知的观测数据中推断出隐含的状态序列,这在许多应用场景中非常有用,如语音识别、自然语言处理、生物信息学等。解决这一问题最常用的算法是维特比算法,一种动态规划方法,它通过计算并记录达到每个状态的最大概率路径,从而找到最可能的状态序列。
```python
import numpy as np
def viterbi(obs, states, start_p, trans_p, emit_p):
"""
Viterbi Algorithm for solving the decoding problem of HMM
obs: 观测序列
states: 隐状态集合
start_p: 初始状态概率
trans_p: 状态转移概率矩阵
emit_p: 观测概率矩阵
"""
V = [{}]
path = {}
# 初始化
for y in states:
V[0][y] = start_p[y] * emit_p[y][obs[0]]
path[y] = [y]
# 对序列从第二个观测开始进行运算
for t in range(1, len(obs)):
V.append({})
newpath = {}
for cur_state in states:
# 选择最可能的前置状态
(prob, state) = max((V[t-1][y0] * trans_p[y0][cur_state] * emit_p[cur_state][obs[t]], y0) for y0 in states)
V[t][cur_state] = prob
newpath[cur_state] = path[state] + [cur_state]
# 不更新path
path = newpath
# 返回最终路径和概率
(prob, state) = max((V[len(obs) - 1][y], y) for y in states)
return (prob, path[state])
# 定义状态、观测序列及模型参数
states = ('Rainy', 'Sunny')
observations = ('walk', 'shop', 'clean')
start_probability = {'Rainy': 0.6, 'Sunny': 0.4}
transition_probability = {
'Rainy' : {'Rainy': 0.7, 'Sunny': 0.3},
'Sunny' : {'Rainy': 0.4, 'Sunny': 0.6},
}
emission_probability = {
'Rainy' : {'walk': 0.1, 'shop': 0.4, 'clean': 0.5},
'Sunny' : {'walk': 0.6, 'shop': 0.3, 'clean': 0.1},
}
# 应用维特比算法
result = viterbi(observations,
states,
start_probability,
transition_probability,
emission_probability)
print(result)
```
#### 3、学习问题
理解隐马尔可夫模型(HMM)的模型学习问题关键在于确定模型参数,以最大化给定观测序列的出现概率。解决这一学习问题的常用方法是鲍姆-韦尔奇算法,这是一种迭代算法,通过交替执行期望步骤(E步骤)和最大化步骤(M步骤)来找到最大化观测序列概率的参数。E步骤计算隐状态的期望值,而M步骤则更新模型参数以最大化观测序列的概率。这一过程会持续重复,直至满足一定的收敛条件,如参数变化量低于特定阈值或达到预设的迭代次数。通过这种方式解决学习问题,我们可以获得一组能够很好解释给定观测数据的模型参数,这表明模型能够捕捉到观测数据中的统计规律,用于生成观测序列、预测未来观测值或识别新观测序列中的模式。
```python
import numpy as np
from hmmlearn import hmm
# 假设我们有一组观测数据,这里我们随机生成一些数据作为示例
# 实际应用中,你应该使用真实的观测数据
n_samples = 1000
n_components = 3 # 假设我们有3个隐状态
obs_dim = 2 # 观测数据的维度,例如二维的观测空间
# 随机生成观测数据
np.random.seed(42)
obs_data = np.random.rand(n_samples, obs_dim)
# 初始化GaussianHMM模型
# 这里我们指定了n_components隐状态数量和covariance_type协方差类型
model = hmm.GaussianHMM(n_components=n_components, covariance_type='full', n_iter=100)
# 使用观测数据训练模型
# 注意:实际应用中的数据可能需要更复杂的预处理步骤
model.fit(obs_data)
# 打印学习到的模型参数
print("学习到的转移概率矩阵:")
print(model.transmat_)
print("\n学习到的均值:")
print(model.means_)
print("\n学习到的协方差:")
print(model.covars_)
```
深入理解隐马尔可夫模型(HMM)处理的三种经典问题——评估问题、解码问题和学习问题,可以将通过一个完整的示例来展示这些问题的应用和解决方案。如有一个简单的天气模型,其中的状态(隐藏状态)包括晴天(Sunny)和雨天(Rainy),观测(可见状态)包括人们的三种活动:散步(Walk)、购物(Shop)和清洁(Clean)。可以使用HMM来处理评估问题、解码问题和学习问题。
```python
from hmmlearn import hmm
import numpy as np
# 定义模型参数
states = ["Rainy", "Sunny"]
n_states = len(states)
observations = ["walk", "shop", "clean"]
n_observations = len(observations)
start_probability = np.array([0.6, 0.4])
transition_probability = np.array([
[0.7, 0.3],
[0.4, 0.6],
])
emission_probability = np.array([
[0.1, 0.4, 0.5],
[0.6, 0.3, 0.1],
])
# 创建模型
model = hmm.MultinomialHMM(n_components=n_states)
model.startprob_ = start_probability
model.transmat_ = transition_probability
model.emissionprob_ = emission_probability
model.n_trials = 4
# 观测序列
obs_seq = np.array([[0], [1], [2]]).T # 对应于观测序列 ['walk', 'shop', 'clean']
# 计算观测序列的概率
logprob = model.score(obs_seq)
print(f"Observation sequence probability: {np.exp(logprob)}")
# 继续使用上面的模型参数和观测序列
# 使用Viterbi算法找出最可能的状态序列
logprob, seq = model.decode(obs_seq, algorithm="viterbi")
print(f"Sequence of states: {', '.join(map(lambda x: states[x], seq))}")
# 假设我们只有观测序列,不知道模型参数
obs_seq = np.array([[0], [1], [2], [0], [1], [2]]).T # 扩展的观测序列
# 初始化模型
model = hmm.MultinomialHMM(n_components=n_states, n_iter=100)
model.fit(obs_seq)
# 打印学习到的模型参数
print("Start probabilities:", model.startprob_)
print("Transition probabilities:", model.transmat_)
print("Emission probabilities:", model.emissionprob_)
```
### 8.7.3 条件随机场
条件随机场(Conditional Random Field)是 **马尔可夫随机场 + 隐状态**的特例。
区别于生成式的隐马尔可夫模型,CRF是**判别式**的。CRF 试图对多个随机变量(代表状态序列)在给定观测序列的值之后的条件概率进行建模:
给定观测序列${X= \left\{ X\mathop{{}}\nolimits_{{1}},X\mathop{{}}\nolimits_{{2}},...,X\mathop{{}}\nolimits_{{n}} \right\} }$,以及隐状态序列${Y= \left\{ y\mathop{{}}\nolimits_{{1}},y\mathop{{}}\nolimits_{{2}},...,y\mathop{{}}\nolimits_{{n}} \right\} }$的情况下,构建条件概率模型${P \left( Y \left| X \right) \right. }$。若随机变量Y构成的是一个马尔科夫随机场,则 ${P \left( Y \left| X \right) \right. }$为CRF。
借助 `sklearn_crfsuite` 库实现
```python
import sklearn_crfsuite
X_train = ...
y_train = ...
crf = sklearn_crfsuite.CRF(
algorithm='lbfgs',
c1=0.1,
c2=0.1,
max_iterations=100,
all_possible_transitions=True
)
crf.fit(X_train, y_train)
# CPU times: user 32 s, sys: 108 ms, total: 32.1 s
# Wall time: 32.3 s
y_pred = crf.predict(X_test)
metrics.flat_f1_score(y_test, y_pred,
average='weighted', labels=labels)
```
## 本章小结
本章主要介绍了时间序列预测模型与投资选股模型。时间序列的核心就是如何预测,对不同体量的序列数据有不同的预测方法,例如:小体量数据我们会用回归建模;中体量数据我们开始使用灰色系统建模;中大型体量数据我们便开始使用ARIMA系列模型建模;等到数据体量为大体量数据时,我们便会使用机器学习尤其是神经网络建模,这在下一章当中就会介绍。另外,当我们能够相对准确预测未来时,我们认为还不够,因为我们最终的目的是根据我们预测的结果进行投资选股的决策,这就又是一个优化问题。在这个优化问题中,我们需要综合考虑风险和收益,想办法让收益最大风险最小,于是便有了马科维兹均值方差理论、最大夏普模型和风险平价模型。二者构成了在量化投资中的理论基石,是一个不可分的整体。
###
================================================
FILE: docs/CH9/第九章-机器学习与统计模型.md
================================================
第9章 机器学习与统计模型
> 内容:@若冰(马世拓)
>
> 审稿:@邢硕
>
> 排版&校对:@牧小熊(聂雄伟)
这一章重点探讨统计模型和机器学习模型,两个大的主题都建立在数据的基础之上,所以要熟练掌握对数据的处理与分析。实际上,机器学习本身就是统计模型的延伸,是在大数据背景下传统统计方法捉襟见肘了,所以才考虑引入机器学习。在学习过程中,大家会接触到大量的算法,一方面要理解算法的基本原理,另一方面又要能针对实际问题进行灵活应用。
注意:本章内容是比较难以学习的一个章节,希望各位能够耐心去看完这一章。另外,大家还要注意,机器学习方法虽然应用非常广泛,但也有它的局限性,并不是所有数学建模问题都适合使用机器学习来处理。
## 9.1 统计分布与假设检验
本节将重点探讨几种常见的假设检验以及它们的分析方法。但在介绍这些假设检验之前,还需要引入重要的概念:统计量与统计分布。在第6章当中已经介绍了一些数据统计的方法,但那些都只是浅层的统计,只能反映现象而无法解释背后的真伪和因果。为了对数据现象背后的真伪性做出判断,这才引入了统计量和统计分布的概念,从而有了一些假设检验方法。
### 9.1.1 统计量与常见统计分布
概率和统计是一对孪生兄弟,前者通过已知总体的所有相关参数信息,来计算特定事件发生的概率;后者则是在总体未知的情况下,通过采样观察样本状态来反推估计总体。因此,尽管概率论中也有随机变量和分布律,数理统计中的统计分布与其仍然存在较大差别。但数理统计中统计量和分布的概念仍然需要借助概率论中的工具来研究。
为何要针对样本构造统计量?举一个简单的例子:比如现在已知一系列随机变量X 1 , X 2 ,...Xn 都服从同一个分布P (X ),那么针对总体的分布就可以求解出它对应的数学期望EX 和方差DX 。但如果从这个总体中抽样了十个随机变量,这时就从理论上的分布映射为了实际上的采样。对这十个随机变量的观测值求均值,它理论上能够逼近EX ,但往往不会等于EX 。这就是区别,我们更想要探究的就是如何通过统计的方法,对实际的、可观测的样本构造统计量让它能够用于合理估计未知的、“理论上的”、总体的分布。
那么聊完统计量,统计分布又是什么呢?事实上,统计分布其实是一组样本观测值的总体表现,可以用它们所属的区间来表示。举一个例子,这里有一个8层的道尔顿板,小球可以从道尔顿板的顶部落下,那么小球每一次都可以有向左或者向右两种走法,概率均为0.5。最终落到九个格点。那么对于一个小球而言,它落到第几个格点是一个随机变量,这个随机变量服从一个二项分布B(8,0.5),即:
$$
{{P \left( X=i \left) =C\mathop{{}}\nolimits_{{i}}^{{8}} \left( \frac{{1}}{{2}} \right) \right. \right. }\mathop{{}}\nolimits^{{8}}}
$$
很显然,小球应该是落到最中间的格子的概率最大。这是理论情况下,可以绘制出不同格点的概率的条形图。现在从这个分布中采样,采集100个小球,每个小球都会依照分布掉落到一个格子内。如图所示。

从图中可以看到,很显然,小球所呈现的条形图并不是一个对称图形,而理论上的概率条形图应该是严格对称的,这也就展示了:样本与总体并不完全一致。但如果仔细对比,可以发现,样本的频率分布直方图和理论上的概率条形图相差并不太大,因此可以用样本来估计总体。对于每一格而言,条形的高度反映了落在这一格点的小球数量或者频率。把这样的能够反映样本频率在不同区间内分布状况的图像叫频率分布直方图。而如果使用更大的道尔顿板,底部格点更多的话,所得到频率分布直方图也更加光滑、更加接近于理论上的概率分布,可以用一条曲线去拟合这个频率分布直方图。这样的曲线其实也就是概率密度曲线。
从上面的例子看来,实际样本的统计量也会呈现出特定的分布。但统计量的分布由于存在多个样本,它与单个随机变量的分布又是有着很大差异的。常见的统计分布包括下面四种:
- 正态分布:正态分布是最基本的统计分布之一。正态分布是一种概率分布,其特征为钟形曲线,且曲线关于均值对称。在统计学中,许多随机变量都服从或近似服从正态分布,如人的身高、考试分数等。正态分布具有三个主要性质:1)集中性,即曲线的峰值位于均值处;2)对称性,即曲线关于均值对称;3)均匀变动性,即正态分布曲线以均值为中心,向两侧均匀展开。在上面道尔顿板的例子中,如果道尔顿板非常大、小球数量非常多,这些小球的分布将会近似服从一个正态分布。影响正态分布的参数是总体的均值和方差,记一个服从正态分布的样本*X*为:
$$
{X\text{~}N \left( \mu , \delta \mathop{{}}\nolimits^{{2}} \right) }
$$
另外,正态分布的概率密度曲线是存在解析式的:
$$
{f=\frac{{1}}{{\sqrt{{2 \pi \sigma }}}}e\mathop{{}}\nolimits^{{-\frac{{ \left( x- \mu \left) \mathop{{}}\nolimits^{{2}}\right. \right. }}{{2 \sigma \mathop{{}}\nolimits^{{2}}}}}}}
$$
- 卡方分布:假设有n个独立的随机变量*X*1 , *X*2 , ..., *X*n ,每个随机变量都来自标准正态分布(均值为0,标准差为1),那么这n个随机变量的平方和除以n就服从自由度为n的卡方分布。形如:
$$
{ \wp \mathop{{}}\nolimits^{{2}} \left( n \left) = {\rm X} \mathop{{}}\nolimits_{{2}}^{{1}}+X\mathop{{}}\nolimits_{{2}}^{{2}}+...+X\mathop{{}}\nolimits_{{2}}^{{n}}\right. \right. }
$$
卡方分布的性质包括:1)随机变量取值范围为非负实数;2)随着自由度的增加,卡方分布趋近于正态分布;3)卡方分布具有可加性,即若随机变量相互独立,则它们的平方和服从卡方分布。常见统计量例如样本方差等都服从卡方分布。
- *t*-分布:*t*-分布是由一个服从标准正态分布的随机变量*X*和一个服从自由度为*n*的卡方分布的随机变量*Y*组合而来的。它的表达式形如:
$$
{t \left( n \left) =\frac{{X}}{{\sqrt{{\frac{{Y}}{{n}}}}}}\right. \right. }
$$
*t*分布具有以下性质:随着自由度的增加,*t*分布趋近于正态分布;*t*分布具有可加性,即若随机变量相互独立,则它们的*t*值之和仍服从*t*分布;对于不同的自由度,*t*分布的形状会发生变化,但总是关于其均值对称。*t*分布在统计学中有着广泛的应用,尤其是在小样本数据分析、方差分析、回归分析等领域。由于*t*分布对样本大小和方差的变化较为稳健,因此在实践中常常用来进行假设检验和置信区间的计算。同时,*t*分布也是构建其他统计量的基础,如Z分布、F分布等。
- F-分布:F分布是通过将两个正态分布的随机变量的比值进行标准化而得到的。具体来说,假设有两个正态分布的随机变量X和Y,它们的方差分别为$σ²x$和$σ²y$,且X和Y相互独立,那么随机变量X²/Y²就服从自由度为m和n的F分布,其中m和n分别为该F分布的第一个和第二个自由度。
$$
{F \left( n \left) =\frac{{X\mathop{{}}\nolimits_{{1}}/n\mathop{{}}\nolimits_{{1}}}}{{X\mathop{{}}\nolimits_{{2}}/n\mathop{{}}\nolimits_{{2}}}}\right. \right. }
$$
F分布具有以下性质:随着自由度的增加,F分布趋近于正态分布;F分布具有可加性,即若两个随机变量相互独立,则它们的F值之和仍服从F分布;对于不同的自由度,F分布的形状会发生变化,但总是关于其均值对称。F分布在统计学中主要用于方差分析和回归分析等领域。在方差分析中,通过比较组间方差和组内方差,可以检验不同组之间的差异是否显著。在回归分析中,通过计算决定系数R²,可以评估模型对数据的拟合程度。
事实上,不仅是整体分布,上面的四种分布都存在对应的概率密度曲线图。通过概率密度曲线图能够分析这些分布的性质。几种分布的概率密度曲线如图所示:

图中展示了正态分布、卡方分布、t分布和F分布四种分布的概率密度函数。正态分布曲线呈现出钟形形状,且关于均值对称。曲线下的面积表示概率,总面积为1。均值影响曲线对称轴,均值越大则曲线越偏右,而若标准差越大曲线最高点则越低。卡方分布曲线随着自由度的增加而逐渐趋近于正态分布。在自由度较小时,曲线呈现偏态特征,而在自由度较大时,曲线接近对称。随着自由度的增加,曲线的形状逐渐变得对称和稳定。t分布曲线随着自由度的增加而逐渐趋近于正态分布。在自由度较小时,曲线呈现出更宽的尾部和更尖的峰部,表现出更强的离散性。随着自由度的增加,曲线的形状逐渐变得平滑,并接近正态分布。F分布曲线在分母自由度较小或分子自由度较大时,曲线呈现出更窄的峰部和更长的尾部,表现出更强的离散性。随着分母自由度的增加,曲线的形状逐渐变得平滑。
### 9.1.2 正态性检验
正态性检验的目的是为了检测一组数据是否服从正态分布,是否表现出正态分布的特性。正态性检验的方法有很多,包括QQ图、KS检验、SW检验、JB检验等等。这里当然不可能把它们全部讲出来,但可以对一些常见方法进行简要介绍:
- Shapiro-Wilk检验是一种用于验证数据集是否符合正态分布的统计方法。该方法通过计算样本数据的顺序统计量,并比较这些观察值与理论正态分布的期望值之间的差异来进行评估。Shapiro-Wilk检验的核心理念在于,它假设数据集遵循正态分布。为了验证这一假设,该方法首先计算Shapiro-Wilk统计量W。这个统计量是一个衡量数据与正态分布拟合程度的指标,其基于实际观察值与理论正态分布期望值之间的差异。如果W值越接近1,则表明数据更符合正态分布。随后,Shapiro-Wilk统计量W与临界值进行比较。临界值是根据特定的显著性水平(通常为5%)和数据集的大小计算得出的。这一比较过程是判断数据是否服从正态分布的关键步骤。最终,根据统计量W与临界值的比较结果,可以得出结论。如果W值显著低于临界值,则可以拒绝零假设,这意味着数据不服从正态分布。相反,如果W值不低于临界值,则不能拒绝零假设,这表明数据可能服从正态分布。核心统计量为:
$$
W = \frac{{{{(\sum\limits_{i = 1}^n {{a_i}} {x_i})}^2}}}{{\sum\limits_{i = 1}^n {{{({x_i} - \bar x)}^2}} }}
$$
- K-S检验:K-S检验(Kolmogorov-Smirnov检验)是一种非参数检验方法,用于检验一个样本是否来自特定的概率分布。对于正态分布的检验,K-S检验通过比较样本数据的累计分布函数与理论正态分布的累计概率分布函数,来判断样本数据是否符合正态分布。首先,计算样本数据的累计分布函数,并与理论正态分布的累计概率分布函数进行比较。如果两个函数之间的最大偏差(D)在修正后小于临界值,则接受原假设,认为样本数据符合正态分布。否则,拒绝原假设,认为样本数据不符合正态分布。该方法在数据分析中广泛应用于正态分布的检验。核心统计量为:
$$
{a= \left[ a\mathop{{}}\nolimits_{{1}},a\mathop{{}}\nolimits_{{2}},...,a\mathop{{}}\nolimits_{{n}} \left] \mathop{{}}\nolimits^{{T}}=\frac{{ \mu \mathop{{}}\nolimits^{{T}}V\mathop{{}}\nolimits^{{-1}}}}{{\sqrt{{ \mu \mathop{{}}\nolimits^{{T}} \left( V\mathop{{}}\nolimits^{{-1}} \left) \mathop{{}}\nolimits^{{T}}V\mathop{{}}\nolimits^{{-1}} \mu \right. \right. }}}}\right. \right. }
$$
- J-B检验:J-B检验(Jarque-Bera检验)是一种用于检验数据是否服从正态分布的统计检验方法。它基于数据的偏度和峰度两个统计量,通过计算统计量的标准化值来判断数据是否符合正态分布。计算过程包括:首先,计算偏度S和峰度K以衡量数据分布的不对称性和尖锐程度。然后,根据这些值计算J-B统计量,它是偏度和峰度的标准化值之和。接下来,查找临界值表或使用软件计算临界值,将J-B统计量与临界值进行比较。如果J-B统计量大于临界值,则拒绝原假设(数据服从正态分布),认为数据不符合正态分布。如果J-B统计量小于临界值,则不能拒绝原假设,认为数据可能服从正态分布。需要注意的是,J-B检验是一种非参数检验方法,对数据分布的假设较少,因此在某些情况下可能比其他参数检验方法更为稳健。核心统计量为:
$$
JB = \sqrt {\frac{{n - 1}}{6}(S + K)}
$$
- QQ图:QQ图是一种直观观察数据是否服从正态性的方法。QQ图可以用于检验一组数据是否服从某一分布,或者检验两个分布是否服从同一个分布。如果QQ图呈现出直线趋势,且数据点大致分布在直线的周围,则说明数据比较接近正态分布。如果数据点呈现出弯曲趋势或分散分布,则说明数据可能偏离正态分布。在画QQ图时,应注意数据的样本量大小、异常值情况等因素,这些因素可能会影响QQ图的准确度。需要注意的是,QQ图是一种直观的图形工具,可以辅助判断数据的正态性,但不能完全准确地判断数据的分布情况。
我们可以通过下面一个例子展示如何去进行正态性检验。首先,通过numpy生成一组服从标准正态分布的样本,这里将样本量扩充到1000个:
```python
import numpy as np
import matplotlib.pyplot as plt
# 生成标准正态分布的数据
data = np.random.normal(0, 1, 1000)
#Python绘制QQ图的方法集成在statsmodels当中,通过如下方式调用:
import statsmodels.api as sm
import matplotlib.pyplot as plt
# 创建 Q-Q 图,并增加 45度线
fig = sm.qqplot(data, line='45')
plt.show()
```
得到QQ图如图所示:

从图中可以看到,蓝色散点表示样本取值与理论分布的关系,横坐标为理论分布的各个分位点,而纵坐标为样本分位点,它们近似分布在一条直线附近,因此可以初步判断它们服从正态分布。但这只是一种现象,是否真的服从正态分布还是要通过假设检验说明。
正态性检验的方法集成在scipy.stats当中。以Shapiro-Wilk检验为例,通过在scipy.stats中引入shapiro方法,对上述样本检验如下:
```python
import scipy.stats as st
# 执行Shapiro-Wilk正态性检验
statistic, p_value = st.shapiro(data)
# 输出检验结果
print("Shapiro-Wilk统计量:", statistic)
print("p-value:", p_value)
```
得到Shapiro-Wilk统计量等于 0.9973,概率值0.098>0.05,无法拒绝原假设。因此,认为数据data具备正态性。同样的,还可以进行K-S检验和J-B检验:
```python
statistic_1, p_value_1 = st.kstest(data,'norm')
# 输出检验结果
print("K-S统计量:", statistic_1)
print("p-value:", p_value_1)
statistic_2, p_value_2 = st.jarque_bera(data)
# 输出检验结果
print("J-B统计量:", statistic_2)
print("p-value:", p_value_2)
```
得到的结果概率也都超过0.05,认为数据是服从正态分布的。在kstest函数中,如果要使用它进行正态性检验,要在后面的参数里选择’norm’表明需要做的检验是正态性检验。
### 9.1.3 独立性检验
有一个典型的例子可以帮助大家理解独立性的问题:抽不抽烟和得不得肺癌的关系。二者不能说存在因果关系,因果关系是指牛顿第二定律一样:物体有合外力就会产生一个加速度,力是产生加速度的原因。但一个人即使抽烟也可能永远不得肺癌,有的人抽烟抽到了一百多岁活得非常健康,有人不抽烟不喝酒却年纪轻轻就得了癌症。因此二者存在的是相关关系而非因果关系。当然这里不是劝大家都去抽烟啊,这里只是举个简单的例子。
卡方独立性检验统计的是离散的相关关系,因为得不得肺癌只有两类离散取值:得或者不得,抽不抽烟也只有两类取值:抽或者不抽。两两组合就有四类人群。统计不同的人群可以列出一个列联表,构造的统计量也是一个服从卡方分布的统计量,因其服从卡方分布所以叫它卡方独立性检验。例如,现在在医院某科室里面调查发现,抽烟的患者有556人,其中得肺癌的有324个人;不抽烟的患者有260人,其中得肺癌的有98人。那么,在进行假设检验的时候,首先要给出原假设:
H0:抽烟和得肺癌是独立的。
根据数据可以列出这样一个列联表:
||抽烟者|不抽烟者|总计|
| - | - | - | - |
|得肺癌者|324|98|422|
|不得肺癌者|232|162|394|
|总计|556|260|816|
通过列联表,构造统计量:
$$
\begin{array}{l}
{K^2} = \sum\limits_i {\sum\limits_j {\frac{{{{({x_{ij}} - {E_{ij}})}^2}}}{{{E_{ij}}}}} } \\
{E_{ij}} = \frac{{\left( {\sum\limits_i {{x_{ij}}} } \right)\left( {\sum\limits_j {{x_{ij}}} } \right)}}{{\sum\limits_i {\sum\limits_j {{x_{ij}}} } }}
\end{array}
$$
这个统计量服从卡方分布,自由度为(*a*-1)(*b*-1),带入*a=b*=2可得自由度为1。通过与对应卡方分布进行对比可以得到概率值,若概率小于置信度(通常可以用0.05)则原假设H0被推翻,接受备择假设:
H1:抽烟与得肺癌是相关的。
在Python中,scipy.stats提供了有关的计算模块调用,上述问题用代码实现如下:
```python
import numpy as np
from scipy.stats import chi2_contingency
data=np.array([[324,98],[232,162]])
# 执行卡方独立性检验
stat, p, dof, expected = chi2_contingency(data)
# 输出结果
print('卡方统计量:', stat)
print('自由度:', dof)
print('期望频数:', expected)
print('p值:', p)
```
结果显示,检验统计量服从自由度为1的卡方分布。得到的概率值是10的-8次方数量级,已经非常接近0了,所以认为二者独立是一个小概率事件。故推翻原假设接受备择假设,换而言之二者是高度相关的。备择假设也就是原假设的反面。
注意:假设检验看的实际上是个概率值,这个概率的测度主要是0.05、0.01和0.001三个测度,小于0.05就认为原假设是一个小概率事件可以推翻了。如果在统计类表格里面看到三颗星,那是概率小于0.001;两颗星是0.01;一颗星是0.05。
我们总结一下,处理一个假设检验问题的一般步骤包括:分析检验问题的类型,确定原假设和备择假设,构造检验统计量,计算检验统计量以及对应概率,判断概率是否在置信区间内,若满足则接受原假设;若不满足则推翻原假设,接受备择假设。
### 9.1.4 两组样本的差异性检验
两组样本的差异性检验可以通过*t*-检验实现。T-检验分为三种不同的类型:单样本t检验、配对样本t检验和独立样本t检验。其中,单样本t检验解决的是检验正态性的问题,这里主要讨论配对样本t检验和独立样本t检验。
- 单样本t检验:单样本t检验用于检验一个样本均值是否显著不同于某个已知的或假设的数值。其基本原理基于t分布理论,通过比较样本均值与已知或假设的数值之间的差异,推断这种差异是由随机误差还是本质差异引起的。如果样本均值与已知或假设的数值相差较大,且这种差异在统计学上是显著的,那么我们可以拒绝接受样本均值与已知或假设的数值相等的原假设。
- 配对样本t检验适合检验同一组样本在进行某一操作前后的状态差异。例如,想探究一笼健康的小白鼠在注射某神经亢奋药物前后的神经活跃性差异,这种情况就适合使用配对样本t检验。因为在注射药物前后,小白鼠始终是同一批小白鼠,没有新的老鼠混进来也没有老鼠逃走,它们只是需要被检测注射药物前后两种不同的状态。配对样本t检验需要构造的统计量为:
$$
t = \frac{{\sqrt n \overline {\left( {{x_1} - {x_2}} \right)} }}{{\sigma ({x_1} - {x_2})}}
$$
- 独立样本t检验适合检验两组不同的样本在某一方面的表现差异。例如,想探究高一学生2000米跑成绩和高三学生2000米跑的成绩差异,这种情况就适合使用独立样本t检验。因为高一学生和高三学生是两批不同的人,它们的男女比例不同、年龄不同、平均身高体重不同……甚至连人数都是不一样的!区别独立样本和配对样本一个最根本的特征就是样本是同一批还是不同的两批,而最直观的特征就是两组样本的数量是否相同。数量不同的两组样本不能构成配对样本。独立样本t检验需要构造的统计量为:
$$
t = \frac{{{{\bar x}_1} - {{\bar x}_2}}}{{\frac{{{\sigma _1}}}{{\sqrt {{n_1}} }} + \frac{{{\sigma _2}}}{{\sqrt {{n_2}} }}}}
$$
差异性检验的原假设H0认为:两类样本之间没有差异。同样可以通过Python中的scipy.stats包调用函数求解,使用ttest_rel进行配对样本t检验,使用ttest_ind进行独立样本t检验。例如,现在有三组数据要进行差异性检验,它们的使用方法形如:
```python
from scipy.stats import ttest_rel,ttest_ind
import numpy as np
# 假设有三组样本的数据
data1 = np.random.normal(10,5,100)
data2 = np.random.normal(12,6,100)
data3 = np.random.normal(10,5,55)
# 执行配对样本t检验
t_statistic_1, p_value_1 = ttest_rel(data1, data2)
# 输出结果
print('t统计量:', t_statistic_1)
print('p值:', p_value_1)
# 执行独立样本t检验
t_statistic_2, p_value_2 = ttest_ind(data1, data3)
# 输出结果
print('t统计量:', t_statistic_2)
print('p值:', p_value_2)
```
在上面的案例中,data1和data2构成了配对样本关系,data1和data3是服从同一正态分布的两组独立样本。对二者同样分析概率大小就可以确定是否有差异了。一般来讲,如果概率比较小(至少小于0.05)可以认为拒绝原假设接受备择假设,认为两类样本有差异。可以看到,配对样本t检验的概率为0.019<0.05,两组数据存在明显的均值差异;而独立样本t检验概率为0.42,不拒绝原假设,认为独立样本之间不存在显著差异。
注意:t检验之前需要分析数据是否满足方差齐性,这一检验通过莱文检验实现。感谢清华大学杜创一同学的科普。
莱文检验(Levene's test)是一种用于检验两组数据方差是否相等的统计检验方法。它的基本思想是比较两组数据的变异程度,如果两组数据的方差相等,那么它们的变异程度应该相似。如果两组数据的方差不相等,则它们的变异程度可能会有显著差异。在进行t检验之前进行莱文检验的原因是,t检验的前提假设是两个样本的方差相等。如果这个假设不成立,t检验的结果可能会受到方差不等的影响,导致错误的结论。因此,在进行t检验之前,需要进行莱文检验来检验两个样本的方差是否相等。代码形如:
```python
from scipy.stats import levene
# 执行莱文检验
w, p = levene(data1, data2)
# 输出结果
print('W统计量:', w)
print('p值:', p)
```
在上述代码中,stats.levene()函数用于执行莱文检验。该函数返回两个值:W统计量和p值。W统计量越小,说明两组数据的方差越接近相等;p值越接近0,说明拒绝原假设(即两组数据的方差相等)的证据越强。通常情况下,如果p值小于设定的显著性水平(例如0.05),则认为两组数据的方差不相等,需要进一步分析或处理。
### 9.1.5 方差分析与事后多重比较
前面的例子介绍的是两组样本之间的差异性分析,那么如果样本存在多组又应该如何处理呢?当不同样本存在不同操作的时候又应该如何处理呢?考虑这样一种情况:现在突然爆发了一种传染病,得了这种传染病的人会腹泻。医院里面有一批患者,医生将这群人分成了两组,一群人通过营养液补充体力和水分;一群人除了注射营养液以外还需要服用由中药成分A和成分B制成的胶囊,发现实验组患者的腹泻频率比对照组低。那这个低是偶然导致的,还是两味药材在一起作用真的有用呢?如果有用,究竟是A在起作用,还是B在起作用,还是二者配方以后一同起作用呢?这些问题就需要交给方差分析来解答。
方差分析(ANOVA)可以用于两个样本及以上样本之间的比较,并可以用于分离各有关因素并估计其对总变异的作用,以及分析因素间的交互作用。方差分析可以用于均数差别的显著性检验、分离各有关因素并估计其对总变异的作用、分析因素间的交互作用和方差齐性检验等。
方差分析的基本思想是通过比较不同组别之间的平均数差异来确定这些差异是否显著。它利用方差度量每个组别的变异,并将这些变异分解为组内和组间变异。通过比较组间变异和组内变异的比例,可以判断不同组别之间的平均数差异是否具有统计意义。如果组间变异的比例较大,说明组别之间的差异显著。反之,如果组内变异的比例较大,说明组别之间的差异不显著,可能是由于随机误差的影响。因此,方差分析可以帮助我们确定不同因素对实验结果的影响程度,进一步揭示数据背后的规律和机制。方差可以分解成三个部分:*Q*=*Q*1 +*Q*2 +*Q*3 。其中,*Q*1 是指多个控制变量单独作用引起的平方和,可以用来描述每个变量单独是否存在影响;*Q*2 是指多个控制变量交互作用引起的离差平方和,可以用来描述变量之间是否存在协同效应或交互;*Q*3 则是随机扰动,用于反映结果受随机影响的程度。
在Python中,可以通过scipy.stats.f_oneway函数实现方差分析。例如,将患者分为四组,对照组仅使用营养液,实验组1除了营养液外服用药剂A,实验组2除了营养液外服用药剂B,实验组3除了营养液外使用AB的复方药物。四个组的患者数量相同,对这四个组的分析可以参考如下代码:
```python
import numpy as np
from scipy.stats import f_oneway
# 创建数据
np.random.seed(0) # 设置随机种子以保证结果可复现
group1 = np.random.normal(loc=5, scale=1, size=10) # 只接受营养液
group2 = np.random.normal(loc=4, scale=1, size=10) # 接受营养液并服用成分A
group3 = np.random.normal(loc=3, scale=1, size=10) # 接受营养液并服用成分B
group4 = np.random.normal(loc=2, scale=1, size=10) # 接受营养液并服用成分A和B
groups = [group1, group2, group3, group4]
group_names = ['只接受营养液', '接受营养液并服用成分A', '接受营养液并服用成分B', '接受营养液并服用成分A和B']
# 执行ANOVA
F_stat, p_value = f_oneway(*groups)
print('F统计量:', F_stat)
print('p值:', p_value)
```
最终得到p值是小于0.05的,说明存在显著性差异。但是究竟是怎样的显著性差异,作用机理是什么?A和B谁更有效?它们是否存在协同作用?这个结果显然不尽人意。为了获得更详细的分析,还可以使用statsmodels中的方差分析。方差分析在statsmodels可以通过OLS实现,但在此之前,需要对数据进行一个整理:
```python
import pandas as pd
import statsmodels.api as sm
from statsmodels.formula.api import ols
# 记录是否服用A
a=[0]*10+[1]*10+[0]*10+[1]*10
# 记录是否服用B
b=[0]*20+[1]*20
groups=np.array(groups).flatten()
data={'A':a,'B':b,'groups':groups}
data=pd.DataFrame(data)
```
此时,数据data中包含了三列:是否服用A,是否服用B和腹泻次数。可以用以下代码创建方差分析模型并分析:
```python
# 创建方差分析模型
model = ols('groups ~ A + B + A*B', data=data).fit()
# 分析方差分析模型
anova_results = sm.stats.anova_lm(model, typ=2)
print(anova_results)
```
在上面的代码中,statsmodels通过ols执行方差分析或线性回归,在模型中输入字符串即可创建模型。模型的因变量为groups,想要调查的因素有三点:A是否影响,B是否影响,AB之间是否存在交互。使用的数据为data。通过anova_lm分析拟合后的模型可以得到结果:

可以看到,A和B的最后一列概率值都是小于0.05的,说明两种药剂都会对腹泻有显著影响效果,但AB的交互项概率为0.91977>0.05,说明实验结果不能证明AB存在显著的协同效应,二者不能相互促进。
想要获得方差检验详细分析的方式还有一个,就是事后多重比较。事后多重比较是指在方差分析之后,对各组之间的差异进行两两比较的方法。在方差分析中,我们只能判断各组均值是否存在显著差异,但无法确定具体是哪些组之间存在差异。通过事后多重比较,我们可以进一步确定哪些组之间的差异是显著的,从而更准确地了解数据之间的具体差异。
事后多重比较的方法有多种,以下列举其中一些常用的方法:
- LSD法(最小显著差数法):该方法适用于在专业上有特殊意义的样本均数间的比较,主要用于探索性分析。LSD法是一种灵敏度较高的方法,通过逐一比较各组之间的差异,确定是否存在显著差异。首先,对所有组进行两两比较,计算每对组之间的差值和显著性水平。然后,根据显著性水平确定差异是否显著,并记录下所有显著的差异。LSD法的思想是尽可能发现所有可能的显著差异,因此具有较高的灵敏度。但是,由于它对假阳性错误较为敏感,因此在结果解释时应谨慎。
- Bonferroni法:这是一种保守的方法,主要用于验证性分析。Bonferroni法的思想是通过调整显著性水平来控制假阳性错误的发生率。由于较为保守,Bonferroni法可能会遗漏一些真正的显著差异,但可以避免由于过度敏感导致的假阳性错误。
- Turkey法:Turkey法是一种相对灵敏的方法,用于多个样本均数之间的全面比较。首先,计算各组之间的差值,并确定差值的分布情况。然后,根据差值的分布情况确定哪些组之间存在显著差异。Turkey法还可以对差异的方向进行判断,即确定是哪些组的均值高于或低于其他组。Turkey法的思想是通过全面比较各组之间的差异来发现真正的显著差异。它要求比较的样本容量相差不大,因此在结果解释时应考虑样本容量的影响。
- Dunnett-t检验:Dunnett-t检验的思想是通过比较每个试验组与对照组的差值来发现显著差异。这种方法特别适用于验证性分析,可以在一个研究中对多个试验组与对照组进行比较。
- SNK-q检验:SNK-q检验与LSD法相似,适用于多个样本均数之间的全面比较。首先,对所有组进行两两比较,计算每对组之间的差值和显著性水平。然后,根据显著性水平判断是否存在显著差异。与LSD法不同的是,SNK-q检验采用q值而非t值进行比较,以控制假阳性错误的发生率。SNK-q检验的思想是通过全面比较各组之间的差异来发现真正的显著差异,并控制假阳性错误的发生率。与LSD法相比,SNK-q检验更为保守,因此可能遗漏一些真正的显著差异。
在Python中,可以使用scipy库中的scipy.stats.multicomp模块来实现事后多重比较。例如,对上面的例子,可以将它与方差分析结合起来,整合形成新的代码:
```python
import numpy as np
from scipy.stats import f_oneway
from scipy.stats import tukey_hsd
# 创建数据
group1 = np.random.normal(loc=5, scale=1, size=10) # 只接受营养液
group2 = np.random.normal(loc=4, scale=1, size=10) # 接受营养液并服用成分A
group3 = np.random.normal(loc=3, scale=1, size=10) # 接受营养液并服用成分B
group4 = np.random.normal(loc=2, scale=1, size=10) # 接受营养液并服用成分A和B
groups = [group1, group2, group3, group4]
group_names = ['只接受营养液', '接受营养液并服用成分A', '接受营养液并服用成分B', '接受营养液并服用成分A和B']
# 执行ANOVA
F_stat, p_value = f_oneway(*groups)
# TurkeyHSD法进行事后多重比较
# 进行事后多重比较
mc_result = tukey_hsd(group1,group2,group3,group4)
# 输出结果
print(mc_result)
```
可以得到结果:

可以看到两组之间的详细差异比较情况。同样通过概率值可以发现哪些组不显著。这里组别1和组别2之间差异不显著,组别2和组别3之间差异不显著。
### 9.1.6 相关系数
相关系数的计算其实并不能称作一种检验,它的本质是针对两组连续值样本之间相关性做出计算和分析。但中学接触到的相关系数是有条件的,数据必须是正态或近似正态并且有一定程度的线性关系,不然不能用。相关系数其实常见的有三种:皮尔逊相关系数,斯皮尔曼相关系数和肯德尔相关系数。它们的相关系数计算方法如下:
- 对于皮尔逊相关系数:
$$
\rho = \frac{{Cov(X,Y)}}{{\sqrt {D(x)} \sqrt {D(Y)} }}
$$
- 对于斯皮尔曼相关系数,它就不要求数据必须正态,是可以有偏的。在X和Y序列中得到每个元素的排名并作差得到新序列d:
$$
\rho = 1 - \frac{{6\sum\limits_{i = 1}^n {d_i^2} }}{{n({n^2} - 1)}}
$$
- 肯德尔相关系数是一个用来测量两个随机变量相关性的统计值。(统计量的构造)
如果数据是一个矩阵想求的是每两列之间的相关系数矩阵,可以按如下形式调用:
```python
data.corr(method='pearson') # 或者换成spearman或kendall
```
一般认为相关系数大于0.7时就具备比较强的相关性了,0.9以上相关性非常强。但是否真的存在相关关系仍然可以通过假设检验的手法去证明。
## 9.2 回归不止用于预测
在第6章中已经学习过了线性回归的基本知识,可以把线性回归视作一种预测模型。但事实上,线性回归的作用远远不止是预测,它还可以揭示数据背后的“效应”,是可以反映变量之间的关联性、揭示作用机理的一种常用模型,尤其是在人文社会科学中有广泛应用。
### 9.2.1 从统计的视角看线性回归
第6章里面研究线性回归是把它当作一种拟合的方法,但其实线性回归不仅可以用来做回归任务还可以研究其中的效应。线性回归中每一项权重都是可以构造统计量进行假设检验的。事实上,线性回归模型和方差分析是可以统一的,这也是为什么statsmodels中ols函数既可以用来做方差分析,还可以用来做线性回归。
为什么说线性回归和方差分析是具备统一性的呢?线性回归和方差分析在统计学中都是用来分析因变量和自变量之间的关系,但它们从不同的角度出发。线性回归关注因变量与自变量之间的线性关系,通过回归方程来描述这种关系。而方差分析则关注不同组之间的差异,通过比较不同组的均值来分析组间差异。从结构上来看,线性回归和方差分析其实非常相似。如果将方差分析的模型扩展到包含自变量,就可以看到不同组之间的差异实际上是由自变量的不同水平所引起的。因此,线性回归和方差分析都是描述因变量与自变量之间关系的工具,只是侧重点不同。它们在一定条件下可以相互转化,具备统一性。
线性回归是可以揭示变量之间的作用关系的。它所提供的不仅是一个单纯的折算方法,更可以通过权重分析不同变量的影响大小。通过R2分数也就是拟合优度来评价这一线性模型对数据的拟合效果好坏,R2越高则表明模型效果越好,自变量和因变量之间关系更加明确。而每一项权重的正负可以揭示正相关还是负相关,对应的检验统计量服从t分布,通过对检验统计量的分析可以反映这一个变量的作用是显著的还是不显著的,从而决定是否需要在模型中剔除掉这一变量。尽管从数学原理的角度线性回归是一个朴素的方法,但在人文社会科学中这是一种被广泛应用且无可匹敌的通用方案,很多的回归都基于线性回归进行。如果是在人文社科领域需要数学建模,读者更应该关注简单的OLS如何解释一个好问题。
在Python中,如果想要获得具体的统计分析结果,可以使用statsmodel构造线性回归模型。例如,随机生成一组样本数据,对应的线性回归分析代码如下:
```python
import numpy as np
import statsmodels.api as sm
import pandas as pd
# 创建一些模拟数据
np.random.seed(0)
X = np.random.rand(100, 3) # 100个样本,每个样本有3个特征
y = X[:, 0] + 2 * X[:, 1] + np.random.rand(100) # 因变量由前两个特征线性生成,并加入一些噪声
# 将数据转换为Pandas DataFrame格式
df = pd.DataFrame(X)
df['y'] = y
# 使用statsmodels进行线性回归
X = sm.add_constant(df[df.columns[:-1]]) # 添加常数项作为截距项
model = sm.OLS(df['y'], X)
results = model.fit()
# 输出回归结果
print(results.summary())
```
在上面的例子中可以看到,因变量y只与第1, 2个自变量有关,但我们想在方程中引入三个自变量。尽管方程的拟合效果比较好,第三个自变量也不应该在方程中出现。如何证明它应当被剔除呢?这里使用了statsmodels的OLS方法。通过add_constant对自变量引入常数项,表明在线性回归方程中是存在常数项的;构造OLS模型并使用fit方法训练模型,然后展示其统计结果如下所示:

从上面的结果来看,R2分数为0.851>0.7,变量存在较为强烈的线性相关关系,且F检验的统计量为1.48e-39<0.05,认为这一线性回归是显著的。针对每一项而言,常数项和前两个变量的系数coef都通过了t检验,唯独第三个变量的系数在进行t检验时概率为0.472>0.05。说明在调整结构时应当将第三个变量剔除出去。
> 注意:这里使用的是大写的OLS而非小写的ols,这两个方法是不同的。但线性回归也可以使用小写的ols进行改写:
```python
import numpy as np
import statsmodels.formula.api as sm
import pandas as pd
# 创建一些模拟数据
np.random.seed(0)
x = np.random.rand(100, 3) # 100个样本,每个样本有3个特征
y = x[:, 0] + 2 * x[:, 1] + np.random.rand(100) # 因变量由前两个特征线性生成,并加入一些噪声
# 使用statsmodels进行线性回归
model = sm.ols(formula='y ~ x', data=df)
results = model.fit()
# 输出回归结果
print(results.summary())
```
思考一个问题,如果有一些样本与其他数据的偏差特别大,这种数据在方程中会不会造成模型出问题?可以参考下面的一个案例,如图所示:

在正常的样本点中混入了两个记号为叉的异常数据。理论上正确的回归方程应该忽略异常样本,也就是图中的黑色虚线;但线性回归方程无法排出这两个异常数据对总体均方误差的影响,就会导致回归直线斜率增大变为红色的异常回归方程。咦?那这个问题怎么解决?
还有一种典型的情况是:如果回归模型在训练的数据上有非常非常小的误差,但预测的时候与实际值的偏差非常之大,这种现象就被称为发生了过拟合。这种情况需要使用正则化手段去对模型进行修正。两种经典的正则化方法包括LASSO回归和岭回归。
LASSO回归使用的是一阶正则:
$$
J(w,b) = \frac{1}{n}\sum\limits_{i = 1}^n {{{({y_i} - w{x_i} - b)}^2}} + \lambda \left\| w \right\|
$$
一阶正则中因为使用的一阶范数(也就是绝对值)难以求微分(类比向量模长的计算方法),所以还有一个叫岭回归的方法,使用的是二阶正则函数:
$$
J(w,b) = \frac{1}{n}\sum\limits_{i = 1}^n {{{({y_i} - w{x_i} - b)}^2}} + \lambda {\left\| w \right\|^2}
$$
于是,得到了LASSO回归和岭回归的定义。它们的本质是对均方误差函数的修正。这里正则化系数是可以自己定义调节的。
statsmodels中对正则化的实现是通过fit_regularized函数实现的。可以参考下面的例子:
```python
import numpy as np
import statsmodels.api as sm
x = np.random.rand(100, 3) # 100个样本,每个样本有3个特征
y = x[:, 0] + 2 * x[:, 1] + 3 * x[:, 2] + np.random.rand(100) # 因变量由前两个特征线性生成,并加入一些随机噪声
# LASSO回归
lasso = sm.OLS(y, x).fit_regularized(alpha=0.1, L1_wt=1) # L1_wt为0进行岭回归,1则进行LASSO回归
print("LASSO Coefficients: ", lasso.params)
# 岭回归(这里不需要额外指定,因为默认是L2正则化)
ridge = sm.OLS(y, x).fit_regularized(alpha=0.1, L1_wt=0)
print("Ridge Coefficients: ", ridge.params)
```
通过对模型的fit_regularized方法实现带正则化的训练。参数alpha为正则化参数,可以自行调节,L1_wt为进行1阶或2阶正则化的选择。若不填写,则默认进行岭回归。另外,在sklearn包中,也在linear_model里集成了Ridge和LASSO方法。
### 9.2.2 逻辑回归
在前面的学习中,我们已经看到了:当自变量是离散变量而因变量是连续变量的时候可以通过方差分析来分析自变量操作的不同对因变量分组均值水平的影响。但如果反过来,自变量是连续变量而因变量是离散变量的时候,又该如何处理呢?以最简单的二分变量为例,若自变量是一系列的连续变量而因变量是一个0-1变量,此时如果做线性回归则容易出现斜率暴增的现象。线性回归方程的预测值将会超过(0,1)的范围。这种情况下用什么办法好呢?
在对这个问题感到焦灼之前,不妨先来看一个函数的性质。这个函数和第3章中接触到逻辑斯蒂模型有关系,我们叫它逻辑斯蒂函数,也可以叫它sigmoid函数:
$$
y = \frac{1}{{1 + {{\rm{e}}^{ - x}}}}
$$
它的图像如图所示:

它的值域处于0到1之间,而且是连续可导的,这是一条重要性质。如果把x的范围再拉大一点,就会发现从0到1的上升过程还是相当迅速的。并且我们发现它有一条很有趣的性质——可以与双曲正切函数互换:
$$
sigmoid(x) = 0.5*\tanh (x) + 0.5
$$
之所以研究这个函数,是因为联想到可以经过数据变换使用各种非线性函数拟合数据,那么从回归问题到分类问题的映射也可以遵循类似的想法。经过sigmoid函数的变换,一个服从全体实数域的数就会被映射到(0,1)之间,这样就规约了范围。又注意到一个(0,1)的数可以抽象为概率,就可以用sigmoid函数的值表示预测结果为1的概率。
逻辑回归是一种用于解决二分类问题的统计学习方法。给定一组特征x和对应的标签y(其中y只有两种可能的取值,例如0和1),逻辑回归通过构建一个逻辑函数来预测标签y的概率。这个逻辑函数通常采用Sigmoid函数的形式,可以将任何实数映射到(0,1)区间,从而表示概率。数学上,逻辑回归的模型可以表示为:
$$
P(y = 1|X) = \frac{1}{{1 + {{\rm{e}}^{ - z}}}}
$$
其中,$z = {W^T}X$,w是权重向量,b是偏置项。通过调整权重和偏置项,逻辑回归可以学习从输入特征到目标标签的映射关系。
想要求解这样一个回归,常规的最小二乘法在这里是行不通的。因为因变量离散的条件下,均方误差难以衡量模型的好坏。这时就要借助信息论里面交叉熵的概念。交叉熵是衡量两个概率分布之间的差异的一种指标,经常用于机器学习和信息论中。在监督学习中,我们通常使用训练数据集中的真实标签和模型预测的概率来计算交叉熵。
我们把*y*想象成样本被分类为正类的概率,那么*y*=*P*(*y*=1|*X*)。分类的平均概率实际上就是交叉熵:
$$
P({y_i}|{X_i},W) = {y_i}P({y_i} = 1|X) + (1 - {y_i})P({y_i} = 0|X)
$$
通过对概率表达式的映射还原并代入似然函数就得到了目标函数:
$$
J(w) = \sum\limits_{i = 1}^m {( - {y_i}w{X_i} + \ln (1 + {{\rm{e}}^{wX}}))} ,w* = \arg \min J(w)
$$
求解*J*(*w*)的梯度符号解需要对交叉熵函数求导,所以求解符号解很困难,故在实际工程中通常使用梯度下降法求目标函数的数值解。
在Python中,逻辑回归的实现封装在sklearn.linear_models当中,关于它的使用方法会在9.5节中进行更详细的说明。
### 9.2.3 分位数回归
考虑这么一个场景:假设一家银行正在考虑给一个个人贷款,他们关心的一个重要因素是借款人的信用评分。银行希望预测借款人的违约概率,即在一定时间内无法偿还贷款的概率。在传统回归分析中,银行可能会使用线性回归来预测违约概率。但是,这种方法存在一些问题:首先,和前面一样,信用评分容易受到异常值影响,大多数人的信用是良好的,它们的评分极高,这就导致贷款模型容易给出总体的“乐观估计”;其次,信用评分和违约概率之间的关系可能不是线性的。也就是说,信用评分增加并不总是导致违约概率的降低。可能存在一个阈值,当信用评分超过这个阈值时,违约概率的变化率会降低。
为了解决这些问题,银行可以使用分位数回归。分位数回归可以提供更加全面和准确的预测,并且能够更好地处理异常值和复杂的非线性关系。通过分位数回归,银行可以了解在不同信用评分分位数下借款人的违约概率,这有助于更准确地评估贷款风险和制定更加合理的信贷政策。
分位数回归是一种更加宏观的回归方法,可以描述响应变量的全局特征,可以挖掘到更为丰富的信息。当我们把一组数据从小到大排列后,如果q分位数是m,那就意味着这组数据中有q*100%的数据是小于m的。分位数回归则是将线性回归与分位数的概念结合在一起。如果我们不通过复杂的数学公式来理解,简单来说,q分位数回归就是为了让拟合的线下面包含q*100%的数据点。比如说,0.25分位数回归线之下包含了25%的数据点。因此,分位数回归并不是像线性回归那样拟合一条曲线,而是可以拟合一簇曲线。如果不同分位数的回归系数不同,那就说明解释变量对不同水平的响应变量有不同的影响,我们可以通过这种方式了解解释变量对响应变量分位数的变化趋势的影响。
若对于数据集y ,q 分位点为mq ,则可以通过以下函数确定分位数回归方程:
$$
{J_y}(\tau ) = \mathop {\arg \min }\limits_{{m_q}} \left( {\sum\limits_{i:{y_i} > {m_q}} {\tau \left| {{y_i} - {m_q}} \right|} + \sum\limits_{i:{y_i} \le {m_q}} {\left( {1 - \tau } \right)\left| {{y_i} - {m_q}} \right|} } \right)
$$
本质上,这个损失函数如果改写为凸函数,可以等价于:
$$
{J_y}(\tau ) = \frac{1}{n}\left( {{{\sum\limits_{i:{y_i} > {m_q}} {\tau \left| {{y_i} - {m_q}} \right|} }^2} + {{\sum\limits_{i:{y_i} \le {m_q}} {\left( {1 - \tau } \right)\left| {{y_i} - {m_q}} \right|} }^2}} \right)
$$
从这里就可以看到,它的本质是加权的最小二乘法。q分位数以下的部分赋予更大的权重,以上的部分则赋予更小的权重,对其进行回归即可针对不同的分位点构造不同的方程,分析方程随着分位点的变化。
Python当中可以使用statsmodels执行分位数回归。例如,如果我们想要调查近二十年来医院数量对卫生技术人员从业数的影响,可以使用statsmodels.formula.api中提供的分位数回归函数进行分析。从国家统计局中收集有关数据并编写代码:
```python
import numpy as np
import matplotlib.pyplot as plt
from statsmodels.formula.api import quantreg
import pandas as pd
data = pd.read_csv('hospital_data.csv')
plt.scatter(data['hospital'],data['people'])
x=data['hospital']
# 分位数回归的参数估计
for q in np.arange(0.1,1,0.2):
mod = quantreg('people ~ hospital', data)
result = mod.fit(q=q) # 拟合模型
# 输出模型摘要信息,包括系数、置信区间等
v=result.params
y=v.Intercept+v.hospital*x
plt.plot(x,y,label='%.2f分位数'%q)
plt.legend()
plt.xlabel("医院数量(个)")
plt.ylabel("卫生技术人员从业数量(万人)")
plt.grid()
plt.show()
```
可以得到分位数回归的分析图如图所示:

可以看到,0.1分位数的回归方程和0.3分位数的回归方程有着截然不同。0.1分位数的回归方程斜率明显低于0.3分位数回归,0.5分位数回归方程实际上就是线性回归方程。0.1分位数回归在总体较为偏下的位置,通过斜率的变化可以明显发现其中出现了某个断点使得断点前后的斜率出现了较大差异。但总体而言,医院数量的增多会拉动对卫生技术人员的需求,只是需求量的大小增速在不同时间内也是不同的。这个断点反映的也就是在该时间出现了某宏观政策调控,使得需求量进一步拉大。
分位数回归的一个特点是允许对数据做截断处理。在固定阈值之外的数据点不会影响未被截断部分分位数的计算,这有助于在数据预处理时减少异常值或离群点的影响。当自变量之间存在高度共线性时,分位数回归的结果可能不稳定。在这种情况下,可以考虑使用主成分分析等方法来降低多重共线性的影响。
### 9.2.4 调节效应与中介效应
我们都听过这么一句话:努力就会有收获。但真的是越努力就收获越多吗?如果真的是这样,世界首富应该是一头驴。努力当然会带来收获,但收获的多少、正比还是反比与大环境有很重要的关系。在大环境好的情况下,谁更努力就会收获更多财富;但在大环境不好或者努力的方向错误的话,努力只能尽量让你亏损的少一些。那么我们就可以说,环境在努力对收获的作用中起到了调节效应,环境成为了调节变量。
调节效应是指一个变量如何增强或减弱另一个变量对因变量的影响。简单的说,环境可以使得努力对收获的效应增强或者减弱。在许多情境中,一个变量对另一个变量的影响可能并不是固定的,而是受到第三个变量的影响。了解这些调节效应可以帮助我们更深入地理解某个现象背后的机制,并针对不同的群体或情境制定更有针对性的策略。
为了探究调节效应的影响,假设环境变量为M,自变量为X,因变量为Y,通过构建一个线性回归方程探究交互项XM:
$$
Y = b + aX + cXM + dM
$$
如果c是显著的,则可以说观察到了显著的调节效应。在这种情况下,M增强或减弱了X对Y的影响。调节效应的效果用R2的差值表示,即:
$$
\begin{array}{l}
\Delta {R^2} = R_1^2 - R_0^2\\
R_1^2:Y = b + aX + cXM + dM\\
R_0^2:Y = b + aX + dM
\end{array}
$$
通过衡量拟合优度的提升程度衡量调节效应能够准确描述调节强度。
在早期人类研究艾滋病的时候,科学家最初认为艾滋病病毒会导致人类死亡。但后来有人通过实验提出另外一个猜想,艾滋病并不是直接导致死亡的罪魁祸首,而是先破坏人体的免疫系统,免疫系统被毁坏进而导致人类容易产生各种并发症而死亡。也就是说,当时对艾滋病病毒作用机理的猜想如图所示:

现在我们已经知道,艾滋病病毒是通过毁坏免疫系统进而导致并发症人才会死亡的,并不能直接致死。但是在当时,这两种方式都被认为是有道理的。就在这张图中,我们说HIV通过影响Immunity再间接影响die是一种中介效应,Immunity在当中充当了中介变量。
中介效应(Mediation Effect)是指在因变量和自变量之间,存在一个或多个中介变量(Mediator),这些中介变量起到传递自变量对因变量的影响作用。简单来说,中介效应描述了一个过程,在这个过程中,一个变量(中介变量)解释了其他两个变量(自变量和因变量)之间的关系。中介效应有助于深入理解事物之间的关系机制,解释自变量如何影响因变量,揭示隐藏的中间过程。它可以帮助我们更好地理解现象背后的原因和机制,提供更准确的预测和更有针对性的干预措施。
在中介效应中,存在以下一组回归关系:
$$
Y = {\alpha _0} + {\alpha _1}D + {\varepsilon _{{Y_1}}} \\
Y = {\beta _0} + {\beta _1}D + {\beta _2}M + {\varepsilon _{{Y_2}}} \\
M = {\gamma _0} + {\gamma _1}D + {\varepsilon _M}
$$
其中,*Y*是结果变量,*D*是处理变量,*M*是中介变量。其中,为*D*对*Y*的总效应,为*D*对*Y*的直接效应,为*D*对*Y*(经由中介变量*M*)的间接效应。因为中介变量与处理变量的相关性,直接效应与总效应产生差值,但间接效应相当于通过中介变量对原本的处理变量和结果变量关系进行补充,此时三者存在如下关系。
$$
{\alpha _1} = {\beta _1} + {\beta _2}{\gamma _1}
$$
如何正确区分调节效应与中介效应?想象你正在烹饪一道菜,调节效应就像火候。火候大,菜可能炒得太熟;火候小,菜可能还没炒透。火候(调节效应)影响菜的味道和熟度(因变量),但它并不解释为什么菜会有这样的味道和熟度(自变量)。中介效应则更像是调料。调料的种类和量影响菜的味道。它是连接烹饪方法和菜的味道之间的桥梁,解释了为什么会有这样的味道。简单说,调节效应关注的是“影响效果”,中介效应关注的是“作用机理”。中介效应能够描述变量之间作用的路径。
若使用Python去探究调节效应,其实本质上还是线性回归的扩展。可以通过下面一个例子看到如何使用OLS去进行调节效应与中介效应研究:
```python
import pandas as pd
import statsmodels.api as sm
import numpy as np
import matplotlib.pyplot as plt
# 生成一些模拟数据
X = np.random.normal(size=200)
M = np.random.normal(size=200)
Y = X + 2 * M + X * M + np.random.normal(size=200)
# 创建调节效应模型
X1 = pd.DataFrame({'X':X,'M':M,'XM':X*M})
X1 = sm.add_constant(X1)
adjust = sm.OLS(Y,X1)
result1 = adjust.fit()
X2 = pd.DataFrame({'X':X,'M':M})
X2 = sm.add_constant(X2)
adjust = sm.OLS(Y,X2)
result2 = adjust.fit()
if result1.pvalues[3]<0.05:
print("观察到了显著的调节效应")
print("引入交互项之前的R2=",result2.rsquared)
print("引入交互项之后的R2=",result1.rsquared)
R=result1.rsquared-result2.rsquared
print("调节效应对R2系数提升度为", R)
# 创建中介效应模型
# 标准化
X = sm.add_constant(X)
M = sm.add_constant(M)
X_scaled = (X - X.mean()) / X.std()
M_scaled = (M - M.mean()) / M.std()
Y_scaled = (Y - Y.mean()) / Y.std()
# 拟合回归模型
reg = sm.OLS(Y_scaled, X_scaled).fit()
# 拟合中介效应模型
med = sm.OLS(M_scaled, X_scaled).fit()
med_y = sm.OLS(Y_scaled, sm.add_constant(med.predict(X_scaled))).fit()
# 计算中介效应和总效应
indirect = med.params[1] * med_y.params[1]
direct = reg.params[1]
total = direct + indirect
print("中介效应:", indirect)
print("总效应:", total)
```
可以得到结果为:

在这个案例中给出了调节效应与中介效应的模板。这段代码使用Python的statsmodels库来探究调节效应和中介效应。首先,代码生成了一些模拟数据,包括三个变量:X、M和Y。Y是因变量,X和M是自变量。X和M之间存在交互作用,并且这种交互作用对Y有显著影响,即存在调节效应。接下来,代码创建了两个线性回归模型来探究调节效应。第一个模型包含X、M和X与M的交互项(XM),第二个模型只包含X和M。如果交互项的系数显著,那么说明存在调节效应。通过比较两个模型的R^2值,可以量化调节效应对模型解释力的提升度。最后,代码创建了中介效应模型。首先,对X、M和Y进行标准化处理。然后,分别拟合X对M的回归模型、M对Y的回归模型(中介效应模型)以及X对Y的回归模型(总效应模型)。通过计算中介效应和总效应的系数,可以探究X对Y的影响是否部分或全部通过M传递,即是否存在中介效应。
我们还可以针对中介效应绘制其路径图:
```python
# 绘制路径图
fig, ax = plt.subplots()
sc = ax.scatter(X_scaled[:,1], Y_scaled, c=M_scaled[:,1], cmap='viridis', alpha=0.5)
plt.colorbar(sc)
fit_x = np.linspace(X_scaled[:,1].min(), X_scaled[:,1].max(), 100).reshape(-1,1)
fit_M = np.array([0.5] * len(fit_x)).reshape(-1,1)
fit_X = sm.add_constant(fit_x)
fit_M = sm.add_constant(fit_M)
fit_X_scaled = (fit_X - X.mean()) / X.std()
fit_M_scaled = (fit_M - M.mean()) / M.std()
fit_Y_pred = reg.predict(fit_X_scaled)
med_Y_pred = med_y.params[0] + med_y.params[1] * med.predict(fit_X_scaled)
ax.plot(fit_x, fit_Y_pred, label='Y', linewidth=2)
ax.plot(fit_x, med_Y_pred, label='M->Y', linewidth=2)
ax.legend()
plt.xlabel('X')
plt.ylabel('Y/M')
plt.show()
```
得到路径图如图所示:

路径图可以展示模型中自变量、中介变量、因变量之间的关系,以及模型拟合的结果。在这个由随机数据集生成的路径图中,横轴代表自变量X,纵轴代表因变量Y和中介变量M,散点图中颜色代表中介变量M的取值,蓝色的线代表自变量X对因变量Y的直接影响路径,橙色的线代表自变量X对中介变量M的直接影响路径,绿色的线代表中介变量M对因变量Y的影响路径,路径上的数字表示相应的系数估计值。可以看到,自变量X对因变量Y的直接影响路径系数为正,表示X对Y有正的直接影响。中介变量M对因变量Y的影响路径系数为正,表示M对Y有正的影响,这说明M在X对Y的影响中起到了中介效应的作用。
### 9.2.5 路径分析法与结构方程
结构方程模型(Structural Equation Model,SEM)作为一种多元统计技术,产生后迅速得到普遍应用。20世纪70年代初一些学者将因子分析、路径分析等统计方法整合,提出结构方程模型的初步概念。随后,有研究者进一步发展了矩阵模型的分析技术来处理共变结构的分析问题,提出测量模型与结构模型的概念,促成SEM的发展。Ullman定义结构方程模型为“一种验证一个或多个自变量与一个或多个因变量之间一组相互关系的多元分析程式,其中自变量和因变量既可是连续的,也可是离散的”,突出其验证多个自变量与多个因变量之间关系的特点,该定义具体一定的代表性。
SEM假定一组隐变量之间存在因果关系,隐变量可以分别用一组显变量表示,是某几个显变量中的线性组合。通过验证显变量之间的协方差,可以估计出线性回归模型的系数,从而在统计上检验所假设的模型对所研究的过程是否合适,如果证实所假设的模型合适,就可以说假设隐变量之间的关系是合理的。SEM与一些新近的分析方法相比,也有其独特优势。SEM将不可直接观察的概念,通过隐变量的形式,利用显变量的模型化分析来加以估计,不仅可以估计测量过程中的误差,还评估测量的信度与效度。探讨变量关系的同时,把测量过程产生的误差包含于分析过程之中,把测量信度的概念整合到路径分析等统计推断决策过程。故而在本题中我们采取SEM的方式来进行模型建立与分析,从而找到各变量之间的回归路径,从而得出对于出生人口的影响因素及其影响方式。
结构方程模型它由随机变量和结构参数构成,包括观察变量、潜在变量和误差项三种主要变量类型。
观察变量是可以直接测量和观测的数据,例如考试成绩、问卷调查的响应等。这些变量通常是我们能够直接收集到的定量信息。
潜在变量则代表那些不可直接观测的抽象概念,它们通常涉及心理、教育、社会等领域的复杂构念。例如,智力、满意度或压力等都是潜在变量,它们不能直接测量,但可以通过一系列相关的观察变量来间接衡量。
结构方程模型的强大之处在于,它不仅能够评估单个变量之间的关系,还能够同时分析多个变量之间的复杂关系,包括潜在变量之间的因果关系。这使得SEM成为研究者在社会科学、教育、心理学等多个领域中,分析复杂数据结构和理论模型的有力工具。
结构方程模型的测量模型方程如下所示:
$$
x = {\Lambda _x}\xi + \delta \\
y = {\Lambda _y}\eta + \varepsilon
$$
结构方程模型的结构模型方程如下所示:
$$
\eta = \beta \eta + \Gamma \xi + \varsigma
$$
其中,$\xi$表示外生潜在变量,$\eta$是内生潜在变量,x为$\varsigma$的测量指标,y为n的测量指标,$\delta$和$\sigma$为表示各测量指标所对应的检测误差, $\Lambma_x$为x在$\xi$上的因子载荷矩阵,$\Lambma_y$为y在$\eta$上的因子载荷矩阵,$\beta$为系数矩阵,含义为内生潜在变量直接的相互影响,$\Gamma$为外生变量对内生变量的影响, $\varsigma$为残差项。
Python可以使用semopy库计算结构方程。semopy是一个Python库,用于执行结构方程模型分析。它提供了丰富的功能和灵活的用法,使研究人员能够轻松地进行SEM分析并探索复杂数据之间的关系。semopy允许用户根据理论或假设构建结构方程模型,并指定变量之间的关系。提供了详细的结果输出,包括参数估计值、标准误差、置信区间等,以帮助用户解释模型结果。
例如,在semopy中的案例holzinger39数据集中提供了多个属性(看有没有必要介绍一下这个数据集)。通过数据集中提供的x1~x9这九个特征,构造三个隐变量y1~y3,这三个隐变量与9个实际存在的变量有关,想要探究下面的一个路径关系是否成立:

Holzinger39数据集是由Holzinger和Swineford于1939年收集的。该数据集包含9个连续的观测变量,包括数字记忆、字母记忆、空间视觉、抽象推理等。这些变量是由23个样本测试者在两个不同时间点进行的测试。这些样本测试者是高中生,平均年龄为15.5岁。
Holzinger39数据集经常被用作SEM的示例数据集,该数据集可以用于展示SEM的许多方面,例如路径分析、测量模型、结构模型等。
在路径图中,每个观测变量用一个圆圈表示,每个潜在变量用一个矩形表示,箭头表示变量之间的路径,箭头上的系数表示路径系数,误差项用一个圆圈表示。通过使用路径图,可以更加直观地理解变量之间的关系。
为了描述x与y之间的测量关系和隐变量之间的结构关系可以构造如下代码:
```python
from semopy import Model
from semopy.examples import holzinger39
# 从 semopy 库加载 holzinger39 数据集
data = holzinger39.get_data()
# 定义模型
desc = '''
# 定义测量模型
y1 =~ x1 + x2 + x3
y2 =~ x4 + x5 + x6
y3 =~ x7 + x8 + x9
# 定义结构模型
y1 ~ y2 + y3
y2 ~~ y3
'''
# 使用 Model 类创建模型对象 mod
mod = Model(desc)
# 对模型进行拟合
mod.fit(data)
# 检验模型,输出每个参数的估计值、标准误等统计信息
estimates = mod.inspect()
print(estimates)
```
可以得到结果如下所示:

最终得到的概率值显示,这样的路径关系是成立的。最终得到的y1 y2 y3三个隐变量可以作为一种因子测量方式。可能看到这里大家就想起来了,隐变量的结构怎么和因子分析这么像呢,对了,它本身就可以看作是因子分析的一种扩展。不仅可以抽象成多个因子,还可以分析原始变量之间的影响路径和因子之间的影响路径。不仅可以分析数据集中存在的变量之间的作用关系,还可以探究数据集中不存在的更抽象的变量如何进行换算。这确确实实是人文社会科学研究中的重大杀器。
### 9.2.6 内生性问题与因果推断
让我们回到之前讨论抽烟与肺癌的例子上来。观察到的数据中,既有吸烟的人也有不吸烟的人,我们发现吸烟的人中有很多得了肺癌。这时,我们可能会认为吸烟是导致肺癌的原因。但是,这里有一个问题:那些选择吸烟的人可能与那些容易患肺癌的人有一些共同的特征或习惯,而不仅仅是吸烟导致肺癌。换句话说,可能是那些人本身的某种特质或习惯导致了他们既选择吸烟又容易患肺癌,而不是吸烟直接导致肺癌。这就产生了所谓的“内生性问题”,也让我们把目光聚焦到因果关系上来。在实际生活中,我们更关心的是因果关系,因为它能为我们提供改变和预测事物发展的依据。比如,如果吸烟是导致肺癌的真正原因,那么劝人们戒烟就有可能降低肺癌的发病率。
目前因果推断是统计研究的热点,尤其是在经济学中,弄清楚因果关系对经济作用机理有着重要意义。这里介绍一些因果推断的常用方法:
- 格兰杰因果检验是一种用于检验两个时间序列变量之间是否存在因果关系的统计方法。它基于这样的基本思想:如果一个变量是另一个变量的原因,那么在控制其他变量的影响后,它应该能够“预测”另一个变量的变化。格兰杰因果检验通常用于时间序列数据,数据特征包括时间序列的平稳性和非白噪音特性。其流程包括单位根检验、协整检验和误差修正模型建立等步骤。通过格兰杰因果检验,我们可以判断一个变量是否是另一个变量的格兰杰原因,即是否存在一种因果关系,这种关系是在考虑了其他所有可能的影响因素后得出的。使用statsmodels执行格兰杰因果检验通过grangercausalitytests函数实现,例如,参考下面这段代码:
```python
import numpy as np
import statsmodels.api as sm
from statsmodels.tsa.stattools import grangercausalitytests
# 生成一些示例数据
np.random.seed(12345)
n = 100
X = np.random.normal(size=(n, 2))
# 添加一些时间趋势
X[:, 0] = np.cumsum(X[:, 0])
X[:, 1] = np.cumsum(X[:, 1]) + np.cumsum(X[:, 0])
# 格兰杰因果检验
results = grangercausalitytests(X, maxlag=2)
print("--------------------------------")
print(f"Variable 1 Granger causes Variable 2:")
print(results[1][0]['ssr_ftest'][1]) # F-statistic
print(results[1][0]['ssr_ftest'][0]) # p-value
print("--------------------------------")
print(f"Variable 2 Granger causes Variable 1:")
print(results[2][0]['ssr_ftest'][1]) # F-statistic
print(results[2][0]['ssr_ftest'][0]) # p-value
```
在这段代码中,我们生成了两个随时间变化的正态分布随机变量X。然后,我们使用grangercausalitytests函数进行格兰杰因果检验。最后,我们输出了每个变量对其他变量的格兰杰因果关系的F统计量和p值。结果显示,变量1是变量2的原因,因为检验概率为0.01<0.05满足置信度要求。
- 二重差分是一种用于处理非平稳时间序列数据的统计方法。它的作用是消除时间序列中的趋势和季节性影响,以识别和估计纯粹由循环因素引起的变动。基本思想是通过两次差分将非平稳序列转换为平稳序列,从而能够应用传统的平稳时间序列分析方法。二重差分本身并不直接用于因果推断,但它可以作为因果推断分析中的一个组成部分。通过消除时间序列数据中的趋势和季节性影响,二重差分可以帮助更好地识别和估计变量之间的因果关系。在进行因果推断时,我们通常关注的是两个或多个变量之间的纯效应,即排除了其他潜在影响因素之后,一个变量对另一个变量的直接影响。为了更准确地估计这种因果关系,我们需要确保所分析的时间序列数据是平稳的,以避免由于趋势和季节性影响导致的偏差。通过二重差分处理,我们可以将非平稳时间序列转换为平稳序列,从而能够更准确地估计因果关系。
- 断点回归是一种用于因果推断的统计方法。它通过识别一个或多个“断点”来分析变量之间的因果关系。断点回归的作用在于利用数据中的非随机变化来估计因果效应。数据特征通常包括一个或多个断点,以及断点附近的数据分布情况。基本思想是利用断点作为自然实验的边界条件,将数据分为两部分:受断点影响的观察值和不受影响的观察值。通过比较这两组观察值的变化趋势,可以推断出因果效应。断点回归的流程包括确定断点、估计因果效应和检验断点的有效性。首先,需要确定一个或多个断点,这些断点通常是根据某种规则或阈值来确定的。然后,通过回归分析或其他统计方法来估计因果效应的大小和方向。最后,通过一系列检验来确认断点的有效性,以排除其他潜在的干扰因素。在Python中,同样可以通过OLS研究断点回归:
```python
import numpy as np
import statsmodels.api as sm
# 创建一些示例数据
n = 1000
X = np.random.normal(size=n)
Y = 3 * X + np.random.normal(size=n) # 假设Y是X的线性函数,并加入一些噪音
# 定义断点
cutoff = 0.5
X_cut = X[X > cutoff]
X_not_cut = X[X <= cutoff]
Y_cut = Y[X > cutoff]
Y_not_cut = Y[X <= cutoff]
# 拟合线性回归模型
X_cut_model = sm.add_constant(X_cut) # 添加常数项
model = sm.OLS(Y_cut, X_cut_model)
results = model.fit()
# 输出结果
print("Coefficients: ", results.params) # 输出系数,即因果效应的大小和方向
print("P-value: ", results.pvalues[0]) # 输出p值,用于检验系数的显著性
# 进行假设检验,以检验断点是否显著影响Y的值
p_value = results.pvalues[0] # p值用于检验断点是否存在显著影响
if p_value < 0.05: # 如果p值小于0.05,我们拒绝原假设,接受对立假设,认为存在因果关系
print(f"存在显著因果关系")
else: # 如果p值大于或等于0.05,我们不能拒绝原假设,认为不存在因果关系或证据不足
print(f"不存在显著因果关系")
```
在因果推断研究中,断点回归的一个关键应用是处理选择性偏差(Selection Bias)。选择性偏差是由于样本选择不当而导致的结果偏差。通过利用断点回归,可以更好地控制选择性偏差,从而更准确地估计因果效应。此外,断点回归还可以用于处理观察性研究中的混杂因素,通过识别和利用断点来控制潜在的混淆变量,从而提高因果推断的准确性。
- 工具变量在因果推断中起着至关重要的作用。它被用来解决因果推断中的内生性问题,即由于数据中存在的遗漏变量、反向因果关系或测量误差等原因导致的估计偏误。工具变量的基本思想是通过一个与内生解释变量相关,但又与误差项无关的变量来影响内生解释变量,从而使得因果关系的估计更为准确。在使用工具变量时,需要找到一个合适的工具变量,以满足与内生解释变量的相关性、与误差项的无关性以及影响内生解释变量的条件。工具变量的使用流程通常包括选择工具变量、检验工具变量的有效性以及进行因果关系的估计。构造工具变量的方法多种多样,可以根据具体的研究问题和数据特征来选择合适的工具变量。常见的构造工具变量的方法包括使用自然实验、使用固定效应模型、使用广义矩估计等。
## 9.3 使用scipy与statsmodels完成统计建模
Python进行统计问题建模时通常会用到scipy和statsmodels两个库。本节的主要目的就是熟悉scipy.stats和statsmodels的使用。
### 9.3.1 scipy进行统计模型构建
Scipy.stats模块是用于统计计算的模块。该模块提供了大量的概率分布函数,包括连续分布(如正态分布、指数分布、泊松分布等)和离散分布(如二项分布、泊松分布、超几何分布等)。这些概率分布函数可以进行概率密度函数(PDF)和累积分布函数(CDF)的计算,以及生成随机数。此外,stats模块还包含了许多常用的统计函数,如描述性统计、假设检验、非参数统计、拟合优度检验等。这些函数可以帮助用户进行各种统计分析任务,如计算均值、中位数、方差、标准差等描述性统计指标,进行t检验、卡方检验、F检验等假设检验,以及计算样本的拟合优度等。它的常用功能可以列举如下:
- 生成特定分布的概率密度。SciPy的stats模块提供了许多概率分布函数,包括连续分布和离散分布。连续分布包括:正态分布norm(norm是分布的类名,下同);指数分布expon,对数正态分布lognorm,柯西分布cauchy,拉普拉斯分布laplace,贝塔分布beta,帕累托分布pareto,指数分布等。离散分布包括:二项分布binom,泊松分布poisson,超几何分布hypergeom,几何分布geom,离散均匀分布uniform等。除此以外,还可以实现多元正态分布multivariate_normal、高斯过程分布:gaussian_process_gp等。这些概率分布函数可以用于计算概率密度函数(PDF)、累积分布函数(CDF)、分位数函数(quantile function)等。
- 根据分布生成随机数:通过上述类的rvs方法可以生成对应的样本。如果已经给定了一个离散分布律,可以使用numpy库中的np.random.choice函数来生成样本。
- 进行一些常见的假设检验:在前面的学习中我们已经接触过一些常用的假设检验函数,包括单样本t检验ttest_1samp,配对t检验ttest_rel,独立样本t检验ttest_ind,方差齐性检验levene ,方差检验f_oneway,卡方检验chi2_contingency。使用这些函数的一般步骤是:首先,导入相应的函数;然后,准备数据并指定要进行的假设检验的类型;最后,调用该函数并传入数据,获取结果。
- 概率密度进行拟合:可以使用fit方法拟合特定分布,下面是一个拟合正态分布的例子,通过这个例子熟悉fit方法的使用。
```python
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt
# 生成样本数据
x = np.random.normal(loc=0, scale=1, size=1000)
# 对正态分布进行拟合
dist = stats.norm
fit_result = dist.fit(x)
# 打印拟合参数
print("拟合均值:", fit_result[0])
print("拟合标准差:", fit_result[1])
# 绘制概率密度图
x_fit = np.linspace(dist.ppf(0.01), dist.ppf(0.99), 100)
y_fit = dist.pdf(x_fit, *fit_result[:2])
plt.plot(x_fit, y_fit, 'r-', lw=5)
# 绘制样本数据直方图
plt.hist(x, bins=30, density=True, alpha=0.5, color='g')
plt.show()
```
### 9.3.2 statsmodels进行统计模型构建
Statsmodels是一个Python库,用于拟合多种统计模型、执行统计测试以及进行数据探索和可视化。它提供了丰富的统计模型、统计检验和描述性统计分析的功能,可以用于回归分析、时间序列分析等多种统计任务。Statsmodels库包含线性模型、广义线性模型和鲁棒线性模型等线性模型,以及线性混合效应模型等。此外,它还包含方差分析(ANOVA)方法、时间序列过程和状态空间模型、广义的矩量法等,可以进行假设检验、置信区间计算、效应量计算等统计测试,并提供了概率分布的计算和可视化等功能。
它的常用功能可以列举如下:
- 方差分析(ANOVA)是统计学中用于检验多个组间均值差异显著性的方法,可以通过Statsmodels中的`anova_lm`函数来实现。此函数专门针对线性最小二乘(OLS)模型进行方差分析,使用户能够判断不同组之间的均值差异是否统计上显著,为数据分析和决策提供重要参考。
- 线性回归是数据分析中的基础技术,Statsmodels的`OLS`类提供了执行此类分析的功能。`OLS`能够处理包括简单线性回归、多元线性回归和多项式回归在内的多种回归模型,通过估计因变量与自变量之间的线性关系,帮助预测因变量的值。无论是探索自变量的影响、控制混杂变量,还是进行统计推断,`OLS`都是经济学、社会科学、医学和生物学等领域中不可或缺的分析工具。
- 时间序列分析是研究时间序列数据以揭示其内在规律和特征的统计方法。Statsmodels的`tsa`模块提供了包括`ARIMA`模型在内的多种时间序列分析工具,如季节性自回归积分滑动平均模型(SARIMA)、向量自回归模型(VAR)等。这些方法不仅支持时间序列的建模和预测,还能进行平稳性检验、趋势分析等,使用户能够更深入地理解并分析时间序列数据。
## 9.4 机器学习的研究范畴
机器学习是一系列数学模型的统称。随着人工智能的爆发,机器学习的概念也逐渐深入人心。但可能很多朋友对机器学习还不够了解,所以这一节会先阐明它的研究范畴,以便让读者朋友们更好地理解:机器学习究竟是在解决一个什么问题。
### 9.4.1 机器学习概念的提出与发展
> 卡耐基梅隆大学版本的《机器学习》一书中这样描述机器学习:
> *A computer program is said to learn from experience E with respect to some class of tasks T and performance measure P if its performance at tasks in T, as measured by P, improves with experience E.*
这是机器学习的官方定义。机器学习本质上就是根据数据训练一个数学模型,并对未知数据能够成功进行合理预测。换言之,机器学习的要素包括训练材料与算法(数据、限制条件)、训练目的(分类、回归或其他)、训练效果的衡量标准(目标函数)。但业界更习惯于采用这三个要素:模型、学习准则、优化算法。
通常学习一个好的模型,分为以下三步。
- (1)选择一个合适的模型:这通常需要依据实际问题而定,针对不同的问题和任务需要选取恰当的模型,模型就是一组函数的集合。
- (2)判断一个函数的好坏:这需要确定一个衡量标准,也就是通常说的损失函数,损失函数的确定也需要依据具体问题而定,如回归问题一般采用欧式距离,分类问题一般采用交叉熵代价函数。
- (3)找出“最好”的函数:如何从众多函数中最快的找出“最好”的那一个,这一步是最大的难点,做到又快又准往往不是一件容易的事情。常用的方法有梯度下降算法、最小二乘法等。
在1956年,Arthur Samuel在开发跳棋程序时,创造了“machine learning”这个词,并提出了使用计算机自我学习的概念。随后,机器学习算法一个接着一个地冒出。从KNN、线性回归、逻辑回归等算法出发,从机器学习的研究框架对它们进行解读从而形成了一套科学的研究方法。在那个时期,一些科学家仿照神经元的结构开发了单层神经网络(感知机),但在那个时期人们对于这些算法的认识还不够深入,没能进行进一步扩展。
到了20世纪80年代,随着计算机技术的发展和数据量的增加,机器学习开始进入真正的实用阶段。这个时期的研究重点是如何从大量数据中提取有用的信息。决策树模型和核方法诞生了,机器学习算法开始得到了进一步扩充。在那个时期有人提出了非常有意义的两个方法:支持向量机和集成学习。同时,有关多层感知机的训练方法也逐渐被拨开重重迷雾,这成为了后来神经网络与深度学习的基础。然而,这个时期的研究并没有得到太大的广泛应用,因为计算机的运算能力和数据量都相对较小。
机器学习在21世纪初得到了更广泛的应用。这个时期的研究重点是如何使用神经网络等深度学习技术来处理大规模数据和复杂任务,例如图像识别、语音识别和自然语言处理等。神经网络在imageNET上的表现让大家相信神经网络模型能够在图像任务、文本任务、信号任务上有不俗的表现。后来ALPHAGO的诞生正式宣告了人工智能时代的到来。深度学习的出现极大地提高了机器学习的性能和应用范围,成为当前机器学习领域的主流技术。
如今,机器学习已经渗透到我们日常生活的方方面面,例如智能语音助手、推荐系统、自动驾驶等。随着技术的不断进步和应用场景的不断拓展,机器学习的发展前景也更加广阔。未来,我们期待机器学习能够为人类带来更多的便利和创新。
### 9.4.2 机器学习中的重要概念
机器学习的第一要素是数据。老话说:“数据决定上限,模型逼近上限”,所有的机器学习模型都是以数据为本。这里就延续在第6章数据处理中讲述数据概念的部分,在这一章我们重点学习表格类数据的处理。数据的每一列被称作属性,数据的体量被称作数据量。那么机器学习可以解决什么问题呢?有很多。如果问题的目标在数据集里面有出现,我们就称呼它是一个有监督学习问题,这个目标列就是标签,是因变量,剩下的属性构成自变量。打个比方,就以线性回归的数据作为例子,在第6章中学习线性回归的时候有一个例子是房价预测,数据集里面出现了很多属性的同时还给出了每套房子的真实房价。我们就称房价这一栏构成了数据集的一个标签。
模型和算法在机器学习中是另外两个概念,一个机器学习模型本质上就是一个数学模型,它是在处理这个问题的过程中列出的一个带参数的优化问题。例如,机器学习的模型就是两个部分:第一是线性回归方程的基本形式,第二是均方误差函数的最优化。这里我们把这个误差函数又叫损失函数。我们记得,损失函数是带有参数的,有些参数是可以优化的自变量,但有些参数是不可以优化的,它是我们手动设置参数值的。我们把必须手动设置的这些参数又叫超参数,寻找效果最好的机器学习模型一个非常重要的过程就是找到最好的一套超参数配置。官方说法叫调参,我们俗话讲叫“炼丹”。
机器学习需要数据来驱动。模型是需要训练、验证和测试的。可能有同学不是特别理解这个区分,这里我举个例子。一个有标签的数据集需要进行切分,分成训练集和测试集两个部分(如果想严格一点可以分成训练、验证和测试三个部分),训练集用于参数训练,而测试集用于评价模型效果。有监督学习的训练过程其实和人在课堂上学习的过程如出一辙。在学习的过程中,老师通常都会有一个题库,并且老师会把70%-80%的问题编成作业给学生练习用以巩固知识;剩余的20%-30%则会被作为考试题用以测试学生的学习情况。这个例子中的“练习题”和“考试题”,则对应了机器学习模型训练过程中的训练数据集和测试数据集这两个概念。那么加上所谓的验证集,就是我只取70%当做作业,15%用来出了模拟考,最后15%当作真题,验证集就是模拟考的这一部分。如果作业反馈情况较差,老师会让学生重新调整学习节奏,这也就是机器学习中的调参。对于一些机器学习模型而言,它们具备举一反三的能力,即:只需要少量的数据学习,即可掌握一类数据的分类方法,这种现象也被称为few-shot。
在机器学习中,偏差和方差是两个重要的概念,用于评估模型的泛化能力。偏差是指模型预测的期望值与真实值之间的差。偏差反映了模型拟合训练数据的能力,即算法的拟合能力。如果偏差较大,说明模型在训练数据上的表现不佳,这可能是由于模型过于简单或者模型未充分拟合训练数据所导致的。方差是指在给定不同训练数据集的情况下,模型预测的期望值的变化量。方差反映了模型对于训练数据的变化的敏感度。如果方差较大,说明模型容易受到训练数据的影响,即训练数据的变化会导致模型预测结果的大幅度变化,这可能导致模型在新的数据集上表现不佳。
欠拟合和过拟合是机器学习中另一对重要的概念。例如,看到下面三张拟合曲线:

欠拟合是指模型在训练数据上的表现不佳,无法充分拟合训练数据,导致在新的数据集上表现也不佳,就像第一张图。这通常是因为模型过于简单,无法捕捉到数据的复杂模式和规律。过拟合是指模型在训练数据上的表现非常好,但在新的数据集上表现不佳,就像第三张图。这是因为模型过于复杂,对训练数据进行了过度的拟合,导致模型泛化能力下降。比如,用一条非常复杂的曲线去拟合一组简单的直线,可能会出现过拟合的情况。在机器学习中,我们通常希望找到一个平衡点,使模型既能充分拟合训练数据,又能具有良好的泛化能力。这需要我们不断调整模型复杂度、添加正则化项、使用更复杂的模型等措施,以减小偏差和方差之间的矛盾。
偏差和方差是有冲突的。偏差反映了学习算法的拟合能力,而方差则反映了同样大小的训练集的变动所导致的学习性能的变化。在训练不足的情况下,学习器的拟合能力不够强,训练数据的扰动不足以使学习器产生显著变化,此时偏差占主导。随着训练程度的加深,学习器的拟合能力逐渐增强,训练数据发生的扰动被学习器学到,方差逐渐占主导。在训练程度充足后,学习器拟合能力已非常强,训练数据的轻微扰动都会导致学习器发生显著变化,若继续学习,则将发生过拟合。如下图所示:

在评估机器学习算法的有效性时,我们通常会采用以下几种方法来确保模型的性能达到预期:
1、数据集划分:为了测试模型在未知数据上的表现,我们可以将数据集分为两部分。通常情况下,我们会将较大的一部分(如70%)用于训练模型,而另一部分(如30%)用于测试模型。这种方法有助于我们了解模型在处理新数据时的准确性和泛化能力。
2、交叉验证:想象你有一批水果,你想知道每种水果的味道。你可以先尝一颗苹果,再尝一颗香蕉,然后一颗橙子,这样交替尝每种水果。交叉验证就像这样,是一种更全面评估模型性能的方法,你不是一次尝完所有水果,而是每次只尝一种,每次仅使用其中一部分作为训练数据,其他部分作为测试数据,这样你就能更全面地了解每种水果的味道,以确保我们对模型的性能有更全面的了解。
选择和使用评估指标是机器学习项目中的关键步骤,因为它们可以帮助你了解模型的性能并指导模型的选择和调整。上面我们已经对确保模型的性能达到预期的两种方法,有了一定的了解,以下是一些建议,教你如何选择和使用这些评估指标:
首先,你需要了解问题类型,确定你的机器学习问题是分类问题还是回归问题。分类问题涉及到将数据分为多个类别,而回归问题则涉及到预测连续值。不同类型的问题需要使用不同的评估指标。
对于分类问题,可以选择准确率、精确率、召回率和F1得分等指标。根据问题的具体需求,你可能需要关注某个指标更多。例如,如果问题对漏检非常敏感,那么召回率和F1得分可能比准确率更重要:
1、准确率:准确率是正确分类的样本数占总样本数的比例。例如,如果你有100个水果,你正确判断了90个,那么你的准确率是90%。
2、精确率:精确率是正确分类的正类样本数占所有被判定为正类的样本数的比例。例如,如果你有10个香蕉,你正确判断了8个,那么你的精确率是80%。
3、召回率:召回率是正确分类的正类样本数占所有实际正类样本数的比例。例如,如果你有10个香蕉,其中有3个是坏的,你正确判断了2个,那么你的召回率是66.6%。
4、F1得分:F1得分是精确率和召回率的调和平均值,它综合反映了模型在精确率和召回率方面的表现。F1得分越高,模型的性能越好。
对于二分类问题,ROC曲线和AUC值是常用的评估工具,它们可以帮助你了解模型在不同阈值下的性能表现:
1、ROC曲线:ROC曲线是一张反映模型在不同阈值下的性能的图。横轴表示假阳性率(即误判为正类的负类样本比例),纵轴表示真阳性率(即正确判为正类的正类样本比例)。ROC曲线越靠近左上角,表示模型的性能越好。
2、AUC****值:AUC****值是ROC****曲线下的面积,它反映了模型在整个阈值范围内的平均性能。AUC****值越高,表示模型的效果越好。**
**对于回归问题,我们可以使用均方误差(MSE****)、平均绝对误差(MAE****)和R****方值等指标来衡量模型的性能。MSE****和MAE****衡量预测值与真实值之间的差异,而R****方值衡量模型解释的变异与总变异的比例:**
**1****、均方误差(MSE****):MSE****是预测值与真实值之差的平方的平均值。MSE****越低,表示模型的预测误差越小。**
**2****、平均绝对误差(MAE****):MAE****是预测值与真实值之差的绝对值的平均值。MAE****越低,表示模型的预测误差越小。**
**3****、R****方值:R****方值是模型解释的变异与总变异的比率。R****方值越高,表示模型的解释能力越强。**
**4****、偏差-****方差分析:我们还可以通过偏差-****方差分析来评估模型的准确性和泛化能力。这就像你在画一幅画,你画得越像真的,这幅画就越准确,但如果你画得太像,就可能失去了原本画的感觉。模型也是这样,如果模型过于强调某个特定数据点,可能会导致对其他数据点的理解不足。因此,我们需要在偏差和方差之间找到一个平衡点,以确保模型既能准确反映数据特征,又具有良好的泛化能力。**
**5****、学习曲线:学习曲线是另一种评估模型性能的方法。通过观察随着训练数据量的增加,模型表现的变化,我们可以判断模型是否具有良好的泛化能力。如果模型性能随着样本数量的增加而提高,说明模型具有良好的泛化能力;如果性能没有提升甚至下降,则可能存在过拟合或欠拟合问题。在这种情况下,我们需要调整模型的复杂度,以达到更好的性能。**
### 9.4.3 机器学习算法的区分
机器学习算法可以分为如图所示的几个类别:

机器学习的任务大体上按照有无标签区分。有标签指导训练过程的任务被称作有监督学习,包含目标为离散标签的分类问题和目标为连续标签的回归问题。无标签指导的任务被称作无监督学习,主要包括聚类问题和降维问题等。此外,还有半监督和强化学习等问题,这些问题我们不作过多讨论。
- 分类问题:比如机器学习中著名的鸢尾花数据集(鸢尾花也就是水仙花),数据集收集了150朵花的萼片长度、萼片宽度、花瓣长度和花瓣宽度等四个形状指标,最后有一列标注数据表明每一条数据数据是哪种鸢尾花(数据集的最后一列是字符串,有setosa,versicolor和virginica三种类型)。数据的最后一列显然是离散变量,而前面四列自变量都是连续变量。这种数据可以用来预测一朵新的鸢尾花是三种鸢尾花中的哪一种,但是分类模型需要基于后续的标签来指导,这叫分类问题。也可以把它抽象成:已知自变量的数值,求因变量数据属于ABC三类中哪一类的选择题。分类问题本身就像做选择题一样,有标准答案。
- 回归问题:也就是在已知自变量的情况下若因变量是连续数据如何去进行建模与预测。例如,著名的波士顿房价数据集,在这个数据集中存在不同的自变量,但因变量房价却是连续的数值。对房价做预测本质上也是一种回归。也可以把它抽象成:已知自变量的数值,求因变量的计算结果,使其与实际结果偏差不大。回归问题本身就像做计算题一样,也有标准答案。
- 聚类问题:另一类比较有意思的就是聚类问题,比如在一家服装专卖店内老板会收集每个VIP用户的信息,例如年龄、性别、消费次数、卡内充值、消费偏好等,但这些都是自变量没有因变量。现在老板需要根据这一系列自变量对用户进行画像,将其分为若干个群,至于分多少群、每个群体有什么样的特征是由数据自变量所决定的。只有自变量没有因变量将数据分群的过程就是聚类,它也可以被抽象为根据自变量的数值做一个论述题,是没有标准答案的。
- 降维问题:主要探究如何用更少的变量表示原始数据,并且尽可能保留更多的信息。常见的降维方法包括主成分分析、因子分析、独立成分分析、t-SNE等,在第7章中我们已经介绍了两种最常用的降维方法,这一章就不单独讨论了。
## 9.5 使用scikit-learn完成机器学习任务
本节在介绍具体的机器学习算法之前就先告诉大家如何去进行实践。因为机器学习算法是比较复杂的,把焦点聚焦于具体的算法原理未必对我们解决实际问题有利,完全可以把机器学习算法视作工具去使用。这也是我们想告诉大家的一点——培养工具思维。使用枪支的人未必会组装枪械,使用手机的人未必进过电子厂,同样的,使用机器学习库scikit-learn的同学也未必对机器学习的底层逻辑理解透彻。但是没关系,只要能够对实际问题做出解答,一切困难都是纸老虎。
### 9.5.1 scikit-learn对数据集的处理
Scikit-learn处理数据集包括对特征的编码、数据集的切分与打乱等操作,并且自带了一部分数据集。对特征的编码与处理是做好后续操作的关键。
首先是特征编码的部分。机器学习算法无法直接处理字符串数据,所以,需要给离散属性进行特征编码保证它们是独立的。常用函数如下
- OneHotEncoder: 输入参数包括data(数据),categories(指定哪些是有效类别)。输出独热编码后的数据,返回值类型取决于输入数据和参数的设置。如果sparse为True,则返回稀疏矩阵;如果为False或未设置,则返回密集矩阵。它的作用是对目标变量进行独热编码,将每个类别转换为一个二进制列向量。
- LabelEncoder: 输入参数包括data(标签数组或列表)。输出参数为转换后的整数标签数组。它的作用是将标签转换为整数,标签会按照出现顺序进行排序并分配整数。未出现的标签将被编码为-1。
- LabelBinarizer: 输入参数包括data(标签数组或列表)。输出参数为二进制形式的标签矩阵,每一行代表一个样本,每一列代表一个标签。如果标签出现在样本中,则对应位置为1;否则为0。它的作用是将标签转换为二进制形式,适合于多标签分类问题。
- MinMaxScaler: 输入参数包括data(特征数组或列表)和可选参数feature_range(一个元组,指定缩放后的范围,例如(0, 1))。输出参数为缩放后的特征数组,每个特征的值都将在指定的范围内。它的作用是对特征进行min-max规约化,将每个特征的值缩放到指定的范围。
- StandardScaler: 输入参数包括data(特征数组或列表),以及可选参数with_mean(布尔值,默认为True)和with_std(布尔值,默认为True)。输出参数为标准化后的特征数组,每个特征都减去其均值并除以其标准差。它的作用是对特征进行Z-score规约化。
- CountVectorizer: 输入参数包括data(文本数据)和可选参数参数如stop_words(停用词列表),max_df(最大文档频率),min_df(最小文档频率)等。输出参数为词频矩阵,其中每一行代表一个样本,每一列代表一个词,矩阵中的元素表示该词在对应样本中出现的次数。它的作用是将文本数据转换为词频矩阵,方便后续的机器学习算法使用。
- TfidfVectorizer: 输入参数包括data(文本数据)和可选参数参数如stop_words(停用词列表),max_df(最大文档频率),min_df(最小文档频率)等。输出参数为TF-IDF矩阵,其中每一行代表一个样本,每一列代表一个词,矩阵中的元素表示该词在对应样本中的TF-IDF值。它的作用是将文本数据转换为TF-IDF矩阵,方便后续的机器学习算法使用。与CountVectorizer不同的是,它同时考虑了词频和逆文档频率,能够更好地反映词语在文本中的重要性。
特征工程是指通过一系列算法和技巧,将原始数据转换为能够被机器学习模型理解和使用的特征的过程。特征工程是机器学习中至关重要的步骤,因为机器学习模型的效果在很大程度上取决于输入特征的质量和数量。通过特征工程,可以提取出更具有代表性和区分度的特征,从而提高模型的准确率和泛化能力。特征工程可以帮助解决数据维度过高、特征相关性、缺失值等问题,同时也可以根据业务背景和经验知识,加入一些手工特征或特征组合,以提升模型效果。对于特征的构造,需要利用一些背景知识和数学方法。但对于特征的选择,则有一些统计方法可以参考。
- 基于特征自身的方差。我们知道,在构建回归方程的时候如果有一列数据都是同一个值,它并不会对结果造成什么太大的影响,而是充当方程的常数项。这是一个特征的极限情况。事实上,如果一个特征的波动情况不大,方差过小的情况下它的影响是非常小的。
- 基于特征与标签的统计结果。通过T检验、卡方检验、方差检验和相关系数等统计量可以分析某个特征的取值不同是否会造成标签出现明显差异,或者说,标签不同的多组样本它们在某个属性上是否呈现出显著的水平差异。
- 基于机器学习模型的分析。一些树模型例如决策树、随机森林、XGBoost等方法可以给不同的特征以权重分数,按照权重分数从高到低排序即可得到特征的重要性顺序。这样就可以选出最重要的特征。
Scikit-learn中提供了一些特征选择与交叉验证的函数:
- RFECV: 输入参数包括estimator(估计器对象,已经训练的模型),data(数据集),param_grid(参数网格),cv(交叉验证的折数)。输出参数为最佳特征子集。它的作用是通过递归特征消除来选择最佳的特征子集,通过在交叉验证过程中逐步删除最不重要的特征来找到最优特征集。
- SelectKBest: 输入参数包括data(数据集),key(特征选择方法,如卡方检验或互信息法),k(要选择的特征数量)。输出参数为选择后的特征矩阵。它的作用是使用卡方检验或互信息法来选择最佳特征,通过计算特征与目标变量之间的相关性来选择最有用的特征。
- SelectPercentile: 输入参数包括data(数据集),percentile(要选择的特征的百分比)。输出参数为选择后的特征矩阵。它的作用是选择一定百分比的预测值最高的特征,通过保留最相关的特征来减少噪声和冗余特征。
- VarianceThreshold: 输入参数包括data(数据集),threshold(阈值,特征的方差低于该值将被视为冗余)。输出参数为选择后的特征矩阵。它的作用是选择高于给定阈值的特征,以减少噪声和冗余特征。通过只保留那些方差较大的特征,可以去除不相关或重复的特征。
- SelectFromModel: 输入参数包括estimator(估计器对象,已经训练的模型),threshold(阈值,低于该值的特征将被视为不重要)。输出参数为选择后的特征矩阵。它的作用是从已训练的模型中选择特征,基于模型的重要性分数或系数来决定哪些特征应该保留。
- KFold: 输入参数包括data(数据集),k(交叉验证的折数)。输出参数为训练和验证的迭代器,每次迭代返回一个训练集和一个验证集。它的作用是将数据集分成k个折,用于k折交叉验证。通过将数据分成k个部分,并在每次迭代中使用k-1个部分进行训练,剩下的一个部分进行验证,可以评估模型的泛化能力。
- LeaveOneOut: 输入参数包括data(数据集)。输出参数为训练和验证的迭代器,每次迭代返回一个训练集和一个验证集。它的作用是将数据集分成n个折,其中n是样本数量,用于留一交叉验证。通过每次迭代中使用n-1个样本进行训练,剩下的一个样本进行验证,可以评估模型的泛化能力。这种方法能够提供较为精确的模型评估结果,但计算成本较高。
- StratifiedKFold: 输入参数包括data(数据集),k(交叉验证的折数),random_state(随机种子)。输出参数为训练和验证的迭代器,每次迭代返回一个训练集和一个验证集。它的作用是将数据集分成k个折,并保持每个折中类别的比例与原始数据集中一致,用于分层交叉验证。通过分层抽样,可以确保每个折中各类别的样本比例与原始数据集一致,以评估模型在各个类别上的性能表现。
` `Scikit-learn将数据集切分为训练集和数据集的过程是通过model_selection.train_test_split函数实现的。它的使用规则如下:
Xtrain, Xtest, Ytrain,Ytest = train_test_split(X,y,test_size=0.3)
` `输入参数为自变量数据X和因变量数据y,通过指定test_size确定测试集的比例。返回训练集自变量、测试集自变量、训练集标签和测试集标签。另外,还可以通过random_state指定是否需要对数据集进行打乱处理,这对提升模型泛化性非常重要。
### 9.5.2 scikit-learn中的模型训练
这一节我就会把scikit-learn里面常用模型的接口全部调出来,如果只是学习如何使用想要快速跳过的话,可以跳过9.6-9.12节。这些接口的使用都非常简单,使用方法也都是一致的。大家可以通过下面的案例快速上手。
鸢尾花数据集是机器学习中非常常用的一个数据集,在sklearn.dataset中自带,通过load_iris()函数导入。鸢尾花数据集是一个用于分类问题的经典数据集,包含三种不同种类的鸢尾花:山鸢尾、变色鸢尾和维吉尼亚鸢尾。数据集中的每个样本都有四个特征:花萼长度、花萼宽度、花瓣长度和花瓣宽度。这四个特征是用来预测鸢尾花卉属于哪一品种的重要依据。每一类各50条样本。我们可以看到它的统计分布图:

**我们可以从上图得出:**
**Setosa:萼片长度>萼片宽度>花瓣长度>花瓣宽度**
**Versicolour:萼片长度>花瓣长度>萼片宽度>花瓣宽度**
**Virginica:萼片长度>花瓣长度>萼片宽度>花瓣宽度**
**当我们在对数据集进行统计描述时,我们通常会关注数据的中心趋势、分散程度、分布形状和相关性等特征。我们可以对Iris数据集进行如下统计描述**
**中心趋势:**
**中心趋势描述了数据集中的观测值倾向于聚集的数值。对于Iris数据集中的量化特征(如萼片长度、萼片宽度、花瓣长度和花瓣宽度)都有一定的中心趋势,我们可以通过计算平均值来了解每个特征的中心位置。而对于分类特征(如花种),我们可以通过统计每个类别的出现频率来描述其中心趋势。在Iris数据集中,Setosa、Versicolour和Virginica三种花种各有50个样本,各占总样本数的1/3。**
**分散程度:**
**Iris数据集中的每个特征都有一定的分散程度,可以通过计算其标准差来描述。分散程度反映了数据点之间的差异程度,标准差越大,数据点之间的差异就越大。**
**例如,Setosa花种的花瓣长度的标准差为0.82厘米,Versicolour花种的花瓣长度的标准差为1.76厘米,Virginica花种的花瓣长度的标准差为2.06厘米。Setosa花种的花瓣长度标准差较小,表明大多数样本的花瓣长度接近平均值。相反,如果一个特征的标准差较大,如Virginica花种的花瓣长度,这可能表明样本之间存在较大的变异。**
**分布形状:**
**分布形状描述了数据的分布模式,是对称、偏斜还是具有峰度。Iris数据集中的每个特征都有一定的分布形状,我们可以通过绘制直方图或密度图来观察数据的分布形状。分布形状反映了数据点的分布特征,对称的分布形状(如正态分布)通常表示数据的中心趋势与分散程度相符,非对称的分布形状则可能表示数据集中于某一范围或两侧。例如,如果Setosa花种的花瓣长度呈现右偏态分布,这可能意味着大多数样本的花瓣长度较短,只有少数样本的花瓣长度较长。这种分布形状可以帮助我们了解数据的集中趋势和异常值的可能性。**
**相关性:**
**相关性描述了数据集中不同特征之间的关系,反映了特征之间的相关程度,相关性越高,特征之间的变化趋势越相似。通过计算相关系数,我们可以量化两个特征之间的线性关系强度。例如,如果花瓣长度和花瓣宽度之间的相关系数接近1,这表明这两个特征之间存在强烈的正相关关系,即花瓣长度较长的花往往花瓣宽度也较大。**
数据集包括4个属性,分别为花萼的长、花萼的宽、花瓣的长和花瓣的宽。对花瓣我们可能比较熟悉,花萼是什么呢?花萼是花冠外面的绿色被叶,在花尚未开放时,保护着花蕾。四个属性的单位都是cm,属于数值变量,四个属性均不存在缺失值的情况,字段如下:
sepal length(萼片长度)
sepal width(萼片宽度)
petal length(花瓣长度)
petal width (花瓣宽度)
Species(品种类别):分别是:Setosa、Versicolour、Virginica
单位都是厘米。
进行机器学习第一步是导入数据并处理数据。通过下面的代码导入数据:
```python
import numpy as np
import pandas as pd
# 鸢尾花数据集,红酒数据集,乳腺癌数据集,糖尿病数据集
from sklearn.datasets import load_iris,load_wine,load_breast_cancer,load_diabetes
# 回归重要指标
from sklearn.metrics import r2_score, mean_absolute_error, mean_squared_error
# 分类重要指标
from sklearn.metrics import accuracy_score, confusion_matrix, f1_score, precision_recall_curve, roc_auc_score
from sklearn.model_selection import train_test_split #训练集训练集分类器
import graphviz #画文字版决策树的模块
import pydotplus #画图片版决策树的模块
from IPython.display import ./src/image #画图片版决策树的模块
iris = load_iris()
print(iris.data) # 数据
print(iris.target_names) # 标签名
print(iris.target) # 标签值
print(iris.feature_names) # 特证名(列名)
iris_dataframe = pd.concat([pd.DataFrame(iris.data),pd.DataFrame(iris.target)],axis=1)
print(iris_dataframe)
Xtrain, Xtest, Ytrain,Ytest = train_test_split(iris.data,iris.target,test_size=0.3)
```
随后选择对应接口创建模型,输入数据通过fit方法进行训练,然后进行predict并评估指标即可。代码如下:
```python
from sklearn.linear_model import LogisticRegression,LinearRegression
from sklearn.neighbors import KNeighborsRegressor,KNeighborsClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.tree import DecisionTreeRegressor,DecisionTreeClassifier
from sklearn.ensemble import RandomForestRegressor,RandomForestClassifier
from sklearn.ensemble import ExtraTreesRegressor,ExtraTreesClassifier
from sklearn.ensemble import AdaBoostRegressor,AdaBoostClassifier
from sklearn.ensemble import GradientBoostingRegressor,GradientBoostingClassifier
clf = RandomForestClassifier()
clf.fit(Xtrain, Ytrain)
Ypredict=clf.predict(Xtest)
print(r2_score(Ypredict,Ytest))
```
其中,决策树、随机森林等具有树形结构的基学习器可以把树形结构打印出来并保存为PDF或png文件,代码如下:
```python
from sklearn import tree
tree_data = tree.export_graphviz(
clf
,feature_names =iris.feature_names
,class_names = iris.feature_names#也可以自己起名
,filled = True #填充颜色
,rounded = True #决策树边框圆形/方形
)
graph1 = graphviz.Source(tree_data.replace('helvetica','Microsoft YaHei UI'), encoding='utf-8')
graph1.render('./iris_tree')
```
Python中想要评估模型效果的话scikit-learn也提供了不同问题的评估指标接口。
Scikit-learn(简称sklearn)是一个非常强大的机器学习库,提供了很多用于分类、回归和聚类的评估指标函数。以下是常用的一些函数:
分类问题常用函数:
- accuracy_score: 输入参数包括y_true(真实标签)和y_pred(预测标签)。输出参数为准确率,即预测正确的样本数占总样本数的比例。它的作用是评估分类器的准确率,通过比较真实标签和预测标签的一致性来计算准确率。
- confusion_matrix: 输入参数包括y_true(真实标签)和y_pred(预测标签)。输出参数为混淆矩阵,展示各类别之间的预测和实际分类情况。它的作用是评估分类器的性能,通过比较真实标签和预测标签的分类情况来生成混淆矩阵,以量化分类器的正确率、精度、召回率和F1分数等指标。
- classification_report: 输入参数包括y_true(真实标签)和y_pred(预测标签)。输出参数为分类报告,包括精确度、召回率和F1分数等分类性能指标。它的作用是提供详细的分类性能评估报告,通过比较真实标签和预测标签的分类情况来计算各类别的精确度、召回率和F1分数,以全面评估分类器的性能。
- roc_auc_score: 输入参数包括y_true(真实标签)和y_pred(预测概率)。输出参数为ROC曲线下的面积,用于评估二元分类器的性能。它的作用是通过计算ROC曲线下的面积来评估分类器的性能,ROC曲线展示了不同分类阈值下真正例率(TPR)和假正例率(FPR)的变化情况,AUC值越大表示分类器性能越好。
- average_precision_score: 输入参数包括y_true(真实标签)和y_score(预测分数)。输出参数为平均精度,用于评估二元分类器的性能。它的作用是计算在不同分类阈值下的平均精度,综合考虑了真正例率(TPR)和假正例率(FPR),以更全面地评估分类器的性能。
- brier_score_loss: 输入参数包括y_true(真实标签)和y_prob(预测概率)。输出参数为Brier分数,用于评估二元或多元分类器的性能。它的作用是计算Brier分数,通过比较真实标签和预测概率的差异来评估分类器的性能。Brier分数越小表示预测概率与真实标签越接近,分类器性能越好。
- f1_score: 输入参数包括y_true(真实标签)和y_pred(预测标签)。输出参数为F1分数,用于评估分类器的性能。它的作用是计算F1分数,综合考虑了精确度和召回率,以更全面地评估分类器的性能。F1分数越高表示分类器性能越好。
回归问题常用函数:
mean_squared_error: 输入参数包括y_true(真实值)和y_pred(预测值)。输出参数为均方误差,即预测值与真实值差的平方的平均值。它的作用是衡量回归模型的预测误差,通过比较预测值和真实值之间的差异来评估模型的性能。
- mean_absolute_error: 输入参数包括y_true(真实值)和y_pred(预测值)。输出参数为平均绝对误差,即预测值与真实值差的绝对值的平均值。它的作用是衡量回归模型的预测误差,通过比较预测值和真实值之间的差异来评估模型的性能。
- median_absolute_error: 输入参数包括y_true(真实值)和y_pred(预测值)。输出参数为中位数绝对误差,即预测值与真实值差的中位数绝对值。它的作用是衡量回归模型的预测误差,通过比较预测值和真实值之间的差异来评估模型的性能。
- r2_score: 输入参数包括y_true(真实值)和y_pred(预测值)。输出参数为R平方值,衡量回归模型的拟合优度。它的作用是通过计算R平方值来评估模型对数据的拟合程度,R平方值越接近于1表示模型拟合越好。
- explained_variance_score: 输入参数包括y_true(真实值)和y_pred(预测值)。输出参数为解释方差,衡量模型对数据的解释程度。它的作用是通过计算解释方差来评估模型对数据的解释能力,解释方差越接近于1表示模型对数据的解释程度越高。
聚类问题常用函数:
- adjusted_mutual_info_score: 输入参数包括y_true(真实标签)和y_pred(预测标签)。输出参数为调整后的互信息分数,用于评估聚类结果的纯度。它的作用是通过计算调整后的互信息分数来评估聚类结果的纯度,该分数综合考虑了真实标签和预测标签的相似性和不相似性,以更准确地评估聚类效果。
- adjusted_rand_score: 输入参数包括y_true(真实标签)和y_pred(预测标签)。输出参数为调整后的Rand指数,衡量聚类结果的相似度。它的作用是通过计算调整后的Rand指数来评估聚类结果的相似度,该指数考虑了聚类结果的排序和类别分配情况,以更准确地评估聚类效果。
- homogeneity_score: 输入参数包括y_true(真实标签)和y_pred(预测标签)。输出参数为同质性分数,衡量聚类结果中每个簇的纯度。它的作用是通过计算同质性分数来评估聚类结果中每个簇的纯度,该分数越高表示每个簇的样本越集中于某个类别,聚类效果越好。
- completeness_score: 输入参数包括y_true(真实标签)和y_pred(预测标签)。输出参数为完整性分数,衡量聚类结果中每个样本被正确分配到其真实类别的情况。它的作用是通过计算完整性分数来评估聚类结果中每个样本被正确分配到其真实类别的情况,该分数越高表示聚类效果越好。
- v-measure: 输入参数包括y_true(真实标签)和y_pred(预测标签)。输出参数为V-measure分数,是调和平均数,结合了同质性和完整性两个方面。它的作用是通过计算V-measure分数来综合评估聚类结果的同质性和完整性,该分数越高表示聚类效果越好。
- silhouette_score: 输入参数包括y_true(真实标签)和y_pred(预测标签),samplewise(是否按样本计算)。输出参数为轮廓系数,衡量聚类效果的指标,值越接近1表示聚类效果越好。它的作用是通过计算轮廓系数来评估聚类效果的指标,该系数越高表示聚类效果越好。
- calinski-harabasz: 输入参数包括data(数据集),labels(标签)。输出参数为卡林斯基-哈拉巴兹指数,衡量聚类效果的一个指标,值越大表示聚类效果越好。它的作用是通过计算卡林斯基-哈拉巴兹指数来评估聚类效果,该指数越高表示聚类效果越好。
- davies-bouldin: 输入参数包括data(数据集),labels(标签)。输出参数为Davies-Bouldin指数,衡量聚类效果的一个指标,值越小表示聚类效果越好。它的作用是通过计算Davies-Bouldin指数来评估聚类效果,该指数越小表示聚类效果越好。
- contingency_matrix: 输入参数包括data1(第一个数据集),data2(第二个数据集),。输出参数为一个稀疏矩阵,表示每个样本的标签在真实标签和预测标签中的分布情况。它的作用是计算条件矩阽矩阵,以展示两个数据集之间的标签分布情况。
以上这些函数都是sklearn库中常用的评估指标函数,可以用来评估分类、回归和聚类等机器学习任务的性能。在使用这些函数时,需要提供真实标签和预测标签作为输入参数,并返回相应的评估结果。需要注意的是,不同的任务和数据集可能需要使用不同的评估指标来衡量模型的性能,因此在使用时需要根据具体情况选择合适的评估指标。
## 9.6 基于距离的KNN模型
KNN又被称为近邻判别算法,这个算法应该是最简单的机器学习算法之一。这一节我们不仅会带领大家看到KNN的基本原理,也会带大家上手实现自己的KNN模型。
### 9.6.1 KNN模型的基本原理
KNN是最基本的监督学习算法之一,你只要找出你的“邻居”是什么标签,然后看哪个标签的“邻居”最多,就给这个样本贴上哪个标签。它和“懒惰学习”有点像,因为它在学习的时候并不会做很多工作,只是把数据存起来,等到要分类的时候才来用。它主要是看样本和谁最像,就像人一样,总爱找和自己像的人玩。怎么才算最像呢?就是看各个样本之间的距离,距离越近越像。就像我们平时说的“物以类聚,人以群分”,这个分类方法也是这样,它通过距离来判断样本的相似度。所以,只要找到与测试样本最近的k个训练样本,看这k个样本里哪个类别最多,就认为这个类别是测试样本的类别。

> 注意:还有一个聚类算法就是KMeans也是用的距离,它们形式上很类似,但本质上是两个完全不同的算法!
KNN算法的执行流程可以概括为以下步骤:
- 计算距离:对于测试样本,计算其与训练集中每个样本的距离,距离的度量方式可以是欧式距离、曼哈顿距离等。
- 选择k个最近样本:选择与测试样本距离最近的k个样本。
- 投票并返回结果:根据k个最近样本的类别标签进行投票,多数决定原则,即哪个类别标签的多数就选择哪个标签作为测试样本的分类结果。
从上面的过程可以看出,KNN算法的好坏决定性因素有三个:距离的计算方式,K值的选取和数据集。在计算距离时可以采用多种不同的距离衡量方式,例如欧几里得距离、曼哈顿距离、车比雪夫距离等或者其他,用的最多的计算方法是欧几里得距离。既然数据集我们没有办法影响,那k值如何选择好呢?
如果K值太小,那测试样本就只会听“隔壁邻居”的意见,要是这个邻居是个“噪音制造者”,那测试样本的分类就会出错。相反,如果K值太大,那远处的邻居也会插嘴,虽然这样可以让分类更“稳重”,但也可能分得不准确,也就是“没分清楚”。所以,K值得试了才知道,不是我们一开始就能决定的。通常,我们会选一个5-15之间的奇数。但具体选哪个,除了实验和交叉验证,还得看看你的数据量有多大,以及训练集的标签有什么特点。为了加快最近邻居的搜索可以利用KD树进行数据结构的优化。KD树是对数据点在k维空间中划分的一种数据结构。在KD 的构造中,每个节点都是k维数值点的二叉树。既然是二叉树,就可以采用二叉树的增删改查操作,这样就大大提升了搜索效率。
### 9.6.2 KNN的代码实现
KNN的代码实现其实相对来说比较简单。使用sklearn的方法已经在前面看过了,这里我们从底层开始动手实现一个KNN。这里仍然选用最基础的鸢尾花数据集,首先仍然是导入数据:
```python
from sklearn.datasets import load_iris
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
x,y=load_iris(return_X_y=True)
x_train,x_test,y_train,y_test=train_test_split(x,y,train_size=0.7,random_state=42)
```
我们很容易得到距离的计算方法:
```python
def distance(a,b):
return np.sqrt(np.sum(np.square(a-b)))
```
针对单一样本,如何得到它最近的K个节点并投票呢?我们说,首先要计算它到其他点的距离,然后找最近的K个抽出来,计数。因此,如果训练数据集已知,可以写函数:
```python
def get_label(x):
dist=list(map(lambda a:distance(a,x),x_train))
ind=np.argsort(dist)
ind=ind[:k]
labels=np.zeros(3)
for i in ind:
label=y_train[ind].astype(int)
labels[label]+=1
return np.argmax(labels)
```
因此,对这个函数进行封装就可以包装为:
```python
def KNN(x_train,y_train,x_test,k):
def get_label(x):
dist=list(map(lambda a:distance(a,x),x_train))
ind=np.argsort(dist)
ind=ind[:k]
labels=np.zeros(3)
for i in ind:
label=y_train[ind].astype(int)
labels[label]+=1
return np.argmax(labels)
y_predict=np.zeros(len(x_test))
for i in range(len(x_test)):
y_predict[i]=get_label(x_test[i])
return y_predict
```
最后,使用不同的K值进行测试:
```python
for k in range(1,10):
y_predict=KNN(x_train,y_train,x_test,k)
print(accuracy_score(y_test,y_predict))
```
所得到的结果都是比较高的,正确率都在0.9以上。
## 9.7 基于优化的LDA与SVM模型
费舍尔判别和支持向量机两种模型都是基于优化推导的,因此可以借助这两个模型体会优化在机器学习中的应用。
### 9.7.1 线性判别分析(摘抄自我的MATLAB建模教材,要改)
线性判别分析(LDA),由于是费舍尔所发明,故又名费舍尔判别。LDA在模式识别领域(比如人脸识别,舰艇识别等图形图像识别领域)中有非常广泛的应用。LDA是一种监督学习的降维技术,也就是说它可以同时实现降维和分类两个操作。LDA的思想可以用一句话概括,就是“投影后类内方差最小,类间方差最大”,如图所示。 我们要将数据在低维度上进行投影,投影后希望每一种类别数据的投影点尽可能的接近,而不同类别的数据的类别中心之间的距离尽可能的大。图8.11是一个LDA的投影效果:
**线性判别分析(LDA)是一种在模式识别领域广泛应用的技术,如在人脸识别、舰艇识别等图像识别任务中。LDA由著名统计学家费舍尔提出,因此也被称为费舍尔判别分析。这种技术不仅能够降低数据的维度,还能在此过程中进行分类,使其成为监督学习中的一种重要降维技术。**
**LDA的核心思想可以用一句话概括,就是“投影后类内方差最小,类间方差最大”即寻找一个投影方向,使得在这个方向上,不同类别的数据点尽可能地分离,即类间方差最大化,而同一类别的数据点尽可能地靠近,即类内方差最小。这种投影可以有效地提取出对于分类任务最重要的特征,同时去除那些对于区分不同类别帮助不大的冗余信息。**
**LDA的优点在于它不仅能够降低数据的复杂性,提高计算效率,还能够提高分类的准确性。通过减少特征的数量,LDA有助于减少过拟合的风险,使得模型更加简洁且易于解释。然而,LDA也有一些局限性,当它假设数据在每个类别中都是高斯分布的,且不同类别的协方差矩阵相同,这在实际应用中不一定都能成立。**
**在实际应用中,LDA通过计算类内散度矩阵和类间散度矩阵来实现这一目标。类内散度矩阵反映了同一类别内数据点的分散程度,而类间散度矩阵则反映了不同类别间数据点的分离程度。LDA的目标是找到一个投影方向,使得在这个方向上,类内散度最小化,类间散度最大化。**
**想象一下,我们有一个由红蓝两种颜色的数据点组成的二维平面图,每个数据点代表一个观测样本,颜色表示它的类别标签。我们的任务是找到一个方法,将这些二维数据点简化表示,同时保持类别的区分度。这就是线性判别分析(LDA)的用武之地。**
**LDA是一种旨在降维和分类的技术。它通过找到一个新的投影方向,将数据点映射到一条直线上(在二维数据的情况下)。这个新的投影不仅减少了数据的复杂性,而且还尽可能保持了原始数据中类别的区分度。在投影后,我们希望看到红色数据点聚集在一起,蓝色数据点也聚集在一起,而两个集群之间有明显的分离。**
**为了实现这一目标,LDA首先计算类内散度矩阵和类间散度矩阵。类内散度矩阵衡量同类别的数据点之间的差异,而类间散度矩阵则衡量不同类别数据点的差异。LDA通过优化这两个矩阵的比值来找到最佳的投影方向,这个比值越大,说明投影后的数据点分类效果越好。**
**图8.11展示了LDA的投影效果:**

图8.11 LDA的投影效果
**假设我们已经找到了最佳的投影方向。当我们将数据点投影到这条直线上时,我们会发现,原本在二维空间中可能混杂在一起的红蓝数据点,现在在一维直线上清晰地分开了。红色数据点形成了一个集群,蓝色数据点形成了另一个集群,两个集群之间有较大的距离,这使得分类变得更加容易。**
**通过这种方式,LDA不仅帮助我们简化了数据表示,还提高了分类的准确性。这种方法特别适合于那些特征维度较高、数据复杂度较大的情况。**
**在实际操作中,我们首先计算类内散度矩阵和类间散度矩阵。类内散度矩阵衡量了同一类别内数据点的分布情况,而类间散度矩阵则反映了不同类别数据点的中心点之间的距离。接下来,我们要找到一个投影方向,使得在这个方向上,类间散度最大而类内散度最小。这个优化问题可以通过计算费舍尔判别系数来解决。**
**值得注意的是,(1)虽然费舍尔判别分析和线性判别分析在早期有所区别,但现在这两个术语通常被交替使用,尤其是在描述LDA时。(2)尽管我们在这里讨论的是二维数据集的投影问题,但在现实世界的机器学习任务中,数据集往往包含多个类别,且特征维度远超过二维。在这些情况下,LDA同样适用,不过我们不是将数据投影到直线上,而是投影到一个低维的超平面上。这样,我们可以通过较少的维度来捕捉数据的主要特征,同时减少计算复杂度和避免过拟合。**
假设有两类数据,分别为红色和蓝色,如图8.11所示,这些数据特征是二维的,希望将这些数据投影到一维的一条直线,让每一种类别数据的投影点尽可能的接近,而红色和蓝色数据中心之间的距离尽可能的大。图的红色数据和蓝色数据各个较为集中,且类别之间的距离明显。以上就是LDA的主要思想了,当然在实际应用中,数据是多个类别的,我们的原始数据一般也是超过二维的,投影后的也一般不是直线,而是一个低维的超平面。
注意:其实在早期费舍尔判别和线性判别还是有一点区别的,但现在可能就没有那么多讲究了。
怎样使得红蓝两个颜色的数据投影后有最大的区分度呢?这个区分度又该怎么定义呢?这也就是线性判别分析的流程:
- 对于给定有标签数据集(xi ,yi ),计算出均值和协方差和。
- 投影到直线y=wT X以后均值和协方差变成了${w^T}{\mu _i}$和${w^T}{\Sigma _i}w$。
- 类内差别尽量小,类间差别尽可能大,列出目标函数:
$$
J = \frac{{{w^T}({\mu _0} - {\mu _1}){{({\mu _0} - {\mu _1})}^T}w}}{{{w^T}({\Sigma _0} + {\Sigma _1})w}}
$$
- 记${S_w} = {\Sigma _0} + {\Sigma _1}$,${S_b} = {({\mu _0} - {\mu _1})^T}({\mu _0} - {\mu _1})$,那目标函数就变成了一个广义瑞利商$J = \frac{{{w^T}{S_b}w}}{{{w^T}{S_w}w}}$,于是问题等价成一个凸优化问题,再将分母规约得到:
$$
\begin{array}{c}
\min - {w^T}{S_b}w\\
s.t.{w^T}{S_w}w = 1
\end{array}
$$
利用拉格朗日法求解可以得到。实际中我们会对Sw 进行SVD分解。
据此,可以编写LDA的Python代码:
```python
def LDA(x, y): # x: all the input vector y: labels
x_1 = np.array([x[i] for i in range(len(x)) if y[i] == 1])
x_2 = np.array([x[i] for i in range(len(x)) if y[i] == -1])
mju1 = np.mean(x_1, axis=0) # mean vector
mju2 = np.mean(x_2, axis=0)
sw1 = np.dot((x_1 - mju1).T, (x_1 - mju1)) # Within-class scatter matrix
sw2 = np.dot((x_2 - mju2).T, (x_2 - mju2))
sw = sw1 + sw2
return np.dot(np.linalg.inv(sw), (mju1 - mju2))
```
对于上例,可以用如下代码找到最优分割超平面:
```python
w = LDA(x, y)
x1 = 1
y1 = -1 / w[1] * (w[0] * x1)
x2 = -1
y2 = -1 / w[1] * (w[0] * x2)
plt.plot([x1, x2], [y1, y2], 'k--')
plt.show()
```
### 9.7.2 支持向量机
支持向量机SVM是从线性可分情况下的最优分类面提出的。所谓最优分类,就是要求分类线不但能够将两类无错误的分开,而且两类之间的分类间隔最大。推广到高维空间,最优分类线就成为最优分类面。
考虑这样一个问题:在一个n维欧式空间内有红蓝两组点,怎么找到一个超平面将它们分隔开?为了方便大家理解,考虑最简单的情况,平面直角坐标系内有红蓝两组点,用一条直线将它们分隔开,如图所示:

这似乎是很容易的一件事,直线的斜率可以不同,斜率相同的情况下截距也可以不同,有很多种分法都可以把两组样本点分割开来。但是,哪一种分法是最好的呢?这里首先就需要定义怎样才能算最好。不妨称:正样本到直线的距离与负样本到直线距离之和最大就是最好。样本到直线的距离定义为样本中所有点到直线距离的最小值。事实上,如果同学们还记得高中解析几何的知识,就知道点到直线的距离等于过这一点作直线平行线进而求解两平行线之间的距离。
所以,目标函数也就是求这样一条直线:过正样本和负样本中各自离直线最近的两点作平行线,使两平行线之间距离最大。为了表示样本的正负性,不妨记正样本标签为+1,负样本标签为-1,可以得到直线的数学表达形式:
$$
\begin{array}{l}
{H_1}:{w^T}x + b = + 1\\
{H_2}:{w^T}x + b = - 1
\end{array}
$$
如果同学们熟悉解析几何当中的内容,就会知道两平行线之间的距离实际上等于$\gamma = \frac{2}{{||w||}}$。但这个函数非凸,为了转化为凸优化,对其进行一些小变换。进而,有了支持向量机的数学表达形式:
$$
\[\begin{array}{c}
{\min _w}\frac{{||w|{|^2}}}{2}\\
{\rm{s}}.t.{y_i}({w^T}{x_i} + b) \ge 1
\end{array}\]
$$
通过这样的一个凸优化问题求解就可以把支持向量机解出来了。真的是这样吗?来看下面一个例子你能不能通过一条直线把它分割出来:

这个时候我们就发现了问题:不是所有的样本都可以用一条直线粗暴地分割开的。即使用直线去分割,一定会有一些样本点会被分错。如何让支持向量机具备容错性呢?这时,就需要借助软间隔的概念。你可以理解为,软间隔是一种正则化手段,它允许支持向量机有一个较小的误差率。加入一些松弛变量模型就变成了:
$$
\begin{array}{c}
\mathop {\min }\limits_w \frac{{||w|{|^2}}}{2} + C\sum\limits_{i = 1}^n {{\xi _i}} \\
s.t.{y_i}({w^T}{x_i} + b) \ge 1 - {\xi _i},
\end{array}
$$
但是,即使允许有一定的分类误差率,用直线分割图中的样本还是太困难了,错误率会非常高。这个时候就有了支持向量机的第二个技巧:核方法。我们这样想,如果能够通过一个多元函数,把二维样本点映射到三维空间当中去,再用一个平面分割它,问题是不是就可以解决了呢?例如,如果使用函数z=x2+y2,图像可以变成:

很显然,经过映射以后,样本点用一个平面就可以完全把它们分开。那么这样的一个映射函数本质上是一个升维操作,这个函数也叫核函数。核方法使得数据能够由低维映射向高维,让形如异或问题这样的线性不可分问题得到解决。常用的核函数包括表8.2中列出的几种。
**表8.2 常见核函数及其形式**

非线性问题往往不好求解,所以希望能用解线性分类问题的方法求解,因此可以采用非线性变换,将非线性问题变换成线性问题。对于这样的问题,可以将训练样本从原始空间映射到一个更高维的空间,使得样本在这个空间中线性可分,如果原始空间维数是有限的,即属性是有限的,那么一定存在一个高维特征空间是样本可分。用核方法映射过后的数值代替X,就可以得到新的超平面形式为$y = {w^T}K(X) + b$。
对于支持向量机的求解,可以使用拉格朗日乘子法。先不考虑核化,引入一系列拉格朗日乘子以后问题可以变形成:
$$
L(w,b,\alpha ) = \frac{1}{2}||w|{|^2} + \sum\limits_{i = 1}^2 {{\alpha _i}} (1 - {y_i}({w^T}{x_i} + b))
$$
对目标函数求偏导就等于:
$$
\left\{ {\begin{array}{*{20}{l}}
{\frac{{\partial L}}{{\partial b}} = \sum\limits_{i = 1}^n {{\alpha _i}} {y_i}}\\
{\frac{{\partial L}}{{\partial w}} = w - \sum\limits_{i = 1}^n {{\alpha _i}} {y_i}{x_i}}
\end{array}} \right.
$$
解方程然后代入,将w和b消掉就只剩下了拉格朗日乘子:
$$
\begin{array}{l}
{\max _\alpha }\sum\limits_{i = 1}^n {{a_i}} - \frac{1}{2}\sum\limits_{i = 1}^n {\sum\limits_{j = 1}^n {{\alpha _i}} } {\alpha _j}{y_i}{y_j}{x_i}{x_j}\\
s.t.\sum\limits_{i = 1}^n {{\alpha _i}} {y_i} = 0,{\alpha _i} \ge 0,i = 1,2,...,n
\end{array}
$$
那么如果引入核函数,这个问题的KKT条件也就可以写作:
$$
\left\{ {\begin{array}{*{20}{l}}
{{\alpha _i} \ge 0}\\
{{y_i}f({x_i}) - 1 \ge 0}\\
{{\alpha _i}({y_i}f({x_i}) - 1) = 0}
\end{array}} \right.
$$
当训练完成后,大部分样本都不需要保留,最终模型只与支持向量有关。如果是经过核化的,就把目标函数变成:
$$
{\max _\alpha }\sum\limits_{i = 1}^n {{a_i}} - \frac{1}{2}\sum\limits_{i = 1}^n {\sum\limits_{j = 1}^n {{\alpha _i}} } {\alpha _j}{y_i}{y_j}k({x_i},{x_j})
$$
这个问题变量太多,传统的优化算法很难求解。在运筹学中有一种方法叫序贯优化算法(SMO)。SMO是用来求解拉格朗日乘子的,它的思想概括为不断地变换主元,只留下两个主元给我自由活动的最小空间(因为只留一个的话那一个可以通过代换的方式定下来所以要确定主元就至少要选两个可变其他的为常量)可以简记为${\alpha _i}{y_1} + {\alpha _2}{y_2} = \delta $,反代入:
$$
\[W = {a_1} + {a_2} - \frac{1}{2}{K_{11}}{a_1}^2 - \frac{1}{2}{K_{22}}{a_2}^2 - {y_1}{y_2}{a_1}{a_2}{K_{12}} - {y_1}{a_1}{v_1} - {y_2}{a_2}{v_2} + {C_0}\]
$$
然后这一凸优化问题可以转化求导就可以解出来了。求出来两个以后再换组合,将这些乘子逐一击破。
对于一般的回归问题,给定训练样本,希望学习到一个f(x)使得其与y尽可能的接近,w,b是待确定的参数。在这个模型中,只有当f(x)与y完全相同时,损失才为零,而支持向量回归假设能容忍的f(x)与y之间最多有ε的偏差,当且仅当f(x)与y的差别绝对值大于ε时,才计算损失,此时相当于以f(x)为中心,构建一个宽度为2ε的间隔带,若训练样本落入此间隔带,则认为是被预测正确的。
支持向量回归的模型形式为
$$
\frac{{||w|{|^2}}}{2} + C\sum\limits_{i = 1}^n {{l_}} (f({x_i}) - {y_i})
$$
其中${l_}(x) = \left\{ {\begin{array}{*{20}{l}}
{0(x \le )}\\
{(|x| - )(x > )}
\end{array}} \right.$,具体的求解过程得先引入松弛变量然后再来引入拉格朗日乘子再对偶。具体这里不展开了,直接给出最后的模型形式:
$$
\begin{array}{l}
f(x) = \sum\limits_{i = 1}^n {(\widehat {{\alpha _i}} - {\alpha _i})} x_i^Tx + b\\
b = {y_i} + - \sum\limits_{i = 1}^n {(\widehat {{\alpha _i}} - {\alpha _i})} x_i^Tx
\end{array}
$$
支持向量机的理论是很复杂的一套优化理论,近年来还有对半监督的SVM、few-shot与SVM结合的研究等,有兴趣的话是可以深入了解的。(这里标蓝的段落摘抄自我的MATLAB版教材,需要改文字表述,另外我感觉讲的有点烂,你们如果也这么觉得可以干脆重写)
> 这里的代码参考了《动手学机器学习》
**在解决支持向量机问题时,拉格朗日乘子法是一种有效的策略。通过引入一系列的拉格朗日乘子,我们可以将原始问题转换为一个对偶问题,其目标函数形式如公式(8.31)所示。对目标函数求偏导的结果,如公式(8.32)所示,可以帮助我们找到最优的解。通过解这些偏导数等于零的方程,我们最终可以消去w和b,只剩下拉格朗日乘子的值,如公式(8.33)所示。**
**当引入核函数后,KKT条件会发生变化,如公式(8.34)所示。训练完成后,只有支持向量对模型的预测有影响,其他样本可以忽略。对于核化的SVM,目标函数的形式变为公式(8.35)。**
**对于回归问题,我们的目标是最小化预测值f(x)和真实值y之间的差异。在支持向量回归中,我们允许最大偏差为ε,构建一个宽度为2ε的间隔带,只有当训练样本的预测值与真实值之差的绝对值超过ε时,我们才计算损失。支持向量回归的模型形式如公式(8.37)所示。求解这个模型需要引入松弛变量和拉格朗日乘子,最终的对偶形式如公式(8.38)所示。**
**支持向量机的理论是一个复杂的优化理论领域,近年来还扩展到了半监督学习、few-shot学习等多个研究方向。如果您对此感兴趣,可以进一步深入了解。**
> 代码:《动手学机器学习》是一本以实践为导向的教材,它不仅提供了丰富的理论知识,还包含了许多可以直接运行的代码示例。本书通过直观的编程练习,帮助读者深入理解和支持向量机等机器学习算法的工作原理。例如,书中对SVM的实现包括了如何选择合适的核函数,以及如何利用序贯最小优化(SMO)算法来解决优化问题。如果您想要亲手尝试这些代码,可以参考《动手学机器学习》中的相关章节,书中详细的步骤和注释将引导您逐步完成每个练习。
```python
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
from tqdm import tqdm, trange
data = np.loadtxt('linear.csv', delimiter=',')
print('数据集大小:', len(data))
x = data[:, :2]
y = data[:, 2]
# 数据集可视化
plt.figure()
plt.scatter(x[y == -1, 0], x[y == -1, 1], color='red', label='y=-1')
plt.scatter(x[y == 1, 0], x[y == 1, 1], color='blue', marker='x', label='y=1')
plt.xlabel(r'$x_1$')
plt.ylabel(r'$x_2$')
plt.legend()
plt.show()
```

```python
def SMO(x, y, ker, C, max_iter):
'''
SMO算法
x,y:样本的值和类别
ker:核函数,与线性回归中核函数的含义相同
C:惩罚系数
max_iter:最大迭代次数
'''
# 初始化参数
m = x.shape[0]
alpha = np.zeros(m)
# 预先计算所有向量的两两内积,减少重复计算
K = np.zeros((m, m))
for i in range(m):
for j in range(m):
K[i, j] = ker(x[i], x[j])
for l in trange(max_iter):
# 开始迭代
for i in range(m):
# 有m个参数,每一轮迭代中依次更新
# 固定参数alpha_i与另一个随机参数alpha_j,并且保证i与j不相等
j = np.random.choice([l for l in range(m) if l != i])
# 用-b/2a更新alpha_i的值
eta = K[j, j] + K[i, i] - 2 * K[i, j] # 分母
e_i = np.sum(y * alpha * K[:, i]) - y[i] # 分子
e_j = np.sum(y * alpha * K[:, j]) - y[j]
alpha_i = alpha[i] + y[i] * (e_j - e_i) / (eta + 1e-5) # 防止除以0
zeta = alpha[i] * y[i] + alpha[j] * y[j]
# 将alpha_i和对应的alpha_j保持在[0,C]区间
# 0 <= (zeta - y_j * alpha_j) / y_i <= C
if y[i] == y[j]:
` `lower = max(0, zeta / y[i] - C)
` `upper = min(C, zeta / y[i])
else:
` `lower = max(0, zeta / y[i])
` `upper = min(C, zeta / y[i] + C)
alpha_i = np.clip(alpha_i, lower, upper)
alpha_j = (zeta - y[i] * alpha_i) / y[j]
# 更新参数
alpha[i], alpha[j] = alpha_i, alpha_j
return alpha
# 设置超参数
C = 1e8 # 由于数据集完全线性可分,我们不引入松弛变量
max_iter = 1000
np.random.seed(0)
alpha = SMO(x, y, ker=np.inner, C=C, max_iter=max_iter)
# 用alpha计算w,b和支持向量
sup_idx = alpha > 1e-5 # 支持向量的系数不为零
print('支持向量个数:', np.sum(sup_idx))
w = np.sum((alpha[sup_idx] * y[sup_idx]).reshape(-1, 1) * x[sup_idx], axis=0)
wx = x @ w.reshape(-1, 1)
b = -0.5 * (np.max(wx[y == -1]) + np.min(wx[y == 1]))
print('参数:', w, b)
# 绘图
X = np.linspace(np.min(x[:, 0]), np.max(x[:, 0]), 100)
Y = -(w[0] * X + b) / (w[1] + 1e-5)
plt.figure()
plt.scatter(x[y == -1, 0], x[y == -1, 1], color='red', label='y=-1')
plt.scatter(x[y == 1, 0], x[y == 1, 1], marker='x', color='blue', label='y=1')
plt.plot(X, Y, color='black')
# 用圆圈标记出支持向量
plt.scatter(x[sup_idx, 0], x[sup_idx, 1], marker='o', color='none',
edgecolor='purple', s=150, label='support vectors')
plt.xlabel(r'$x_1$')
plt.ylabel(r'$x_2$')
plt.legend()
plt.show()
```

我们再来看一个线性不可分的例子。
```python
data = np.loadtxt('spiral.csv', delimiter=',')
print('数据集大小:', len(data))
x = data[:, :2]
y = data[:, 2]
# 数据集可视化
plt.figure()
plt.scatter(x[y == -1, 0], x[y == -1, 1], color='red', label='y=-1')
plt.scatter(x[y == 1, 0], x[y == 1, 1], marker='x', color='blue', label='y=1')
plt.xlabel(r'$x_1$')
plt.ylabel(r'$x_2$')
plt.legend()
plt.axis('square')
plt.show()
```

定义不同的核函数:
```python
# 简单多项式核
def simple_poly_kernel(d):
def k(x, y):
return np.inner(x, y) ** d
return k
# RBF核
def rbf_kernel(sigma):
def k(x, y):
return np.exp(-np.inner(x - y, x - y) / (2.0 * sigma ** 2))
return k
# 余弦相似度核
def cos_kernel(x, y):
return np.inner(x, y) / np.linalg.norm(x, 2) / np.linalg.norm(y, 2)
# sigmoid核
def sigmoid_kernel(beta, c):
def k(x, y):
return np.tanh(beta * np.inner(x, y) + c)
return k
测试不同核函数下样本会被如何核化:
kernels = [
simple_poly_kernel(3),
rbf_kernel(0.1),
cos_kernel,
sigmoid_kernel(1, -1)
]
ker_names = ['Poly(3)', 'RBF(0.1)', 'Cos', 'Sigmoid(1,-1)']
C = 1e8
max_iter = 500
# 绘图准备,构造网格
plt.figure()
fig, axs = plt.subplots(2, 2, figsize=(10, 10))
axs = axs.flatten()
cmap = ListedColormap(['coral', 'royalblue'])
# 开始求解 SVM
for i in range(len(kernels)):
print('核函数:', ker_names[i])
alpha = SMO(x, y, kernels[i], C=C, max_iter=max_iter)
sup_idx = alpha > 1e-5 # 支持向量的系数不为零
sup_x = x[sup_idx] # 支持向量
sup_y = y[sup_idx]
sup_alpha = alpha[sup_idx]
# 用支持向量计算 w^T*x
def wx(x_new):
s = 0
for xi, yi, ai in zip(sup_x, sup_y, sup_alpha):
s += yi * ai * kernels[i](xi, x_new)
return s
# 计算b*
neg = [wx(xi) for xi in sup_x[sup_y == -1]]
pos = [wx(xi) for xi in sup_x[sup_y == 1]]
b = -0.5 * (np.max(neg) + np.min(pos))
# 构造网格并用 SVM 预测分类
G = np.linspace(-1.5, 1.5, 100)
G = np.meshgrid(G, G)
X = np.array([G[0].flatten(), G[1].flatten()]).T # 转换为每行一个向量的形式
Y = np.array([wx(xi) + b for xi in X])
Y[Y < 0] = -1
Y[Y >= 0] = 1
Y = Y.reshape(G[0].shape)
axs[i].contourf(G[0], G[1], Y, cmap=cmap, alpha=0.5)
# 绘制原数据集的点
axs[i].scatter(x[y == -1, 0], x[y == -1, 1], color='red', label='y=-1')
axs[i].scatter(x[y == 1, 0], x[y == 1, 1], marker='x', color='blue', label='y=1')
axs[i].set_title(ker_names[i])
axs[i].set_xlabel(r'$x_1$')
axs[i].set_ylabel(r'$x_2$')
axs[i].legend()
plt.show()
```

## 9.8 基于树形结构的模型
什么是决策树?决策树是一种利用树状数据结构来进行分类或者回归的算法。在决策树的生成中,我们通过信息论里面几个数值的计算判断分类例如熵,信息增益,增益率,基尼指数等,然后通过这些指标去生成一整个决策的树形图。
### 9.8.1 信息论中的基本概念
在决策树的生成中,我们通过信息论里面几个数值的计算判断分类:熵,信息增益,增益率,基尼指数。
- 信息熵:熵是衡量数据集中类别混乱程度的一个指标。在决策树中,我们使用熵来评估一个节点中的数据分布情况。如果一个节点中的数据大部分都属于同一个类别,那么这个节点的熵值就比较低,意味着这个节点的分类比较明确;反之,如果数据分布比较均匀,则熵值较高,表示这个节点的分类比较模糊。通过计算熵,我们可以确定在哪个节点进行分裂,以最大化分类的准确性。如果对于样本集合S,一共可以划分为k个类,每个类概率是pk ,那么信息熵定义为:
$$
E(S) = - \sum\limits_{i = 1}^k {{p_k}} \log {p_k}
$$
如果对数据集S按照某一个属性A对S进行划分,将它划分成v个子集,定义属性A的信息熵为:
$$
E(S,A) = \sum\limits_{i = 1}^v {\frac{{|{A_i}|}}{{|S|}}} E({A_i})
$$
- 信息增益:数据集本身的k个类是按照最后的返回标签排的,所以数据集本身的信息熵与属性的信息熵并不是一个东西。信息增益就是这个差值,看看按照这个属性划分我的信息到底增长了多少。信息增益用于衡量某个属性对于分类的贡献程度。在决策树中,我们选择信息增益最大的属性作为当前节点的分裂属性,这样可以使得子节点的数据更加集中,从而提高分类的准确性。
$$
Gain(A) = E(S) - E(S,A)
$$
- 增益率:增益与信息熵的比值,它解决了信息增益偏向于选择具有多个值的属性问题。增益率结合了信息增益和属性熵,使得决策树在选择分裂属性时既考虑了分类的纯度,又考虑了属性的分散程度。
$$
GainRatio(A) = \frac{{Gain(A)}}{{E(S,A)}}
$$
- 基尼指数:基尼指数另一种衡量数据纯度的方式。它基于一个假设,即如果一个样本被错误分类,则该样本的基尼指数增加。因此,通过计算基尼指数,我们可以确定数据集的纯度,并选择能够最小化基尼指数的属性进行分裂。假设数据集有n个类,第k类的概率是pk ,定义基尼指数:
$$
GINI(S) = 1 - \sum\limits_{i = 1}^n {p_k^2}
$$
通过引入这些信息论中的统计量,我们可以更好地构建决策树。它们帮助我们评估数据集的纯度,确定最佳的分裂属性,从而构建一棵结构简单、分类准确度高的决策树。这不仅提高了决策树的性能,也使得决策树在处理实际问题时更加可靠和有效。
9.8.2 ID3决策树
ID3决策树能够处理自变量和标签都是离散型的分类问题。它是以信息熵和信息增益度为衡量标准,从而实现对数据的归纳分类。它是在已知各种情况发生概率的基础上,通过构成决策树来求取净现值的期望值大于等于零的概率,评价项目风险,判断其可行性的决策分析方法。
ID3决策树的训练步骤分四步:
(1)创建根节点,确定属性是什么。
(2)若全部样本都是一类,那就全部落在叶子结点上。否则,根据每个属性计算信息增益,根据最大的信息增益确定划分属性与划分原则。
(3)根据划分属性把属性值不同的样本划到对应边上。
(4)根据不同属性的分类准则递归生成决策树。
> 代码参考了,特别鸣谢!
例如,用一个非常简单的案例实现ID3决策树。由于它只能处理离散自变量与离散标签的问题,这里选用的案例也比较简单,是一份贷款申请成功表,包含四个自变量:年龄段(青年、中年、老年),有工作(是、否),有自己的房子(是、否)和信贷情况(一般、好、非常好),最终标签是是否贷款成功。可以导入数据:
```python
import numpy as np
dataSet = [[0, 0, 0, 0, 'no'], #数据集
[0, 0, 0, 1, 'no'],
[0, 1, 0, 1, 'yes'],
[0, 1, 1, 0, 'yes'],
[0, 0, 0, 0, 'no'],
[1, 0, 0, 0, 'no'],
[1, 0, 0, 1, 'no'],
[1, 1, 1, 1, 'yes'],
[1, 0, 1, 2, 'yes'],
[1, 0, 1, 2, 'yes'],
[2, 0, 1, 2, 'yes'],
[2, 0, 1, 1, 'yes'],
[2, 1, 0, 1, 'yes'],
[2, 1, 0, 2, 'yes'],
[2, 0, 0, 0, 'no']]
dataSet=np.array(dataSet)
labels = ['年龄', '有工作', '有自己的房子', '信贷情况'] #分类属性
```
首先,编写函数计算数据集的信息熵。这里需要对标签出现的次数进行计数,通过numpy.unique函数实现计数功能。这里我们使用了一个技巧,为了防止计算熵时出现log0,在原有基础上增加0.00001一个很小的数。
```python
def EntropyData(dataset):
n = len(dataset) #返回数据集的行数
dataset=dataset[:,-1]
count = np.unique(dataset, return_counts=True)[1]
ent = -np.sum([c/n * np.log2(c/n + 0.00001) for c in count]) # 防止出现log0
return ent
```
随后,如果选中了某个特征需要进行分类,则对其进行筛选和剔除操作。这个特征也被选中成为决策树的节点。
```python
def maxcount(y):
y,c = np.unique(y,return_counts=True)
return y[c==max(c)]
def splitdata(dataset, f, value):
dataset = dataset[dataset[:, f] == value, :]
retDataSet=np.delete(dataset,f,1)
return retDataSet #返回划分后的数据集
```
有了这些前期工作,就可以计算信息增益:
```python
def infoGain(fList, i,dataset):
baseEntropy = EntropyData(dataset) #计算数据集的香农熵
newEntropy = 0.0 #经验条件熵
for value in fList: #计算信息增益
subDataSet = splitdata(dataset, i, value) #subDataSet划分后的子集
prob = len(subDataSet) / float(len(dataset)) #计算子集的概率
newEntropy += prob * EntropyData(subDataSet) #根据公式计算经验条件熵
infoGain = baseEntropy - newEntropy #信息增益
return infoGain
```
这里i是特征编号,传递进来用以计算针对特定特征的信息增益。通过对每个特征的增益计算,找到划分后增益最大的特征,于是开始选择节点并构建决策树:
```python
def choose(dataset):
numFeatures = len(dataset[0]) - 1 #特征数量
bestInfoGain = 0.0 #信息增益
bestFeature = -1 #最优特征的索引值
for i in range(numFeatures): #遍历所有特征
#获取dataSet的第i个所有特征
featList = [example[i] for example in dataset]
uniqueVals = set(featList) #创建set集合{},元素不可重复
iGain = infoGain(uniqueVals,i,dataset) #信息增益
print("第%d个特征的增益为%.3f" % (i, iGain)) #打印每个特征的信息增益
if (iGain > bestInfoGain): #计算信息增益
bestInfoGain = iGain #更新信息增益,找到最大的信息增益
bestFeature = i #记录信息增益最大的特征的索引值
return bestFeature #返回信息增益最大的特征的索引值
def createID3(dataSet, labels, featLabels):
classList = [example[-1] for example in dataSet] #取分类标签(是否放贷:yes or no)
if classList.count(classList[0]) == len(classList): #如果类别完全相同则停止继续划分
return classList[0]
if len(dataSet[0]) == 1: #遍历完所有特征时返回出现次数最多的类标签
return maxcount(classList)
bestFeat = choose(dataSet) #选择最优特征
bestFeatLabel = labels[bestFeat] #最优特征的标签
featLabels.append(bestFeatLabel)
myTree = {bestFeatLabel:{}} #根据最优特征的标签生成树
del(labels[bestFeat]) #删除已经使用特征标签
featValues = [example[bestFeat] for example in dataSet] #得到训练集中所有最优特征的属性值
uniqueVals = set(featValues) #去掉重复的属性值
for value in uniqueVals:
subLabels=labels[:]
#递归调用函数createTree(),遍历特征,创建决策树。
myTree[bestFeatLabel][value] = createTree(splitdata(dataSet, bestFeat, value), subLabels, featLabels)
return myTree
```
无论是决策树还是写力扣中与二叉树有关的题目,最基本的想法就是递归。这里我们就是递归生成了决策树,用字典分支。运行这一系列函数:
```python
featLabels = []
myTree = createID3(dataSet, labels, featLabels)
print(myTree)
```
首先计算得到第0个特征的增益为0.083,第1个特征的增益为0.324,第2个特征的增益为0.420,第3个特征的增益为0.363。选择第2个特征分裂,删除它,还剩下三个特征。重新计算:第0个特征的增益为0.252,第1个特征的增益为0.918,第2个特征的增益为0.474。此时已经可以满足分类需求,递归停止。得到树的结构为:
{'有自己的房子': {'1': 'yes', '0': {'有工作': {'1': 'yes', '0': 'no'}}}}
### 9.8.3 C4.5决策树
为了处理连续属性的自变量和缺失值问题,对ID3决策树进行改进就得到了C4.5决策树,后来又诞生了C5.0决策树。C4.5决策树在每个节点处都会选择一个最佳的属性进行分支,选择的标准通常是信息增益或增益率。信息增益代表信息不确定性较少的程度,信息增益越大,说明不确定性降低的越多,因此该特征对分类来说越重要。C4.5通过阈值自动把连续变量分成两部分来处理,在数据特性和基本结构上具有很强的灵活性和泛化能力,能够处理各种类型的数据,并构建出准确度高的分类模型。
C4.5决策树的本质是一种分治策略,基本步骤包括:
(1)将连续数值离散化,创建树。
(2)确定连续属性的阈值,计算信息增益率,确定划分属性。
(3)根据划分属性把属性值不同的样本划到对应边上。
(4)根据不同属性的分类准则递归生成树。
有了前面ID3的基础,实现C4.5其实非常简单。首先需要定义信息增益率的写法:
```python
def splitdata_C4_5(dataset, f, value):
dataset = dataset[dataset[:, f] <= value, :]
retDataSet=np.delete(dataset,f,1)
return retDataSet #返回划分后的数据集
def infoGain_rate(fList,i,dataset):
H = EntropyData(dataset)
IG = infoGain(fList,i,dataset)
return IG/H
```
然后使用这两处替换ID3代码的对应主体部分即可。得到的生成树结构为:
{'信贷情况': {'no': {'有自己的房子': {'1': '0', '0': '0'}}, 'yes': {'有自己的房子': {'1': {'有工作': {'1': {'年龄': {'1': array(['0'], dtype='t,如果训练轮次还没有达到训练次数上限T ,则训练出在Dt 统计分布下采样数据集生成的学习器。
- **评估调整权重**
- **每训练出一个弱学习器,我们就会用它来对样本进行预测,并计算出预测的准确性。如果某个样本被错误分类,我们会提高这个样本的权重,这样在下一轮训练中,模型就会更加关注这些难以分类的样本。同时,我们会根据每个学习器的表现来调整其在最终模型中的权重。**
- 计算误差率,然后计算出权重:
$$
{\alpha _t} = \frac{1}{2}\ln \frac{{1 - {_t}}}{{{_t}}}
$$
- **更新样本分布:**
- **在每次迭代后,我们会根据样本的新权重来更新样本分布。这样做是为了确保在接下来的训练轮次中,模型能够集中注意力在那些之前被错误分类的样本上。**
- 更新分布:
$$
{D_{t + 1}}(i) = \frac{{{D_t}(i)}}{{{Z_t}}}\exp ( - {\alpha _t}{y_i}{h_t}({x_i}))
$$
` `其中,Zt 为正则化因子,Zt正则化因子在Adaboost算法中起到了平衡样本权重、防止过拟合和提高泛化能力的作用。 它的目的是使得Dt+1 服从正态分布。
- 当次数未达到上限或精度不达标时,返回步骤(2)继续执行。
- **循环并及时更新:**
- **这个过程会一直重复,直到我们达到了预先设定的训练次数上限,或者模型的预测性能已经足够好。每次循环,我们都会得到一个新的弱学习器,并根据其表现更新样本权重和分布。**
- **构建最终模型:**
- **当所有训练轮次完成后,我们会将所有弱学习器组合起来形成一个强学习器。这个强学习器会综合所有弱学习器的预测结果,通过加权等方式来进行最终的分类或预测。**
> 代码参考了博客与,特别鸣谢!
现在,我们可以自己动手实现一个AdaBoost算法。首先,我们需要定义一个简单的基学习器,这里使用简单的决策树桩实现。为了扩展方便,我们将决策树封装为类,但是不需要像上一节里面说的那么复杂,可以简单一点:
```python
class DecisionTreeClassifierWithWeight:
def __init__(self):
` `self.best_err = 1 # 最小的加权错误率
` `self.best_fea_id = 0 # 最优特征id
` `self.best_thres = 0 # 选定特征的最优阈值
` `self.best_op = 1 # 阈值符号,其中 1: >, 0: <
def fit(self, X, y, sample_weight=None):
` `if sample_weight is None:
` `sample_weight = np.ones(len(X)) / len(X)
` `n = X.shape[1]
` `for i in range(n):
` `feature = X[:, i] # 选定特征列
` `fea_unique = np.sort(np.unique(feature)) # 将所有特征值从小到大排序
` `for j in range(len(fea_unique)-1):
` `thres = (fea_unique[j] + fea_unique[j+1]) / 2 # 逐一设定可能阈值
` `for op in (0, 1):
` `y_ = 2*(feature >= thres)-1 if op==1 else 2*(feature < thres)-1 # 判断何种符号为最优
` `err = np.sum((y_ != y)*sample_weight)
` `if err < self.best_err: # 当前参数组合可以获得更低错误率,更新最优参数
` `self.best_err = err
` `self.best_op = op
` `self.best_fea_id = i
` `self.best_thres = thres
` `return self
def predict(self, X):
feature = X[:, self.best_fea_id]
return 2*(feature >= self.best_thres)-1 if self.best_op==1 else 2*(feature < self.best_thres)-1
def score(self, X, y, sample_weight=None):
y_pre = self.predict(X)
if sample_weight is not None:
return np.sum((y_pre == y)*sample_weight)
return np.mean(y_pre == y)
```
那么接下来的操作就是在一定轮次中不断训练基学习器,并计算归一化权重将基学习器集成起来。形如:
```python
class AdaBoostClassifier:
def __init__(self, n_estimators=50):
` `self.n_estimators = n_estimators
` `self.estimators = []
` `self.alphas = []
def fit(self, X, y):
` `sample_weight = np.ones(len(X)) / len(X) # 初始化样本权重为 1/N
` `for _ in range(self.n_estimators):
` `dtc = DecisionTreeClassifierWithWeight().fit(X, y, sample_weight) # 训练弱学习器
` `alpha = 1/2 * np.log((1-dtc.best_err)/dtc.best_err) # 权重系数
` `y_pred = dtc.predict(X)
` `sample_weight *= np.exp(-alpha*y_pred*y) # 更新迭代样本权重
` `sample_weight /= np.sum(sample_weight) # 样本权重归一化
` `self.estimators.append(dtc)
` `self.alphas.append(alpha)
` `return self
def predict(self, X):
` `y_pred = np.empty((len(X), self.n_estimators)) # 预测结果二维数组,其中每一列代表一个弱学习器的预测结果
` `for i in range(self.n_estimators):
` `y_pred[:, i] = self.estimators[i].predict(X)
` `y_pred = y_pred * np.array(self.alphas) # 将预测结果与训练权重乘积作为集成预测结果
` `return 2*(np.sum(y_pred, axis=1)>0)-1 # 以0为阈值,判断并映射为-1和1
def score(self, X, y):
` `y_pred = self.predict(X)
` `return np.mean(y_pred==y)
使用sklearn自带的乳腺癌数据集测试:
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
X, y = load_breast_cancer(return_X_y=True)
y = 2*y-1 # 将0/1取值映射为-1/1取值
x_train, x_test, y_train, y_test = train_test_split(X, y,random_state=42)
print(AdaBoostClassifier_().fit(x_train, y_train).score(x_test, y_test))
```
最终的准确率达到了0.97,是比较不错的水平。
### 9.9.2 Bagging方法与随机森林
Bagging是另一种集成学习技术,与Boosting不同,它通过从原始数据集中随机抽取样本(有放回地)来训练基学习器。这种方式能够降低模型对数据集的过度拟合,提高模型的泛化能力。Bagging通过引入随机性来降低各个基学习器之间的相关性,从而获得更好的集成效果。
Bagging方法的特点有以下几点:
- 自助采样:Bagging采用有放回的自助采样方法从原始数据集中随机抽取样本,每个样本被选中的概率相等。这样每个基学习器都从不同的数据子集中进行训练,降低了它们之间的相关性。
- 简单平均:Bagging通过简单平均的方式将多个基学习器的预测结果结合起来,形成一个更稳定的预测结果。与Boosting不同,Bagging不会对每个基学习器的预测结果赋予不同的权重,而是平等对待它们。
- 降低方差:Bagging通过降低基学习器之间的相关性来降低集成学习模型的方差,从而提高泛化性能。这使得Bagging对于噪声数据和异常值具有一定的鲁棒性。
随机森林是Bagging最有代表性的算法。随机森林的名称中有两个关键词,一个是“随机”,一个就是“森林”。“森林”我们很好理解,一棵叫做树,那么成百上千棵就可以叫做森林了,这样的比喻还是很贴切的,其实这也是随机森林的主要思想——集成思想的体现。那随机是什么?就是在生成决策树的时候会随机选择一些属性。一般的决策树在选择划分属性时是在当前结点的所有d属性中选择一个最优属性,而在随机森林的基学习器上不一定用完全部的属性而是抽样抽出一部分k,然后从该属性集中选择最优的划分属性。
随机森林的优势包括以下几点:
- 能够处理很高维度的数据。
- 在训练完成后,可以给出哪些属性比较重要,这一方法也被用于自动化特征工程。
- Bagging是一种并行化方法,训练速度快。
- 方便进行可视化展示,便于后续分析。
随机森林是Bagging系列的典型代表。而Bagging又是并行式集成学习方法的著名代表,它是基于自助采样法(有放回的取样)来提高学习器泛化能力的一种很高效的集成学习方法。它的策略如下:
- 从样本集里面Bootstrap采集n个样本。
- 在树的每个节点上,从所有属性中随机选择k个属性,选择出一个最佳分割属性作为节点,建立决策树,一般,d表示属性的总个数。
- 重复以上两步m次,建立m棵决策树。重复的过程可以并行化。
- 通过简单平均或加权平均形成随机森林。
### 9.9.3 GBDT框架
GBDT 是一种基于梯度提升(Gradient Boosting)的集成学习算法。它通过不断学习一系列的决策树模型,并将这些模型组合起来,形成一个强大的集成模型。在每一次迭代中,GBDT 会对上一次迭代的残差进行建模,也就是预测值与实际值之间的差。这样,GBDT 能够逐步地提高模型的预测精度。
在传统集成学习中,Boosting系列算法的基学习器往往是串行生成,Bagging系列算法(例如随机森林)往往是并行生成。能不能提出一种可以并行或部分并行的Boosting方法呢?传统的集成学习算法(如bagging和随机森林)是对原始数据进行重采样和特征重选择来构建多个基模型。而GBDT是直接对原始的损失函数进行优化,学习的是残差函数,这使得GBDT能更好地逼近真实函数。在每一步,GBDT 都会根据上一轮的误差来更新样本权重,这样在下一轮迭代中,模型会对之前难以预测的样本给予更大的关注。这种动态调整样本权重的策略使得 GBDT 在处理非平衡数据、噪声数据和连续特征等方面有更好的鲁棒性。并且GBDT是可以并行化训练的。
GBDT有三个典型的实现:XGBoost是2017年的一种相当强力的机器学习方法,梯度提升树框架的第一个里程碑;CatBoost针对类别型数据进行了改进,对离散特征数据进行了优化;LightGBM针对XGB的效率进行改进,通过梯度采样和直方图算法支持并行化。
我们想一想,传统使用树结构去进行集成学习分类或回归任务是不是这样的:
$$
{\hat y_i} = \sum\limits_{i = 1}^K {{f_k}({x_i})} ,f(x) = {w_{q(x)}}
$$
在这个式子中,f 表示回归树基学习器(CART),函数f根据规则q,在给定数据也就是自变量x 的条件下给出一个评分函数w。w 是树f 上不同节点的权重分数。那么,如果想提升它的性能可以从哪些地方入手呢?主要就是两个想法:从基学习器本身入手和从误差优化入手。这里我们考虑误差优化。 我们说,模型的误差=“距离定义”+正则化因子,所谓的距离其实也就是交叉熵损失或者均方误差等。这里我们可以列出它的公式:
$$
\begin{array}{l}
\arg \min L = \sum\limits_{i = 1}^n {l({{\hat y}_i},{y_i})} + \sum\limits_{k = 1}^K {\Omega ({f_k})} \\
s.t.\Omega (f) = \gamma T + \frac{1}{2}\lambda {\left\| w \right\|^2}
\end{array}
$$
Boosting系列集成器为串行生成,所以第i轮的预测结果也就是在第i-1轮的基础上加上 fi(x):
$$
{L^{(t)}} = \sum\limits_{i = 1}^n {l({y_i},\hat y_i^{(t - 1)} + {f_t}({x_i}))} + \Omega ({f_t})
$$
L的本质是针对y的二元函数,现在欲求解y在 ft(x) 这个增量变化后函数值的大小,可以使对y的预测值进行二阶泰勒展开近似
$$
{L^{(t)}} = \sum\limits_{i = 1}^n {[l({y_i},\hat y_i^{(t - 1)}) + \frac{{\partial l({y_i},\hat y_i^{(t - 1)})}}{{\partial \hat y_i^{(t - 1)}}}{f_t}({x_i}) + \frac{1}{2}\frac{{{\partial ^2}l({y_i},\hat y_i^{(t - 1)})}}{{\partial {{(\hat y_i^{(t - 1)})}^2}}}f_t^2({x_i})]} + \Omega ({f_t})
$$
既然第一项是不太能动的部分,那可优化的部分就很明确了,就是从第二项以后都可以优化。这也就是为什么叫梯度提升树的原因。对第t 轮损失函数中标蓝部分进一步化简,Ij 为决策方案构成的空间:

可以解得函数的最优解为:
$$
\begin{array}{c}
w_j^* = - \frac{{\sum\limits_{i \in {I_j}} {{g_i}} }}{{\sum\limits_{i \in {I_j}} {{h_i}} + \lambda }}\\
{{\tilde L}^{(t)}}(q) = - \frac{1}{2}\sum\limits_{j = 1}^T {\frac{{{{\left( {\sum\limits_{i \in {I_j}} {{g_i}} } \right)}^2}}}{{\sum\limits_{i \in {I_j}} {{h_i}} + \lambda }}} + \gamma T
\end{array}
$$
那么问题来了,怎么去搜索这个函数的最优解呢?大体上搜索策略会有三种想法:暴力搜索、启发式搜索和贪心搜索。暴力搜素那必然不可取,启发式搜索可以成为一个优化方向但目前做的不多,我们采用贪心的想法解。搜索分支点的策略如下:

它的基本想法很纯粹,对左子树梯度优化与右子树梯度优化减去不分支的整体梯度优化 就可以得到对某个节点分枝后的增益。分支的算法有精确搜索和粗略搜索两个版本,这里放上精确搜索的版本:

计算每个特征的分数后会根据这个分数排序,选择信息增益最大的特征进行梯度提升树的生长。不断迭代学习器并集成就得到了XGBoost。
XGBoost作为机器学习领域的一个里程碑,其独特的优势和特点使其在众多算法中脱颖而出。首先,XGBoost对梯度进行了优化,提高了预测的准确度。与传统的机器学习算法相比,XGBoost通过优化梯度下降的方式,更加精确地逼近真实函数,从而在许多数据集上取得了更高的预测精度。其次,XGBoost能够自主处理数据缺失的问题,这极大地削弱了我们预处理的困难。在数据预处理阶段,如何处理缺失值一直是一个令人头疼的问题。而XGBoost通过一种创新的策略,能够自动处理缺失值,无需我们手动填充或删除数据,大大简化了数据预处理的流程。此外,XGBoost还能自主感知数据的稀疏性,从而减少了人工降维的工作量。在许多实际问题中,数据往往是高度稀疏的,即大多数特征的值都是0或缺失的。XGBoost通过特殊的处理方式,能够有效地利用这些稀疏特征,避免了人工降维的需要,同时也提高了模型的预测性能。从计算理论的角度讲,打破了传统Boosting串行化的传统,打响了并行化Boost的第一枪。从系统结构的角度来看,XGBoost的另一个重要特点是其Cache感知能力。通过这一特性,XGBoost能够在搜索过程中进行内存优化,提高了搜索效率。传统的搜索算法往往只关注数据的连续读写,而忽视了非连续读写的可能性。XGBoost则不同,它能够充分利用非连续读写的特性,给予更多的缓冲机会,使得数据读写更加高效。
```python
# 用XGBoost做尝试
import xgboost as xgb
from sklearn.model_selection import train_test_split
from sklearn.metrics import f1_score
from matplotlib import pyplot as plt
from sklearn.metrics import classification_report
from sklearn import preprocessing
X=data[['X%d'%i for i in range(1,65)]]
Y=data['class']
xgb_n_clf = xgb.XGBClassifier(
max_depth=12
,learning_rate=0.1
,reg_lambda=1
,n_estimators=150
,subsample = 0.9
,colsample_bytree = 0.9
,random_state=0
,eval_metric='logloss')
xgb_n_clf.fit(X,Y)
Y_test=xgb_n_clf.predict(X)
print(classification_report(Y,Y_test))
pd.DataFrame(xgb_n_clf.predict_proba(X)).to_csv("2021-predict.csv")
```
XGBoost作为机器学习领域的一个强大工具,尽管具有许多优势,但仍存在一些问题需要关注。首先,精确贪心算法在XGBoost中的实现需要反复迭代和遍历,这导致了较大的计算量和内存消耗。在处理大规模数据集时,这种算法复杂度可能会成为性能瓶颈。其次,XGBoost采用的level-wise策略在构建树的过程中可能会产生许多不必要的叶子节点。这不仅增加了模型的复杂度,还可能导致过拟合问题。过多的叶子节点可能捕捉到训练数据中的噪声,降低模型的泛化能力。此外,尽管XGBoost具有Cache感知的特性,但在实际应用中仍存在大量Cache Missing的情况。这导致了频繁的页面调度,增加了I/O操作的开销。对于大规模数据处理任务,频繁的页面调度可能导致显著的性能下降。在实际应用中,针对这些问题进行优化和改进将有助于进一步提高XGBoost的性能和泛化能力。
**XGBoost是机器学习领域的一种强大算法,但它也有一些局限性需要注意。**
1. **精确贪心算法需要大量的迭代和遍历来构建决策树。这会导致计算量和内存消耗增大,特别是处理大规模数据集时,可能会成为性能的瓶颈。**
1. **XGBoost在构建树时采用了一种level-wise策略,这种策略可能会导致生成过多不必要的叶子节点。这不仅增加了模型的复杂度,还可能导致过拟合,即模型在训练数据上表现很好,但在未见过的数据上表现不佳。**
1. **尽管XGBoost具有减少缓存丢失的特性,但在实际应用中仍然可能遇到大量缓存丢失,导致频繁的磁盘I/O操作,这对于大规模数据处理任务来说,可能会显著影响性能。**
**为了提高XGBoost的性能和泛化能力,需要针对这些问题进行优化和改进。例如,可以通过调整参数来减少不必要的计算,采用更高效的树构建策略来控制模型复杂度,或者优化缓存策略来减少缓存丢失。这样,XGBoost就能在保持强大性能的同时,更好地适应实际应用的需求。**
LightGBM在XGBoost的基础上做了不少优化的工作,包括提出直方图算法、数据并行与特征并行、GOSS梯度采样、EFB方法等。
直方图算法先对特征值进行装箱处理(对每个特征的取值做个分段函数,将所有样本在该特征上的取值划分到某一段bin中)。最终把特征取值从连续值转化成了离散值。遍历数据时,根据离散化后的值作为索引在直方图中累积统计量,当遍历一次数据后,直方图累积了需要的统计量,然后根据直方图的离散值,遍历寻找最优的分割点。直方图算法是牺牲了一定的准确性而换取训练速度和节省内存空间消耗的算法。每次分裂只需计算分裂后样本数较少的子节点的直方图然后通过做差的方式获得另一个子节点的直方图,进一步提高效率。将连续数据离散化为直方图的形式,对于数据量较小的情形可以使用小型的数据类型来保存训练数据,不需要额外的较大的内存。降低了并行通信的代价。
建立直方图的复杂度为O(feature×data),如果降低特征数或者降低样本数,训练的时间会大大减少。以往的降低样本数的方法中,要么不能直接用在GBDT上,要么会损失精度。而降低特征数的直接想法是去除弱的特征(通常用PCA完成),然而,这些方法往往都假设特征是有冗余的,然而通常特征是精心设计的,去除它们中的任何一个可能会影响训练精度。因此LightGBM提出了GOSS算法和EFB算法。
一个有高维特征空间的数据往往是稀疏的,而稀疏的特征空间中,许多特征是互斥的。所谓互斥就是他们从来不会同时具有非0值(比如one-hot编码后的类别特征)。LightGBM利用这一点提出Exclusive Feature Bundling(EFB,互斥特征捆绑)算法来进行互斥特征的合并,从而减少特征的数目。做法是先确定哪些互斥的特征可以合并(可以合并的特征放在一起,称为bundle),然后将各个bundle合并为一个特征。当bundle远小于feature时,直方图构建的复杂度从O(data×feature)变为O(data×bundle)。这样GBDT能在精度不损失的情况下进一步提高训练速度。
内存优化的策略方面,使用bin来表示特征,一般bin的个数都是控制在比较小的范围内,这样可以使用更少的Byte来存储,降低为XGB的1/8。对梯度的访问,因为不需要对特征进行排序,同时,所有的特征都采用同样的方式进行访问,所以只需要对梯度访问的顺序进行一个重新的排序,所有的特征都能连续地访问梯度。
直方图算法不需要数据到模型的一个索引表,没有cache-miss的问题。把速度提升了四倍以上。LightGBM通过更改决策树算法的决策规则,直接原生支持类别特征,不需要额外的离散化。并且通过一些实验,MRSA研究人员验证了直接使用离散特征可以比使用0-1离散化后的特征,速度快到8倍以上 。并且支持的并行方式更多,包括特征并行、数据并行、集成并行等多种方式。
Python可以安装lightgbm库实现相关算法。上面使用XGBoost实现的代码如果改写为LightGBM实现可以改写为:
```python
import lightgbm as lgb
from sklearn.model_selection import train_test_split
from sklearn.metrics import f1_score
from matplotlib import pyplot as plt
from sklearn.metrics import classification_report
from sklearn import preprocessing
X=data[['X%d'%i for i in range(1,65)]]
Y=data['class']
lgb_n_clf = lgb.LGBMClassifier(
max_depth=12
,learning_rate=0.1
,reg_lambda=1
,n_estimators=150
,subsample = 0.9
,colsample_bytree = 0.9
,random_state=0
,eval_metric='logloss')
lgb_n_clf.fit(X,Y)
Y_test=lgb_n_clf.predict(X)
print(classification_report(Y,Y_test))
pd.DataFrame(lgb_n_clf.predict_proba(X)).to_csv("2021-predict.csv")
```
## 9.10 从神经网络模型到深度学习
神经网络是一种具有非常强大能力的模型,不仅能够做常规的分类、回归等任务,还可以处理非结构化数据。但神经网络需要在大量数据下使用才有其意义,需要注意使用条件。从神经网络的诞生开始,一直到现在的ChatGPT,都是神经网络的应用。为此,它开辟了机器学习的一个新的分支:深度学习。
### 9.10.1 多层感知机(这一节抄自我的MATLAB版教材,要改)
多层感知机(MLP,Multilayer Perceptron)也叫人工神经网络(ANN,Artificial Neural Network),除了输入输出层,它中间可以有多个隐层。这东西是个非常厉害的模型,有神经网络,才有了今天的深度学习,后面我们会了解到。
**多层感知机(MLP)是一种神经网络,它由三种类型的层组成:输入层、一个或多个隐藏层(中间层),以及输出层。在这些层之间,数据会经过线性映射和激活函数处理,然后传递到下一层。**
**为了更好地理解这个过程,我们可以将其分解为以下步骤:**
**1. 输入层接收输入数据,并将其传递到第一个隐藏层。**
**2. 在每一层,数据会经过线性映射(即,每个神经元的输入是上一层输出的加权和),然后通过激活函数进行非线性转换。**
**3. 经过所有隐藏层的处理后,数据最终到达输出层,得到预测结果。**
**在建立了基本的神经网络模型之后,接下来的任务是训练模型,即学习模型的参数。由于神经网络包含多层结构,计算梯度变得非常复杂。为了解决这个问题,我们采用误差反向传播算法(Backpropagation)来更新网络参数。**
**误差反向传播算法是一种高效的梯度计算方法,它基于以下步骤:**
**1. 从输入层开始,数据逐层传递到输出层,计算预测结果与实际数据之间的误差。**
**2. 对于每一层,我们计算误差与上一层输出的关系。这需要对每一层的激活函数求导,以找到误差对权重的影响。**
**3. 通过这种链式求导过程,我们可以计算出每一层权重对损失函数的梯度,从而更新权重以最小化误差。**
**这种方法类似于线性回归中的梯度下降法,但在神经网络中,我们需要对多层结构进行求导。通过这种方式,我们可以有效地训练神经网络,使其在各种任务中表现出色。**
图8.15所示是一个多层感知机的结构图:

如图8.15所示,对于每两层之间神经元与神经元之间都有连接,这个连接用一个权重表示。那么,如果上一层有m个神经元,下一层n个神经元,那么权值就有mn个,可以排成一个矩阵。加上自己的偏置项,一个数据信息在这两层之间的传播实际上就是一个线性方程yn (X)=Wn X+b。没完,在神经网络中,还有个很重要的成分叫做激活函数。激活函数再将通过线性变换的数据映射成一个新的变量才能作为响应,再来反馈给下一层。常用的激活函数有sigmoid,tanh,softmax,还有。
**在神经网络的构建过程中,我们通常会遇到两个主要的概念:线性方程和激活函数。这两个组成部分共同决定了数据在网络中的传播和变化方式。**
**1. 线性方程(Linear Equation)**
**线性方程是神经网络中最基本的数学模型,用于描述数据如何在各层之间传播。一个简单的线性方程可以表示为 y(X) = W * X + b,其中 X 是输入数据,W 是权重矩阵,* 表示矩阵乘法,b 是偏置项。这个方程的输出 y(X) 是输入数据经过线性变换的结果。**
**2. 激活函数(Activation Function)**
**在神经网络中,激活函数的作用是引入非线性,使得网络能够学习和近似复杂的函数。如果没有激活函数,无论神经网络有多少层,最终都只能表示线性映射,这限制了网络的表达能力。常见的激活函数包括 Sigmoid、tanh 和 softmax。**
**Sigmoid 函数:将实数映射到 (0,1) 区间,其公式为 f(x) = 1 / (1 + e^(-x))。Sigmoid 函数的输出范围在 0 到 1 之间,这使得它可以被用于二分类问题的输出层。**
` `**tanh 函数:将实数映射到 (-1,1) 区间,其公式为 f(x) = (e^(x) - e^(-x)) / (e^(x) + e^(-x))。与 Sigmoid 函数类似,但输出范围是 -1 到 1,它在某些情况下可以提供更好的数值稳定性。**
**-Softmax 函数:将实数映射为一组表示概率分布的值,其公式为 f(x_i) = e^(x_i) / Σ(e^(x_j)),其中 i 和 j 是输入向量的元素索引。Softmax 函数常用于多分类问题的输出层,因为它的输出可以解释为属于各个类别的概率。**
**在神经网络中,线性方程和激活函数交替使用,构建出多层的网络结构。每一层的输出都会作为下一层的输入,经过一次次的线性变换和非线性激活,最终得到网络的输出。通过调整网络中的权重和偏置项,神经网络可以学习到从输入数据到期望输出的映射规律。这种学习过程通常通过一种叫做反向传播(Backpropagation)的算法来实现,配合梯度下降(Gradient Descent)等优化方法,以达到最小化预测误差的目的。**
现在咱们知道了,多层感知机实际上是有三种层,输入层,中间层和输出层。两层之间的过程是线性映射,再激活函数,然后输出去。
每两层前后的输入输出递推形式如下,本质上就是一个复合函数:
$$
\[{X_n} = {f_n}({W_n}{X_{n - 1}} + {b_n})\]
$$
基本的模型搭建完成后,训练的时候所做的就是完成模型参数的学习。由于存在多层的网络结构,因此即使是数值解梯度下降都非常困难,一个方法就是用误差反向传播算法。误差反向传播是一种非常行之有效的更新算法。每次我们的数据从输入层逐层传入到输出层,发现和实际数据有偏差。但是具体的偏差是多少呢?我们对每一层逐层求导,每一层的误差都与上一层的输出有关。这样就建立了复合函数偏导数的关系,形成一种链式反应。
后向传播算法是基于梯度下降的一种改进。还记得我们在线性回归里面说到的凸优化方法,对损失函数求导。这里也是一样,思想还是打算对权值求导。但是神经网络相比于线性回归和逻辑回归模型复杂了不少,再想像那样直接求导就很难了。但是从作用原理出发,对于某一层而言,权值wij 先影响了对应的输入值,再影响输出值,从而影响损失函数E。那么,我们可以进行一个链式求导的过程:
$$
\frac{{\partial E}}{{\partial {w_{ij}}}} = \frac{{\partial E}}{{\partial \widehat {{y_j}}}}\frac{{\partial \widehat {{y_j}}}}{{\partial {\beta _j}}}\frac{{\partial {\beta _j}}}{{\partial {w_{ij}}}}
$$
值得注意的是最后一项,这最后一项本质上就是上一层网络的输出啊孩子们,这样本层损失与上层输出就建立关系了。另外我们将损失函数对输入层求微分:
$$
\frac{{\partial E}}{{\partial x}} = \frac{{\partial E}}{{\partial {y_n}}}\frac{{\partial {y_n}}}{{\partial {y_{n - 1}}}}...\frac{{\partial {y_1}}}{{\partial x}}
$$
可以看到嗷,这个东西本质上就还是会和上一层的输出扯上关系。误差项就通过这样一个方程,实现了后向传播。通过不断地迭代和训练,最终权重系数等参数都会稳定下来,达到逼近的目的。
注意:训练过程是一个迭代的过程,不断迭代不断更新权重。所以它是吃时间吃算力的。如果真的做深度学习研究那得配置比较好的显卡。
在神经网络的训练过程中,调节参数是一个至关重要的步骤。这些参数决定了网络的结构和行为,进而影响模型的预测性能。一般来说,我们需要通过反复试验和比较来找到最优的参数组合。在调节参数时,通常会考虑以下几个方面:
- 学习率(Learning Rate):学习率决定了模型在每次迭代中更新权重的幅度。如果学习率过大,可能导致模型震荡而无法收敛;如果学习率过小,则可能导致训练速度缓慢。因此,需要根据实际情况选择一个合适的学习率。
- 批处理大小(Batch Size):批处理大小决定了每次迭代中使用多少样本进行权重更新。较大的批处理大小可能会加快训练速度,但也可能导致内存不足;较小的批处理大小则可以减少内存使用,但训练速度可能会变慢。
- 迭代次数(Epochs):迭代次数决定了整个数据集被遍历的次数。过多的迭代可能会导致过拟合,而太少的迭代则可能无法充分训练模型。
- 正则化参数(Regularization Parameters):正则化用于防止模型过拟合。常见的正则化参数包括L1和L2正则化系数、dropout概率等。这些参数可以帮助控制模型的复杂度,避免过度拟合训练数据。
- 优化器(Optimizer):优化器决定了如何更新模型的权重。常见的优化器有SGD(随机梯度下降)、Adam、RMSprop等。不同的优化器适用于不同的问题和数据集,选择合适的优化器也是调节参数的一个重要方面。
通过调整这些参数,我们可以找到一个最优的平衡点,使模型在训练集和测试集上都能取得较好的性能。这个过程可能需要多次尝试和调整,因此耐心和经验积累是非常重要的。
**为了优化误差反向传播算法的训练过程,我们可以采取多种策略来提升神经网络的性能和训练效率。:**
**1. 学习率调整:选择合适的学习率对训练过程至关重要。如果学习率太高,可能会导致模型在最优解附近震荡;如果学习率太低,训练过程可能会非常缓慢。使用自适应学习率算法,如Adam,可以帮助我们在训练过程中自动调整学习率,使模型更快收敛。**
**2. 批量大小的选择:批量大小决定了每次参数更新前要处理的数据量。较大的批量可以提供更稳定的梯度估计,但计算成本更高;较小的批量可以更快地适应参数更新,但可能导致梯度估计不稳定。实践中,可以尝试不同的批量大小,找到适合当前任务的最佳值。**
**3. 权重初始化:在训练开始时,如何初始化权重对模型的收敛速度和最终性能有显著影响。使用如He初始化或Xavier初始化等策略,可以减少训练初期的梯度消失或爆炸问题,从而加速收敛。**
**4. 正则化技术:为了防止模型过拟合,我们可以在训练过程中加入正则化项,如L1或L2正则化。此外,Dropout是一种简单而有效的正则化方法,它通过在训练过程中随机“丢弃”一部分神经元,减少了模型对特定参数的依赖,增强了泛化能力。**
**5. 早停策略:当我们在训练集上不断改进模型,但在验证集上的性能停止提升时,可能是过拟合的信号。此时,采用早停策略,即停止进一步训练,可以避免过拟合,保持模型的泛化性能。**
**6. 激活函数的选择:激活函数为神经网络引入非线性,选择合适的激活函数可以影响模型的学习能力。ReLU(Rectified Linear Unit)是目前常用的激活函数之一,因为它在正区间内保持梯度不衰减,有助于加速训练。**
**7. 优化器的选用:除了传统的梯度下降,还有许多改进的优化器可供选择,如带动量的梯度下降和Nesterov加速梯度(NAG)。这些优化器通过考虑之前梯度的动量,帮助模型更有效地逃离局部最小值和鞍点。**
**8. 网络结构的调整:根据具体任务的需求,可以尝试调整网络结构,比如增加或减少隐藏层的数量,或者改变层间的连接方式。这些调整可以帮助模型更好地捕捉数据的特征,提高性能。**
**9. 数据增强:在训练数据中引入变化,如旋转、缩放、裁剪等,可以增加数据的多样性,使模型对输入的微小变化,减少过拟合的风险。**
**10. 梯度裁剪:在训练过程中,有时梯度的值会变得非常大,导致参数更新不稳定。通过梯度裁剪,我们可以限制梯度的最大值,防止梯度爆炸问题。**
**通过这些策略的综合应用,我们可以有效地提升神经网络的训练效果,使其在各种任务中都能表现出色。对于新手来说,理解和实践这些优化技巧,将有助于构建更加强大和可靠的神经网络模型。**
### 9.10.2 循环神经网络
循环神经网络是对传统神经网络结构的一次重要革新。让我们回顾在时间序列中学到的东西,时间序列预测的基本想法就是用历史预测未来。那么神经网络能不能用在时间序列里面呢?当然是可以的,神经网络若将上一步获得的输出作为下一步的输入,这就构成了一个循环神经网络。循环神经网络可以表示为:
$$
{y_t} = f(W{X_t} + H{y_{t - 1}} + b)
$$
最简单的循环神经网络通常使用双曲正切函数作为激活函数。这样就会带来一系列问题:对于一层RNN而言,梯度应该怎么传播?双曲正切函数在这种传播机制下会出现什么问题?对于RNN而言,梯度是随着时间而反向传播的(Backward Propagation Through Time),而在梯度反向传播的过程中梯度是随时间来求导的,这就导致小的梯度值会越来越小直到变成0,大的梯度值会越来越大直到变成无穷大,这两种现象就是RNN训练中容易出现的梯度逸散和梯度爆炸。为了克服这两个问题,需要开发新的循环神经网络结构。
LSTM神经网络是由Sepp Hochreiter等人提出的神经网络模型,在RNN的基础上引入了遗忘门。传统的RNN可以看作是同一结构的反复迭代,每次迭代的结果都会被传到下一个值进行再处理。RNNs一旦展开,可以将之视为一个所有层共享同样权值的深度前馈神经网络。虽然它们的目的是学习长期的依赖性,但理论的和经验的证据表明很难学习并长期保存信息。而LSTM则是对RNN的改进,是一种特殊的RNN模型,被更广泛地应用于文本预测、时间序列预测等领域。
注意:RNN本质上就是拿历史前一项或前几项作为自变量,线性方程后用tanh激活做输出。所以本质上还是上一章讲到的“历史预测未来”的思想。
LSTM网络与普通RNN一样,都有重复的单元结构。但不同的是,传统的普通RNN单元只有比较简单的网络结构,而LSTM的单元由三个不同的门组成:输入门,输出门和遗忘门。基本架构如图8.16所示。
循环神经网络(RNN)是一种用于处理序列数据的神经网络结构。在最简单的RNN中,通常使用双曲正切函数(tanh)作为激活函数。然而,这种设计在训练过程中会引发一些问题。
**让我们先来探讨一下RNN中的梯度传播。在RNN中,梯度是通过时间反向传播的(Backpropagation Through Time, BPTT)。这意味着,当我们计算梯度时,需要沿着时间轴反向进行。在这个过程中,我们会发现一个关键问题:梯度的值会随着时间的传递而发生变化。如果梯度值很小,它们会逐渐减小,最终消失;如果梯度值很大,它们会逐渐增大,最终可能导致无穷大。这种现象分别被称为梯度消失和梯度爆炸,它们都会对RNN的训练造成严重影响。**
**为了解决这些问题,研究人员开发了新的RNN结构,其中最知名的是长短期记忆网络(LSTM)。LSTM是由Sepp Hochreiter等人提出的,它在传统RNN的基础上增加了一种特殊的机制——遗忘门。这个遗忘门允许网络决定哪些信息应该被保留,哪些应该被遗忘。这样,LSTM能够更好地处理长期依赖关系,避免了传统RNN在长序列处理中的困难。**
**LSTM网络的结构与传统RNN有所不同。虽然RNN可以看作是重复相同结构的单元,每次迭代的结果都会传递到下一个时间步进行处理,但LSTM通过引入遗忘门、输入门和输出门,形成了一个更为复杂的网络结构。这种结构使得LSTM在处理文本预测、时间序列预测等领域的任务时,表现得更为出色。**
**值得注意的是,尽管RNN和LSTM都是处理序列数据的强大工具,但它们的核心思想是相似的:利用历史信息来预测未来。在RNN中,这通常是通过将前一时间步的输出作为当前时间步的输入来实现的。通过这种方式,网络能够捕捉到序列中的时间动态,并据此做出预测。**
**LSTM通过引入遗忘门和其他机制,有效地解决了RNN在处理长序列时遇到的梯度消失和梯度爆炸问题。这使得LSTM在需要捕捉长期依赖关系的序列预测任务中,成为了一个非常受欢迎的选择,理解这些概念和方法是构建有效序列处理模型的基础。**
**我们来解释下LSTM是如何解决梯度消失问题**
**长短期记忆网络(LSTM)通过引入特殊的结构单元——称为“记忆单元”(memory cell)——来解决梯度消失问题。这些记忆单元能够学习在长序列中保持重要的信息,同时遗忘不相关的信息。LSTM的核心在于其内部的门控机制,这些门控制着信息的流动,包括遗忘门(forget gate)、输入门(input gate)和输出门(output gate)。**

图8.16 LSTM单元的基本架构
图8.16最上方的ct-1 到ct 的总线贯穿始终,是整个LSTM网络的核心。在总线的下方是三个门。从左到右的三个门分别为遗忘门,输入门和输出门。
**LSTM首先决定哪些信息需要从记忆单元中被遗忘。遗忘门通过一个sigmoid激活函数来实现这一点。给定当前输入和上一个隐藏状态,遗忘门会为记忆单元中的每个元素生成一个介于0到1之间的值。值为0表示该元素应该被遗忘(即,梯度为0),而值为1表示该元素应该被保留。然后,这个值会与记忆单元中的旧内容相乘,从而实现遗忘操作。**
对于遗忘门,它的作用是将接收过的信息进行选择性地遗忘,可以主动调节不同位置信息的作用大小。对此,有:
$$
{f_t} = \sigma ({W_f}({h_{t - 1}},{x_t}) + {b_f})
$$
**接下来,LSTM决定哪些新信息将被存储在记忆单元中。输入门由两部分组成:一个sigmoid层和一个tanh层。Sigmoid层决定哪些值我们将要更新,而tanh层则创建一个新的候选值向量,这些值可以被加入到记忆单元中。**
而输入门的作用是更新单元的状态。将新的信息有选择性地输入来代替被遗忘的信息,并生成候选向量C。下面的方程解释了输入门生成的候选向量:
$$
\begin{array}{c}
{i_t} = \sigma ({W_i}({h_{t - 1}},{x_t}) + {b_i})\\
C = \tanh ({W_C}({h_{t - 1}},{x_t}) + {b_C})
\end{array}
$$
**输出门决定了记忆单元中的哪些信息将被用于当前的输出。首先,一个sigmoid层决定记忆单元中哪些信息是重要的。然后,这些信息会通过一个tanh激活函数,将它们转换为-1到1之间的值。最后,这些值与sigmoid门的输出相乘,以决定最终的输出。**
输出门可以给出结果,同时将先前的信息保存到隐层中去。同样的,有:
$$
\begin{array}{l}
{o_t} = \sigma ({W_o}({h_{t - 1}},{x_t}) + {b_o})\\
{c_t} = {f_t} \cdot {c_{t - 1}} + {i_t} \cdot C\\
{h_t} = {o_t} \cdot \tanh ({c_t})
\end{array}
$$
可以看到,LSTM宏观上也是关于xt-1 和xt 的函数,但是由于多了门控单元对长期信息和短期信息的不同处理模式,网络能够对先前的长期信息保持一定记忆,这克服了传统RNN只能针对先前的短期数据进行计算的缺点。
### 9.10.3 深度学习的发展
深度学习的概念源于人工神经网络的研究。在20世纪80年代,神经网络的研究已经取得了初步的成果。然而,由于计算能力的限制和训练方法的不足,神经网络的性能并未得到充分发挥。直到2006年,深度学习的概念被提出,才真正开启了神经网络的新篇章。深度学习通过构建多层次的神经网络结构,使得机器能够从原始数据中提取出更高级的特征,从而提高了分类和识别的准确率。
随着计算能力的提升和大数据的出现,深度学习在近年来得到了飞速的发展。2012年,AlexNet在./src/imageNet挑战赛中取得了优异的成绩,标志着深度学习在计算机视觉领域的突破。随后,卷积神经网络(CNN)在语音识别、自然语言处理等领域也取得了显著的进展。此外,循环神经网络(RNN)和长短期记忆网络(LSTM)等深度学习模型在处理序列数据方面也表现出了强大的能力。

深度学习已经在许多领域得到了广泛应用。在图像识别方面,深度学习可以用于人脸识别、物体检测等任务。在自然语言处理领域,深度学习可以实现机器翻译、情感分析、问答系统等应用。在医疗领域,深度学习可以帮助医生进行疾病诊断和治疗方案的制定。此外,深度学习还在金融、自动驾驶、语音助手等领域得到了广泛应用。
随着技术的不断发展,深度学习的未来发展前景广阔。一方面,随着计算能力的不断提升,深度学习的模型规模和训练数据量将进一步扩大,从而提高模型的性能和泛化能力。另一方面,随着深度学习算法的不断优化和创新,将会有更多的问题得到解决。例如,目前深度学习在处理自然语言理解、语音识别等方面的性能已经达到了较高的水平,但在处理语义理解、常识推理等方面仍存在挑战。未来,深度学习有望在这些领域取得更大的突破。
此外,深度学习与强化学习的结合也是未来的一个重要趋势。目前,深度学习已经在监督学习和无监督学习方面取得了很大的进展,但强化学习仍然是一个具有挑战性的领域。未来,通过将深度学习与强化学习相结合,有望实现更加智能化的决策和行为。
## 9.11 使用PyTorch实现神经网络
目前Python实现深度学习的最佳框架就是PyTorch。本讲以PyTorch为基础,介绍神经网络的搭建与训练。
### 9.11.1 Torch基本语法
Torch的运算基于张量进行。张量本质上是向量与矩阵的扩展。在torch中,我们有几种常见的张量创建方法:
1. 随机初始化矩阵
我们可以通过`torch.rand()`的方法,构造一个随机初始化的矩阵:
```python
import torch
x = torch.rand(4, 3)
print(x)
```
```python
tensor([[0.7569, 0.4281, 0.4722],
[0.9513, 0.5168, 0.1659],
[0.4493, 0.2846, 0.4363],
[0.5043, 0.9637, 0.1469]])
```
2. 全0矩阵的构建
我们可以通过`torch.zeros()`构造一个矩阵全为 0,并且通过`dtype`设置数据类型为 long。除此以外,我们还可以通过torch.zero_()和torch.zeros_like()将现有矩阵转换为全0矩阵.
```python
import torch
x = torch.zeros(4, 3, dtype=torch.long)
print(x)
```
```python
tensor([[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]])
```
3. 张量的构建
我们可以通过`torch.tensor()`直接使用数据,构造一个张量:
```python
import torch
x = torch.tensor([5.5, 3])
print(x)
```
```python
tensor([5.5000, 3.0000])
```
在接下来的内容中,我们将介绍几种常见的张量的操作方法:
1. 加法操作:
```python
import torch
# 方式1
y = torch.rand(4, 3)
print(x + y)
# 方式2
print(torch.add(x, y))
# 方式3 in-place,原值修改
y.add_(x)
print(y)
```
```python
tensor([[ 2.8977, 0.6581, 0.5856],
[-1.3604, 0.1656, -0.0823],
[ 2.1387, 1.7959, 1.5275],
[ 2.2427, -0.3100, -0.4826]])
tensor([[ 2.8977, 0.6581, 0.5856],
[-1.3604, 0.1656, -0.0823],
[ 2.1387, 1.7959, 1.5275],
[ 2.2427, -0.3100, -0.4826]])
tensor([[ 2.8977, 0.6581, 0.5856],
[-1.3604, 0.1656, -0.0823],
[ 2.1387, 1.7959, 1.5275],
[ 2.2427, -0.3100, -0.4826]])
```
2. 索引操作:(类似于numpy)
**需要注意的是:索引出来的结果与原数据共享内存,修改一个,另一个会跟着修改。如果不想修改,可以考虑使用copy()等方法**
```python
import torch
x = torch.rand(4,3)
# 取第二列
print(x[:, 1])
```
```python
tensor([-0.0720, 0.0666, 1.0336, -0.6965])
```
```python
y = x[0,:]
y += 1
print(y)
print(x[0, :]) # 源tensor也被改了了
```
```python
tensor([3.7311, 0.9280, 1.2497])
tensor([3.7311, 0.9280, 1.2497])
```
3. 维度变换
张量的维度变换常见的方法有`torch.view()`和`torch.reshape()`,下面我们将介绍第一中方法`torch.view()`:
```python
x = torch.randn(4, 4)
y = x.view(16)
z = x.view(-1, 8) # -1是指这一维的维数由其他维度决定
print(x.size(), y.size(), z.size())
```
```python
torch.Size([4, 4]) torch.Size([16]) torch.Size([2, 8])
```
注: `torch.view()` 返回的新`tensor`与源`tensor`共享内存(其实是同一个`tensor`),更改其中的一个,另外一个也会跟着改变。(顾名思义,view()仅仅是改变了对这个张量的观察角度)
```python
x += 1
print(x)
print(y) # 也加了了1
```
```python
tensor([[ 1.3019, 0.3762, 1.2397, 1.3998],
[ 0.6891, 1.3651, 1.1891, -0.6744],
[ 0.3490, 1.8377, 1.6456, 0.8403],
[-0.8259, 2.5454, 1.2474, 0.7884]])
tensor([ 1.3019, 0.3762, 1.2397, 1.3998, 0.6891, 1.3651, 1.1891, -0.6744,
0.3490, 1.8377, 1.6456, 0.8403, -0.8259, 2.5454, 1.2474, 0.7884])
```
上面我们说过torch.view()会改变原始张量,但是很多情况下,我们希望原始张量和变换后的张量互相不影响。为为了使创建的张量和原始张量不共享内存,我们需要使用第二种方法`torch.reshape()`, 同样可以改变张量的形状,但是此函数并不能保证返回的是其拷贝值,所以官方不推荐使用。推荐的方法是我们先用 `clone()` 创造一个张量副本然后再使用 `torch.view()`进行函数维度变换 。
注:使用 `clone()` 还有一个好处是会被记录在计算图中,即梯度回传到副本时也会传到源 Tensor 。
3. 取值操作
如果我们有一个元素 `tensor` ,我们可以使用 `.item()` 来获得这个 `value`,而不获得其他性质:
```python
import torch
x = torch.randn(1)
print(type(x))
print(type(x.item()))
```
```python
```
PyTorch中的 Tensor 支持超过一百种操作,包括转置、索引、切片、数学运算、线性代数、随机数等等,具体使用方法可参考[官方文档](https://pytorch.org/docs/stable/tensors.html)。
## 2.1.4 广播机制
当对两个形状不同的 Tensor 按元素运算时,可能会触发广播(broadcasting)机制:先适当复制元素使这两个 Tensor 形状相同后再按元素运算。
```python
x = torch.arange(1, 3).view(1, 2)
print(x)
y = torch.arange(1, 4).view(3, 1)
print(y)
print(x + y)
```
```python
tensor([[1, 2]])
tensor([[1],
[2],
[3]])
tensor([[2, 3],
[3, 4],
[4, 5]])
```
由于x和y分别是1行2列和3行1列的矩阵,如果要计算x+y,那么x中第一行的2个元素被广播 (复制)到了第二行和第三行,⽽y中第⼀列的3个元素被广播(复制)到了第二列。如此,就可以对2个3行2列的矩阵按元素相加。
### 9.11.2 全连接神经网络搭建
```python
import numpy as np
import torch
from collections import Counter
from sklearn import datasets
import torch.nn.functional as Fun
# 1. 数据准备
dataset = datasets.load_iris()
dataut=dataset['data']
priciple=dataset['target']
input=torch.FloatTensor(dataset['data'])
label=torch.LongTensor(dataset['target'])
# 2. 定义BP神经网络
class Net(torch.nn.Module):
def __init__(self, n_feature, n_hidden, n_output):
super(Net, self).__init__()
self.hidden = torch.nn.Linear(n_feature, n_hidden) # 定义隐藏层网络
self.out = torch.nn.Linear(n_hidden, n_output) # 定义输出层网络
def forward(self, x):
x = Fun.relu(self.hidden(x)) # 隐藏层的激活函数,采用relu,也可以采用sigmod,tanh
x = self.out(x) # 输出层不用激活函数
return x
# 3. 定义优化器损失函数
net = Net(n_feature=4, n_hidden=20, n_output=3) #n_feature:输入的特征维度,n_hiddenb:神经元个数,n_output:输出的类别个数
optimizer = torch.optim.SGD(net.parameters(), lr=0.02) # 优化器选用随机梯度下降方式
loss_func = torch.nn.CrossEntropyLoss() # 对于多分类一般采用的交叉熵损失函数,
# 4. 训练数据
for t in range(500):
out = net(input) # 输入input,输出out
loss = loss_func(out, label) # 输出与label对比
optimizer.zero_grad() # 梯度清零
loss.backward() # 前馈操作
optimizer.step() # 使用梯度优化器
# 5. 得出结果
out = net(input) #out是一个计算矩阵,可以用Fun.softmax(out)转化为概率矩阵
prediction = torch.max(out, 1)[1] # 返回index 0返回原值
pred_y = prediction.data.numpy()
target_y = label.data.numpy()
# 6.衡量准确率
accuracy = float((pred_y == target_y).astype(int).sum()) / float(target_y.size)
print("莺尾花预测准确率",accuracy)
```
### 9.11.3 循环神经网络搭建
```python
# 配置环境
import matplotlib.pyplot as plt
import pandas as pd
import torch.nn as nn
import numpy as np
import torch.optim
# ---------------- 准备数据 -------------------
data = pd.read_csv('Hospital1.csv') # 读取csv文件
data = np.array(data['RATE']) # 提取占有率
seq_l = 6 # 回溯及预测步长,其间隔为 5min
# 构造 rnn 模型数据结构
def rnn_data(sequence, length):
output_sequence = np.zeros([sequence.shape[0]-length, length, 1]) # [batch, seq, feature]
output_y = np.zeros([sequence.shape[0]-length, 1])
for i in range(sequence.shape[0] - length):
output_sequence[i, :, 0] = sequence[i:(i+length)]
output_y[i, 0] = sequence[i+length]
return output_sequence, output_y
x, y = rnn_data(data, seq_l)
x_tensor = torch.tensor(x, dtype=torch.float) # 转化为 torch.tensor 格式
y_tensor = torch.tensor(y, dtype=torch.float)
# ---------------- 搭建模型 -------------------
class LSTM(nn.Module):
def __init__(self, input_size, hidden_size, output_size, seq_l):
super().__init__()
self.lstm = nn.LSTM(input_size, hidden_size, batch_first=True) # lstm layer
self.linear = nn.Linear(hidden_size * seq_l, output_size) # linear layer
def forward(self, x_in):
x_in, _ = self.lstm(x_in)
b, s, f = x_in.shape # batch_size, sequence_length, feature_number
x_in = x_in.reshape(b, s * f)
x_in = self.linear(x_in)
return x_in
# ---------------- 初始化、实例化 -------------------
net = LSTM(1, 12, 1, seq_l) # 网络
loss_function = nn.MSELoss() # 损失函数
optimizer = torch.optim.Adam(net.parameters()) # 优化器
epoch = 1000 # 最大迭代次数
# ---------------- 训练 -------------------
for i in range(epoch):
optimizer.zero_grad() # 梯度清零
y_predict = net(x_tensor) # 预测
loss = loss_function(y_predict, y_tensor) # 计算损失
loss.backward() # 损失回传,计算梯度
optimizer.step() # 根据梯度更新模型
# 拟合曲线图
if (i+1) % 10 == 0:
print('loss =', loss.item())
x_plot = x_tensor.detach().numpy()
y_predict_plot = y_predict.detach().numpy()
y_plot = y_tensor.detach().numpy()
plt.clf()
plt.plot(y_plot, label='original')
plt.plot(y_predict_plot, label='predicted')
plt.legend()
plt.tight_layout()
plt.pause(0.5)
plt.ioff()
```
## 9.12 聚类算法
这一节不讲分类讲聚类。聚类理论上是一种无监督学习问题,样本是没有标记的,也就算不出来准确率。这一节的算法虽然形式上和KNN、决策树这些内容有一定的相似和互通之处,但核心上这是两类完全不同的问题!
### 9.12.1 基于原型的聚类算法:KMeans算法
假设你有一组客户数据,每个客户都有一些特征,比如年龄、收入和购买历史。现在,你想将这些客户分成两组。对于分类问题,你可以设定一个规则,比如将年龄小于30岁的客户归为一组,年龄大于30岁的客户归为另一组。这样,你的目标就是判断每个客户属于哪个年龄段。对于聚类问题,你可以不设定任何规则,而是让算法根据客户之间的相似性自动将他们分成两组。比如,算法可能会发现有一组客户年龄较轻且收入较高,另一组客户年龄较老且收入较低。这样,你的目标就是找到数据中的自然分组或集群。分类问题通常基于明确规则或阈值进行分类,而聚类问题则基于数据之间的相似性进行分组,以发现数据中的自然集群。
k-means 算法是根据给定的 n 个数据对象的数据集,构建 k 个划分聚类的方法,每个划分聚类即为一个簇。该方法将数据划分为 n 个簇,每个簇至少有一个数据对象,每个数据对象必须属于而且只能属于一个簇。同时要满足同一簇中的数据对象相似度高,不同簇中的数据对象相似度较小。聚类相似度是利用各簇中对象的均值来进行计算的。
K-Means算法的基本流程如下:
- 选择K值:需要确定要将数据集分成多少个类别,即簇的数量K。这一参数往往在最开始就需要设定好。
- 初始化质心:随机从数据集中选择K个点作为初始的簇中心。这些点通常是数据集中的任意点,但最常见的是选择K个数据点的均值作为初始质心。
- 分配数据点:接下来,算法会遍历数据集中的每个点,并根据这些点与簇中心的距离,将其分配给最近的簇中心。这个过程叫做“分配”。计算每个数据点到每个质心的距离时,通常使用欧几里得距离或余弦相似度等度量方法。
- 重新计算质心:一旦数据点被分配给了各自的簇,算法会重新计算每个簇的质心。这是通过取每个簇中所有点的坐标的平均值来完成的。新的质心将被设置为每个簇所有点的平均位置。
- 迭代优化:然后,算法回到步骤3,再次分配数据点,并重新计算质心。这个过程会重复进行多次,直到达到预设的迭代次数或质心位置的变化小于某个预设的阈值。这个阈值用于判断质心是否已经稳定,即是否已经收敛。
- 输出结果:最后,算法会输出K个簇和每个数据点的归属。每个数据点将属于与其最近的质心所在的簇。同时,还会输出每个簇的质心位置,这些质心表示了每个簇的中心点或平均形态。
KMeans算法对初始质心的选择和K值的确定非常敏感。不同的初始质心可能会导致完全不同的聚类结果,而K值的选取也会影响聚类的质量和效果。通过迭代并尝试不同的K值和初始质心,以找到最佳的聚类结果。最佳K值往往通过肘部图法获得,也就是绘制silhoutte稀疏随k的变化曲线找到曲线拐点即可。
衡量聚类好坏的标准可以用轮廓系数来描述。轮廓系数的定义为
$$
s(i) = \frac{{b(i) - a(i)}}{{\max \{ a(i),b(i)\} }}
$$
其中,*a*(***i***)是***i***向量到同一簇内其他点不相似程度的平均值,*b*(***i***)是***i***向量到其他簇的平均不相似程度的最小值。轮廓系数在[-1,1]之间,越大越合理。
判断最优的*K*值会采取肘部图策略。肘部法则的计算原理是分析损失函数随着聚类簇数量的变化曲线找到其拐点。聚类中的损失函数是每个变量点到其类别中心的位置距离平方和。在选择类别数量上,肘部法则会把不同值的成本函数值画出来。肘部就是指这个图的拐点,下降从快到慢的点。
对上述代码进行Python实现如下:
```python
def init_cent(dataset,K):
idx=np.random.choice(np.arange(len(dataset)),size=K,replace=False)
return dataset[idx]
def Kmeans(dataset,K,init_cent):
centroids=init_cent(dataset,K)
cluster=np.zeros(len(dataset))
changed=True
while changed:
changed=False
loss=0
for i,data in enumerate(dataset):
dis=np.sum((centroids-data)**2,axis=-1)
k=np.argmin(dis)
if cluster[i]!=k:
` `cluster[i]=k
` `changed=True
loss+=np.sum((data-centroids[k])**2)
for i in range(K):
centroids[i]=np.mean(dataset[cluster==i],axis=0)
return centroids,cluster
```
在sklearn.cluster中也提供了KMeans算法的接口。例如,可以看到下面的案例。
```python
from sklearn.cluster import KMeans
import numpy as np
# 创建一个随机数据集
X = np.random.rand(100, 2)
# 创建KMeans聚类模型,设置簇的数量为3
kmeans = KMeans(n_clusters=3)
# 使用数据集拟合模型
kmeans.fit(X)
# 输出聚类中心点
print("Cluster centers:")
print(kmeans.cluster_centers_)
# 对数据集进行预测,得到每个数据点的聚类标签
labels = kmeans.predict(X)
print("Labels of data points:")
print(labels)
```
这段代码首先导入所需的库和模块,然后创建一个随机数据集。接下来,创建一个KMeans聚类模型,并设置簇的数量为3。然后,使用数据集拟合模型,并输出聚类中心点。最后,对数据集进行预测,得到每个数据点的聚类标签。
### 9.12.2 基于层次的聚类算法:层次聚类法
层次聚类法是一种基于距离的聚类算法,它将数据点按照距离的远近进行层次式的分组,使得同一组内的数据点尽可能相似或接近,不同组之间的数据点尽可能不同或远离。为什么需要引入层次聚类法:在许多实际应用中,我们可能需要对数据进行更细致的分类或者处理具有层次结构的数据。例如,在市场细分中,我们可能希望将消费者按照购买行为、偏好和特征进行更精细的分类;在社交网络分析中,我们可能希望找到具有相似兴趣或行为的用户群体。层次聚类法可以帮助我们实现这些目标,因为它能够揭示数据的层次结构和细微差别,并提供更为精确和细致的聚类结果。
层次聚类法按照聚类的顺序可以分为自底向上和自顶向下两种类型。
- 自底向上的层次聚类(凝聚层次聚类):先单独考虑每个对象,然后逐渐合并这些原子簇,直到所有对象都在一个簇中或满足终止条件。这种方法的优点是不需要预先设定簇的数量,适合发现任意形状的簇,并且对异常值不敏感。缺点是计算量大,容易受到初始值的影响,可能陷入局部最优解。
- 自顶向下的层次聚类(分裂层次聚类):开始时将所有对象视作一个簇,然后逐渐分裂这个簇,直到每个对象都是一个簇或满足终止条件。优点是可以发现任意形状的簇,能够处理大规模数据集。缺点是需要预先设定簇的数量,容易受到初始值的影响,可能陷入局部最优解。
这两种方法没有孰优孰劣之分,只是在实际应用的时候要根据数据特点以及你想要的“类”的个数,来考虑是自上而下更快还是自下而上更快。判断”类”的方法就是:最短距离法、最长距离法、中间距离法、类平均法等,其中类平均法往往被认为是最常用也最好用的方法,一方面因为其良好的单调性,另一方面因为其空间扩张/浓缩的程度适中。
自顶而下的层次聚类输入样本数据和聚类数量后进行以下操作。
(1)将样本归为一类。
(2)在一个类中计算样本的距离,找到距离最远的a和b。
(3)将a和b分到两个不同的簇中。
(4)剩下样本到a和到b哪个距离更小就去哪个簇。
(5)递归生成聚类树。
自底而上的层次聚类则按照以下顺序进行。
(1)一个样本作为一个类。
(2)计算两两之间的距离,最小的两个点合并为一个类别。
(3)重复上一个操作,直到所有数据被归为一类。
基于scipy可以实现层次聚类法。例如,参考下面的代码:
```python
import numpy as np
import matplotlib.pyplot as plt
from scipy.cluster.hierarchy import dendrogram, linkage
# 生成随机数据
np.random.seed(0)
X = np.random.multivariate_normal([0, 0], [[1, 0.5], [0.5, 1]], size=500)
# 执行层次聚类
linked = linkage(X, 'ward')
# 绘制层次聚类模型图
fig = plt.figure(figsize=(10, 7))
dendrogram(linked,
orientation='top',
distance_sort='descending',
show_leaf_counts=True)
plt.show()
```
首先,我们使用numpy库生成一个500个样本的随机数据集。然后,我们使用scipy.cluster.hierarchy模块中的linkage函数执行层次聚类。最后,我们使用matplotlib库中的dendrogram函数绘制层次聚类模型图如图所示。

在图中,每个节点表示一个数据点,节点之间的连线表示聚类关系。上面的代码在sklearn中也有对应的接口,我们使用sklearn库中的AgglomerativeClustering类执行层次聚类。该类提供了许多参数来控制聚类的过程,其中n_clusters指定了最终要形成的簇的数量,affinity指定了相似度的度量方式(这里使用欧几里得距离),memory参数用于指定用于存储中间结果的内存大小。我们用sklearn来改写上面的代码:
```python
from sklearn.cluster import AgglomerativeClustering
import matplotlib.pyplot as plt
# 生成随机数据
X = np.random.multivariate_normal([0, 0], [[1, 0.5], [0.5, 1]], size=500)
# 执行层次聚类
cluster = AgglomerativeClustering(n_clusters=2, affinity='euclidean', memory='auto')
cluster.fit(X)
labels = cluster.labels_
# 绘制层次聚类模型图
fig = plt.figure(figsize=(10, 7))
plt.scatter(X[:, 0], X[:, 1], c=labels)
plt.show()
```
### 9.12.3 基于密度的聚类算法:DBSCAN算法
DBSCAN算法是密度聚类算法,所谓密度聚类算法,就是说这个算法是根据样本的密集程度来进行聚类。但在介绍具体的算法之前,先来介绍一些基本的定义和概念。要根据样本中的数据密度进行聚类,首先定义样本中数据密度大的地方应该怎样表示,于是引出了两个概念:
(1)ε -邻域:若Xi 是一个样本点,邻域就是指距离Xi 不超过ε 的范围。本质上,就是衡量离A 样本有多远。
(2)核心对象:如果Xi 的ε -邻域内至少含有M 个样本,则Xi 是一个核心对象。这个M 也被定义为密集的阈值。
有了这两个定义以后,下面定义核心对象之间的几何关系。
(1)密度直达:如果*B*样本位于*A*样本的*ε*-邻域内,则称*A*样本和*B*样本密度直达。
(2)若*A*样本和*B*样本密度直达,*B*样本和*C*样本密度直达,*A*样本和*C*样本之间不是密度直达,则称*A*样本和*C*样本密度可达。
(3)若*B*样本和*A*、*C*样本均密度可达,则称*A*样本和*C*样本密度相连。
本质上,这个算法就是统计数据中的核心对象并将其归类,但这个算法有一个特点就是非核心对象会被判定为离群点,所以安排一个特殊的离群类并记为-1。具体的代码操作如下。
**DBSCAN是一种基于密度的聚类算法,它通过样本点的密集程度来进行聚类。在深入探讨DBSCAN算法之前,让我们先了解一下与密度聚类相关的一些基本概念。**
**我们定义一个样本点Xi的ε-邻域,这指的是与Xi的距离不超过ε的所有样本点构成的区域。这个ε值帮助我们衡量样本之间的接近程度。**
**我们引入核心对象的概念。如果一个样本点Xi的ε-邻域内包含至少M个样本,那么Xi被称为核心对象。这里的M被称为密度阈值,它决定了什么樣的样本点被视为密集区域的一部分。**
**了解核心对象后,我们可以定义它们之间的几种特殊几何关系:**
**1. 密度直达:如果样本点B位于样本点A的ε-邻域内,那么我们说A和B是密度直达的。**
**2. 密度可达:如果样本点B是样本点A的密度直达点,且B也是样本点C的密度直达点,但A和C之间不是密度直达,那么我们说A和C是密度可达的。**
**3. 密度相连:如果样本点B同时与样本点A和C密度可达,那么我们说A和C是密度相连的。**
**DBSCAN算法的基本原理就是识别这些核心对象和它们之间的密度连接,并将它们聚类。算法还会将非核心对象(即那些ε-邻域内少于M个样本的点)视为离群点,并将它们归为特殊的离群类,通常标记为-1。**
**在实际应用中,DBSCAN算法的代码实现涉及以下几个步骤:**
**1. 选择合适的ε值和M值。**
**2. 计算每个样本点的ε-邻域。**
**3. 确定核心对象。**
**4. 建立密度连接关系。**
**5. 合并密度相连的核心对象为同一聚类。**
**6. 将离群点分类。**
通过上述步骤,DBSCAN能够有效地对数据集进行聚类,识别出数据中的模式和结构。
我们可以使用sklearn.cluster模块中的DBSCAN类执行DBSCAN聚类。在创建DBSCAN对象时,我们指定两个参数:eps和min_samples。eps参数指定了两个样本被认为是邻居的最大距离,而min_samples参数指定了一个样本点被视为核心点所需的最小邻居数。通过调整这些参数,我们可以控制聚类的结果。例如,参考下面的代码:
```python
from sklearn.cluster import DBSCAN
from sklearn.datasets import make_moons
import matplotlib.pyplot as plt
# 生成半月形数据集
X, y = make_moons(n_samples=200, noise=0.05, random_state=0)
# 执行DBSCAN聚类
dbscan = DBSCAN(eps=0.3, min_samples=5)
dbscan.fit(X)
labels = dbscan.labels_
# 绘制聚类结果
unique_labels = set(labels)
colors = [plt.cm.Spectral(each)
` `for each in np.linspace(0, 1, len(unique_labels))]
for k, col in zip(unique_labels, colors):
if k == -1:
# Black used for noise.
col = [0, 0, 0, 1]
class_member_mask = (labels == k)
xy = X[class_member_mask & (labels != -1)]
plt.plot(xy[:, 0], xy[:, 1], 'o', markerfacecolor=tuple(col),
` `markeredgecolor='k', markersize=14)
plt.title('DBSCAN')
plt.show()
```
效果如图所示:

### 9.12.4 基于模型的聚类算法:高斯混合聚类法
大家还记得正态分布吗?正态分布不仅有一元正态分布,还有多元正态分布。实际上,任何一组样本其实都可以看做是若干个正态分布的叠加。高斯混合聚类就是基于这种思想,GMM也可以看作是K-means的推广,因为GMM不仅是考虑到了数据分布的均值,也考虑到了协方差。和K-means一样,我们需要提前确定簇的个数。GMM的基本假设为数据是由几个不同的高斯分布的随机变量组合而成,而聚类的任务就是确定数据的叠加方法。
假设要将样本分为K个簇,每个簇有对应的均值与协方差矩阵,为了确定样本属于每个簇的概率大小,将这个概率写作矩阵W并构造最大似然函数:
$$
L(W) = \prod\limits_{i = 1}^n {\left( {\sum\limits_{j = 1}^k {{W_{i,j}}P({X_i}|{\mu _j},{\Sigma _j})} } \right)}
$$
接下来的操作就是要通过优化W找到最佳的似然函数极小值了。这里选择使用EM算法。EM(Expectation-Maximization)算法是一种迭代优化算法,主要用于寻找参数的最大似然估计。它的基本思想是:在每一步迭代中,先对参数进行估计,然后根据这些估计值来更新模型,接着再用新的模型来估计参数,如此反复迭代,直到参数收敛或达到预设的迭代次数。
在进行优化过程时,需要轮换执行E操作和M操作。对于E操作,主要目的是更新W。第i个变量属于第m簇的概率为:
$$
{W_{i,m}} = \frac{{{\pi _m}P({X_i}|{\mu _j},{\Sigma _j})}}{{\sum\limits_{j = 1}^n {{\pi _j}P({X_i}|{\mu _j},{\Sigma _j})} }}
$$
根据W,我们就可以更新每一簇的占比。而在M步骤中,我们需要根据上面一步得到的W来更新均值和方差。 因为这里的数据是二维的,第m簇的第k个分量的均值与方差:
$$
\begin{array}{l}
{\mu _{m,k}} = \frac{{\sum\limits_{i = 1}^n {{W_{i,m}}{X_{i,k}}} }}{{\sum\limits_{i = 1}^n {{W_{i,m}}} }}\\
{\Sigma _{m,k}} = \frac{{\sum\limits_{i = 1}^n {{W_{i,m}}{{({X_{i,k}} - {\mu _{m,k}})}^2}} }}{{\sum\limits_{i = 1}^n {{W_{i,m}}} }}
\end{array}
$$
Python使用mixture中的GaussianMixture实现高斯混合聚类。例如,我们看到下面的简单案例:
```python
from sklearn.mixture import GaussianMixture
import numpy as np
import matplotlib.pyplot as plt
# 生成随机样本数据
np.random.seed(0)
X = np.concatenate([
np.random.multivariate_normal([0, 0], [[1, 0.5], [0.5, 1]], 50),
np.random.multivariate_normal([0, 5], [[1, 0.5], [0.5, 1]], 50),
np.random.multivariate_normal([5, 0], [[1, 0.5], [0.5, 1]], 50),
np.random.multivariate_normal([5, 5], [[1, 0.5], [0.5, 1]], 50)
])
# 使用GaussianMixture进行聚类
gmm = GaussianMixture(n_components=4)
gmm.fit(X)
labels = gmm.predict(X)
# 可视化聚类结果
plt.scatter(X[:, 0], X[:, 1], c=labels)
plt.show()
```
这段代码会生成四个基于正态分布的样本混合,将其聚类成四类。聚类效果如图所示:

可以看到,算法能够较为准确地分析出样本的分布规律。
## 9.13 关联关系挖掘模型
关联规则挖掘发现大量数据中项集之间有趣的关联或者相互联系。关联规则挖掘的一个典型例子就是购物篮分析,该过程通过发现顾客放入其购物篮中不同商品之间的联系,分析出顾客的购买习惯,通过了解哪些商品频繁地被顾客同时买入,能够帮助零售商制定合理的营销策略。
注意:关联关系和相关关系是两码事!
### 9.13.1 关联关系的定义
在几十年前的美国有一个怪现象,一个超市的员工发现,超市里面啤酒和尿布在同一个购物篮里面的频率特别高。这个现象引起了统计学家的注意,他们开始思考:是否还具有这类我们没想到的关联物品?由此引发了关联关系挖掘的研究。其实,想要解释这件事并不难,因为在几十年前的美国,家里面打酱油啊买尿布啊这些事都是爸爸在做。爸爸在出门给孩子买尿布的时候捎几瓶啤酒晚上回去宵夜,很合理吧?
但是挖掘关联关系只能靠统计一起出现的商品频率吗?这个频率应该多少次算高呢?这个量化够吗?
关联关系挖掘是一种数据挖掘技术,用于发现数据集中的关联规则和模式。它通过分析数据集中的项之间的关系,揭示它们之间的潜在关联性。关联关系挖掘的目标是找到频繁项集和强关联规则。频繁项集指的是在数据集中经常同时出现的项集,而强关联规则指的是具有一定置信度和支持度的关联规则。通过计算支持度和置信度,可以筛选出频繁项集和强关联规则。为了量化关联规则,我们先引入一些概念:
- 项和项集。在关联规则挖掘中,“项”可以看作是单个的商品或产品,而“项集”则是由多个项组成的集合,例如“牛奶”和“面包”都可以看作是项,而“牛奶、面包”的组合则是一个项集。如果一个项集包含*k*个项,则称它为*k*-项集。空集是指不包含任何项的项集。例如,{啤酒,尿布,牛奶}是一个3-项集。
- 支持度。支持度描述的是一个项集在数据集中出现的频率,简单来说就是有多少人同时购买了这些商品。例如,如果100个人中有50个人同时购买了牛奶和面包,那么“牛奶、面包”这个项集的支持度就是50%。项集的一个重要性质是它的支持度计数,即包含特定项集的事务个数。说白了就是这些订单里面包含想研究的关联商品的单数。支持度被定义为:
$$
S\left( {X \to Y} \right) = \frac{{\sigma \left( {X \cup Y} \right)}}{N}
$$
- 置信度。置信度则是指当某个项集中的商品出现时,另一个商品出现的概率有多大。例如,如果购买牛奶的人中有70%的人也会购买面包,那么“如果牛奶,那么面包”这个关联规则的置信度就是70%。置信度为:
$$
C\left( {X \to Y} \right) = \frac{{\sigma \left( {X \cup Y} \right)}}{{\sigma (X)}}
$$
- 关联规则。关联规则是通过支持度和置信度的计算得出的,它表示了商品之间的关联关系。例如,“如果牛奶,那么面包”就是一个关联规则,表示购买牛奶的人很可能会购买面包。关联规则有很多种形式,如“如果A,那么B”、“A和B”、“A->B”等。关联规则是形如*X*→*Y*的蕴含表达式,其中*X*和*Y*是不相交的项集。关联规则的强度可以用它的支持度和置信度来度量。支持度确定规则可以用于给定数据集的频繁程度,而置信度确定*Y*在包含*X*的事务中出现的频繁程度。
### 9.13.2 Apriori算法
Apriori算法是一种用于关联关系挖掘的经典算法。它的基本思想是利用项集的支持度来生成频繁项集,再利用频繁项集生成关联规则。Apriori算法通过不断剪枝和迭代,找出数据集中频繁出现的项集,并利用这些频繁项集来挖掘强关联规则。它的核心思想是利用已知的频繁项集来推导出更大规模的频繁项集,从而减少了计算量和时间复杂度。简单来说,Apriori算法就是通过不断寻找更大规模的频繁项集来发现数据集中的关联规则。
Apriori算法的基本流程如下:
- 初始化:设置参数,包括最小支持度阈值和最小置信度阈值。初始化L1,即频繁1项集的集合。扫描整个数据集,计算每个项集的支持度,将支持度大于或等于最小支持度阈值的项集加入L1。
- 迭代:从L1出发,生成候选的k项集(k大于1),记为Ck。扫描整个数据集,对Ck中的每个候选进行计数,并计算每个候选的支持度。将支持度大于或等于最小支持度阈值的候选加入Lk。
- 剪枝:在生成Ck的过程中,如果存在某个候选的子集的支持度大于该候选本身,那么该候选可以被剪掉,因为它的非空子集的支持度更高。
- 终止:当无法找到更多的Lk时,算法结束。
- 生成关联规则:对于每个频繁项集Lk,如果存在一个子集A和一个超集B,使得A和B都是频繁的,且A和B的交集非空,那么存在一个关联规则“A->B”。计算该规则的置信度,如果置信度大于或等于最小置信度阈值,则输出该规则。

实现一个apriori算法并不困难。在Python中,可以使用apyori包实现该算法。
```python
import pandas as pd
from apyori import apriori
# 读取数据集
data = pd.read_csv('dataset.csv', header=None)
# 将数据集转换为Apriori算法所需的格式
data = data.iloc[:, 1:].values
transactions = list(data)
# 执行Apriori算法
min_support = 0.3
min_confidence = 0.7
results = apriori(transactions, min_support=min_support, min_confidence=min_confidence)
# 输出关联规则
for result in results:
print(result)
```
### 9.13.3 FP-Growth算法
Apriori通过不断的构造候选集、筛选候选集挖掘出频繁项集,需要多次扫描原始数据,当原始数据较大时,磁盘I/O次数太多,效率比较低下。这毫无疑问会成为Apriori算法最大的缺点一频繁项集发现的速度太慢。 FP-growth算法其实是在Apriori算法基础上进行了优化得到的算法,FPGrowth算法则只需扫描原始数据两遍,通过FP-tree数据结构对原始数据进行压缩,效率较高。
FP-growth算法只需要对数据库进行了两次扫描,而Apriori算法对于每个潜在的频繁项集都会扫描数据集判定给定模式是否频繁,因此FP-growth算法的速度要比Apriori算法快。在小规模数据集上,这不是什么问题,但是当处理大规模数据集时,就会产生很大的区别。
FP-growth算法主要分为两个步骤:基于数据集构建FP树和从FP树递归挖掘频繁项集。FP-tree构建通过两次数据扫描,将原始数据中的事务压缩到一个FP-tree树,该FP-tree类似于前缀树,相同前缀的路径可以共用,从而达到压缩数据的目的。
在Python中,使用pyfpgrowth库可以实现该算法。可以看到下面的简单demo:
```python
import pyfpgrowth
transactions = [[1, 2, 3],
` `[2, 4, 5],
` `[1, 2, 4],
` `[1, 3, 5],
` `[2, 3],
` `[1, 3, 5],
` `[1, 2, 3, 5],
` `[1, 2, 3]]
patterns = pyfpgrowth.find_frequent_patterns(transactions, 3)
rules = pyfpgrowth.generate_association_rules(patterns, 0.5)
print(patterns)
print('===============')
print(rules)
```
代码会找到超过3次的频繁项集并保存在patterns中,并以字典的形式在rule中返回每个规则以及对应的置信度(实际上也可以看做是条件概率)。
## 9.14 图数据与PageRank算法
PageRank是一种由搜索引擎利用网页间超链接计算网页重要度的算法,常被称为网页排名、谷歌左侧排名。该算法是以Google公司创始人拉里·佩奇(Larry Page)的姓氏命名的,作为网页排名的重要要素之一。事实上,PageRank 可以定义在任意有向图上,后来被应用到社会影响力分析、文本摘要等多个问题。
PageRank算法的基本思想是通过对网页之间的链接关系进行分析,计算每个网页的重要程度,从而实现对网页的排序。PageRank算法认为,一个网页的重要性取决于它被其他网页引用的次数以及这些引用的网页的重要性。因此,算法通过分析网页之间的链接关系,构建出一个链接矩阵,然后对这个矩阵进行迭代运算,最终得到每个网页的PageRank值,从而实现对网页的排序。PageRank算法能够有效地解决信息过载问题,帮助用户快速找到高质量的网页,提高搜索结果的准确性和相关性。
PageRank算法的基本概念是在有向图中构建一个随机游走模型,即马尔可夫链的一阶形式。这个模型描述了随机游走者在有向图中如何随机访问各个节点。PageRank表示该马尔可夫链的平稳分布,每个节点的平稳概率值就是其PageRank值。这个值赋予每个网页一个正实数,代表其重要性。所有网页的PageRank值共同构成一个向量,其中PageRank值越高的网页越重要,因此在互联网搜索结果中可能被排在前面。PageRank是递归定义的,可以通过迭代算法进行计算。
如果一个网页的PageRank值越高,那么随机跳转到该网页的概率也就越大,从而该网页的PageRank值会进一步提高,使其变得更为重要。PageRank值依赖于网络的拓扑结构,一旦网络的连接关系确定,PageRank值也就随之确定。
在互联网的有向图上,可以运用PageRank算法进行计算,通常采用迭代过程。先假设一个初始分布,然后通过迭代不断计算所有网页的PageRank值,直到达到收敛状态为止。
PageRank的迭代过程如下面的式子所示:
$$
P(X) = \frac{{1 - d}}{N} + d\sum\limits_{i \to X}^N {\frac{{P({T_i})}}{{C({T_i})}}}
$$
P表示游走到该节点的概率,T是指向X的所有节点,而C(T)表示节点T的出度。通过阻尼系数d调节使其迭代更加稳定。在最开始,所有节点访问的概率是均等的,随着迭代次数进行不同节点出现了不同的概率,也就反映了不同节点的重要程度。
```python
import numpy as np
# PageRank算法
def pagerank_algorithm(adjacency_matrix, damping_factor=0.85, max_iterations=100, convergence_threshold=0.0001):
n = len(adjacency_matrix)
# 构建转移矩阵
transition_matrix = adjacency_matrix / np.sum(adjacency_matrix, axis=0, keepdims=True)
# 初始化PageRank向量
pagerank = np.ones(n) / n
# 开始迭代
for i in range(max_iterations):
old_pagerank = np.copy(pagerank)
# 计算新的PageRank向量
pagerank = damping_factor * np.dot(transition_matrix, old_pagerank) + (1 - damping_factor) / n
# 判断是否收敛
if np.sum(np.abs(pagerank - old_pagerank)) < convergence_threshold:
break
return pagerank
# 测试代码
if __name__ == '__main__':
links = [(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]
n = 4
adjacency_matrix = np.zeros((n, n))
for link in links:
adjacency_matrix[link[1]][link[0]] = 1
pagerank = pagerank_algorithm(adjacency_matrix)
print('PageRank:', pagerank)
```
## 9.15 朴素贝叶斯与贝叶斯网络
我想各位在中学阶段其实已经是对线性规划有所了解了,不过如果有些学校没学的话也没关系。在这一节当中我们会回顾以前中学接触到的线性规划,补充一些线性代数的知识和matlab解线性代数问题的指令,最后引出线性规划的基本形式。数学建模是一门应用数学课程,与基础数学不同,我这里不打算抢读者线性代数老师的饭碗太严重,但我们会尽可能多补充一些基础的常用的有关理论。
### 9.15.1 朴素贝叶斯算法
讲朴素贝叶斯算法就不得不提到贝叶斯公式。贝叶斯公式是用来描述两个条件概率之间的关系,比如P(A|B)和P(B|A)。它基于贝叶斯定理,该定理指出在已知先验概率的情况下,可以通过新的证据来更新事件的概率。它的数学表达为:
$$
P(B|A) = \frac{{P(A|B)P(B)}}{{P(A)}}
$$
不知道大家有没有收到过垃圾邮件的经历。现在的邮箱软件是可以自动区分谁是垃圾邮件谁是正常邮件的,怎么做到的呢?首先,有些发信者会在同一时间发送好多封信,这种人多半就是打小广告的。这种就叫做基于行为的垃圾邮件识别。还有一种呢就是因为被太多人举报于是把发信者拉进了黑名单,这种就是基于白名单的垃圾邮件识别。而基于内容的垃圾邮件识别就要用到贝叶斯公式了。具体来说,垃圾邮件分类的任务是根据邮件的特征(如关键词、发件人、主题等)判断该邮件是否为垃圾邮件。首先,我们需要统计训练集中各类别(垃圾邮件、正常邮件)以及各个特征的出现概率。然后,对于待分类的邮件,我们根据其特征计算该邮件属于各个类别的概率。最后,将该邮件归为概率最大的类别。
贝叶斯公式在垃圾邮件分类中的作用原理在于利用条件概率来表示给定特征下各个类别的概率。具体来说,我们使用贝叶斯公式计算给定特征下各个类别的概率,即P(C|X),其中C表示类别(垃圾邮件或正常邮件),X表示特征(如关键词、发件人、主题等)。在垃圾邮件分类中,贝叶斯公式通常用于实现朴素贝叶斯分类器。该分类器基于贝叶斯定理,通过计算给定特征下各个类别的概率,将待分类的邮件归为概率最大的类别。
朴素贝叶斯算法是一种基于贝叶斯定理的分类方法。它假设各个特征之间相互独立,通过计算每个类别在给定特征下的概率,将待分类的样本归为概率最大的类别。朴素贝叶斯算法在处理文本分类、垃圾邮件过滤等任务中表现出色,并且具有简单、高效的特点。它的核心思想是根据已知的训练数据集,为每个类别计算出特征条件独立的概率,然后利用这些概率来预测新样本的类别。
朴素贝叶斯算法处理垃圾邮件的基本流程可以分为以下几个步骤:
- 特征提取:从邮件数据集中提取出有意义的特征,通常采用TF-IDF(词频-逆文档频率)方法进行特征提取。
- 训练模型:将提取出的特征和对应的类别进行训练,计算出每个特征在不同类别下的条件概率。
- 分类:对未知样本进行分类,根据已知的特征和对应的条件概率计算出每个类别的概率,将样本归为概率最大的类别。
假设我们正在使用朴素贝叶斯分类器对一组文本进行分类,其中一个类别是“正面情感”。我们的训练数据集中包含了一些正面的文本和负面的文本,但是正面的文本数量较少。在训练过程中,我们发现一个常见的词语“好”在正面的文本中出现了很多次,但在负面的文本中只出现了一次。根据朴素贝叶斯分类器的原理,每个词语的出现概率是独立的,因此在计算正面情感类别的条件概率时,我们不能简单地认为“好”这个词语的出现概率是1(在正面的文本中)和0(在负面的文本中)。这是因为负面的文本中“好”这个词出现的概率虽然很小,但并不为0。为了解决这个问题,我们可以使用拉普拉斯平滑方法。拉普拉斯平滑的核心思想是在计算概率时,给每个事件添加一个小的常数,以避免出现零概率的情况。具体来说,我们可以给“好”这个词的出现概率增加一个小的常数(例如0.01),这样在计算正面情感类别的条件概率时,就不会出现分母为0的情况。通过引入拉普拉斯平滑,我们可以更准确地计算每个词语的出现概率,从而使得朴素贝叶斯分类器的分类效果更好。这是因为拉普拉斯平滑方法能够处理训练数据中未出现的事件,避免将它们的概率估计为0。
朴素贝叶斯算法的朴素就在于它的思想其实很简单。实现自己的朴素贝叶斯模型也并不是很困难的事情。下面我们自己实现一个朴素贝叶斯分类器。首先,我们创建一个比较小的分类样本数据:
> 代码参考了,特别鸣谢!
```python
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import Binarizer
X_train = np.array([[0, 0], [0, 1], [0, 1], [0, 0], [0, 0],
` `[1, 0], [1, 1], [1, 1], [1, 2], [1, 2],
` `[2, 2], [2, 1], [2, 1], [2, 2], [2, 2]])
y_train = np.array([0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0])
X_test = np.array([[1, 0]])
```
样本非常小,只有十几个。我们统计一下特征的取值与标签的取值,创建矩阵用于存储先验分布与条件概率。
```python
N = len(y_train)
K = len(np.unique(y_train))
S = len(np.unique(X_train[:,0])) # 特征取值
D = X_train.shape[1] # 维度
n = len(X_test)
d = X_test.shape[1]
prior = np.zeros(K)
condition = np.zeros((K, D, S)) #条件概率
lambda_=3
```
接下来是实现朴素贝叶斯的主体部分,包括概率计算与预测。
```python
# 朴素贝叶斯训练
def trainNB(X_train, y_train):
for i in range(0, N):
prior[y_train[i]] += 1
for j in range(0, D):
condition[y_train[i]][j][X_train[i][j]] += 1
prior_probability = (prior + lambda_) / (N + K*lambda_) # 拉普拉斯平滑
return prior_probability, condition
def predictNB(prior_probability, condition, X_test):
predict_label = -1 * np.ones(n)
for i in range(0, n):
predict_probability = np.ones(K)
to_predict = X_test[i]
for j in range(0, K):
prior_prob = prior_probability[j]
for k in range(0, d):
` `conditional_probability = (condition[j][k][to_predict[k]] + lambda_) / (sum(condition[j][k]) + S*lambda_)
` `predict_probability[j] *= conditional_probability
predict_probability[j] *= prior_prob
predict_label[i] = np.argmax(predict_probability)
print("Sample %d predicted as %d" % (i, predict_label[i]))
return predict_label
```
测试代码如下:
```python
print("Start naive bayes training...")
prior, conditional = trainNB(X_train=X_train, y_train=y_train)
print("Testing on %d samples..." % len(X_test))
predictNB(prior_probability=prior,condition=conditional,X_test=X_test)
```
经过测试,样本被分类为0,测试结束。这是一个比较简单的实现,有兴趣的同学可以把它扩充为面向对象版本。
### 9.15.2 贝叶斯网络与概率图模型
贝叶斯网络,又称为信念网络或有向无环图模型,是一种强大的概率图形模型。它通过有向无环图来表示变量之间的概率依赖关系,这种图由代表变量的节点和连接这些节点的有向边组成。每个节点代表一个随机变量,而节点之间的有向边则表达了变量间的因果关系,即从父节点指向子节点。这些关系通过条件概率来量化,而对于没有父节点的节点,我们则使用先验概率来描述其信息。
**贝叶斯网络的应用非常广泛,它能够帮助我们理解和分析那些涉及不确定性和概率性的事件。在实际应用中,这种模型特别适合于需要根据多个条件因素进行决策的场景。比如,在医疗诊断中,医生可能会使用贝叶斯网络来分析各种症状与疾病之间的关系,从而得出最有可能的诊断结果。在其他领域,如产品推荐、天气预报和网络安全等,贝叶斯网络也发挥着重要的作用。**
**在处理数据时,贝叶斯网络可以从不完全、不精确或不确定的信息中进行推理,这使得它在面对现实世界中的复杂问题时尤为有用。通过学习数据中的模式和关系,贝叶斯网络能够预测新的数据点,并给出相应的概率分布。**
**总的来说,贝叶斯网络是一种非常有用的工具,它能够帮助我们理解和处理现实世界中的不确定性,并做出更加可靠的决策。无论是新手还是有经验的分析师,掌握贝叶斯网络的基本原理和方法,都将极大地提升他们在面对复杂问题时进行有效分析和决策的能力。**
================================================
FILE: docs/README.md
================================================
# 数学建模导论
- 项目背景:本项目基于我的课程《数学建模导论——基于Python语言》整理而来,是为了帮助学生更好的学习数学建模、数据科学知识所用。可用于大学生参加数学建模竞赛,也可用作工作中的平时学习。万物皆可建模,人工智能的本质也是数学模型,所以我们开放这门课程的教程。
- 项目内容目录:本项目一共包含十章内容,包括解析几何与方程模型、微分方程与动力系统模型、函数极值与规划模型、复杂网络与图论模型、进化计算与群体智能算法、数据处理与拟合模型、权重生成与评价模型、时间序列与投资模型、机器学习与统计模型、多模态数据处理模型等十个方面内容,旨在尽可能多地向大家展示数学建模中所用到的数学基础与算法知识,打造属于自己的数学建模宇宙。
---
## RoadMap
目前稿件基本已经完工
---
## 参与贡献
- 如果你想参与到项目中来欢迎查看项目的 [Issue](https://github.com/datawhalechina/repo-template/blob/main/docs) 查看没有被分配的任务。
- 如果你发现了一些问题,欢迎在 [Issue](https://github.com/datawhalechina/repo-template/blob/main/docs) 中进行反馈🐛。
- 如果你对本项目感兴趣想要参与进来可以通过 [Discussion](https://github.com/datawhalechina/repo-template/blob/main/docs) 进行交流💬。
如果你对 Datawhale 很感兴趣并想要发起一个新的项目,欢迎查看 [Datawhale 贡献指南](https://github.com/datawhalechina/DOPMC#为-datawhale-做出贡献)。
---
## 贡献者名单
> 项目负责人&主编:马世拓(若冰,马马老师,B站id:平成最强假面骑士)
>
> 编辑&校对:
>
> - 陈思州(小红花):第1章,第2章,第10章
> - 刘旭(卡拉比丘流形):第3章,第5章
> - 萌弟:第6章,第8章
> - 聂雄伟(牧小熊):第7章,第8章
> - 邢硕(Susan):第4章,第9章
>
> 排版&美工:何瑞杰(拟身怪乖宝宝),聂雄伟(牧小熊),陈思州(小红花),骆秀韬(Epsilon Luo)
## 关注我们
扫描下方二维码关注公众号:Datawhale
[](https://raw.githubusercontent.com/datawhalechina/pumpkin-book/master/res/qrcode.jpeg)
## LICENSE
[](http://creativecommons.org/licenses/by-nc-sa/4.0/)
本作品采用[知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议](http://creativecommons.org/licenses/by-nc-sa/4.0/)进行许可。
# 目录
- 绪论:走进数学建模的大门
- 第1章:解析几何与方程模型
- 1.1 向量表示法与几何建模基本案例
- 1.2 Numpy与线性代数
- 1.3 平面几何模型的构建
- 1.4 立体几何模型的构建
- 1.5 使用Python解方程与方程组
- 第2章:微分方程与动力系统
- 2.1 微分方程的理论基础
- 2.2 使用Scipy与Sympy解微分方程
- 2.3 偏微分方程的数值求解
- 2.4 微分方程的应用案例
- 2.5 差分方程的应用案例
- 2.6 元胞自动机与仿真模拟
- 2.7 数值计算方法与微分方程求解
- 第3章:函数极值与规划模型
- 3.1 从线性代数到线性规划
- 3.2 使用Numpy进行矩阵计算
- 3.3 线性规划的算法原理
- 3.4 线性规划的建模案例
- 3.5 从线性规划到非线性规划
- 3.6 非线性规划的建模案例
- 3.7 整数规划与指派问题
- 3.8 使用scipy与cvxpy解决规划问题
- 3.9 动态规划与贪心算法
- 3.10 博弈论与排队论初步
- 3.11 多目标规划
- 3.12 蒙特卡洛模拟
- 第4章:复杂网络与图论模型
- 4.1 复杂网络概念与理论
- 4.2 图论算法:遍历,二分图与最小生成树
- 4.3 图论算法:最短路径与最大流问题
- 4.4 使用Networkx完成复杂网络建模
- 4.5 TSP问题与VRP问题
- 第5章:进化计算与群体智能
- 5.1 遗传算法理论与实现
- 5.2 粒子群算法理论与实现
- 5.3 蚁群算法理论与实现
- 5.4 模拟退火算法理论与实现
- 5.5 使用sci-opt实现群体智能算法
- 第6章:数据处理与拟合模型
- 6.1 什么是数据
- 6.2 数据的预处理
- 6.3 使用Pandas进行数据处理与分析
- 6.4 插值模型
- 6.5 回归与拟合模型
- 6.6 数据可视化与matplotlib、seaborn
- 第7章:权重生成与评价模型
- 7.1 层次分析法
- 7.2 熵权分析法
- 7.3 TOPSIS分析法
- 7.4 CRITIC法
- 7.5 模糊综合分析法
- 7.6 秩和比分析法
- 7.7 主成分分析法
- 7.8 因子分析法
- 7.9 数据包络分析法
- 7.10 评价模型总结
- 第8章:时间序列与投资模型
- 8.1 时间序列的基本概念
- 8.2 移动平均法与指数平滑法
- 8.3 ARIMA系列模型
- 8.4 GARCH系列模型
- 8.5 灰色系统模型
- 8.6 组合投资问题的一些策略
- 8.7 马尔可夫模型
- 第9章:机器学习与统计模型
- 9.1 统计分布与假设检验
- 9.2 回归不止用于预测
- 9.3 使用scipy与statsmodels完成统计建模
- 9.4 机器学习的研究范畴
- 9.5 使用scikit-learn完成机器学习任务
- 9.6 基于距离的KNN模型
- 9.7 基于优化的LDA和SVM模型
- 9.8 基于树形结构的模型
- 9.9 集成学习模型与GBDT
- 9.10 从神经网络到深度学习
- 9.11 使用PyTorch构造神经网络
- 9.12 聚类算法
- 9.13 关联关系挖掘模型
- 9.14 图数据与PageRank算法
- 9.15 朴素贝叶斯模型
- 第10章:多模数据与智能模型
- 10.1 数字图像处理与计算机视觉
- 10.2 计算语言学与自然语言处理
- 10.3 数字信号处理与智能感知
- 10.4 多模态数据与人工智能
================================================
FILE: docs/_sidebar.md
================================================
- 绪论 [走进数学建模的大门](CH0/绪论-走进数学建模的大门.md)
- 第1章 [解析方法与几何模型](CH1/第1章-解析方法与几何模型.md)
- 第2章 [微分方程与动力系统](CH2/第2章-微分方程与动力系统.md)
- 第3章 [函数极值与规划模型](CH3/第三章-函数极值与规划模型.md)
- 第4章 [复杂网络与图论模型](CH4/第4章-复杂网络与图论模型.md)
- 第5章 [进化计算与群体智能](CH5/第五章-进化计算与群体智能.md)
- 第6章 [数据处理与拟合模型](CH6/第六章-数据处理与拟合模型.md)
- 第7章 [权重生成与评价模型](CH7/第7章-权重生成与评价模型.md)
- 第8章 [时间序列与投资模型](CH8/第8章-时间序列.md)
- 第9章 [机器学习与统计模型](CH9/第九章-机器学习与统计模型.md)
- 第10章 [图像、文本与信号数据](CH10/第10章-图像、文本与信号数据.md)
================================================
FILE: docs/index.html
================================================
Datawhale 数学建模教程
Loding...