Python-时间序列分析秘籍第二版-全-
Python 时间序列分析秘籍第二版(全)
原文:
annas-archive.org/md5/7277c6f80442eb633bdbaf16dcd96fad译者:飞龙
第一章:1 开始时间序列分析
加入我们在 Discord 上的书籍社区

当你开始学习Python编程时,你经常会按照指示安装包并导入库,然后进入一段跟着代码走的学习流程。然而,在任何数据分析或数据科学过程中,一个常常被忽视的部分就是确保正确的开发环境已经搭建好。因此,从一开始就打好基础是至关重要的,这样可以避免未来可能出现的麻烦,比如实现过于复杂、包冲突或依赖危机。搭建好合适的开发环境会在你完成项目时帮助你,确保你能够以可重现和生产就绪的方式交付成果。
这样的主题可能不会那么有趣,甚至可能会感觉有些行政负担,而不是直接进入核心主题或当前的项目。但正是这个基础,才将一个经验丰富的开发者与其他人区分开来。就像任何项目一样,无论是机器学习项目、数据可视化项目,还是数据集成项目,一切都始于规划,并确保在开始核心开发之前,所有需要的部分都已经到位。
本章中,你将学习如何设置Python 虚拟环境,我们将介绍两种常见的方法来实现这一点。步骤将涵盖常用的环境和包管理工具。本章旨在实践操作,避免过多的行话,并让你以迭代和有趣的方式开始创建虚拟环境。
随着本书的进展,你将需要安装多个特定于时间序列分析、时间序列可视化、机器学习和深度学习(针对时间序列数据)的 Python 库。不管你有多大的诱惑跳过这一章,都不建议这么做,因为它将帮助你为随后的任何代码开发打下坚实的基础。在这一章结束时,你将掌握使用conda或venv创建和管理 Python 虚拟环境所需的技能。
本章将涵盖以下内容:
-
开发环境设置
-
安装 Python 库
-
安装 JupyterLab 和 JupyterLab 扩展
技术要求
本章中,你将主要使用命令行。对于 macOS 和 Linux,默认的终端将是 (bash 或 zsh),而在 Windows 操作系统中,你将使用Anaconda 提示符,这是 Anaconda 或 Miniconda 安装包的一部分。关于如何安装 Anaconda 或 Miniconda,将在随后的准备工作部分讨论。
我们将使用 Visual Studio Code 作为 IDE,它可以免费获取,网址为 code.visualstudio.com。它支持 Linux、Windows 和 macOS。
其他有效的替代选项也可以让你跟随学习,具体包括:
-
Sublime Text,网址为 https://www.sublimetext.com
-
Spyder,网址为
www.spyder-ide.org -
PyCharm Community Edition,网址为
www.jetbrains.com/pycharm/download/ -
Jupyter Notebook,网址为
jupyter.org
本章的源代码可以在 github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook 获取
开发环境设置
当我们深入本书提供的各种配方时,你将会创建不同的 Python 虚拟环境,以便安装所有依赖项而不影响其他 Python 项目。
你可以将虚拟环境看作是独立的桶或文件夹,每个文件夹都包含一个 Python 解释器和相关的库。下面的图示说明了独立的自包含虚拟环境的概念,每个虚拟环境都有不同的 Python 解释器,并且安装了不同版本的包和库:

图 1.1:三个不同 Python 虚拟环境的示例,每个环境对应一个 Python 项目
如果你安装了 Anaconda,那么这些环境通常会存储在 envs 子文件夹中的单独文件夹内,该子文件夹位于主 Anaconda(或 Miniconda)文件夹的安装目录下。举例来说,在 macOS 上,你可以在 Users/<yourusername>/opt/anaconda3/envs/ 路径下找到 envs 文件夹。在 Windows 操作系统中,路径可能看起来像 C:\Users\<yourusername>\anaconda3\envs。如果你安装的是 Miniconda,那么 main 文件夹将是 miniconda3 而不是 anaconda3。
每个环境(文件夹)都包含一个 Python 解释器,该解释器在创建环境时指定,例如 Python 2.7.18 或 Python 3.9 解释器。
一般来说,如果没有测试作为策略的一部分,升级 Python 版本或包可能会导致许多不希望出现的副作用。常见的做法是复制当前的 Python 环境,在进行所需的升级并进行测试之后,再决定是否继续进行升级。这正是环境管理器(conda 或 venv)和包管理器(conda 或 pip)在你的开发和生产部署过程中所带来的价值。
准备工作
在本节中,假设你已经通过以下任一方式安装了最新版本的 Python:
-
推荐的方法是通过像 Anaconda(
www.anaconda.com/products/distribution)这样的 Python 发行版来安装,Anaconda 自带所有必需的包,并支持 Windows、Linux 和 macOS(2022.05 版本起支持 M1)。另外,你也可以安装 Miniconda(docs.conda.io/en/latest/miniconda.html)或 Miniforge(github.com/conda-forge/miniforge)。 -
直接从官方 Python 网站下载安装程序:
www.python.org/downloads/。 -
如果你熟悉Docker,你可以下载官方的 Python 镜像。你可以访问 Docker Hub 来确定要拉取的镜像:
hub.docker.com/_/python。同样,Anaconda 和 Miniconda 可以通过遵循官方说明与 Docker 一起使用,说明请见此处:docs.anaconda.com/anaconda/user-guide/tasks/docker/
截至撰写时,最新的 Python 版本是 Python 3.11.3。
Anaconda 支持的最新 Python 版本
Anaconda 的最新版本是 2023.03,于 2023 年 4 月发布。默认情况下,Anaconda 会将 Python 3.10.9 作为基础解释器。此外,你可以使用
conda create创建一个 Python 版本为 3.11.3 的虚拟环境,稍后你将在本食谱中看到如何操作。
获取快速顺利启动的最简单有效方法是使用像Anaconda或Miniconda这样的 Python 发行版。如果你是初学者,我甚至更推荐使用 Anaconda。
如果你是 macOS 或 Linux 用户,一旦安装了 Anaconda,你几乎可以直接使用默认的终端。要验证安装情况,打开终端并输入以下命令:
$ conda info
以下截图展示了运行 conda info 时的标准输出,列出了有关已安装 conda 环境的信息。你应该关注列出的 conda 和 Python 的版本:

图 1.2 – 在 Linux(Ubuntu)上使用终端验证 Conda 的安装
如果你在 Windows 操作系统上安装了 Anaconda,你需要使用 Anaconda Prompt。要启动它,你可以在 Windows 搜索栏中输入 Anaconda,并选择列出的其中一个 Anaconda Prompt(Anaconda Prompt 或 Anaconda PowerShell Prompt)。一旦启动了 Anaconda Prompt,你可以运行 conda info 命令。

图 1.3:使用 Anaconda Prompt 在 Windows 上验证 Conda 的安装
如何操作…
在本指南中,我将介绍两个流行的环境管理工具。如果你已经安装了 Anaconda、Miniconda 或 Miniforge,那么conda应该是你的首选,因为它为 Python(并且支持许多其他语言)提供了包依赖管理和环境管理。另一方面,另一个选项是使用venv,这是一个内置的 Python 模块,提供环境管理,无需额外安装。
conda和venv都允许你为你的 Python 项目创建多个虚拟环境,这些项目可能需要不同版本的 Python 解释器(例如,3.4、3.8 或 3.9)或不同的 Python 包。此外,你可以创建一个沙箱虚拟环境来尝试新的包,以了解它们如何工作,而不会影响你基础的 Python 安装。
为每个项目创建一个单独的虚拟环境是许多开发者和数据科学实践者采纳的最佳实践。遵循这一建议从长远来看会对你有所帮助,避免在安装包时遇到常见问题,如包依赖冲突。
使用 Conda
首先打开你的终端(Windows 的 Anaconda 提示符):
- 首先,让我们确认你拥有最新版本的
conda。你可以通过以下命令来做到这一点:
$ conda update conda
上面的代码将更新 conda 包管理器。如果你使用的是现有的安装,这将非常有用。通过这种方式,你可以确保拥有最新版本。
- 如果你已经安装了 Anaconda,可以使用以下命令更新到最新版本:
$ conda update anaconda
- 现在,你将创建一个名为
py310的新虚拟环境,指定的 Python 版本为 Python 3.10:
$ conda create -n py310 python=3.10
在这里,-n是--name的快捷方式。
-
conda可能会识别出需要下载和安装的其他包。系统可能会提示你是否继续。输入y然后按Enter键继续。 -
你可以通过添加
-y选项跳过前面步骤中的确认消息。如果你对自己的操作有信心,并且不需要确认消息,可以使用此选项,让conda立即继续而不提示你进行响应。你可以通过添加-y或--yes选项来更新你的命令,如以下代码所示:
$ conda create -n py10 python=3.10 -y
- 一旦设置完成,你就可以激活新的环境。激活一个 Python 环境意味着我们的$PATH环境变量会被更新,指向虚拟环境(文件夹)中的指定 Python 解释器。你可以使用echo命令来确认这一点:
$ echo $PATH
> /Users/tarekatwan/opt/anaconda3/bin:/Users/tarekatwan/opt/anaconda3/condabin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
上面的代码适用于 Linux 和 macOS。如果你使用的是 Windows Anaconda 提示符,可以使用echo %path%。在 Anaconda PowerShell 提示符中,你可以使用echo $env:path。
在这里,我们可以看到我们的$PATH变量指向的是基础的conda环境,而不是我们新创建的虚拟环境。
- 现在,激活你新的
py310环境,并再次测试$PATH环境变量。你会注意到它现在指向envs文件夹——更具体地说,是py310/bin子文件夹:
$ conda activate py39
$ echo $PATH
> /Users/tarekatwan/opt/anaconda3/envs/py310/bin:/Users/tarekatwan/opt/anaconda3/condabin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
- 另一种确认我们新虚拟环境是活动环境的方法是运行以下命令:
$ conda info –envs
上面的命令将列出所有已创建的 conda 环境。请注意,py310 前面有一个 *,表示它是当前活动环境。以下截图显示我们有四个虚拟环境,且 py310 是当前活动的环境:

图 1.4:在 MacOS 上通过 conda 创建的所有 Python 虚拟环境列表
- 一旦激活了特定环境,你安装的任何软件包将只会在该隔离环境中可用。例如,假设我们安装 pandas 库并指定要在
py310环境中安装哪个版本。写作时,pandas 2.0.1 是最新版本:
$ conda install pandas=2.0.1
请注意,conda 会再次提示你确认,并告知将下载并安装哪些额外的软件包。在这里,conda 正在检查 pandas 2.0.1 所需的所有依赖项,并为你安装它们。你还可以通过在命令末尾添加 -y 或 --yes 选项来跳过此确认步骤。
消息还会指出安装将发生的环境位置。以下是安装 pandas 2.0.1 时的一个提示消息示例:

图 1.5:Conda 确认提示,列出所有软件包
如果你遇到
PackagesNotFoundError错误,你可能需要添加 conda-forge 通道来安装最新版本的 pandas(例如 2.0.1)。你可以使用以下命令来完成此操作:$ conda config --add channels conda-forge
Conda-Forge 提供适用于不同平台和架构的构建,并会自动选择适合你平台和架构的构建版本。
作为示例,如果你想为 MacOS ARM 指定一个 conda-forge 构建版本,你可以按照以下方式指定该构建:
$ conda config --add channels conda-forge/osx-arm64
-
一旦你按下 y 并按 Enter 键,
conda将开始下载并安装这些软件包。 -
一旦你完成在当前
py310环境中的工作,你可以使用以下命令deactivate来退出并返回到基础 Python 环境:
$ conda deactivate
- 如果你不再需要 py310 环境并希望删除它,你可以使用
env remove命令来删除它。该命令将完全删除该环境及所有已安装的库。换句话说,它将删除(移除)该环境的整个文件夹:
$ conda env remove -n py310
使用 venv
一旦安装了 Python 3x,你可以使用内置的venv模块,该模块允许你创建虚拟环境(类似于conda)。注意,当使用venv时,你需要提供一个路径,以指定虚拟环境(文件夹)创建的位置。如果没有提供路径,虚拟环境将会在你运行命令的当前目录下创建。在下面的代码中,我们将在Desktop目录中创建虚拟环境。
按照以下步骤创建新环境、安装包并使用venv删除该环境:
- 首先,决定你希望将新的虚拟环境放在哪里,并指定路径。在这个示例中,我已经导航到
Desktop并运行了以下命令:
$ cd Desktop
$ python -m venv py310
上面的代码将在Desktop目录中创建一个新的 py310 文件夹。py310文件夹包含若干子目录、Python 解释器、标准库以及其他支持文件。该文件夹结构类似于conda在envs目录中创建的环境文件夹。
- 让我们激活 py310 环境,并检查$PATH 环境变量以确认它已经被激活。以下脚本适用于 Linux 和 macOS(bash 或 zsh),假设你是在 Desktop 目录下运行命令:
$ source py310/bin/activate
$ echo $ PATH
> /Users/tarekatwan/Desktop/py310/bin:/Users/tarekatwan/opt/anaconda3/bin:/Users/tarekatwan/opt/anaconda3/condabin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
在这里,我们可以看到py310环境已经被激活。
在 Windows 上使用 Anaconda PowerShell Prompt 时,没有bin子文件夹,因此你需要使用以下语法运行命令,假设你是在Desktop目录下运行命令:
$ .py310\Scripts\activate
在 Scripts 文件夹下有两个激活文件:activate.bat和Activate.ps1,后者应在 Anaconda PowerShell Prompt 中使用,而不是 Anaconda Windows 命令提示符。通常在 PowerShell 中,如果你省略文件扩展名,正确的脚本会被执行。但最好是指定正确的文件扩展名,例如指定Activate.ps1,如下所示:
.\py310\Scripts\Activate.ps1
- 现在,让我们使用以下命令检查已安装的版本:
$ python --version
> Python 3.10.10
- 一旦完成使用py310环境进行开发,你可以使用deactivate命令将其停用,返回到基础 Python 环境:
$ deactivate
- 如果你不再需要
py310环境并希望删除它,只需删除整个py310文件夹即可。
它是如何工作的…
一旦虚拟环境被激活,你可以验证当前活跃的 Python 解释器位置,以确认你正在使用正确的解释器。之前,你看到激活虚拟环境后,$PATH环境变量是如何变化的。在 Linux 和 macOS 中,你可以使用which命令实现类似的效果,在 Windows PowerShell 中使用Get-Command,或者在 Windows 命令提示符中使用where命令。
以下是 macOS 或 Linux 的示例:
$ which python
> /Users/tarekatwan/opt/anaconda3/envs/py310/bin/python
以下是 Windows 操作系统(PowerShell)的示例:
$ where.exe python
where命令的替代方法是Get-Command,如下所示:
$ Get-Command python
这些命令将输出活动 Python 解释器的路径。前述命令的输出将显示不同的路径,具体取决于活动环境是使用conda还是venv创建的。激活conda虚拟环境时,它将位于envs文件夹中,如 MacOS 上所示:
/Users/tarekatwan/opt/anaconda3/envs/py310/bin/python
当激活venv虚拟环境时,路径将与创建时提供的路径相同,如 MacOS 上的示例所示:
/Users/tarekatwan/Desktop/py310/bin/python
您在激活虚拟环境后安装的任何额外包或库都将与其他环境隔离,并保存在该环境的文件夹结构中。
如果我们比较venv和conda的文件夹结构,您会看到相似之处,如下图所示:

图 1.6:使用 conda 和 venv 比较文件夹结构
请回忆,当使用conda时,所有环境默认位于anaconda3/或minconda3/目录下的/envs/位置。而使用venv时,您需要提供一个路径来指定创建目录或项目的位置;否则,它将默认为您运行命令时所用的当前目录。类似地,您可以使用conda通过选项-p或--prefix指定不同的路径。请注意,使用venv时,您无法指定 Python 版本,因为它依赖于当前活动或基础 Python 版本来运行命令。这与conda不同,conda允许您指定不同的 Python 版本,而不管基础Python 版本是什么。例如,当前基础环境的 Python 版本是 3.10,您仍然可以使用以下命令创建一个 3.11.x 环境:
conda create -n py311 python=3.11 -y
上述代码将创建一个新的py311环境,并安装 Python 3.11.3 版本。
conda的另一个优点是,它提供了两个功能:包和依赖管理器以及虚拟环境管理器。这意味着我们可以使用相同的conda环境,通过conda create创建额外的环境,并使用conda install <package name>安装包,这将在下一个章节“安装 Python 库”中使用。
请记住,使用venv时,它仅是一个虚拟环境管理器,您仍然需要依赖pip作为包管理器来安装包;例如,使用pip install <package name>。
此外,当使用conda安装包时,它会检查是否存在冲突,并会提示您进行任何推荐操作,包括是否需要升级、降级或安装额外的包依赖。
最后,使用conda的一个额外好处是,您不仅可以为 Python 创建环境,还可以为其他语言创建环境。包括 Julia、R、Lua、Scala、Java 等。
还有更多…
在前面的示例中,你可以使用conda或venv从零开始创建 Python 虚拟环境。你创建的虚拟环境可能还不包含所需的包,因此你需要特别为项目安装这些包。你将会在接下来的食谱“安装 Python 库”中了解如何安装包。
还有其他方式可以在conda中创建虚拟环境,我们将在这里讨论这些方式。
使用 YAML 文件创建虚拟环境
你可以从YAML文件创建虚拟环境。这个选项可以让你在一步中定义环境的多个方面,包括应该安装的所有包。
你可以在 VSCode 中创建一个 YAML 文件。以下是一个env.yml文件的示例,使用 Python 3.10 创建一个名为tscookbook的conda环境:
# A YAML for creating a conda environment
# file: env.yml
# example creating an environment named tscookbook
name: tscookbook
channels:
- conda-forge
- defaults
dependencies:
- python=3.10
- pip
# Data Analysis
- statsmodels
- scipy
- pandas
- numpy
- tqdm
# Plotting
- matplotlib
- seaborn
# Machine learning
- scikit-learn
- jupyterlab
要使用env.yml文件创建虚拟环境,可以使用conda env create命令,并添加-f或--file选项,如下所示:
$ conda env create -f env.yml
一旦这个过程完成,你可以激活该环境:
$ conda activate tscookbook
你还可以从现有环境引导生成 YAML 文件。如果你想与他人分享环境配置或为将来使用创建备份,这个方法非常有用。以下三个命令将使用稍微不同的语法选项,达到将py310的conda环境导出为env.yml的 YAML 文件的相同效果:
$ conda env export -n py310 > env.yml
$ conda env export -n py310 -f env.yml
$ conda env export –name py310 –file env.yml
这将在当前目录下为你生成env.yml文件。
从另一个环境克隆虚拟环境
这是一个很棒的功能,适用于你想要尝试新包或升级现有包,但又不希望破坏当前项目中的现有代码。使用–clone选项,你可以一步创建环境的副本或克隆。这样可以实现与前面使用conda env export命令生成现有环境的 YAML 文件,再基于该 YAML 文件创建新环境相同的效果。以下示例将把py310的conda环境克隆为一个名为py310_clone的新环境:
$ conda create --name py310_clone --clone py310
另见
值得一提的是,Anaconda 附带了一个工具,叫做 anaconda-project,它可以将你的conda项目工件打包并创建一个 YAML 文件以确保可重复性。这个工具非常适合创建、共享和确保数据科学项目的可重复性。可以把它看作是手动编写 YAML 的替代方案。更多信息,请参考官方 GitHub 仓库:github.com/Anaconda-Platform/anaconda-project。
要查看参数列表,你可以在终端中输入以下命令:
$ anaconda-project --help
如果你正在使用一台不允许安装软件的机器,或者使用一台性能有限的旧机器,不用担心。还有其他选项可以让你在本书中进行动手实践。
你可以探索的一些替代选项如下:
-
Google Colab 是一个基于云的平台,允许你在笔记本中编写和运行 Python 代码,这些笔记本已经预装了一些最受欢迎的数据科学包,包括
pandas、statsmodels、scikit-learn和TensorFlow。Colab 允许你通过pip install在笔记本中安装额外的包。Colab 的一个好特点是你可以选择配置你的笔记本,以便免费使用 CPU、GPU 或 TPU。你可以通过访问colab.research.google.com/来探索 Colab。 -
Kaggle Notebooks 类似于 Colab,包含托管的 Jupyter 笔记本,并且已经预装了许多最受欢迎的数据科学包。它也允许你通过
pip install安装任何需要的额外包。有关更多信息,请参考www.kaggle.com/docs/notebooks。 -
Replit 提供一个免费的在线 IDE,支持超过 50 种语言,包括 Python。你只需创建一个帐户,并通过访问
replit.com/来创建你的新replit空间。 -
Binder 是一个在线开源平台,允许你将 Git 仓库转化为一组交互式笔记本。你可以通过访问
mybinder.org来探索 Binder。 -
Deepnote 类似于 Colab,是一个在线平台,允许你编写、运行和协作 Python 笔记本,并提供了一个免费的计划,你可以在这里查看
deepnote.com。
安装 Python 库
在之前的配方中,你已经了解了 YAML 环境配置文件,它允许你通过一行代码一步创建一个 Python 虚拟环境和所有必要的包。
$ conda env create -f env.yml
在本书的整个过程中,你需要安装若干个 Python 库来跟随本书的配方。你将探索几种安装 Python 库的方法。
准备工作
在本书中,你将创建并使用不同的文件,包括 requirements.txt、environment_history.yml 和其他文件。这些文件可以从本书的 GitHub 仓库中下载:github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook/tree/main/code/Ch1。
在本章中,你将学习如何生成你的 requirements.txt 文件,并了解如何安装库的一般过程。
如何做...
一次性安装一组库的最简单方法是使用 requirements.txt 文件。
简而言之,requirements.txt文件列出了你想要安装的 Python 库及其相应的版本。你可以手动创建requirements.txt文件,也可以从现有的 Python 环境中导出它。
该文件不一定需要命名为
requirements.txt,它更多的是一种命名约定,并且是 Python 社区非常常见的一种命名方式。一些工具,如 PyCharm,如果requirements.txt文件位于项目的根目录中,会自动检测到该文件。
使用 conda
使用conda时,你有不同的选择来批量安装包。你可以创建一个新的环境,并一次性安装requirements.txt文件中列出的所有包(使用conda create命令),或者你可以使用requirements.txt文件将 Python 包安装到现有的环境中(使用conda install命令):
- 选项 1:创建一个新的
conda环境,并一步到位安装库。例如,你可以为每一章创建一个新的环境,并使用关联的requirements.txt文件。以下示例将创建一个名为ch1的新环境,并安装requirements.txt文件中列出的所有包:
$ conda create --name ch1 -f requirements.txt
- 选项 2:将所需的库安装到现有的
conda环境中。在此示例中,你有一个现有的timeseries环境,首先需要激活该环境,然后从requirements.txt文件中安装库:
$ conda activate timeseries
$ conda install -f requirements.txt
使用 venv 和 pip
由于venv只是一个环境管理器,你将需要使用pip作为包管理工具。你将首先使用venv创建一个新环境,然后使用pip安装包:
- 在MacOS/Linux上:创建并激活
venv环境,然后再安装包:
$ python -m venv Desktopn/timeseries
$ source Desktop/timeseries/bin/activate
$ pip install -r requirements.txt
- 在Windows上:创建并激活
venv环境,然后安装包:
$ python -m venv .\Desktop\timeseries
$ .\Desktop\timeseries\Scripts\activate
$ pip install -r requirements.txt
注意,在前面的 Windows 代码中,没有指定activate文件的扩展名(.bat或.ps1)。这是有效的,能够在 Windows 命令提示符或 PowerShell 中工作。
它是如何工作的…
在前面的代码中,提供了requirements.txt文件,以便你可以安装所需的库。
那么,如何生成你的requirements.txt文件呢?
创建requirements.txt文件有两种方法。让我们来看看这两种方法。
手动创建文件
由于它是一个简单的文件格式,你可以使用任何文本编辑器来创建该文件,例如 VSCode,并列出你想要安装的包。如果你没有指定包的版本,那么安装时将考虑最新版本。请参见以下simple.txt文件的示例:
pandas==1.4.2
matplotlib
首先,让我们测试一下venv和pip。运行以下脚本(我在 Mac 上运行此操作):
$ python -m venv ch1
$ source ch1/bin/activate
$ pip install -r simple.txt
$ pip list
Package Version
--------------- -------
cycler 0.11.0
fonttools 4.33.3
kiwisolver 1.4.2
matplotlib 3.5.2
numpy 1.22.4
packaging 21.3
pandas 1.4.2
Pillow 9.1.1
pip 22.0.4
pyparsing 3.0.9
python-dateutil 2.8.2
pytz 2022.1
setuptools 58.1.0
six 1.16.0
$ deactivate
那些额外的包是什么?这些是基于pip为我们识别和安装的pandas和matplotlib的依赖关系。
现在,让我们这次使用conda,并使用相同的simple.txt文件:
$ conda create -n ch1 --file simple.txt python=3.10
安装完成后,您可以激活环境并列出已安装的包:
$ conda activate ch1
$ conda list
您可能会注意到列表相当庞大。与 pip 方法相比,安装了更多的包。您可以使用以下命令来计算已安装的库数:
$ conda list | wc -l
> 54
这里有几个需要注意的事项:
-
conda从 Anaconda 仓库以及 Anaconda 云安装包。 -
pip从 Python 包索引(PyPI) 仓库安装包。 -
conda会对它计划下载的所有包进行非常彻底的分析,并且在处理版本冲突时比pip做得更好。
引导文件
第二个选项是从现有环境中生成 requirements.txt 文件。当您为将来的使用重建环境,或与他人共享包和依赖关系列表时,这非常有用,可以确保可重现性和一致性。假设您在一个项目中工作并安装了特定的库,您希望确保在共享代码时,其他用户能够安装相同的库。这时,生成 requirements.txt 文件就显得非常方便。同样,导出 YAML 环境配置文件的选项之前已经展示过。
让我们看看如何在pip和conda中执行此操作。请记住,两种方法都会导出已经安装的包及其当前版本的列表。
venv 和 pip freeze
pip freeze 允许您导出环境中所有通过 pip 安装的库。首先,激活之前用 venv 创建的 ch1 环境,然后将包列表导出到 requirements.txt 文件中。以下示例是在 macOS 上使用终端:
$ source ch1/bin/activate
$ pip freeze > requirements.txt
$ cat requirements.txt
>>>
cycler==0.11.0
fonttools==4.33.3
kiwisolver==1.4.2
matplotlib==3.5.2
numpy==1.22.4
...
完成后,您可以运行 deactivate 命令。
Conda
让我们激活之前使用 conda 创建的环境(ch1 环境),并导出已安装的包列表:
$ conda activate ch1
$ conda list -e > conda_requirements.txt
$ cat conda_requirements.txt
>>>
# This file may be used to create an environment using:
# $ conda create --name <env> --file <this file>
# platform: osx-64
blas=1.0=mkl
bottleneck=1.3.4=py39h67323c0_0
brotli=1.0.9=hb1e8313_2
ca-certificates=2022.4.26=hecd8cb5_0
certifi=2022.5.18.1=py39hecd8cb5_0
cycler=0.11.0=pyhd3eb1b0_0
...
还有更多……
当您使用 conda 导出已安装包的列表时,conda_requirements.txt 文件包含了大量的包。如果您只想导出您显式安装的包(而不是 conda 添加的附加包),则可以使用带有 --from-history 标志的 conda env export 命令:
$ conda activate ch1
$ conda env export --from-history > env.yml
$ cat env.yml
>>>
name: ch1
channels:
- defaults
dependencies:
- matplotlib
- pandas==1.2.0
prefix: /Users/tarek.atwan/opt/anaconda3/envs/ch1
请注意,您不必像之前那样先激活环境。相反,您可以添加 -n 或 --name 选项来指定环境的名称。否则,它将默认为当前激活的环境。这是修改后的脚本示例:
conda env export -n ch1 --from-history > env.yml
另请参见
-
要查找 Anaconda 中所有可用的包,您可以访问
docs.anaconda.com/anaconda/packages/pkg-docs/。 -
要在 PyPI 仓库中搜索包,您可以访问
pypi.org/。
安装 JupyterLab 和 JupyterLab 扩展
在本书中,你可以使用自己喜欢的 Python IDE(例如 PyCharm 或 Spyder)或文本编辑器(例如 Visual Studio Code、Atom 或 Sublime)进行操作。另一种选择是基于笔记本概念的交互式学习,它通过 Web 界面进行。更具体地说,Jupyter Notebook 或 Jupyter Lab 是学习、实验和跟随本书教程的首选方法。有趣的是,Jupyter 这个名字来源于三种编程语言:Julia、Python 和 R。或者,你也可以使用 Google 的 Colab 或 Kaggle Notebooks。欲了解更多信息,请参考本章 开发环境设置 教程中的 另见 部分。如果你不熟悉 Jupyter Notebooks,可以在这里了解更多信息:jupyter.org/。
在本教程中,你将安装 Jupyter Notebook、JupyterLab 以及额外的 JupyterLab 扩展。
此外,你还将学习如何安装单个软件包,而不是像前面教程中那样采用批量安装方法。
在后续示例中使用 CONDA
在后续的操作中,当新环境被创建时,代码将使用 conda 进行编写。前面的教程已经涵盖了创建虚拟环境的两种不同方法(venv 与 conda)以及安装软件包的两种方法(pip 与 conda),这将允许你根据自己的选择继续操作。
准备就绪
我们将创建一个新环境并安装本章所需的主要软件包,主要是 pandas:
$ conda create -n timeseries python=3.9 pandas -y
这段代码创建了一个名为 timeseries 的新的 Python 3.9 环境。语句的最后部分列出了你将要安装的各个软件包。如果软件包列表较大,建议使用 requirements.txt 文件。如果软件包不多,可以直接用空格分隔列出,如下所示:
$ conda create -n timeseries python=3.9 pandas matplotlib statsmodels -y
一旦环境创建完毕并且软件包安装完成,可以继续激活该环境:
$ conda activate timeseries
操作方法……
现在我们已经创建并激活了环境,接下来安装 Jupyter:
- 现在我们已经激活了环境,可以使用
condainstall安装conda create时未包含的其他软件包:
$ conda install jupyter -y
- 你可以通过输入以下命令启动 JupyterLab 实例:
$ jupyter lab
请注意,这会运行一个本地 Web 服务器,并在你的默认浏览器中启动 JupyterLab 界面,指向 localhost:8888/lab。以下截图展示了你在终端中输入上述代码后看到的类似界面:

图 1.6:启动 JupyterLab 将运行本地 Web 服务器
-
要终止 Web 服务器,请在终端中按 Ctrl + C 两次,或者在 Jupyter GUI 中点击 Shut Down,如以下截图所示:
![图 1.7:关闭 JupyterLab 网络服务器]()
图 1.7:关闭 JupyterLab 网络服务器
-
现在,你可以安全地关闭浏览器。
-
请注意,在前面的例子中,当 JupyterLab 启动时,它是在默认浏览器中启动的。如果你希望使用不同的浏览器,可以像这样更新代码:
$ jupyter lab --browser=chrome
在这个例子中,我指定了我要在 Chrome 上启动,而不是 Safari,这是我机器上的默认浏览器。你可以将值更改为你偏好的浏览器,比如 Firefox、Opera、Chrome 等。
在 Windows 操作系统中,如果前面的代码没有自动启动 Chrome,你需要使用webbrowser.register()注册浏览器类型。为此,首先使用以下命令生成 Jupyter Lab 配置文件:
jupyter-lab --generate-config
打开jupyter_lab_config.py文件,并在顶部添加以下内容:
import webbrowser
webbrowser.register('chrome', None, webbrowser.GenericBrowser('C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'))
保存并关闭文件。你可以重新运行jupyter lab --browser=chrome,这应该会启动 Chrome 浏览器。
- 如果你不希望系统自动启动浏览器,可以使用以下代码:
$ jupyter lab --no-browser
网络服务器将启动,你可以手动打开任何你偏好的浏览器,并将其指向http://localhost:8888。
如果系统要求你输入 token,你可以复制并粘贴终端中显示的带 token 的 URL,格式如下:
To access the server, open this file in a browser:
file:///Users/tarek.atwan/Library/Jupyter/runtime/jpserver-44086-open.html
Or copy and paste one of these URLs:
http://localhost:8888/lab?token=5c3857b9612aecd3
c34e9a40e5eac4509a6ccdbc8a765576
or http://127.0.0.1:8888/lab?token=5c3857b9612aecd3
c34e9a40e5eac4509a6ccdbc8a765576
- 最后,如果默认的
port 8888端口正在使用中,或者你希望更改端口号,可以添加-p并指定你想要的端口号,如下例所示。在这里,我指示网络服务器使用port 8890:
$ jupyter lab --browser=chrome --port 8890
这将启动 Chrome 并指向localhost:8890/lab。
请注意,当 JupyterLab 启动时,你只会在笔记本/控制台部分看到一个内核。这是基础 Python 内核。我们原本预期看到两个内核,分别反映我们拥有的两个环境:基础环境和timeseries虚拟环境。让我们使用以下命令检查我们有多少个虚拟环境:
-
下图显示了 JupyterLab 界面,只有一个内核,属于基础环境:
![图 1.8:JupyterLab 界面,只显示一个内核,该内核属于基础环境]()
图 1.8:JupyterLab 界面,只显示一个内核,该内核属于基础环境
-
下图显示了两个 Python 环境:

图 1.9:显示两个 Python 环境
我们可以看到 timeseries 虚拟环境是活动的。
- 你需要为新的
timeseries环境安装 Jupyter 内核。首先,关闭网络服务器(即使没有关闭它也可以继续使用)。假设你仍然处于活动的timeseriesPython 环境中,只需输入以下命令:
$ python -m ipykernel install --user --name timeseries --display-name "Time Series"
> Installed kernelspec timeseries in /Users/tarek.atwan/Library/Jupyter/kernels/timeseries
- 我们可以使用以下命令检查 Jupyter 可用的内核数量:
$ jupyter kernelspec list
下图显示了创建的kernelspec文件及其位置:

图 1.10:Jupyter 可用内核列表
这些作为指针,将 GUI 与适当的环境连接,以执行我们的 Python 代码。
- 现在,你可以重新启动 JupyterLab,并注意到变化:
$ jupyter lab
启动后将出现以下屏幕:

图 1.11:现在我们的时间序列内核已在 JupyterLab 中可用
它是如何工作的……
当你创建了新的 timeseries 环境并使用 conda install 安装所需的包时,它会在 envs 文件夹中创建一个新的子文件夹,以隔离该环境及其安装的包与其他环境(包括基础环境)。当从基础环境执行 jupyter notebook 或 jupyter lab 命令时,它需要读取 kernelspec 文件(JSON),以映射到可用的内核,确保它们可以使用。kernelspec 文件可以通过 ipykernel 创建,方法如下:
python -m ipykernel install --user --name timeseries --display-name "Time Series"
这里,--name 是环境名称,--display-name 是 Jupyter GUI 中的显示名称,可以是你想要的任何名称。现在,任何你在 timeseries 环境中安装的库都可以通过内核从 Jupyter 访问(再次说明,它是 Jupyter GUI 与后端 Python 环境之间的映射)。
还有更多……
JupyterLab 允许你安装多个有用的扩展。这些扩展中有些由 Jupyter 创建和管理,而其他的则由社区创建。
你可以通过两种方式管理 JupyterLab 扩展:通过命令行使用 jupyter labextension install <someExtension> 或通过图形界面使用 扩展管理器。以下截图显示了 Jupyter 扩展管理器 UI 的样子:

图 1.12:点击 JupyterLab 中的扩展管理器图标
一旦你点击 启用,你将看到可用的 Jupyter 扩展列表。要安装扩展,只需点击 安装 按钮。
一些包需要先安装 Node.js 和 npm,你将看到类似以下的警告:

图 1.13:当需要 Node.js 和 npm 时,扩展安装错误
你可以直接从 nodejs.org/en/ 下载并安装 Node.js。
另外,你也可以使用 conda 安装 Node.js,方法是使用以下命令:
$ conda install -c conda-forge nodejs
另见
-
要了解更多关于 JupyterLab 扩展的信息,请参阅官方文档:
jupyterlab.readthedocs.io/en/stable/user/extensions.html。 -
如果你想了解更多关于如何创建 JupyterLab 扩展的示例演示,请参考官方 GitHub 仓库:
github.com/jupyterlab/extension-examples。 -
在第 9 步中,我们手动安装了
kernelspec文件,这为 Jupyter 和我们的conda环境之间创建了映射。这个过程可以通过nb_conda自动化。关于nb_conda项目的更多信息,请参考官方 GitHub 仓库:github.com/Anaconda-Platform/nb_conda。
第二章:2 从文件读取时间序列数据
加入我们的 Discord 书籍社区

在本章中,我们将使用 pandas,一个流行的 Python 库,具有丰富的 I/O 工具、数据处理和日期/时间功能,用于简化处理 时间序列数据。此外,您还将探索 pandas 中可用的多个读取函数,来导入不同文件类型的数据,如 逗号分隔值 (CSV)、Excel 和 SAS。您将探索如何从文件中读取数据,无论这些文件是存储在本地驱动器上,还是远程存储在云端,如 AWS S3 桶。
时间序列数据是复杂的,可能有不同的形状和格式。幸运的是,pandas 的读取函数提供了大量的参数(选项),以帮助处理数据的多样性。
pandas 库提供了两个基本的数据结构:Series 和 DataFrame,它们作为类实现。DataFrame 类是一个用于处理表格数据(类似于电子表格中的行和列)的独特数据结构。它们之间的主要区别在于,Series 是一维的(单列),而 DataFrame 是二维的(多列)。它们之间的关系是,当你从 DataFrame 中切片出一列时,你得到的是一个 Series。你可以将 DataFrame 想象成是两个或多个 Series 对象的并排拼接。
Series 和 DataFrame 数据结构的一个特性是,它们都具有一个叫做索引的标签轴。你在时间序列数据中常见的索引类型是 DatetimeIndex,你将在本章中进一步了解。通常,索引使切片和切割操作变得非常直观。例如,为了使 DataFrame 准备好进行时间序列分析,你将学习如何创建具有 DatetimeIndex 类型索引的 DataFrame。
我们将涵盖以下将数据导入 pandas DataFrame 的方法:
-
从 CSV 和其他分隔文件读取数据
-
从 Excel 文件读取数据
-
从 URL 读取数据
-
从 Parquet 文件读取数据处理大型数据文件
为什么选择 DATETIMEINDEX?
一个具有
DatetimeIndex类型索引的 pandas DataFrame 解锁了在处理时间序列数据时所需的大量功能和有用的函数。你可以将其视为为 pandas 增加了一层智能或感知,使其能够将 DataFrame 视为时间序列 DataFrame。
技术要求
在本章及后续章节中,我们将广泛使用 pandas 2.2.0(2024 年 1 月 20 日发布)。
在我们的整个过程中,你将安装其他 Python 库,以便与 pandas 一起使用。你可以从 GitHub 仓库下载 Jupyter 笔记本(github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./blob/main/code/Ch2/Chapter%202.ipynb)来跟着做。
你可以通过以下链接从 GitHub 仓库下载本章使用的数据集:github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./tree/main/datasets/Ch2。
从 CSV 文件和其他分隔符文件读取数据
在这个示例中,你将使用pandas.read_csv()函数,它提供了一个庞大的参数集,你将探索这些参数以确保数据正确读取到时间序列 DataFrame 中。此外,你将学习如何指定索引列,将索引解析为DatetimeIndex类型,并将包含日期的字符串列解析为datetime对象。
通常,使用 Python 从 CSV 文件读取的数据会是字符串格式(文本)。当使用read_csv方法在 pandas 中读取时,它会尝试推断适当的数据类型(dtype),在大多数情况下,它做得非常好。然而,也有一些情况需要你明确指示哪些列应转换为特定的数据类型。例如,你将使用parse_dates参数指定要解析为日期的列。
准备工作
你将读取一个包含假设电影票房数据的 CSV 文件。该文件已提供在本书的 GitHub 仓库中。数据文件位于datasets/Ch2/movieboxoffice.csv。
如何操作…
你将使用 pandas 读取我们的 CSV 文件,并利用read_csv中的一些可用参数:
- 首先,加载所需的库:
import pandas as pd
from pathlib import Path
- 为文件位置创建一个
Path对象:
filepath =\
Path('../../datasets/Ch2/movieboxoffice.csv')
- 使用
read_csv函数将 CSV 文件读取到 DataFrame 中,并传递包含额外参数的filepath。
CSV 文件的第一列包含电影发布日期,需要将其设置为DatetimeIndex类型的索引(index_col=0和parse_dates=['Date'])。通过提供列名列表给usecols来指定你希望包含的列。默认行为是第一行包含表头(header=0):
ts = pd.read_csv(filepath,
header=0,
parse_dates=['Date'],
index_col=0,
infer_datetime_format=True,
usecols=['Date',
'DOW',
'Daily',
'Forecast',
'Percent Diff'])
ts.head(5)
这将输出以下前五行:

图 2.1:JupyterLab 中 ts DataFrame 的前五行
- 打印 DataFrame 的摘要以检查索引和列的数据类型:
ts.info()
>> <class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 128 entries, 2021-04-26 to 2021-08-31
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 DOW 128 non-null object
1 Daily 128 non-null object
2 Forecast 128 non-null object
3 Percent Diff 128 non-null object
dtypes: object(4)
memory usage: 5.0+ KB
- 注意,
Date列现在是一个索引(而非列),类型为DatetimeIndex。另外,Daily和Forecast列的 dtype 推断错误。你本来期望它们是float类型。问题在于源 CSV 文件中的这两列包含了美元符号 ($) 和千位分隔符(,)。这些非数字字符会导致列被解释为字符串。具有dtype为object的列表示该列包含字符串或混合类型的数据(不是同质的)。
要解决这个问题,你需要去除美元符号 ($) 和千位分隔符(,)或任何其他非数字字符。你可以使用 str.replace() 来完成此操作,它可以接受正则表达式来移除所有非数字字符,但排除小数点(.)。移除这些字符不会转换 dtype,因此你需要使用 .astype(float) 将这两列转换为 float 类型:
clean = lambda x: x.str.replace('[^\\d]','', regex=True)
c_df = ts[['Daily', 'Forecast']].apply(clean, axis=1)
ts[['Daily', 'Forecast']] = c_df.astype(float)
打印更新后的 DataFrame 摘要:
ts.info()
>> <class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 128 entries, 2021-04-26 to 2021-08-31
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 DOW 128 non-null object
1 Daily 128 non-null float64
2 Forecast 128 non-null float64
3 Percent Diff 128 non-null object
dtypes: float64(2), object(2)
memory usage: 5.0+ KB
现在,你拥有一个 DatetimeIndex 的 DataFrame,并且 Daily 和 Forecast 列的 dtype 都是 float64(数字类型)。
它是如何工作的……
使用 pandas 进行数据转换非常快速,因为它将数据加载到内存中。例如,read_csv 方法会读取并将整个数据加载到内存中的 DataFrame 中。当使用 info() 方法请求 DataFrame 的摘要时,输出除了显示列和索引的数据类型外,还会显示整个 DataFrame 的内存使用情况。要获取每个列的确切内存使用情况,包括索引,你可以使用 memory_usage() 方法:
ts.memory_usage()
>>
Index 1024
DOW 1024
Daily 1024
Forecast 1024
Percent Diff 1024
dtype: int64
总计将与 DataFrame 摘要中提供的内容匹配:
ts.memory_usage().sum()
>> 5120
到目前为止,你在使用 read_csv 读取 CSV 文件时,已经使用了一些可用的参数。你对 pandas 阅读函数中不同选项越熟悉,你在数据读取(导入)过程中就能做更多的前期预处理工作。
你使用了内建的 parse_dates 参数,它接收一个列名(或位置)列表。将 index_col=0 和 parse_dates=[0] 组合在一起,生成了一个具有 DatetimeIndex 类型的索引的 DataFrame。
让我们查看官方 pandas.read_csv() 文档中定义的本示例中使用的参数(pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_csv.html):
-
filepath_or_buffer:这是第一个位置参数,也是读取 CSV 文件时所需的唯一必填字段。这里,你传递了一个名为filepath的 Python 路径对象。它也可以是一个表示有效文件路径的字符串,例如'../../datasets/Ch2/movieboxoffice.csv',或者指向远程文件位置的 URL,例如 AWS S3 存储桶(我们将在本章的 从 URL 读取数据 示例中进一步探讨)。 -
sep:该参数用于指定分隔符的字符串。默认分隔符是逗号(,),假设是一个 CSV 文件。如果文件使用其他分隔符,例如管道符号(|)或分号(;),可以更新该参数,例如sep="|"或sep=";"。 -
sep的另一个别名是delimiter,也可以作为参数名使用。 -
header:在这种情况下,你指定了第一行(0)包含表头信息。默认值是infer,通常在大多数情况下可以直接使用。如果 CSV 文件没有表头,则需要指定header=None。如果 CSV 文件有表头,但你希望提供自定义的列名,则需要指定header=0并通过names参数提供新的列名列表来覆盖它。 -
parse_dates:在本示例中,你提供了列位置的列表[0],这表示仅解析第一列(按位置)。parse_dates参数可以接受列名的列表,例如["Date"],或者列位置的列表,例如[0, 3],表示第一列和第四列。如果你仅打算解析index_col参数中指定的索引列,只需传递True(布尔值)。 -
index_col:你指定了第一列的位置(index_col=0)作为 DataFrame 的索引。或者,你也可以提供列名作为字符串(index_col='Date')。该参数还可以接受一个整数列表(位置索引)或字符串列表(列名),这将创建一个MultiIndex对象。 -
usecols:默认值为None,表示包含数据集中的所有列。限制列的数量,仅保留必要的列可以加快解析速度,并减少内存使用,因为只引入了需要的数据。usecols参数可以接受一个列名的列表,例如['Date', 'DOW', 'Daily', 'Percent Diff', 'Forecast'],或者一个位置索引的列表,例如[0, 1, 3, 7, 6],两者会产生相同的结果。
回想一下,你通过将列名列表传递给usecols参数,指定了要包含的列。这些列名是基于文件头部(CSV 文件的第一行)。
如果你决定提供自定义的列名,则无法在usecols参数中引用原始列名;这会导致以下错误:ValueError: Usecols do not match columns.。
还有更多内容……
有些情况下,parse_dates可能无法正常工作(即无法解析日期)。在这种情况下,相关列将保持原样,并且不会抛出错误。这时,date_format参数可以派上用场。
以下代码展示了如何使用date_format:
ts = pd.read_csv(filepath,
parse_dates=[0],
index_col=0,
date_format="%d-%b-%Y",
usecols=[0,1,3, 7, 6])
ts.head()
上述代码将打印出ts DataFrame 的前五行,正确地展示解析后的Date索引。

图 2.2:使用 JupyterLab 查看 ts DataFrame 的前五行
让我们分解一下。在上面的代码中,由于日期以 26-Apr-2021 这样的字符串形式存储,你传递了 "%d-%b-%Y" 来反映这一点:
-
%d表示月份中的日期,例如01或02。 -
%b表示缩写的月份名称,例如Apr或May。 -
%Y表示四位数的年份,例如2020或2021。
其他常见的字符串代码包括以下内容:
-
%y表示两位数的年份,例如19或20。 -
%B表示月份的全名,例如January或February。 -
%m表示月份,作为两位数,例如01或02。
有关 Python 字符串格式用于表示日期的更多信息,请访问 strftime.org。
另见
处理更复杂的日期格式时,另一个选项是使用 to_datetime() 函数。to_datetime() 函数用于将字符串、整数或浮动数值转换为日期时间对象。
最初,你将按原样读取 CSV 数据,然后应用 to_datetime() 函数将特定列解析为所需的日期时间格式。以下代码展示了这一过程:
ts = pd.read_csv(filepath,
index_col=0,
usecols=[0,1,3, 7, 6])
ts.index = pd.to_datetime(ts.index, format="%d-%b-%Y")
最后一行,ts.index = pd.to_datetime(ts.index, format="%d-%b-%Y"), 将 ts 数据框的索引转换为 DatetimeIndex 对象。请注意,我们如何指定数据字符串格式,类似于在 还有更多… 部分的 read_csv() 函数中使用 date_format 参数的方式。
从 Excel 文件中读取数据
要从 Excel 文件中读取数据,你需要使用 pandas 提供的不同读取函数。一般来说,处理 Excel 文件可能会有些挑战,因为文件可能包含格式化的多行标题、合并的标题单元格以及图片。它们还可能包含多个工作表,每个工作表都有自定义的名称(标签)。因此,在操作 Excel 文件之前,务必先检查文件。最常见的场景是读取包含多个工作表的 Excel 文件,这也是本教程的重点。
在这个教程中,你将使用 pandas.read_excel() 函数,并检查可用的各种参数,以确保数据作为具有 DatetimeIndex 的 DataFrame 正确读取,用于时间序列分析。此外,你还将探索读取包含多个工作表的 Excel 文件的不同选项。
准备工作
要使用 pandas.read_excel(),你需要安装额外的库来读取和写入 Excel 文件。在 read_excel() 函数中,你将使用 engine 参数指定处理 Excel 文件所需的库(引擎)。根据你所处理的 Excel 文件扩展名(例如 .xls 或 .xlsx),你可能需要指定不同的引擎,这可能需要安装额外的库。
支持读取和写入 Excel 的库(引擎)包括 xlrd、openpyxl、odf 和 pyxlsb。处理 Excel 文件时,最常用的两个库通常是 xlrd 和 openpyxl。
xlrd 库只支持 .xls 文件。因此,如果你正在处理较旧的 Excel 格式,例如 .xls,那么 xlrd 就能很好地工作。对于更新的 Excel 格式,例如 .xlsx,我们需要使用不同的引擎,在这种情况下,推荐使用 openpyxl。
要使用 conda 安装 openpyxl,请在终端运行以下命令:
>>> conda install openpyxl
要使用 pip 安装,请运行以下命令:
>>> pip install openpyxl
我们将使用 sales_trx_data.xlsx 文件,你可以从本书的 GitHub 仓库下载。请参阅本章的 技术要求 部分。该文件包含按年份拆分的销售数据,分别存在两个工作表中(2017 和 2018)。
如何操作…
你将使用 pandas 和 openpyxl 导入 Excel 文件(.xlsx),并利用 read_excel() 中的一些可用参数:
- 导入此配方所需的库:
import pandas as pd
from pathlib import Path
filepath = \
Path('../../datasets/Ch2/sales_trx_data.xlsx')
- 使用
read_excel()函数读取 Excel(.xlxs)文件。默认情况下,pandas 只读取第一个工作表。这个参数在sheet_name中指定,默认值设置为0。在传递新的参数之前,你可以先使用pandas.ExcelFile来检查文件并确定可用工作表的数量。ExcelFile类将提供额外的方法和属性,例如sheet_name,它返回一个工作表名称的列表:
excelfile = pd.ExcelFile(filepath)
excelfile.sheet_names
>> ['2017', '2018']
如果你有多个工作表,可以通过将一个列表传递给 read_excel 中的 sheet_name 参数来指定要导入的工作表。该列表可以是位置参数,如第一个、第二个和第五个工作表 [0, 1, 4],工作表名称 ["Sheet1", "Sheet2", "Sheet5"],或两者的组合,例如第一个工作表、第二个工作表和一个名为 "Revenue" 的工作表 [0, 1, "Revenue"]。
在以下代码中,你将使用工作表位置来读取第一个和第二个工作表(0 和 1 索引)。这将返回一个 Python dictionary 对象,包含两个 DataFrame。请注意,返回的字典(键值对)具有数字键(0 和 1),分别表示第一个和第二个工作表(位置索引):
ts = pd.read_excel(filepath,
engine='openpyxl',
index_col=1,
sheet_name=[0,1],
parse_dates=True)
ts.keys()
>> dict_keys([0, 1])
- 或者,你可以传递一个工作表名称的列表。请注意,返回的字典键现在是字符串,表示工作表名称,如以下代码所示:
ts = pd.read_excel(filepath,
engine='openpyxl',
index_col=1,
sheet_name=['2017','2018'],
parse_dates=True)
ts.keys()
>> dict_keys(['2017', '2018'])
- 如果你想从所有可用工作表中读取数据,可以传递
None。在这种情况下,字典的键将表示工作表名称:
ts = pd.read_excel(filepath,
engine='openpyxl',
index_col=1,
sheet_name=None,
parse_dates=True)
ts.keys()
>> dict_keys(['2017', '2018'])
字典中的两个 DataFrame 在它们的架构(列名和数据类型)上是相同的(同类型)。你可以通过 ts['2017'].info() 和 ts['2018'].info() 来检查每个 DataFrame。
它们都有一个 DatetimeIndex 对象,你在 index_col 参数中指定了该对象。2017 年的 DataFrame 包含 36,764 行,2018 年的 DataFrame 包含 37,360 行。在这种情况下,你希望将两个 DataFrame 堆叠(合并)(类似于 SQL 中的 UNION),得到一个包含所有 74,124 行且 DatetimeIndex 从 2017-01-01 到 2018-12-31 的单一 DataFrame。
要沿着索引轴(一个接一个堆叠)将两个 DataFrame 合并,你将使用 pandas.concat() 函数。concat() 函数的默认行为是沿着索引轴连接(axis=0)。在以下代码中,你将明确指定要连接哪些 DataFrame:
ts_combined = pd.concat([ts['2017'],ts['2018']])
ts_combined.info()
>> <class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 74124 entries, 2017-01-01 to 2018-12-31
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Line_Item_ID 74124 non-null int64
1 Credit_Card_Number 74124 non-null int64
2 Quantity 74124 non-null int64
3 Menu_Item 74124 non-null object
dtypes: int64(3), object(1)
memory usage: 2.8+ MB
- 当返回多个 DataFrame 时(比如多个工作表),你可以对返回的字典使用
concat()函数。换句话说,你可以在一个语句中将concat()和read_excel()函数结合使用。在这种情况下,最终你会得到一个MultiIndexDataFrame,其中第一级是工作表名称(或编号),第二级是DatetimeIndex。例如,使用ts字典,你会得到一个两级索引:MultiIndex([('2017', '2017-01-01'), ..., ('2018', '2018-12-31')], names=[None, 'Date'], length=74124)。
要减少级别数,你可以使用 droplevel(level=0) 方法,在 pandas .concat() 之后删除第一级,示例如下:
ts_combined = pd.concat(ts).droplevel(level=0)
- 如果你只读取一个工作表,行为会略有不同。默认情况下,
sheet_name被设置为0,这意味着它读取第一个工作表。你可以修改这个设置并传递一个不同的值(单一值),无论是工作表名称(字符串)还是工作表位置(整数)。当传递单一值时,返回的对象将是一个 pandas DataFrame,而不是字典:
ts = pd.read_excel(filepath,
index_col=1,
sheet_name='2018',
parse_dates=True)
type(ts)
>> pandas.core.frame.DataFrame
但请注意,如果你在两个括号内传递一个单一值([1]),那么 pandas 会以不同的方式解释它,返回的对象将是一个包含一个 DataFrame 的字典。
最后,请注意,在最后一个示例中你不需要指定引擎。read_csv 函数将根据文件扩展名确定使用哪个引擎。所以,假设该引擎的库没有安装,在这种情况下,它会抛出一个 ImportError 消息,指出缺少该库(依赖项)。
工作原理……
pandas.read_excel() 函数与之前使用过的 pandas.read_csv() 函数有许多相同的常见参数。read_excel 函数可以返回一个 DataFrame 对象或一个包含 DataFrame 的字典。这里的依赖关系在于你是传递一个单一值(标量)还是一个列表给 sheet_name。
在 sales_trx_data.xlsx文件中,两个工作表具有相同的架构(同质类型)。销售数据按年份进行分区(拆分),每个工作表包含特定年份的销售数据。在这种情况下,连接这两个 DataFrame 是一个自然的选择。pandas.concat()函数类似于DataFrame.append()函数,其中第二个 DataFrame 被添加(附加)到第一个 DataFrame 的末尾。对于来自 SQL 背景的用户来说,这应该类似于UNION子句的行为。
还有更多…
另一种读取 Excel 文件的方法是使用pandas.ExcelFile()类,它返回一个 pandas ExcelFile对象。在本食谱的早些时候,您使用ExcelFile()通过sheet_name属性检查 Excel 文件中的工作表数量。
ExcelFile类具有多个有用的方法,包括parse()方法,用于将 Excel 文件解析为 DataFrame,类似于pandas.read_excel()函数。
在下面的示例中,您将使用ExcelFile类解析第一个工作表,将第一列作为索引,并打印前五行:
excelfile = pd.ExcelFile(filepath)
excelfile.parse(sheet_name='2017',
index_col=1,
parse_dates=True).head()
您应该会看到类似的结果,显示数据框(DataFrame)的前五行:

图 2.3:使用 JupyterLab 显示数据框的前五行
从图 2.3中,应该能清楚地看出,ExcelFile.parse()相当于pandas.read_excel()。
另请参见
有关pandas.read_excel()和pandas.ExcelFile()的更多信息,请参考官方文档:
-
pandas.read_excel:pandas.pydata.org/docs/reference/api/pandas.read_excel.html -
pandas.ExcelFile.parse:pandas.pydata.org/docs/reference/api/pandas.ExcelFile.parse.html
从 URL 读取数据
文件可以下载并存储在本地计算机上,或存储在远程服务器或云端位置。在前两个示例中,从 CSV 和其他分隔文件读取和从 Excel 文件读取数据,两个文件都存储在本地。
pandas 的许多读取函数可以通过传递 URL 路径从远程位置读取数据。例如,read_csv()和read_excel()可以接受一个 URL 来读取通过互联网访问的文件。在本例中,您将使用pandas.read_csv()读取 CSV 文件,使用pandas.read_excel()读取 Excel 文件,数据源来自远程位置,如 GitHub 和 AWS S3(私有和公共桶)。您还将直接从 HTML 页面读取数据并导入到 pandas DataFrame 中。
准备工作
您需要安装AWS SDK for Python(Boto3),以便从 S3 桶读取文件。此外,您还将学习如何使用storage_options参数,它在 pandas 中的许多读取函数中可用,用于在没有 Boto3 库的情况下从 S3 读取数据。
要在 pandas 中使用 S3 URL(例如,s3://bucket_name/path-to-file),您需要安装 s3fs 库。您还需要安装一个 HTML 解析器,当我们使用 read_html() 时。比如,解析引擎(HTML 解析器)可以选择安装 lxml 或 html5lib;pandas 会选择安装的解析器(它会首先查找 lxml,如果失败,则查找 html5lib)。如果您计划使用 html5lib,则需要安装 Beautiful Soup(beautifulsoup4)。
使用 pip 安装,您可以使用以下命令:
>>> pip install boto3 s3fs lxml html5lib
使用 Conda 安装,您可以使用:
>>> conda install boto3 s3fs lxml html5lib -y
如何操作…
本节将向您展示从在线(远程)源读取数据时的不同场景。让我们先导入 pandas,因为在整个本节中都会使用它:
import pandas as pd
从 GitHub 读取数据
有时,您可能会在 GitHub 上找到有用的公共数据,希望直接使用并读取(而不是下载)。GitHub 上最常见的文件格式之一是 CSV 文件。让我们从以下步骤开始:
- 要从 GitHub 读取 CSV 文件,您需要获取原始内容的 URL。如果您从浏览器复制文件的 GitHub URL 并将其作为文件路径使用,您将得到一个如下所示的 URL:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./blob/main/datasets/Ch2/AirQualityUCI.csv。此 URL 是指向 GitHub 网页,而不是数据本身;因此,当使用pd.read_csv()时,它会抛出错误:
url = 'https://github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./blob/main/datasets/Ch2/AirQualityUCI.csv'
pd.read_csv(url)
ParserError: Error tokenizing data. C error: Expected 1 fields in line 62, saw 2
-
相反,您需要原始内容,这会给您一个如下所示的 URL:
raw.githubusercontent.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./main/datasets/Ch2/AirQualityUCI.csv:![图 2.4:CSV 文件的 GitHub 页面。注意查看原始按钮]()
图 2.4:CSV 文件的 GitHub 页面。注意查看原始按钮
-
在 图 2.4 中,请注意值没有用逗号分隔(不是逗号分隔文件);相反,文件使用分号(
;)来分隔值。
文件中的第一列是 Date 列。您需要解析(使用 parse_date 参数)并将其转换为 DatetimeIndex(index_col 参数)。
将新的 URL 传递给 pandas.read_csv():
url = 'https://media.githubusercontent.com/media/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./main/datasets/Ch2/AirQualityUCI.csv'
df = pd.read_csv(url,
delimiter=';',
parse_dates=['Date'],
index_col='Date')
df.iloc[:3,1:4]
>>
CO(GT) PT08.S1(CO) NMHC(GT)
Date
10/03/2004 2.6 1360.00 150
10/03/2004 2.0 1292.25 112
10/03/2004 2.2 1402.00 88
我们成功地将 GitHub 上的 CSV 文件数据导入到 DataFrame 中,并打印了选定列的前三行数据。
从公共 S3 存储桶读取数据
AWS 支持 虚拟主机风格 的 URL,如 https://bucket-name.s3.Region.amazonaws.com/keyname,路径风格 的 URL,如 https://s3.Region.amazonaws.com/bucket-name/keyname,以及使用 S3://bucket/keyname。以下是这些不同 URL 在我们文件中的示例:
-
一个虚拟托管样式的 URL 或对象 URL:
tscookbook.s3.us-east-1.amazonaws.com/AirQualityUCI.xlsx -
一个路径样式的 URL:
s3.us-east-1.amazonaws.com/tscookbook/AirQualityUCI.xlsx -
一个 S3 协议:
s3://tscookbook/AirQualityUCI.csv
在此示例中,您将读取 AirQualityUCI.xlsx 文件,该文件只有一个工作表,包含与先前从 GitHub 读取的 AirQualityUCI.csv 相同的数据。
请注意,在 URL 中,您不需要指定 us-east-1 区域。us-east-1 区域代表美国东部(北弗吉尼亚),是一个 例外。其他区域则不是这种情况:
url = 'https://tscookbook.s3.amazonaws.com/AirQualityUCI.xlsx'
df = pd.read_excel(url,
index_col='Date',
parse_dates=True)
使用 S3:// URL 读取相同的文件:
s3uri = 's3://tscookbook/AirQualityUCI.xlsx'
df = pd.read_excel(s3uri,
index_col='Date',
parse_dates=True)
您可能会遇到如下错误:
ImportError: Install s3fs to access S3
这表明您要么没有安装 s3fs 库,要么可能没有使用正确的 Python/Conda 环境。
从私有 S3 存储桶读取数据
从私有 S3 存储桶读取文件时,您需要传递凭证以进行身份验证。pandas 中许多 I/O 函数中的一个便捷参数是 storage_options,它允许您在请求中发送额外的内容,例如自定义头部或所需的云服务凭证。
您需要传递一个字典(键值对),以便与请求一起提供额外的信息,例如用户名、密码、访问密钥和密钥访问密钥,传递给 storage_options,如 {"username": username, "password": password}。
现在,您将读取位于私有 S3 存储桶中的 AirQualityUCI.csv 文件:
- 您将从将您的 AWS 凭证存储在 Python 脚本之外的
.cfg配置文件开始。然后,使用configparser读取这些值,并将其存储在 Python 变量中。您不希望凭证暴露或硬编码在代码中:
# Example aws.cfg file
[AWS]
aws_access_key=your_access_key
aws_secret_key=your_secret_key
您可以使用 config.read() 加载 aws.cfg 文件:
import configparser
config = configparser.ConfigParser()
config.read('aws.cfg')
AWS_ACCESS_KEY = config['AWS']['aws_access_key']
AWS_SECRET_KEY = config['AWS']['aws_secret_key']
- AWS 访问密钥 ID 和 密钥访问密钥 现在存储在
AWS_ACCESS_KEY和AWS_SECRET_KEY中。使用pandas.read_csv()读取 CSV 文件,并通过传递您的凭证来更新storage_options参数,如下代码所示:
s3uri = "s3://tscookbook-private/AirQuality.csv"
df = pd.read_csv(s3uri,
index_col='Date',
parse_dates=True,
storage_options= {
'key': AWS_ACCESS_KEY,
'secret': AWS_SECRET_KEY
})
df.iloc[:3, 1:4]
>>
CO(GT) PT08.S1(CO) NMHC(GT)
Date
2004-10-03 2.6 1360.0 150.0
2004-10-03 2.0 1292.0 112.0
2004-10-03 2.2 1402.0 88.0
- 或者,您可以使用 AWS 的 Python SDK(Boto3)来实现类似的功能。
boto3Python 库为您提供了更多的控制和额外的功能(不仅仅是读取和写入 S3)。您将传递之前存储在AWS_ACCESS_KEY和AWS_SECRET_KEY中的凭证,并通过boto3进行身份验证:
import boto3
bucket = "tscookbook-private"
client = boto3.client("s3",
aws_access_key_id =AWS_ACCESS_KEY,
aws_secret_access_key = AWS_SECRET_KEY)
现在,client 对象可以访问许多特定于 AWS S3 服务的方法,用于创建、删除和检索存储桶信息等。此外,Boto3 提供了两种级别的 API:客户端和资源。在前面的示例中,您使用了客户端 API。
客户端是一个低级服务访问接口,提供更精细的控制,例如,boto3.client("s3")。资源是一个更高级的面向对象接口(抽象层),例如,boto3.resource("s3")。
在 第四章,将时间序列数据持久化到文件 中,您将探索写入 S3 时的 resource API 接口。目前,您将使用客户端接口。
- 您将使用
get_object方法来检索数据。只需提供存储桶名称和密钥。这里的密钥是实际的文件名:
data = client.get_object(Bucket=bucket, Key='AirQuality.csv')
df = pd.read_csv(data['Body'],
index_col='Date',
parse_dates=True)
df.iloc[:3, 1:4]
>>
CO(GT) PT08.S1(CO) NMHC(GT)
Date
2004-10-03 2,6 1360.0 150.0
2004-10-03 2 1292.0 112.0
2004-10-03 2,2 1402.0 88.0
调用 client.get_object() 方法时,将返回一个字典(键值对),如以下示例所示:
{'ResponseMetadata': {
'RequestId':'MM0CR3XX5QFBQTSG',
'HostId':'vq8iRCJfuA4eWPgHBGhdjir1x52Tdp80ADaSxWrL4Xzsr
VpebSZ6SnskPeYNKCOd/RZfIRT4xIM=',
'HTTPStatusCode':200,
'HTTPHeaders': {'x-amz-id-2': 'vq8iRCJfuA4eWPgHBGhdjir1x52
Tdp80ADaSxWrL4XzsrVpebSZ6SnskPeYNKCOd/RZfIRT4xIM=',
'x-amz-request-id': 'MM0CR3XX5QFBQTSG',
'date': 'Tue, 06 Jul 2021 01:08:36 GMT',
'last-modified': 'Mon, 14 Jun 2021 01:13:05 GMT',
'etag': '"2ce337accfeb2dbbc6b76833bc6f84b8"',
'accept-ranges': 'bytes',
'content-type': 'binary/octet-stream',
'server': 'AmazonS3',
'content-length': '1012427'},
'RetryAttempts': 0},
'AcceptRanges': 'bytes',
'LastModified': datetime.datetime(2021, 6, 14, 1, 13, 5, tzinfo=tzutc()),
'ContentLength': 1012427,
'ETag': '"2ce337accfeb2dbbc6b76833bc6f84b8"',
'ContentType': 'binary/octet-stream',
'Metadata': {},
'Body': <botocore.response.StreamingBody at 0x7fe9c16b55b0>}
你感兴趣的内容在响应体中的 Body 键下。你将 data['Body'] 传递给 read_csv() 函数,它会将响应流(StreamingBody)加载到 DataFrame 中。
从 HTML 中读取数据
pandas 提供了一种优雅的方式来读取 HTML 表格并使用 pandas.read_html() 函数将内容转换为 pandas DataFrame:
- 在以下示例中,我们将从 Wikipedia 提取 HTML 表格,用于按国家和地区跟踪 COVID-19 大流行病例(
en.wikipedia.org/wiki/COVID-19_pandemic_by_country_and_territory):
url = "https://en.wikipedia.org/wiki/COVID-19_pandemic_by_country_and_territory"
results = pd.read_html(url)
print(len(results))
>>
pandas.read_html()返回一个包含 DataFrame 的列表,每个 HTML 表格对应一个 DataFrame,位于 URL 中找到的每个 HTML 表格。请注意,网站内容是动态的,且会定期更新,因此结果可能会有所不同。在我们的例子中,返回了 69 个 DataFrame。索引为15的 DataFrame 包含按地区划分的 COVID-19 病例和死亡情况的汇总。获取该 DataFrame(位于索引15)并将其分配给df变量,接着打印返回的列:
df = results[15]
df.columns
>>
Index(['Region[30]', 'Total cases', 'Total deaths', 'Cases per million',
'Deaths per million', 'Current weekly cases', 'Current weekly deaths',
'Population millions', 'Vaccinated %[31]'],
dtype='object')
- 显示
Total cases、Total deaths和Cases per million列的前五行:
df[['Region[30]','Total cases', 'Total deaths', 'Cases per million']].head(3)
>>
Region[30] Total cases Total deaths Cases per million
0 European Union 179537758 1185108 401363
1 North America 103783777 1133607 281404
2 Other Europe 57721948 498259 247054
它是如何工作的……
大多数 pandas 读取器函数都接受 URL 作为路径。以下是一些示例:
-
pandas.read_csv() -
pandas.read_excel() -
pandas.read_parquet() -
pandas.read_table() -
pandas.read_pickle() -
pandas.read_orc() -
pandas.read_stata() -
pandas.read_sas() -
pandas.read_json()
URL 需要是 pandas 支持的有效 URL 方案之一,包括 http 和 https、ftp、s3、gs,或者 file 协议。
read_html() 函数非常适合抓取包含 HTML 表格数据的网站。它检查 HTML 并搜索其中的所有 <table> 元素。在 HTML 中,表格行使用 <tr> </tr> 标签定义,表头使用 <th></th> 标签定义。实际数据(单元格)包含在 <td> </td> 标签中。read_html() 函数查找 <table>、<tr>、<th> 和 <td> 标签,并将内容转换为 DataFrame,并根据 HTML 中的定义分配列和行。如果一个 HTML 页面包含多个 <table></table> 标签,read_html 会返回所有的表格,并且你将得到一个 DataFrame 列表。
以下代码演示了pandas.read_html()的工作原理:
from io import StringIO
import pandas as pd
html = """
<table>
<tr>
<th>Ticker</th>
<th>Price</th>
</tr>
<tr>
<td>MSFT</td>
<td>230</td>
</tr>
<tr>
<td>APPL</td>
<td>300</td>
</tr>
<tr>
<td>MSTR</td>
<td>120</td>
</tr>
</table>
</body>
</html>
"""
df = pd.read_html(StringIO(html))
df[0]
>>
Ticker Price
0 MSFT 230
1 APPL 300
2 MSTR 120
传递 HTML 字面字符串
从 pandas 版本 2.1.0 开始,你需要将 HTML 代码包装在
io.StringIO中。StringIO(<HTML CODE>)将从 HTML 字符串创建一个内存中的类文件对象,可以直接传递给read_html()函数。
在前面的代码中,read_html()函数从类文件对象读取 HTML 内容,并将用“<table> … </table>”标签表示的 HTML 表格转换为 pandas 数据框。<th>和</th>标签之间的内容表示数据框的列名,<tr><td>和</td></tr>标签之间的内容表示数据框的行数据。请注意,如果你删除<table>和</table>标签,你将遇到ValueError: No tables found错误。
还有更多…
read_html()函数有一个可选的attr参数,它接受一个包含有效 HTML<table>属性的字典,例如id或class。例如,你可以使用attr参数来缩小返回的表格范围,仅限那些匹配class属性sortable的表格,如<table class="sortable">。read_html函数将检查整个 HTML 页面,确保你定位到正确的属性集。
在前一个练习中,你使用了read_html函数在 COVID-19 维基百科页面上,它返回了 71 个表格(数据框)。随着维基百科的更新,表格数量可能会随着时间的推移增加。你可以通过使用attr选项来缩小结果集并保证一定的一致性。首先,使用浏览器检查 HTML 代码。你会看到一些<table>元素有多个类,如sortable。你可以寻找其他独特的标识符。
<table class="wikitable sortable mw-datatable covid19-countrynames jquery-tablesorter" id="thetable" style="text-align:right;">
请注意,如果你收到html5lib not found的错误,请安装它,你需要同时安装html5lib和beautifulSoup4。
使用conda安装,使用以下命令:
conda install html5lib beautifulSoup4
使用pip安装,使用以下命令:
pip install html5lib beautifulSoup4
现在,让我们使用sortable类并重新请求数据:
url = "https://en.wikipedia.org/wiki/COVID-19_pandemic_by_country_and_territory"
df = pd.read_html(url, attrs={'class': 'sortable'})
len(df)
>> 7
df[3].columns
>>
Index(['Region[28]', 'Total cases', 'Total deaths', 'Cases per million',
'Deaths per million', 'Current weekly cases', 'Current weekly deaths',
'Population millions', 'Vaccinated %[29]'],
dtype='object')
返回的列表是一个较小的表格子集(从71减少到7)。
另见
如需更多信息,请参考官方的pandas.read_html文档:pandas.pydata.org/docs/reference/api/pandas.read_html.html。
从 Parquet 文件读取数据
Parquet文件已成为在数据工程和大数据分析领域中高效存储和处理大数据集的热门选择。最初由 Twitter 和 Cloudera 开发,Parquet 后来作为开源列式文件格式贡献给了Apache 基金会。Parquet 的重点是优先考虑快速的数据检索和高效的压缩。它的设计专门针对分析型工作负载,并且作为数据分区的优秀选择,你将在本食谱中以及第四章将时间序列数据持久化到文件中再次探索它。因此,Parquet 已成为现代数据架构和云存储解决方案的事实标准。
在本食谱中,你将学习如何使用 pandas 读取 parquet 文件,并学习如何查询特定分区以实现高效的数据检索。
准备工作
你将读取包含来自美国国家海洋和大气管理局的洛杉矶机场站点天气数据的 parquet 文件,这些文件可以在datasets/Ch2/ LA_weather.parquet文件夹中找到。它包含 2010-2023 年的天气读数,并按年份进行分区(14 个子文件夹)。
你将使用pandas.read_parquet()函数,这需要你安装一个 Parquet 引擎来处理文件。你可以安装fastparquet或PyArrow,后者是 pandas 的默认选择。
使用conda安装 PyArrow,运行以下命令:
conda install -c conda-forge pyarrow
使用pip安装 PyArrow,运行以下命令:
pip install pyarrow
如何操作…
PyArrow库允许你将额外的参数(**kwargs)传递给pandas.read_parquet()函数,从而在读取文件时提供更多的选项,正如你将要探索的那样。
读取所有分区
以下步骤用于一次性读取LA_weather.parquet文件夹中的所有分区:
- 创建一个路径来引用包含分区的 Parquet 文件夹,并将其传递给
read_parquet函数。
file = Path('../../datasets/Ch2/LA_weather.parquet/')
df = pd.read_parquet(file,
engine='pyarrow')
- 你可以使用
.info()方法验证和检查模式
df.info()
这应该会产生以下输出:
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 4899 entries, 0 to 4898
Data columns (total 17 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 STATION 4899 non-null object
1 NAME 4899 non-null object
2 DATE 4899 non-null object
3 PRCP 4899 non-null float64
4 PRCP_ATTRIBUTES 4899 non-null object
5 SNOW 121 non-null float64
6 SNOW_ATTRIBUTES 121 non-null object
7 SNWD 59 non-null float64
8 SNWD_ATTRIBUTES 59 non-null object
9 TAVG 3713 non-null float64
10 TAVG_ATTRIBUTES 3713 non-null object
11 TMAX 4899 non-null int64
12 TMAX_ATTRIBUTES 4899 non-null object
13 TMIN 4899 non-null int64
14 TMIN_ATTRIBUTES 4899 non-null object
15 DT 4899 non-null datetime64[ns]
16 year 4899 non-null category
dtypes: category(1), datetime64ns, float64(4), int64(2), object(9)
memory usage: 617.8+ KB
读取特定分区
以下步骤说明如何使用filters参数读取特定分区或分区集,并使用 PyArrow 库中的columns参数指定列:
- 由于数据按年份进行分区,你可以使用
filters参数来指定特定的分区。在下面的示例中,你将只读取 2012 年的分区:
filters = [('year', '==', 2012)]
df_2012 = pd.read_parquet(file,
engine='pyarrow',
filters=filters)
- 要读取一组分区,例如 2021 年、2022 年和 2023 年的数据,你可以使用以下任意选项,这些选项会产生类似的结果:
filters = [('year', '>', 2020)]
filters = [('year', '>=', 2021)]
filters = [('year', 'in', [2021, 2022, 2023])]
在定义filters对象后,你可以将其分配给read_parquet()函数中的filters参数,如下所示:
df = pd.read_parquet(file,
engine='pyarrow',
filters=filters)
- 另一个有用的参数是
columns,它允许你指定要检索的列名,作为 Python 列表传递。
columns = ['DATE', 'year', 'TMAX']
df = pd.read_parquet(file,
engine='pyarrow',
filters=filters,
columns=columns)
在上面的代码中,read_parquet() 函数将仅检索 Parquet 文件中的指定列(‘Date’、‘year’ 和 ‘TMAX’),并使用定义的过滤器。你可以通过运行 df.head() 和 df.info() 来验证结果。
df.head()
>>
DATE year TMAX
0 2021-01-01 2021 67
1 2021-01-02 2021 63
2 2021-01-03 2021 62
3 2021-01-04 2021 59
4 2021-01-05 2021 57
df.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 881 entries, 0 to 880
Data columns (total 3 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 DATE 881 non-null object
1 year 881 non-null category
2 TMAX 881 non-null int64
dtypes: category(1), int64(1), object(1)
memory usage: 15.2+ KB
注意,通过指定数据分析所需的分区和列,缩小数据选择范围后,内存使用显著减少。
如何运作…
使用 Parquet 文件格式有多个优势,尤其是在处理大型数据文件时。Parquet 的列式格式提供了更快的数据检索和高效的压缩,使其非常适合云存储,并有助于减少存储成本。Parquet 采用了先进的数据编码技术和算法,从而提高了压缩率。
以下图 2.5 显示了一个按年份划分的 Parquet 数据集文件夹结构示例。每个年份都有自己的子文件夹,在每个子文件夹内,包含有单独的文件。

图 2.5:按年份划分的 Parquet 数据集文件夹结构示例
Parquet 文件被称为“自描述”文件,因为每个文件包含编码的数据和位于页脚部分的附加元数据。元数据包括 Parquet 格式的版本、数据模式和结构(例如列类型),以及其他统计信息,如列的最小值和最大值。因此,在使用 pandas 读写 Parquet 数据集时,你会注意到 DataFrame 的模式得以保留。
根据官方 pandas 文档,你需要熟悉一些关键参数,这些文档可在其官网页面中找到(pandas.pydata.org/docs/reference/api/pandas.read_parquet.html)。你已经在之前的 如何操作… 部分使用了其中一些参数。以下是你已经使用过的关键参数:
read_parquet(
path: 'FilePath | ReadBuffer[bytes]',
engine: 'str' = 'auto',
columns: 'list[str] | None' = None,
**kwargs,
)
-
path:这是第一个位置参数,也是读取 Parquet 文件时所需的唯一必填字段(至少需要此项)。在我们的示例中,你传递了名为file的 Python 路径对象作为参数。你也可以传递一个有效的 URL,指向远程 Parquet 文件的位置,例如 AWS S3 存储桶。 -
engine:如果你没有向引擎参数传递任何参数,则默认值为“auto”。另两个有效选项是“pyarrow”和“fastparquet”,具体取决于你安装的引擎。在我们的示例中,你安装了 PyArrow 库(参见 准备工作 部分)。“auto”选项将首先尝试加载 PyArrow,如果不可用,则回退到 fastparquet。 -
columns:在这里,您可以指定在读取时希望限制的列。即使只选择一列,您也将其作为 Python 列表传递。在我们的示例中,您将列变量定义为columns = ['DATE', 'year', 'TMAX'],然后将其作为参数传递。 -
**kwargs:表示可以将额外的参数传递给引擎。在我们的案例中,我们使用了PyArrow库;因此,read_parquet()pandas 函数将这些参数传递给 PyArrow 库中的pyarrow.parquet.read_table()函数。在之前的示例中,我们将包含过滤条件的列表传递给了pyarrow.parquet.read_table()中的filters参数。有关可以使用的其他参数,您可以在这里查看pyarrow.parquet.read_table()的官方文档:arrow.apache.org/docs/python/generated/pyarrow.parquet.read_table.html
还有更多…
-
回想一下在准备工作部分中,您已将 PyArrow 库安装为处理 Parquet 文件的后端引擎。当您在 pandas 中使用
read_parquet()读取函数时,pyarrow引擎是默认的。 -
既然您已经安装了该库,您可以直接利用它像使用 pandas 一样处理 Parquet 文件。您将使用
pyarrow.parquet.read_table()函数,而不是pandas.read_parquet()函数,如下所示:
import pyarrow.parquet as pq
from pathlib import Path
file = Path('../../datasets/Ch2/LA_weather.parquet/')
table = pq.read_table(file, filters=filters, columns=columns)
table对象是pyarrow.Table类的一个实例。您可以使用以下代码验证这一点:
import pyarrow as pa
isinstance(table, pa.Table)
>> True
table对象包含许多有用的方法,包括.to_pandas()方法,用于将该对象转换为 pandas DataFrame:
df = table.to_pandas()
以下内容进一步说明了read_table()和read_parquet()函数之间的相似性:
columns = ['DATE','year', 'TMAX']
filters = [('year', 'in', [2021, 2022, 2023])]
tb = pq.read_table(file,
filters=filters,
columns=columns,
use_pandas_metadata=True)
df_pa = tb.to_pandas()
df_pd = pd.read_parquet(file,
filters=filters,
columns=columns,
use_pandas_metadata=True)
df_pa和df_pd是等价的。
PyArrow 库提供了低级接口,而 pandas 提供了建立在 PyArrow 之上的高级接口。如果您决定安装fastparquet库,情况也是如此。请注意,PyArrow 是Apache Arrow的 Python 实现,Apache Arrow 是一个用于内存数据(列式内存)的开源项目。虽然 Apache Parquet 指定了用于高效存储和检索的列式文件格式,Apache Arrow 则使我们能够高效地在内存中处理如此庞大的列式数据集。
另见
To learn more about pandas.read_parquet() function, you can read their latest documentation here https://pandas.pydata.org/docs/reference/api/pandas.read_parquet.html
处理大数据文件
使用 pandas 的一个优点是,它提供了内存分析的数据结构,这使得处理数据时具有性能优势。然而,当处理大数据集时,这一优势也可能变成约束,因为您可以加载的数据量受可用内存的限制。当数据集超过可用内存时,可能会导致性能下降,尤其是在 pandas 为某些操作创建数据的中间副本时。
在现实场景中,有一些通用的最佳实践可以缓解这些限制,包括:
-
采样或加载少量行进行探索性数据分析(EDA):在将数据分析策略应用于整个数据集之前,采样或加载少量行是一种良好的实践。这可以帮助您更好地了解数据,获得直觉,并识别可以删除的不必要列,从而减小整体数据集的大小。
-
减少列数:仅保留分析所需的列可以显著减少数据集的内存占用。
-
分块处理:利用 pandas 中许多读取函数提供的
chunksize参数,您可以将数据分成较小、易于管理的块进行处理。此技巧有助于通过逐块处理来应对大规模数据集。 -
使用其他大数据集库:有一些专门为处理大数据集设计的替代库,它们提供类似于 pandas 的 API,例如 Dask、Polars 和 Modin。
在本教程中,您将学习 pandas 中处理大数据集的技巧,如 分块处理。随后,您将探索三个新库:Dask、Polars 和 Modin。这些库是 pandas 的替代方案,特别适用于处理大规模数据集。
准备工作
在本教程中,您将安装 Dask 和 Polars 库。
使用 pip 安装,您可以使用以下命令:
>>> pip install “dask[complete]” "modin[all]" polars
使用 Conda 安装,您可以使用:
>>> conda install -c conda-forge dask polars modin-all
在本教程中,您将使用来自 www.nyc.gov/site/tlc/about/tlc-trip-record-data.page 的纽约出租车数据集,并且我们将使用 2023 年的 Yellow Taxi 旅行记录(涵盖 1 月至 5 月)。在本书的 GitHub 仓库中,我提供了 run_once() 函数,您需要执行一次。它将结合五个月的数据集(五个 parquet 文件),并生成一个大型 CSV 数据集(约 1.72 GB)。
以下是脚本作为参考:
# Script to create one large data file
import pandas as pd
import glob
def run_once():
# Directory path where Parquet files are located
directory = '../../datasets/Ch2/yellow_tripdata_2023-*.parquet'
# Get a list of all Parquet files in the directory
parquet_files = glob.glob(directory)
# Read all Parquet files into a single DataFrame
dfs = []
for file in parquet_files:
df = pd.read_parquet(file)
dfs.append(df)
# Concatenate all DataFrames into a single DataFrame
combined_df = pd.concat(dfs)
combined_df.to_csv('../../datasets/Ch2/yellow_tripdata_2023.csv', index=False)
run_once()
如何操作……
在本教程中,您将探索四种不同的方法来处理大型数据集以进行摄取。这些方法包括:
-
使用
chunksize参数,该参数在 pandas 的许多读取函数中都有提供。 -
使用 Dask 库
-
使用 Polars 库
memory_profiler 库将用于演示内存消耗。您可以使用 pip 安装该库:
Pip install -U memory_profiler
要在 Jupyter Notebook 中使用 memory_profiler,您需要先运行以下命令一次:
%load_ext memory_profiler
加载后,您可以在任何代码单元格中使用它。只需在 Jupyter 代码单元格中以 %memit 或 %%memit 开头即可。典型的输出将显示峰值内存大小和增量大小。
-
峰值内存表示在执行特定代码行时的最大内存使用量。
-
增量表示当前行与上一行之间的内存使用差异。
使用 Chunksize
pandas 中有多个读取函数支持通过chunksize参数进行分块。当你需要导入一个大型数据集时,这种方法非常方便,但如果你需要对每个数据块执行复杂逻辑(这需要各个数据块之间的协调),它可能不适用。
pandas 中支持chunksize参数的一些读取函数包括:pandas.read_csv(),pandas.read_table(),pandas.read_sql(),pandas.read_sql_query(),pandas.read_sql_table(),pandas.read_json(),pandas.read_fwf(),pandas.read_sas(),pandas.read_spss(),以及pandas.read_stata()。
- 首先,让我们开始使用传统方法,通过
read_csv读取这个大型文件,而不进行数据块分割:
import pandas as pd
from pathlib import Path
file_path = Path('../../datasets/Ch2/yellow_tripdata_2023.csv')
%%time
%%memit
df_pd = pd.read_csv(file_path, low_memory=False)
由于我们有两个魔法命令%%time和%%memit,输出将显示内存使用情况和 CPU 时间,如下所示:
peak memory: 10085.03 MiB, increment: 9922.66 MiB
CPU times: user 21.9 s, sys: 2.64 s, total: 24.5 s
Wall time: 25 s
- 使用相同的
read_csv函数,你可以利用 chunksize 参数,它表示每个数据块读取的行数。在这个例子中,你将使用chunksize=10000,,这将每 10000 行创建一个数据块。
%%time
%%memit
df_pd = pd.read_csv(file_path, low_memory=False, chunksize=10000)
这将产生以下输出
peak memory: 3101.41 MiB, increment: 0.77 MiB
CPU times: user 25.5 ms, sys: 13.4 ms, total: 38.9 ms
Wall time: 516 ms
执行如此快速的原因是返回的是一个迭代器对象,类型为TextFileReader,如下所示:
type(df_pd)
pandas.io.parsers.readers.TextFileReader
-
要检索每个数据块中的数据,你可以使用
get_chunk()方法一次检索一个数据块,或者使用循环检索所有数据块,或者简单地使用pandas.concat()函数: -
选项 1:使用
get_chunk()方法或 Python 的next()函数。这将一次检索一个数据块,每个数据块包含 10000 条记录。每次运行一个 get_chunk()或 next()时,你将获得下一个数据块。
%%time
%%memit
df_pd = pd.read_csv(file_path, low_memory=False, chunksize=10000)
df_pd.get_chunk()
>>
peak memory: 6823.64 MiB, increment: 9.14 MiB
CPU times: user 72.3 ms, sys: 40.8 ms, total: 113 ms
Wall time: 581 ms
# this is equivalent to
df_pd = pd.read_csv(file_path, low_memory=False, chunksize=10000)
Next(df_pd)
- 选项 2:遍历数据块。这在你希望对每个数据块执行简单操作后再合并各个数据块时非常有用:
%%time
%%memit
df_pd = pd.read_csv(file_path, low_memory=False, chunksize=10000)
final_result = pd.DataFrame()
for chunk in df_pd:
final_result = pd.concat([final_result, chunk])
- 选项 3:使用 pd.concat()一次性检索所有数据块。这在整体性能上可能不如其他方法:
%%time
%%memit
df_pd = pd.read_csv(file_path, low_memory=False, chunksize=10000)
final_result = pd.concat(pf_pd)
>>
peak memory: 9145.42 MiB, increment: 697.86 MiB
CPU times: user 14.9 s, sys: 2.81 s, total: 17.7 s
Wall time: 18.8 s
内存和 CPU 时间的测量是为了说明问题。如你所见,遍历数据块并逐个追加每个数据块可能是一个耗时的过程。
接下来,你将学习如何使用 Polars,它提供了与 pandas 非常相似的 API,使得学习 Polars 的过渡变得更简单。
使用 Polars
类似于 pandas,Polars 库的设计也主要用于单台机器,但在处理大型数据集时,Polars 的性能优于 pandas。与单线程且无法利用单台机器多个核心的 pandas 不同,Polars 可以充分利用单台机器上所有可用的核心进行高效的并行处理。在内存使用方面,pandas 提供了内存数据结构,因此它在内存分析中非常流行。这也意味着,当你加载 CSV 文件时,整个数据集会被加载到内存中,因此,处理超出内存容量的数据集可能会遇到问题。另一方面,Polars 在执行类似操作时所需的内存比 pandas 少。
Polars 是用 Rust 编写的,这是一种性能与 C 和 C++ 相似的编程语言,由于其与 Python 相比的优越性能,Rust 在机器学习操作(MLOps)领域变得非常流行。
在本教程中,你将探索 Polars 的基础知识,主要是使用 read_csv() 读取大型 CSV 文件。
- 首先,导入 Polars 库:
import polars as pl
from pathlib import Path
file_path = Path('../../datasets/Ch2/yellow_tripdata_2023.csv')
- 现在你可以使用
read_csv函数来读取 CSV 文件,类似于你使用 pandas 时的做法。注意为了演示目的,使用了 Jupyter 魔法命令%%time和%%memit:
%%time
%%memit
df_pl = pl.read_csv(file_path)
>>
peak memory: 8633.58 MiB, increment: 2505.14 MiB
CPU times: user 4.85 s, sys: 3.28 s, total: 8.13 s
Wall time: 2.81 s
- 你可以使用
.head()方法打印出 Polars DataFrame 的前 5 条记录,这与 pandas 类似。
df_pl.head()
- 要获取 Polars DataFrame 的总行数和列数,你可以使用
.shape属性,类似于 pandas 的用法:
df_pl.shape
>>
(16186386, 20)
- 如果你决定使用 Polars 来处理大型数据集,但后来决定将结果输出为 pandas DataFrame,可以使用
.to_pandas()方法来实现:
df_pd = df_pl.to_pandas()
从这些简单的代码运行中可以清楚地看出 Polars 的速度有多快。当将 Polars 库中的read_csv与 pandas 中的read_csv进行比较时,从内存和 CPU 指标上可以更加明显地看出这一点。
使用 Dask
另一个处理大型数据集的流行库是 Dask。它与 pandas 的 API 相似,但在分布式计算能力上有所不同,允许它超越单台机器进行扩展。Dask 与其他流行的库如 pandas、Scikit-Learn、NumPy 和 XGBoost 等集成得很好。
此外,你还可以安装 Dask-ML,这是一款附加库,提供可扩展的机器学习功能,并与流行的 Python ML 库如 Scikit-Learn、XGBoot、PyTorch 和 TensorFlow/Keras 等兼容。
在本教程中,你将探索 Polars 的基础知识,主要是使用 read_csv() 读取大型 CSV 文件。
- 首先,从
dask库中导入dataframe模块:
import dask.dataframe as dd
from pathlib import Path
file_path = Path('../../datasets/Ch2/yellow_tripdata_2023.csv')
- 现在你可以使用
read_csv函数来读取 CSV 文件,类似于你使用 pandas 时的做法。注意为了演示目的,使用了 Jupyter 魔法命令%%time和%%memit:
%%time
%%memit
df_dk = dd.read_csv(file_path)
>>
peak memory: 153.22 MiB, increment: 3.38 MiB
CPU times: user 44.9 ms, sys: 12.1 ms, total: 57 ms
Wall time: 389 ms
以内存和 CPU 利用率方面的有趣输出为例。一个人可能会以为什么都没有被读取。让我们运行一些测试来了解发生了什么。
- 你将使用在 pandas 中常用的技术来探索 df_dk DataFrame,例如检查 DataFrame 的大小:
df_dk.shape
>>
(Delayed('int-0ab72188-de09-4d02-a76e-4a2c400e918b'), 20)
df_dk.info()
<class 'dask.dataframe.core.DataFrame'>
Columns: 20 entries, VendorID to airport_fee
dtypes: float64(13), int64(4), string(3)
注意,我们可以看到列的数量和它们的数据类型,但没有有关总记录数(行数)的信息。此外,注意 Dask 中的 Delayed 对象。我们稍后会再次提到它。
- 最后,尝试使用 print 函数输出 DataFrame:
print(df_dk)
>>
Dask DataFrame Structure:
VendorID tpep_pickup_datetime tpep_dropoff_datetime passenger_count trip_distance RatecodeID store_and_fwd_flag PULocationID DOLocationID payment_type fare_amount extra mta_tax tip_amount tolls_amount improvement_surcharge total_amount congestion_surcharge Airport_fee airport_fee
npartitions=26
int64 string string float64 float64 float64 string int64 int64 int64 float64 float64 float64 float64 float64 float64 float64 float64 float64 float64
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
Dask Name: to_pyarrow_string, 2 graph layers
输出非常有趣;显示的几乎完全是 DataFrame 的结构或布局,但没有数据。为了简化解释,Dask 采用了一种称为懒加载(lazy loading)或懒评估(lazy evaluation)策略。换句话说,Dask 中的大多数工作负载是懒惰的;它们不会立即执行,直到你通过特定操作触发它们,例如使用compute()方法。这个特性使得 Dask 能够通过延迟实际计算,直到它变得明确,从而处理大数据集和分布式计算。相反,Dask 几乎瞬间构建了一个任务图或执行逻辑,但任务图或执行逻辑并未被触发。
使用read_csv时,Dask 并不会立即加载整个数据集“”。它仅在你执行特定操作或函数时才读取数据。例如,使用head()方法将只检索前 5 条记录,仅此而已。这样可以节省内存并提高性能。
- 使用 head 方法打印 Dask DataFrame 的前五条记录:
df_dk.head()
你会注意到,前五(5)条记录的打印结果与使用 pandas 时的预期类似。
- 要获取数据集中的总记录数,你可以使用
compute()方法,这将强制进行评估:
%%time
%%memit
print(df_dk.shape[0].compute())
>>
16186386
peak memory: 6346.53 MiB, increment: 1818.44 MiB
CPU times: user 19.3 s, sys: 5.29 s, total: 24.6 s
Wall time: 10.5 s
- 最后,如果你能够将 DataFrame 的大小缩小到便于 pandas 加载的程度,可以使用
compute()方法将 Dask DataFrame 转换为 pandas DataFrame:
df_pd = df_dk.compute()
type(df_pd)
>>
pandas.core.frame.DataFrame
Dask 库提供了不同的 API。你只探讨了类似于 pandas 库的 DataFrame API。Dask 提供了许多优化功能,对于处理非常大数据集并需要扩展当前工作流程的人来说,它有一定的学习曲线,无论是从 NumPy、Scikit-Learn 还是 pandas。
它是如何工作的…
由于 pandas 的流行,许多库如 Polars 和 Dask 受到 pandas 简洁性和 API 的启发。因为这些库的设计目标是针对 pandas 用户,提供解决方案,解决 pandas 最重要的限制之一:缺乏扩展能力,无法处理无法放入内存的非常大数据集。
还有更多…
到目前为止,你已经了解了在处理大文件时,比使用 pandas 更好的选择,特别是当你有内存限制,无法将所有数据加载到可用内存中时。pandas 是一个单核框架,无法提供并行计算功能。相反,有一些专门的库和框架可以进行并行处理,旨在处理大数据。这些框架不依赖于将所有数据加载到内存中,而是可以利用多个 CPU 核心、磁盘使用,或扩展到多个工作节点(也就是多台机器)。之前,你探讨了Dask,它将数据分块,创建计算图,并在后台将较小的任务(分块)并行化,从而加速整体处理时间并减少内存开销。
这些框架虽然很好,但需要你花时间学习框架,可能还需要重写原始代码以利用这些功能。所以,最开始可能会有学习曲线。幸运的是,Modin项目正是在这一过程中发挥作用。Modin 库作为Dask或Ray的封装器,或者更具体地说,是其上面的抽象,使用与 pandas 类似的 API。Modin 让优化 pandas 代码变得更加简单,无需学习另一个框架;只需要一行代码。
首先导入必要的库:
from pathlib import Path
from modin.config import Engine
Engine.put("dask") # Modin will use Dask
import modin.pandas as pd
file_path = Path('../../datasets/Ch2/yellow_tripdata_2023.csv')
注意几点,首先我们指定了要使用的引擎。在这种情况下,我们选择使用 Dask。Modin 支持其他引擎,包括 Ray 和 MPI。其次,请注意import modin.pandas as pd语句,这一行代码就足够将你的现有 pandas 代码进行扩展。请记住,Modin 项目仍在积极开发中,这意味着随着 pandas 的成熟和新功能的增加,Modin 可能仍在追赶。
让我们读取 CSV 文件并比较 CPU 和内存利用率:
%%time
%%memit
pd.read_csv(file_path)
>>
peak memory: 348.02 MiB, increment: 168.59 MiB
CPU times: user 1.23 s, sys: 335 ms, total: 1.57 s
Wall time: 8.26 s
你的数据加载很快,你可以运行其他 pandas 函数进一步检查数据,例如df_pd.head(), df_pd.info(),df_pd.head(),你会注意到结果出现的速度非常快:
df_pd.info()
>>
<class 'modin.pandas.dataframe.DataFrame'>
RangeIndex: 16186386 entries, 0 to 16186385
Data columns (total 20 columns):
# Column Dtype
--- ------ -----
0 VendorID int64
1 tpep_pickup_datetime object
2 tpep_dropoff_datetime object
3 passenger_count float64
4 trip_distance float64
5 RatecodeID float64
6 store_and_fwd_flag object
7 PULocationID int64
8 DOLocationID int64
9 payment_type int64
10 fare_amount float64
11 extra float64
12 mta_tax float64
13 tip_amount float64
14 tolls_amount float64
15 improvement_surcharge float64
16 total_amount float64
17 congestion_surcharge float64
18 Airport_fee float64
19 airport_fee float64
dtypes: float64(13), int64(4), object(3)
memory usage: 2.4+ GB
使用 Modin 可以让你在不学习新框架的情况下,利用现有的 pandas 代码、技能和库经验。这包括访问 pandas 的 I/O 函数(读写函数)以及你从 pandas 库中期望的所有参数。
另见
其他 Python 项目致力于使大数据集的处理更具可扩展性和性能,在某些情况下,它们提供了比 pandas 更好的选择。
第三章:3 从数据库中读取时间序列数据
加入我们的书籍社区,加入 Discord

数据库扩展了你可以存储的内容,包括文本、图像和媒体文件,并且被设计用于在大规模下进行高效的读写操作。数据库能够存储数 TB 甚至 PB 的数据,并提供高效优化的数据检索功能,例如在执行分析操作时用于数据仓库和数据湖。数据仓库是一个专门设计用于存储大量结构化数据的数据库,数据大多来自多个源系统的集成,专为支持商业智能报告、仪表板和高级分析而构建。另一方面,数据湖以原始格式存储大量结构化、半结构化或非结构化数据。本章中,我们将继续使用pandas库从数据库中读取数据。我们将通过读取关系型(SQL)数据库和非关系型(NoSQL)数据库的数据来创建时间序列 DataFrame。
此外,你将探索如何与第三方数据提供商合作,从他们的数据库系统中拉取金融数据。
在本章中,你将创建带有 DatetimeIndex 数据类型的时间序列 DataFrame,并涉及以下食谱:
-
从关系型数据库中读取数据
-
从 Snowflake 中读取数据
-
从文档数据库中读取数据
-
从时间序列数据库中读取数据
技术要求
在本章中,我们将广泛使用 pandas 2.2.2(于 2024 年 4 月 10 日发布)。
你将与不同类型的数据库一起工作,例如 PostgreSQL、Amazon Redshift、MongoDB、InfluxDB 和 Snowflake。你需要安装额外的 Python 库以连接到这些数据库。
你还可以从本书的 GitHub 仓库下载 Jupyter 笔记本(github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook)来进行跟随练习。
作为一个良好的实践,你会将你的数据库凭据存储在 Python 脚本之外的 database.cfg 配置文件中。你可以使用 configparser 来读取并将值存储在 Python 变量中。你不希望凭据暴露或硬编码在代码中:
# Example of configuration file "database.cfg file"
[SNOWFLAKE]
user=username
password=password
account=snowflakeaccount
warehouse=COMPUTE_WH
database=SNOWFLAKE_SAMPLE_DATA
schema=TPCH_SF1
role=somerole
[POSTGRESQL]
host: 127.0.0.1
dbname: postgres
user: postgres
password: password
[AWS]
host=<your_end_point.your_region.redshift.amazonaws.com>
port=5439
database=dev
username=<your_username>
password=<your_password>
你可以使用 config.read() 加载 database.cfg 文件:
import configparser
config = configparser.ConfigParser()
config.read(database.cfg')
从关系型数据库中读取数据
在这个食谱中,你将从 PostgreSQL 读取数据,这是一个流行的开源关系型数据库。
你将探索两种连接并与 PostgreSQL 交互的方法。首先,你将使用 psycopg,这是一个 PostgreSQL Python 连接器,来连接并查询数据库,然后将结果解析到 pandas DataFrame 中。第二种方法是再次查询相同的数据库,但这次你将使用SQLAlchemy,一个与 pandas 集成良好的对象关系映射器(ORM)。
准备工作
在这个食谱中,假设你已经安装了最新版本的 PostgreSQL。在写这篇文章时,版本 16 是最新的稳定版本。
要在 Python 中连接和查询数据库,你需要安装 psycopg,它是一个流行的 PostgreSQL 数据库适配器。你还需要安装 SQLAlchemy,它提供了灵活性,可以根据你希望管理数据库的方式(无论是写入还是读取数据)来进行选择。
要使用 conda 安装库,运行以下命令:
>>> conda install -c conda-forge psycopg sqlalchemy -y
要使用 pip 安装库,运行以下命令:
>>> pip install sqlalchemy psycopg
如果你无法访问 PostgreSQL 数据库,最快的方式是通过 Docker (hub.docker.com/_/postgres) 来启动。以下是一个示例命令:
docker run -d \
--name postgres-ch3 \
-p 5432:5432 \
-e POSTGRES_PASSWORD=password \
-e PGDATA=/var/lib/postgresql/data/pgdata \
postgres:16.4-alpine
这将创建一个名为 postgres-ch3 的容器。username 为 postgres,密码是 password。创建的默认 database 名为 postgres。
一旦postgres-ch3容器启动并运行,你可以使用DBeaver进行连接,如下所示:

图 3.1 – DBeaver PostgreSQL 连接设置
你将使用存放在 datasets/Ch3/MSFT.csv 文件夹中的 MSFT 股票数据集。我已经通过DBeaver Community Edition上传了数据集到数据库,你可以在这里下载 dbeaver.io/download/
你可以通过右键点击 public 模式下的 tables 来导入 CSV 文件,如下图所示:

图 3.2 – 使用 DBeaver 导入数据到 PostgreSQL
你可以确认所有记录已被写入 postgres 数据库中的 msft 表,如下所示。

图 3.3 – 在 DBeaver 中使用 SQL 编辑器运行 SQL 查询,以查询 msft 表
如何操作…
我们将首先连接到 PostgreSQL 实例,查询数据库,将结果集加载到内存中,并将数据解析为时间序列 DataFrame。
在这个食谱中,我将连接到一个本地运行的 PostgreSQL 实例,因此我的连接将是 localhost (127.0.0.1)。你需要根据你自己的 PostgreSQL 数据库设置来调整此连接。
使用 psycopg
Psycopg 是一个 Python 库(也是数据库驱动程序),它在使用 PostgreSQL 数据库时提供了额外的功能和特性。请按照以下步骤操作:
- 开始时导入必要的库。你将从
database.cfg文件中导入所需的连接参数,正如技术要求部分中所突出显示的那样。你将创建一个 Python 字典来存储所有连接到数据库所需的参数值,比如host、database名称、user名称和password:
import psycopg
import pandas as pd
import configparser
config = configparser.ConfigParser()
config.read('database.cfg')
params = dict(config['POSTGRESQL'])
- 你可以通过将参数传递给
connect()方法来建立连接。一旦连接成功,你可以创建一个游标对象,用来执行 SQL 查询:
conn = psycopg.connect(**params)
cursor = conn.cursor()
- 游标对象提供了多个属性和方法,包括
execute、executemany、fetchall、fetchmany和fetchone。以下代码使用游标对象传递 SQL 查询,然后使用rowcount属性检查该查询所产生的记录数:
cursor.execute("""
SELECT date, close, volume
FROM msft
ORDER BY date;
""")
cursor.rowcount
>> 1259
- 执行查询后返回的结果集将不包括标题(没有列名)。或者,你可以通过使用
description属性从游标对象中获取列名,代码如下所示:
cursor.description
>>
[<Column 'date', type: varchar(50) (oid: 1043)>,
<Column 'close', type: float4 (oid: 700)>,
<Column 'volume', type: int4 (oid: 23)>]
- 你可以使用列表推导式从
cursor.description中提取列名,并在创建 DataFrame 时将其作为列标题传入:
columns = [col[0] for col in cursor.description]
columns
>>
['date', 'close', 'volume']
- 要获取执行查询所产生的结果,你将使用
fetchall方法。你将根据获取的结果集创建一个 DataFrame。确保传入你刚才捕获的列名:
data = cursor.fetchall()
df = pd.DataFrame(data, columns=columns)
df.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1259 entries, 0 to 1258
Data columns (total 3 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 date 1259 non-null object
1 close 1259 non-null float64
2 volume 1259 non-null int64
dtypes: float64(1), int64(1), object(1)
memory usage: 29.6+ KB
注意,date 列作为 object 类型返回,而不是 datetime 类型。
- 使用
pd.to_datetime()解析date列,并将其设置为 DataFrame 的索引:
df['date'] = pd.to_datetime(df['date'])
df = df.set_index('date')
print(df.tail(3))
>>
close volume
date
2024-08-30 417.14 24308300
2024-09-03 409.44 20285900
2024-09-04 408.84 9167942
在前面的代码中,游标返回了一个没有标题的元组列表(没有列名)。你可以通过运行以下代码来确认这一点:
data = cursor.fetchall()
data[0:5]
>>
[('2019-09-04', 131.45726, 17995900),
('2019-09-05', 133.7687, 26101800),
('2019-09-06', 132.86136, 20824500),
('2019-09-09', 131.35222, 25773900),
('2019-09-10', 129.97684, 28903400)]
你可以指示游标返回一个 dict_row 类型,它将包括列名信息(即标题)。这在转换为 DataFrame 时更为方便。可以通过将 dict_row 类传递给 row_factory 参数来实现:
from psycopg.rows import dict_row
conn = psycopg.connect(**params, row_factory=dict_row)
cursor = conn.cursor()
cursor.execute("SELECT * FROM msft;")
data = cursor.fetchall()
data[0:2]
>>
[{'date': '2019-09-04',
'open': 131.14206,
'high': 131.51457,
'low': 130.35883,
'close': 131.45726,
'volume': 17995900},
{'date': '2019-09-05',
'open': 132.87086,
'high': 134.08391,
'low': 132.53656,
'close': 133.7687,
'volume': 26101800}]
注意列名已经可用。你现在可以像以下代码所示那样创建一个 DataFrame:
df = pd.DataFrame(data)
print(df.head())
>>
date open high low close volume
0 2019-09-04 131.14206 131.51457 130.35883 131.45726 17995900
1 2019-09-05 132.87086 134.08391 132.53656 133.76870 26101800
2 2019-09-06 133.74963 133.89291 132.00171 132.86136 20824500
3 2019-09-09 133.32938 133.48220 130.33977 131.35222 25773900
4 2019-09-10 130.66455 130.75050 128.47725 129.97684 28903400
- 关闭游标和数据库连接:
cursor.close()
conn.close()
注意,psycopg 连接和游标可以在 Python 的 with 语句中用于处理异常,以便在提交事务时进行异常处理。游标对象提供了三种不同的获取函数;即 fetchall、fetchmany 和 fetchone。fetchone 方法返回一个单独的元组。以下示例展示了这一概念:
with psycopg.connect(**params) as conn:
with conn.cursor() as cursor:
cursor.execute('SELECT * FROM msft')
data = cursor.fetchone()
print(data)
>>
('2019-09-04', 131.14206, 131.51457, 130.35883, 131.45726, 17995900)
使用 pandas 和 SQLAlchemy
SQLAlchemy 是一个非常流行的开源库,用于在 Python 中操作关系型数据库。SQLAlchemy 可以被称为对象关系映射器(ORM),它提供了一个抽象层(可以把它当作一个接口),让你可以使用面向对象编程与关系型数据库进行交互。
你将使用 SQLAlchemy,因为它与 pandas 的集成非常好,多个 pandas SQL 读写函数依赖于 SQLAlchemy 作为抽象层。SQLAlchemy 会为任何 pandas 的 SQL 读写请求做幕后翻译。这种翻译确保了 pandas 中的 SQL 语句会以适用于底层数据库类型(如 MySQL、Oracle、SQL Server 或 PostgreSQL 等)的正确语法/格式表示。
一些依赖于 SQLAlchemy 的 pandas SQL 读取函数包括 pandas.read_sql、pandas.read_sql_query 和 pandas.read_sql_table。让我们执行以下步骤:
- 开始时导入必要的库。注意,在幕后,SQLAlchemy 将使用 psycopg(或任何其他已安装且 SQLAlchemy 支持的数据库驱动程序):
import pandas as pd
from sqlalchemy import create_engine
engine =\
create_engine("postgresql+psycopg://postgres:password@localhost:5432/postgres")
query = "SELECT * FROM msft"
df = pd.read_sql(query,
engine,
index_col='date',
parse_dates={'date': '%Y-%m-%d'})
print(df.tail(3))
>>
open high low close volume
date
2024-08-30 415.60 417.49 412.13 417.14 24308300
2024-09-03 417.91 419.88 407.03 409.44 20285900
2024-09-04 405.63 411.24 404.37 408.84 9167942
在前面的示例中,对于 parse_dates,你传递了一个字典格式的参数 {key: value},其中 key 是列名,value 是日期格式的字符串表示。与之前的 psycopg 方法不同,pandas.read_sql 更好地处理了数据类型的正确性。注意,我们的索引是 DatetimeIndex 类型:
df.info()
>>
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 1259 entries, 2019-09-04 to 2024-09-04
Data columns (total 5 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 open 1259 non-null float64
1 high 1259 non-null float64
2 low 1259 non-null float64
3 close 1259 non-null float64
4 volume 1259 non-null int64
dtypes: float64(4), int64(1)
memory usage: 59.0 KB
- 你也可以使用
pandas.read_sql_query来完成相同的操作:
df = pd.read_sql_query(query,
engine,
index_col='date',
parse_dates={'date':'%Y-%m-%d'})
pandas.read_sql_table是 pandas 提供的另一个 SQL 读取函数,它接受表名而不是 SQL 查询。可以把它看作是一个SELECT * FROM tablename查询:
df = pd.read_sql_table('msft',
engine,
index_col='date',
parse_dates={'date':'%Y-%m-%d'})
read_sql读取函数更为通用,因为它是read_sql_query和read_sql_table的封装器。read_sql函数既可以接受 SQL 查询,也可以接受表名。
它是如何工作的……
在本教程中,你探索了两种连接 PostgreSQL 数据库的方法:直接使用 psycopg 驱动程序或利用 pandas 和 SQLAlchemy。
使用psycopg连接 PostgreSQL 时,首先需要创建一个连接对象,然后创建一个游标对象。连接对象和游标的概念在 Python 中的不同数据库驱动程序之间是一致的。创建游标对象后,可以访问多个方法,包括:
-
Execute()– 执行 SQL 查询(CRUD)或命令到数据库 -
Executemany()– 使用一系列输入数据执行相同的数据库操作,例如,这对于批量插入的INSERT INTO操作非常有用。 -
Fetchall()– 返回当前查询结果集中的所有剩余记录 -
Fetchone()- 从当前查询结果集中返回下一条记录(单条记录) -
fetchmany(n)– 从当前查询结果集中返回n条记录 -
close()- 关闭当前游标并释放关联的资源
另一方面,创建 engine 对象是使用 SQLAlchemy 时的第一步,因为它提供了关于正在使用的数据库的说明,这被称为 dialect(方言)。
当你使用 create_engine 创建引擎时,你传递了一个作为连接字符串的 URL。让我们检查一下 SQLAlchemy 的引擎连接字符串:
create_engine("dialect+driver://username:password@host:port/database")
-
dialect– SQLAlchemy 方言(数据库类型)的名称,例如 postgresql、mysql、sqlite、oracle 或 mssql。 -
driver– 连接指定方言的已安装驱动程序(DBAPI)的名称,例如,PostgreSQL 数据库的psycopg或pg8000。 -
username- 数据库身份验证的登录用户名 -
password- 为指定的用户名设置的密码 -
host- 数据库所在的服务器 -
port- 数据库的特定端口 -
database- 你要连接的具体数据库的名称
之前,你使用 psycopg 作为 PostgreSQL 的数据库驱动程序。psycopg 驱动程序被称为 数据库 API (DBAPI),SQLAlchemy 支持多种基于 Python DBAPI 规范的 DBAPI 包装器,用于连接和与各种类型的关系型数据库进行交互。SQLAlchemy 已经内置了多种方言,以便与不同类型的关系数据库管理系统(RDBMS)协作,具体包括:
-
SQL Server
-
SQLite
-
PostgreSQL
-
MySQL
-
Oracle
-
Snowflake
当使用 SQLAlchemy 连接到数据库时,你需要指定要使用的 方言 和 驱动程序(DBAPI)。这是 PostgreSQL 的 URL 字符串格式:
create_engine("postgresql+psycopg2://username:password@localhost:5432/dbname")
如果你使用 MySQL 数据库并配合 PyMySQL 驱动,连接 URL 将如下所示:
create_engine("mysql+pymysql://username:password@localhost:3306/dbname")
在前面 如何做…… 部分的代码示例中,你无需指定 psycopg 驱动程序,因为它是 SQLAlchemy 默认使用的 DBAPI。假设已经安装了 psycopg,此示例将可以正常工作:
create_engine("postgresql://username:password@localhost:5432/dbname")
SQLAlchemy 支持其他 PostgreSQL 驱动程序(DBAPI),包括以下内容:
-
psycopg -
pg8000 -
asyncpg
如需查看更全面的支持的方言和驱动程序列表,你可以访问官方文档页面 docs.sqlalchemy.org/en/20/dialects/index.html。
使用 SQLAlchemy 的优势在于,它与 pandas 集成良好。如果你查阅官方 pandas 文档中的 read_sql、read_sql_query、read_sql_table 和 to_sql,你会注意到 con 参数期望一个 SQLAlchemy 连接对象(引擎)。
另一个优势是,你可以轻松更换后端引擎(数据库),例如从 PostgreSQL 切换到 MySQL,而无需修改其余代码。
还有更多……
在本节中,我们将探讨一些附加概念,帮助你更好地理解 SQLAlchemy 的多功能性,并将第二章 处理大型数据文件 中介绍的一些概念与本教程结合起来,这些概念讨论了如何从文件中读取时间序列数据。
具体来说,我们将讨论以下内容:
-
在 SQLAlchemy 中生成连接 URL
-
扩展到 Amazon Redshift 数据库
-
pandas 中的分块处理
在 SQLAlchemy 中生成连接 URL
在这个教程中,你已经学习了 SQLAlchemy 库中的 create_engine 函数,它通过 URL 字符串来建立数据库连接。到目前为止,你一直是手动创建 URL 字符串,但有一种更方便的方式可以为你生成 URL。这可以通过 SQLAlchemy 中 URL 类的 create 方法来实现。
以下代码演示了这一点:
from sqlalchemy import URL, create_engine
url = URL.create(
drivername='postgresql+psycopg',
host= '127.0.0.1',
username='postgres',
password='password',
database='postgres',
port= 5432
)
>>
postgresql+psycopg://postgres:***@127.0.0.1:5432/postgres
请注意,drivername 包含 方言 和 驱动程序,格式为 dialct+driver。
现在,你可以像以前一样将 url 传递给 create_engine。
engine = create_engine(url)
df = pd.read_sql('select * from msft;', engine)
扩展到 Amazon Redshift 数据库
我们讨论了 SQLAlchemy 的多功能性,它允许你更改数据库引擎(后端数据库),而保持其余代码不变。例如,使用 PostgreSQL 或 MySQL,或者 SQLAlchemy 支持的任何其他方言。我们还将探讨如何连接到像 Amazon Redshift 这样的云数据仓库。
值得一提的是,Amazon Redshift是一个基于 PostgreSQL 的云数据仓库。你将安装适用于 SQLAlchemy 的 Amazon Redshift 驱动程序(它使用 psycopg2 DBAPI)。
你也可以使用conda进行安装:
conda install conda-forge::sqlalchemy-redshift
你也可以使用pip进行安装:
pip install sqlalchemy-redshift
因为我们不希望在代码中暴露你的 AWS 凭证,你将更新在技术要求部分讨论的database.cfg文件,以包含你的 AWS Redshift 信息:
[AWS]
host=<your_end_point.your_region.redshift.amazonaws.com>
port=5439
database=dev
username=<your_username>
password=<your_password>
你将使用configparser加载你的值:
from configparser import ConfigParser
config = ConfigParser()
config.read('database.cfg')
config.sections()
params = dict(config['AWS'])
你将使用 URL.create 方法来生成你的 URL:
url = URL.create('redshift+psycopg2', **params)
aws_engine = create_engine(url)
现在,你可以将之前代码中的引擎切换,从指向本地 PostgreSQL 实例的引擎,改为在 Amazon Redshift 上运行相同的查询。这假设你在 Amazon Redshift 中有一个msft表。
df = pd.read_sql(query,
aws_engine,
index_col='date',
parse_dates=True)
要了解更多关于sqlalchemy-redshift的信息,可以访问该项目的仓库:github.com/sqlalchemy-redshift/sqlalchemy-redshift。
Amazon Redshift 的例子可以扩展到其他数据库,例如 Google BigQuery、Teradata 或 Microsoft SQL Server,只要这些数据库有支持的 SQLAlchemy 方言即可。要查看完整的列表,请访问官方页面:
docs.sqlalchemy.org/en/20/dialects/index.html
使用 pandas 进行 chunking
当你执行针对msft表的查询时,它返回了 1259 条记录。试想一下,如果处理一个更大的数据库,可能会返回数百万条记录,甚至更多。这就是chunking参数派上用场的地方。
chunksize参数允许你将一个大的数据集拆分成较小的、更易于管理的数据块,这些数据块能够适应本地内存。当执行read_sql函数时,只需将要检索的行数(每个数据块)传递给chunksize参数,之后它会返回一个generator对象。你可以循环遍历这个生成器对象,或者使用next()一次获取一个数据块,并进行所需的计算或处理。让我们看一个如何实现 chunking 的例子。你将每次请求500条记录(行):
df_gen = pd.read_sql(query,
engine,
index_col='date',
parse_dates=True,
chunksize=500)
上面的代码将生成三个(3)数据块。你可以像下面这样遍历df_gen生成器对象:
for idx, data in enumerate(df_gen):
print(idx, data.shape)
>>
0 (500, 5)
1 (500, 5)
2 (259, 5)
上面的代码展示了 chunking 如何工作。使用chunksize参数应该减少内存使用,因为每次加载的行数较少。
另见:
若要获取关于这些主题的更多信息,请查看以下链接:
-
对于SQLAlchemy,你可以访问
www.sqlalchemy.org/ -
关于
pandas.read_sql函数,请访问pandas.pydata.org/docs/reference/api/pandas.read_sql_table.html -
关于
pandas.read_sql_query函数,请访问pandas.pydata.org/docs/reference/api/pandas.read_sql_query.html -
关于
pandas.read_sql_table函数,请访问pandas.pydata.org/docs/reference/api/pandas.read_sql_table.html
从 Snowflake 读取数据
一个非常常见的数据分析提取来源通常是公司的数据仓库。数据仓库托管了大量的数据,这些数据大多是集成的,用来支持各种报告和分析需求,此外还包含来自不同源系统的历史数据。
云计算的发展为我们带来了云数据仓库,如Amazon Redshift、Google BigQuery、Azure SQL Data Warehouse和Snowflake。
在这个教程中,你将使用Snowflake,一个强大的软件即服务(SaaS)基于云的数据仓库平台,可以托管在不同的云平台上,例如Amazon Web Services(AWS)、Google Cloud Platform(GCP)和Microsoft Azure。你将学习如何使用 Python 连接到 Snowflake,提取时间序列数据,并将其加载到 pandas DataFrame 中。
准备工作
这个教程假设你有访问 Snowflake 的权限。你将探索三种(3)不同的方法来连接 Snowflake,因此你需要安装三种(3)不同的库。
推荐的雪花连接器-python库安装方法是使用pip,这样可以让你安装像pandas这样的附加组件,如下所示:
pip install snowflake-sqlalchemy snowflake-snowpark-python
pip install "snowflake-connector-python[pandas]"
你也可以使用conda进行安装,但如果你想要将snowflake-connector-python与 pandas 一起使用,你需要使用 pip 安装。
conda install -c conda-forge snowflake-sqlalchemy snowflake-snowpark-python
conda install -c conda-froge snowflake-connector-python
确保你在技术要求部分创建的配置文件database.cfg包含了你的Snowflake连接信息:
[SNOWFLAKE]
user=username
password=password
account=snowflakeaccount
warehouse=COMPUTE_WH
database=SNOWFLAKE_SAMPLE_DATA
schema=TPCH_SF1
role=somerole
在这个教程中,你将使用SNOWFLAKE_SAMPLE_DATA数据库和 Snowflake 提供的TPCH_SF1模式。
获取正确的
account值可能会让许多人感到困惑。为了确保你获得正确的格式,请使用 Snowflake 中的复制帐户 URL选项,它可能像这样https://abc1234.us-east-1.snowflakecomputing.com,其中abc1234.us-east-1部分是你将用作account值的部分。
如何操作...
我们将探索三种(3)方法和库来连接到 Snowflake 数据库。在第一种方法中,你将使用 Snowflake Python 连接器建立连接,并创建一个游标来查询和提取数据。在第二种方法中,你将使用 Snowflake SQLAlchemy。在第三种方法中,你将探索Snowpark Python API。让我们开始吧:
使用 snowflake-connector-python
- 我们将从导入必要的库开始:
import pandas as pd
from snowflake import connector
from configparser import ConfigParser
- 使用
ConfigParser,你将提取[SNOWFLAKE]部分下的内容,以避免暴露或硬编码你的凭据。你可以读取[SNOWFLAKE]部分的所有内容并将其转换为字典对象,如下所示:
config = ConfigParser()
config.read(database.cfg')
params = dict(config['SNOWFLAKE'])
- 你需要将参数传递给
connector.connect()来与 Snowflake 建立连接。我们可以轻松地 解包 字典内容,因为字典的键与参数名匹配。一旦连接建立,我们可以创建我们的 游标:
con = connector.connect(**params)
cursor = con.cursor()
- 游标对象有许多方法,如
execute、fetchall、fetchmany、fetchone、fetch_pandas_all和fetch_pandas_batches。
你将从 execute 方法开始,向数据库传递 SQL 查询,然后使用任何可用的获取方法来检索数据。在以下示例中,你将查询 ORDERS 表,然后利用 fetch_pandas_all 方法将整个结果集作为 pandas DataFrame 检索:
query = "SELECT * FROM ORDERS;"
cursor.execute(query)
df = cursor.fetch_pandas_all()
之前的代码可以写成如下:
df = cursor.execute(query).fetch_pandas_all()
- 使用
df.info()检查 DataFrame:
df.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1500000 entries, 0 to 1499999
Data columns (total 9 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 O_ORDERKEY 1500000 non-null int32
1 O_CUSTKEY 1500000 non-null int32
2 O_ORDERSTATUS 1500000 non-null object
3 O_TOTALPRICE 1500000 non-null float64
4 O_ORDERDATE 1500000 non-null object
5 O_ORDERPRIORITY 1500000 non-null object
6 O_CLERK 1500000 non-null object
7 O_SHIPPRIORITY 1500000 non-null int8
8 O_COMMENT 1500000 non-null object
dtypes: float64(1), int32(2), int8(1), object(5)
memory usage: 81.5+ MB
- 从前面的输出中可以看到,DataFrame 的索引仅是一个数字序列,且
O_ORDERDATE列不是一个Date类型的字段。你可以使用pandas.to_datetime()函数将O_ORDERDATE列解析为日期时间类型,然后使用DataFrame.set_index()方法将该列设置为 DataFrame 的索引:
df_ts = (
df.set_index(
pd.to_datetime(df['O_ORDERDATE'])
)
.drop(columns='O_ORDERDATE'))
让我们显示 df_ts DataFrame 的前四(4)列和前五(5)行:
print(df_ts.iloc[0:3, 1:5])
>>
O_CUSTKEY O_ORDERSTATUS O_TOTALPRICE O_ORDERPRIORITY
O_ORDERDATE
1994-02-21 13726 F 99406.41 3-MEDIUM
1997-04-14 129376 O 256838.41 4-NOT SPECIFIED
1997-11-24 141613 O 150849.49 4-NOT SPECIFIED
- 检查 DataFrame 的索引。打印前两个索引:
df_ts.index[0:2]
>>
DatetimeIndex(['1994-02-21', '1997-04-14'], dtype='datetime64[ns]', name='O_ORDERDATE', freq=None)
- 最后,你可以关闭游标和当前连接。
Cursor.close()
con.close()
现在你拥有一个具有DatetimeIndex的时间序列 DataFrame。
使用 SQLAlchemy
在之前的例子中,从关系数据库读取数据,你探索了 pandas 的 read_sql、read_sql_query 和 read_sql_table 函数。这是通过使用 SQLAlchemy 和安装一个支持的方言来完成的。在这里,我们将在安装 snowflake-sqlalchemy 驱动程序后使用 Snowflake 方言。
SQLAlchemy 与 pandas 更好地集成,正如你将在本节中体验的那样。
- 开始时,导入必要的库,并从
database.cfg文件中的[SNOWFLAKE]部分读取 Snowflake 连接参数。
from sqlalchemy import create_engine
from snowflake.sqlalchemy import URL
import configparser
config = ConfigParser()
config.read('database.cfg')
params = dict(config['SNOWFLAKE'])
- 你将使用 URL 类来生成 URL 连接字符串。我们将创建我们的引擎对象,然后使用
engine.connect()打开连接:
url = URL(**params)
engine = create_engine(url)
connection = engine.connect()
- 现在,你可以使用
read_sql或read_sql_query来对 SNOWFLAKE_SAMPLE_DATA 数据库中的ORDERS表执行查询:
query = "SELECT * FROM ORDERS;"
df = pd.read_sql(query,
connection,
index_col='o_orderdate',
parse_dates='o_orderdate')
df.info()
>>
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 1500000 entries, 1992-04-22 to 1994-03-19
Data columns (total 8 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 o_orderkey 1500000 non-null int64
1 o_custkey 1500000 non-null int64
2 o_orderstatus 1500000 non-null object
3 o_totalprice 1500000 non-null float64
4 o_orderpriority 1500000 non-null object
5 o_clerk 1500000 non-null object
6 o_shippriority 1500000 non-null int64
7 o_comment 1500000 non-null object
dtypes: float64(1), int64(3), object(4)
memory usage: 103.0+ MB
注意与之前使用 Snowflake Python 连接器的方法相比,我们是如何在一个步骤中解析 o_orderdate 列并将其设置为索引的。
- 最后,关闭数据库连接。
connection.close()
engine.dispose()
通过使用 上下文管理器,可以进一步简化代码,以自动分配和释放资源。以下示例使用了 with engine.connect():
query = "SELECT * FROM ORDERS;"
url = URL(**params)
engine = create_engine(url)
with engine.connect() as connection:
df = pd.read_sql(query,
connection,
index_col='o_orderdate',
parse_dates=['o_orderdate'])
df.info()
这样应该能够得到相同的结果,而无需关闭连接或处理引擎。
使用 snowflake-snowpark-python
Snowpark API 支持 Java、Python 和 Scala。你已经按照本配方中 准备工作 部分的描述安装了 snowflake-snowpark-python。
- 从导入必要的库并从
database.cfg文件的[SNOWFLAKE]部分读取 Snowflake 连接参数开始
from snowflake.snowpark import Session
from configparser import ConfigParser
config = ConfigParser()
config.read('database.cfg')
params = dict(config['SNOWFLAKE'])
- 通过与 Snowflake 数据库建立连接来创建会话
session = Session.builder.configs(params).create()
- 会话有多个
DataFrameReader方法,如read、table和sql。这些方法中的任何一个都会返回一个 Snowpark DataFrame 对象。返回的 Snowpark DataFrame 对象可以使用to_pandas方法转换为更常见的 pandas DataFrame。你将探索read、table和sql方法,以返回相同的结果集。
从 read 方法开始。更具体地说,你将使用 read.table 并传入一个表名。这将返回该表的内容并通过 to_pandas 方法转换为 pandas DataFrame。可以将其视为等同于 SELECT * FROM TABLE。
orders = session.read.table("ORDERS").to_pandas()
类似地,table 方法接受一个表名,返回的对象(Snowpark DataFrame)可以使用 to_pandas 方法:
orders = session.table("ORDERS").to_pandas()
最后,sql 方法接受一个 SQL 查询:
query = 'SELECT * FROM ORDERS'
orders = session.sql(query).to_pandas()
orders.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1500000 entries, 0 to 1499999
Data columns (total 9 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 O_ORDERKEY 1500000 non-null int32
1 O_CUSTKEY 1500000 non-null int32
2 O_ORDERSTATUS 1500000 non-null object
3 O_TOTALPRICE 1500000 non-null float64
4 O_ORDERDATE 1500000 non-null object
5 O_ORDERPRIORITY 1500000 non-null object
6 O_CLERK 1500000 non-null object
7 O_SHIPPRIORITY 1500000 non-null int8
8 O_COMMENT 1500000 non-null object
dtypes: float64(1), int32(2), int8(1), object(5)
memory usage: 81.5+ MB
这三种方法应该产生相同的 pandas DataFrame。
它是如何工作的...
Snowflake Python 连接器、Snowflake SQLAlchemy 驱动程序和 Snowpark Python 需要相同的输入变量来建立与 Snowflake 数据库的连接。这些包括以下内容:

表 3.1 – Snowflake Python 连接器的输入变量
回想一下,在前一个活动中,你在 database.cfg 文件的 [SNOWFLAKE] 部分为所有三种方法使用了相同的配置。
Snowflake Python 连接器
当使用 Python 连接器时,你首先通过 con = connector.connect(**params) 来建立与数据库的连接。一旦连接被接受,你使用 cursor = con.cursor() 创建一个游标对象。
游标提供了执行和获取操作的方法,如 describe()、execute()、execute_async()、executemany()、fetchone()、fetchall()、fetchmany()、fetch_pandas_all() 和 fetch_pandas_batches(),每个游标还具有若干属性,包括 description、rowcount、rownumber 等。注意,当使用 Python 连接器 psycopg 时,之前的配方 从关系数据库读取数据 中讨论了熟悉的方法和属性。
-
Execute()– 执行 SQL 查询(CRUD)或命令到数据库 -
executemany()– 使用一系列输入数据执行相同的数据库操作,例如,这在使用 INSERT INTO 进行批量插入时非常有用。 -
Fetchall()– 返回当前查询结果集中的所有剩余记录 -
fetchone()- 从当前查询结果集中返回下一条记录(一条记录) -
fetchmany(n)– 从当前查询结果集中返回n条记录 -
fetch_pandas_all()- 返回当前查询结果集中的所有剩余记录,并将它们加载到 pandas DataFrame 中 -
fetch_pandas_batches()- 返回当前查询结果集中的一部分剩余记录,并将它们加载到 pandas DataFrame 中 -
close()- 关闭当前游标并释放相关资源 -
describe()– 返回结果集的元数据,但不会执行查询。或者,你可以使用execute(),然后使用description属性来获取相同的元数据信息。
要查看完整的属性和方法列表,请参考官方文档:docs.snowflake.com/en/user-guide/python-connector-api.html#object-cursor。
SQLAlchemy API
当使用 SQLAlchemy 时,你可以利用pandas.read_sql、pandas.read_sql_query和pandas.read_sql_query读取函数,并利用许多可用参数在读取时转换和处理数据,如index_col和parse_dates。另一方面,当使用 Snowflake Python 连接器时,fetch_pandas_all()函数不接受任何参数,你需要在之后解析和调整 DataFrame。
Snowflake SQLAlchemy 库提供了一个方便的方法URL,帮助构建连接字符串以连接到 Snowflake 数据库。通常,SQLAlchemy 期望提供以下格式的 URL:
'snowflake://<user>:<password>@<account>/<database>/<schema>
?warehouse=<warehouse>&role=<role>'
使用URL方法,我们传递了参数,方法会自动构造所需的连接字符串:
engine = create_engine(URL(
account = '<your_account>',
user = '<your_username>',
password = '<your_password>',
database = '<your_database>',
schema = '<your_schema>',
warehouse = '<your_warehouse>',
role='<your_role>',
))
或者,我们将 Snowflake 参数存储在配置文件 database.cfg 中,并以 Python 字典的形式存储。这样,你就不会在代码中暴露你的凭证。
params = dict(config['SNOWFLAKE'])
url = create_engine(URL(**params))
如果你比较本食谱中的过程,使用SQLAlchemy连接 Snowflake,与之前食谱中的从关系数据库读取数据过程,你会发现两者在过程和代码上有许多相似之处。这就是使用 SQLAlchemy 的优势之一,它为各种数据库创建了一个标准流程,只要 SQLAlchemy 支持这些数据库。SQLAlchemy 与 pandas 集成得很好,可以轻松地切换方言(后端数据库),而无需对代码做太多修改。
Snowpark API
在之前的方法中,你只是使用了snowflake-connector-python和snowflake-connector-python库作为连接器连接到你的 Snowflake 数据库,然后提取数据以在本地处理。
Snowpark 不仅仅提供了一种连接数据库的机制。它允许您直接在 Snowflake 云环境中处理数据,而无需将数据移出或在本地处理。此外,Snowpark 还非常适合执行更复杂的任务,如构建复杂的数据管道或使用 Snowpark ML 处理机器学习模型,所有这些都在 Snowflake 云中完成。
在我们的方案中,类似于其他方法,我们需要与 Snowflake 建立连接。这是通过使用Session类来实现的。
params = dict(config['SNOWFLAKE'])
session = Session.builder.configs(params).create()
Snowpark 和 PySpark(Spark)在 API 和概念上有很多相似之处。更具体地说,Snowpark DataFrame 被视为延迟求值的关系数据集。to_pandas方法执行了两件事:它执行查询并将结果加载到 pandas DataFrame 中(数据会被从 Snowflake 外部提取)。要将 pandas DataFrame 转换回 Snowpark DataFrame(在 Snowflake 内部),您可以使用如下的create_dataframe方法:
df = session.create_dataframe(orders)
为了成功执行前面的代码,您需要具有写权限,因为 Snowflake 会创建一个临时表来存储 pandas DataFrame(在 Snowflake 中),然后返回一个指向该临时表的 Snowpark DataFrame。或者,如果您希望将 pandas DataFrame 持久化到一个表中,您可以使用如下的write_pandas方法:
df = session.write_pandas(orders, table_name='temp_table')
在前面的代码中,您传递了 pandas DataFrame 和表名。
还有更多...
您可能已经注意到,当使用 Snowflake Python 连接器和 Snowpark 时,返回的 DataFrame 中的列名全部以大写字母显示,而在使用 Snowflake SQLAlchemy 时,它们则是小写的。
之所以如此,是因为 Snowflake 默认在创建对象时将未加引号的对象名称存储为大写。例如,在之前的代码中,我们的Order Date列被返回为O_ORDERDATE。
为了明确指出名称是区分大小写的,您在创建 Snowflake 对象时需要使用引号(例如,'o_orderdate' 或 'OrderDate')。相对而言,使用 Snowflake SQLAlchemy 时,默认会将名称转换为小写。
另见
-
有关Snowflake Python 连接器的更多信息,您可以访问官方文档:
docs.snowflake.com/en/user-guide/python-connector.html -
有关Snowflake SQLAlchemy的更多信息,您可以访问官方文档:
docs.snowflake.com/en/user-guide/sqlalchemy.html -
有关Snowpark API的更多信息,您可以访问官方文档:
docs.snowflake.com/developer-guide/snowpark/reference/python/latest/snowpark/index
从文档数据库读取数据
MongoDB 是一个NoSQL 数据库,使用文档存储数据,并使用 BSON(一种类似 JSON 的结构)来存储无模式的数据。与关系型数据库不同,关系型数据库中的数据存储在由行和列组成的表中,而面向文档的数据库则将数据存储在集合和文档中。
文档代表存储数据的最低粒度,就像关系型数据库中的行一样。集合像关系型数据库中的表一样存储文档。与关系型数据库不同,集合可以存储不同模式和结构的文档。
准备工作
在本教程中,假设你已经有一个正在运行的 MongoDB 实例。为准备此教程,你需要安装 PyMongo Python 库来连接 MongoDB。
要使用 conda 安装 MongoDB,请运行以下命令:
conda install -c conda-forge pymongo -y
要使用 pip 安装 MongoDB,请运行以下命令:
python -m pip install pymongo
如果你无法访问 PostgreSQL 数据库,最快的启动方式是通过 Docker (hub.docker.com/_/mongo)。以下是一个示例命令:
docker run -d \
--name mongo-ch3 \
-p 27017:27017 \
--env MARIADB_ROOT_PASSWORD=password \
mongo:8.0-rc
另外,你可以尝试免费使用MongoDB Atlas,访问 www.mongodb.com/products/platform/atlas-database。MongoDB Atlas 是一个完全托管的云数据库,可以部署在你喜欢的云提供商上,如 AWS、Azure 和 GCP。
关于使用 MongoDB Atlas 的注意事项
如果你连接的是 MongoDB Atlas(云)免费版或其 M2/M5 共享版集群,则需要使用
mongodb+srv协议。在这种情况下,你可以在 pip 安装时指定python -m pip install "pymongo[srv]"
可选地,如果你希望通过图形界面访问 MongoDB,可以从这里安装MongoDB Compass www.mongodb.com/products/tools/compass
我正在使用 MongoDB Compass 来创建数据库、集合并加载数据。在第五章,将时间序列数据持久化到数据库,你将学习如何使用 Python 创建数据库、集合并加载数据。
使用Compass选择创建数据库选项。对于数据库名称,可以输入 stock_data,对于集合名称,可以输入 microsoft。勾选时间序列复选框,并将 date 设置为时间字段。

图 3.4 – MongoDB Compass 创建数据库界面
一旦数据库和集合创建完成,点击导入数据并选择 datasets/Ch3/MSFT.csv 文件夹中的 MSFT 股票数据集。

图 3.5 – MongoDB Compass 导入数据界面

图 3.6 – MongoDB Compass 在导入前审核数据类型界面
最终页面确认数据类型。最后,点击导入。
如何操作……
在本教程中,你将连接到你已设置的 MongoDB 实例。如果你使用的是本地安装(本地安装或 Docker 容器),则连接字符串可能类似于mongodb://<username>:<password>@<host>:<port>/<DatabaseName>。如果你使用的是 Atlas,连接字符串可能更像是mongodb+srv://<username>:<password>@<clusterName>.mongodb.net/<DatabaseName>?retryWrites=true&w=majority。
执行以下步骤:
- 首先,让我们导入必要的库:
import pandas as pd
from pymongo import MongoClient, uri_parser
建立与 MongoDB 的连接。对于自托管实例,例如本地安装,连接字符串可能是这样的:
# connecting to a self-hosted instance
url = "mongodb://127.0.0.1:27017"
client = MongoClient(url)
>>
MongoClient(host=['localhost:27017'], document_class=dict, tz_aware=False, connect=True)
这等同于以下内容:
client = MongoClient(host=['127.0.0.1:27017'],
password=None,
username=None,
document_class=dict,
tz_aware=False,
connect=True)
如果你的自托管 MongoDB 实例具有用户名和密码,你必须提供这些信息。
uri_parser是一个有用的实用函数,允许你验证MongoDB 的 URL,如下所示:
uri_parser.parse_uri("mongodb://127.0.0.1:27107")
>>
{'nodelist': [('127.0.0.1', 27107)],
'username': None,
'password': None,
'database': None,
'collection': None,
'options': {},
'fqdn': None}
如果你连接的是MongoDB Atlas,那么你的连接字符串看起来应该像这样:
# connecting to Atlas cloud Cluster
cluster = 'cluster0'
username = 'user'
password = 'password'
database = 'stock_data'
url = \
f"mongodb+srv://{username}:{password}@{cluster}.3rncb.mongodb.net/{database}"
client = MongoClient(url)
client
>>
MongoClient(host=['cluster0-shard-00-00.3rncb.mongodb.net:27017', 'cluster0-shard-00-01.3rncb.mongodb.net:27017', 'cluster0-shard-00-02.3rncb.mongodb.net:27017'], document_class=dict, tz_aware=False, connect=True, authsource='somesource', replicaset='Cluster0-shard-0', ssl=True)
在本章之前的教程中,我们使用了一个配置文件,例如database.cfg文件,用来存储我们的连接信息并隐藏凭证。你也应该遵循这个建议。
如果你的用户名或密码包含特殊字符,包括空格字符(
:/?#[]@!$&'()* ,;=%),你需要对它们进行编码。你可以使用urllibPython 库中的quote_plus()函数进行百分号编码(百分号转义)。这是一个示例:
username = urllib.parse.quote_plus('user!*@')
password = urllib.parse.quote_plus('pass/w@rd')更多信息,请阅读这里的文档
- 一旦连接成功,你可以列出所有可用的数据库。在此示例中,我将数据库命名为
stock_data,集合命名为microsoft:
client.list_database_names()
>>
['admin', 'config', 'local', 'stock_data']
- 你可以使用
list_collection_names列出stock_data数据库下可用的集合:
db = client['stock_data']
db.list_collection_names()
>>
['microsoft', 'system.buckets.microsoft', 'system.views']
- 现在,你可以指定查询哪个集合。在这个例子中,我们感兴趣的是名为
microsoft的集合:
collection = db['microsoft']
- 现在,使用
find方法将数据库查询到一个 pandas DataFrame 中:
results = collection.find({})
msft_df = (pd.DataFrame(results)
.set_index('date')
.drop(columns='_id'))
msft_df.head()
>>
close low volume high open
date
2019-09-04 131.457260 130.358829 17995900 131.514567 131.142059
2019-09-05 133.768707 132.536556 26101800 134.083908 132.870864
2019-09-06 132.861359 132.001715 20824500 133.892908 133.749641
2019-09-09 131.352219 130.339762 25773900 133.482199 133.329371
2019-09-10 129.976837 128.477244 28903400 130.750506 130.664546
它是如何工作的……
第一步是连接到数据库,我们通过使用MongoClient创建 MongoDB 实例的客户端对象来实现。这将使你能够访问一组方法,如list_databases_names()、list_databases(),以及其他属性,如address和HOST。
MongoClient()接受一个连接字符串,该字符串应遵循 MongoDB 的 URI 格式,如下所示:
client = MongoClient("mongodb://localhost:27017")
另外,也可以通过显式提供host(字符串)和port(数字)位置参数来完成相同的操作,如下所示:
client = MongoClient('localhost', 27017)
主机字符串可以是主机名或 IP 地址,如下所示:
client = MongoClient('127.0.0.1', 27017)
请注意,要连接到使用默认端口(27017)的 localhost,你可以在不提供任何参数的情况下建立连接,如以下代码所示:
# using default values for host and port
client = MongoClient()
此外,你可以显式地提供命名参数,如下所示:
client = MongoClient(host='127.0.0.1',
port=27017,
password=password,
username=username,
document_class=dict,
tz_aware=False,
connect=True)
让我们来探讨这些参数:
-
host– 这可以是主机名、IP 地址或 MongoDB URI。它也可以是一个包含主机名的 Python 列表。 -
password– 你分配的密码。请参阅 Getting Ready 部分中关于特殊字符的说明。 -
username– 你分配的用户名。请参阅 Getting Ready 部分中关于特殊字符的说明。 -
document_class– 指定用于查询结果中文档的类。默认值是dict。 -
tz_aware– 指定 datetime 实例是否是时区感知的。默认值为False,意味着它们是“天真”的(不具备时区感知)。 -
connect– 是否立即连接到 MongoDB 实例。默认值是True。
若需要更多参数,你可以参考官方文档页面 pymongo.readthedocs.io/en/stable/api/pymongo/mongo_client.html。
一旦与 MongoDB 实例建立连接,你可以指定使用的数据库,列出其集合,并查询任何可用的集合。在可以查询和检索文档之前的整体流程是:指定 数据库,选择你感兴趣的 集合,然后提交 查询。
在前面的示例中,我们的数据库名为 stock_data,其中包含一个名为 microsoft 的集合。一个数据库可以包含多个集合,而一个集合可以包含多个文档。如果从关系型数据库的角度考虑,集合就像是表格,而文档代表表格中的行。
在 PyMongo 中,你可以使用不同的语法来指定数据库,如以下代码所示。请记住,所有这些语句都会生成一个 pymongo.database.Database 对象:
# Specifying the database
db = client['stock_data']
db = client.stock_data
db = client.get_database('stock_data')
在前面的代码中,get_database() 可以接受额外的参数,如 codec_options、read_preference、write_concern 和 read_concern,其中后两个参数更关注节点间的操作以及如何确定操作是否成功。
同样地,一旦你拥有了 PyMongo 数据库对象,你可以使用不同的语法来指定集合,如以下示例所示:
# Specifying the collection
collection = db.microsoft
collection = db['microsoft']
collection = db.get_collection('microsoft')
get_collection() 方法提供了额外的参数,类似于 get_database()。
前面的三个语法变体返回一个 pymongo.database.Collection 对象,它带有额外的内建方法和属性,如 find、find_one、find_one_and_delete、find_one_and_replace、find_one_and_update、update、update_one、update_many、delete_one 和 delete_many 等。
让我们探索不同的检索集合方法:
-
find()– 基于提交的查询从集合中检索多个文档。 -
find_one()– 基于提交的查询,从集合中检索单个文档。如果多个文档匹配,则返回第一个匹配的文档。 -
find_one_and_delete()– 查找单个文档,类似于find_one,但它会从集合中删除该文档,并返回删除的文档。 -
find_one_and_replace()- 查找单个文档并用新文档替换它,返回原始文档或替换后的文档。 -
find_one_and_update()- 查找单个文档并更新它,返回原始文档或更新后的文档。与find_one_and_replace不同,它是更新现有文档,而不是替换整个文档。
一旦你到达集合级别,你可以开始查询数据。在本示例中,你使用了find(),它类似于 SQL 中的SELECT语句。
在如何实现…部分的步骤 5中,你查询了整个集合,通过以下代码检索所有文档:
collection.find({})
空字典{}在find()中表示我们的过滤条件。当你传递一个空的过滤条件{}时,实际上是检索所有数据。这类似于 SQL 数据库中的SELECT *。另外,你也可以使用collection.find()来检索所有文档。
要在 MongoDB 中查询文档,你需要熟悉 MongoDB 查询语言(MQL)。通常,你会编写查询并将其传递给find方法,find方法类似于一个过滤器。
查询或过滤器采用键值对来返回匹配指定值的文档。以下是一个示例查询,查找收盘价大于 130 的股票:
query = {"close": {"$gt": 130}}
results = collection.find(query)
结果对象实际上是一个游标,它还没有包含结果集。你可以遍历游标或将其转换为 DataFrame。通常,当执行collection.find()时,它返回一个游标(更具体地说,是一个pymongo.cursor.Cursor对象)。这个游标对象只是查询结果集的指针,允许你遍历结果。你可以使用for循环或next()方法(可以类比为 Python 中的迭代器)。然而,在这个示例中,我们没有直接循环遍历游标对象,而是将整个结果集便捷地转换成了 pandas DataFrame。
这是一个将结果集检索到 pandas DataFrame 的示例。
df = pd.DataFrame(results)
df.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1256 entries, 0 to 1255
Data columns (total 7 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 date 1256 non-null datetime64[ns]
1 close 1256 non-null float64
2 _id 1256 non-null object
3 low 1256 non-null float64
4 volume 1256 non-null int64
5 high 1256 non-null float64
6 open 1256 non-null float64
dtypes: datetime64ns, float64(4), int64(1), object(1)
memory usage: 68.8+ KB
请注意,输出中添加了_id列,这是原始MSFT.csv文件中没有的。MongoDB 自动为集合中的每个文档添加了这个唯一标识符。
在前面的代码中,查询作为过滤器,仅检索close值大于130的数据。PyMongo 允许你传递一个字典(键值对),指定要检索的字段。下面是一个示例:
query = {"close": {"$gt": 130}}
projection = {
"_id": 0,
"date":1,
"close": 1,
"volume": 1
}
results = collection.find(query, projection)
df = pd.DataFrame(results).set_index(keys='date')
print(df.head())
>>
close volume
date
2019-09-04 131.457260 17995900
2019-09-05 133.768707 26101800
2019-09-06 132.861359 20824500
2019-09-09 131.352219 25773900
2019-09-11 130.014984 24726100
在前面的代码中,我们指定了不返回_id,只返回date、close和volume字段。
最后,在我们前面的例子中,注意查询中使用的$gt。它表示“大于”,更具体地说,它翻译为“大于 130”。在 MQL 中,操作符以美元符号$开头。以下是 MQL 中常用操作符的示例列表:
-
$eq- 匹配等于指定值的值- 示例:查询
{"close": {"$eq": 130}}查找close字段值恰好为 130 的文档。
- 示例:查询
-
$gt- 匹配大于指定值的值- 示例:查询
{"close": {"$gt": 130}}查找收盘价大于 130 的文档。
- 示例:查询
-
$gte- 匹配大于或等于指定值的值- 示例:查询
{"close": {"$gte": 130}}查找收盘价大于或等于 130 的文档。
- 示例:查询
-
$lt- 匹配小于指定值的值- 示例:查询
{"close": {"$lt": 130}}查找收盘价小于 130 的文档。
- 示例:查询
-
$lte- 匹配小于或等于指定值的值- 示例:查询
{"close": {"$lt3": 130}}查找收盘价小于或等于 130 的文档。
- 示例:查询
-
$and- 使用逻辑AND操作符连接查询子句,所有条件必须为真。- 示例:查询
{"$and": [{"close": {"$gt": 130}}, {"volume": {"$lt": 20000000}}]}查找收盘价大于 130且交易量小于 20,000,000 的文档。
- 示例:查询
-
$or- 使用逻辑OR操作符连接查询子句,至少有一个条件必须为真。- 示例:查询
{"$or": [{"close": {"$gt": 135}}, {"volume": {"$gt": 30000000}}]}查找收盘价大于 135或交易量大于 30,000,000 的文档。
- 示例:查询
-
$in- 匹配数组(列表)中指定的值- 示例:查询
{"date": {"$in": [datetime.datetime(2019, 9, 4), datetime.datetime(2019, 9, 5), datetime.datetime(2019, 9, 6)]}}查找日期字段与以下指定日期之一匹配的文档:2019 年 9 月 4 日;2019 年 9 月 5 日;2019 年 9 月 6 日。
- 示例:查询
要查看 MQL 中操作符的完整列表,您可以访问官方文档:www.mongodb.com/docs/manual/reference/operator/query/
还有更多……
有多种方法可以使用PyMongo从 MongoDB 中检索数据。在前面的部分,我们使用了db.collection.find(),它总是返回一个游标。正如我们之前讨论的,find()返回的是指定集合中所有匹配的文档。如果您只想返回匹配文档的第一个实例,那么db.collection.find_one()将是最佳选择,它会返回一个字典对象,而不是游标。请记住,这只会返回一个文档,示例如下:
db.microsoft.find_one()
>>>
{'date': datetime.datetime(2019, 9, 4, 0, 0),
'close': 131.45726013183594,
'_id': ObjectId('66e30c09a07d56b6db2f446e'),
'low': 130.35882921332006,
'volume': 17995900,
'high': 131.5145667829114,
'open': 131.14205897649285}
在处理游标时,您有多种方法可以遍历数据:
- 使用
pd.DataFrame(cursor)将数据转换为 pandas DataFrame,如以下代码所示:
cursor = db.microsoft.find()
df = pd.DataFrame(cursor)
- 转换为 Python list 或 tuple:
data = list(db.microsoft.find())
您还可以将 Cursor 对象转换为 Python 列表,然后将其转换为 pandas DataFrame,像这样:
data = list(db.microsoft.find())
df = pd.DataFrame(data)
- 使用
next()将指针移动到结果集中的下一个项目:
cursor = db.microsoft.find()
cursor.next()
- 通过对象进行循环,例如,使用
for循环:
cursor = db.microsoft.find()
for doc in cursor:
print(doc)
前面的代码将遍历整个结果集。如果您想遍历前 5 条记录,可以使用以下代码:
cursor = db.microsoft.find()
for doc in cursor[0:5]:
print(doc)
- 指定 index。在这里,我们打印第一个值:
cursor = db.microsoft.find()
cursor[0]
请注意,如果提供了一个切片,例如 cursor[0:1],这是一个范围,那么它将返回一个游标对象(而不是文档)。
另见
欲了解更多有关 PyMongo API 的信息,请参考官方文档,您可以在此找到:pymongo.readthedocs.io/en/stable/index.html。
从时间序列数据库读取数据
时间序列数据库,一种 NoSQL 数据库,专为时间戳或时间序列数据进行了优化,并在处理包含 IoT 数据或传感器数据的大型数据集时提供了更好的性能。过去,时间序列数据库的常见使用场景主要与金融股票数据相关,但它们的应用领域已经扩展到其他学科和领域。在本教程中,您将探索三种流行的时间序列数据库:InfluxDB、TimescaleDB 和 TDEngine。
InfluxDB 是一个流行的开源时间序列数据库,拥有一个庞大的社区基础。在本教程中,我们将使用本文写作时的 InfluxDB 最新版本,即 2.7.10 版本。最近的 InfluxDB 版本引入了 Flux 数据脚本语言,您将通过 Python API 使用该语言查询我们的时间序列数据。
TimescaleDB 是 PostgreSQL 的扩展,专门为时间序列数据进行了优化。它利用 PostgreSQL 的强大功能和灵活性,同时提供了额外的功能,专门为高效处理带时间戳的信息而设计。使用 TimescaleDB 的一个优势是,您可以利用 SQL 查询数据。TimescaleDB 是一个开源的时间序列数据库,在本教程中,我们将使用 TimescaleDB 最新的版本 2.16.1。
TDEngine 是一个开源的时间序列数据库,专为物联网(IoT)、大数据和实时分析设计。与 TimescaleDB 类似,TDEngine 使用 SQL 查询数据。在本教程中,我们将使用 TDEngine 的最新版本 3.3.2.0。
准备工作
本食谱假设您可以访问正在运行的 InfluxDB、TimeseriesDB 或 TDEngine 实例。您将安装适当的库来使用 Python 连接和与这些数据库交互。对于InfluxDB V2,您需要安装 influxdb-client;对于TimescaleDB,您需要安装 PostgreSQL Python 库 psycopg2(回想在本章的从关系数据库读取数据食谱中,我们使用了 psycopg3);最后,对于TDEngine,您需要安装 taospy。
您可以通过以下方式使用 pip 安装这些库:
pip install 'influxdb-client[ciso]'
pip install 'taospy[ws]'
pip install psycopg2
要使用 conda 安装,请使用以下命令:
conda install -c conda-forge influxdb-client
conda install -c conda-forge taospy taospyws
conda install -c conda-forge psycopg2
如果您没有访问这些数据库的权限,那么最快速的方式是通过 Docker。以下是 InlfuxDB、TimescaleDB 和 TDEngine 的示例命令:
InfluxDB Docker 容器
要创建 InfluxDB 容器,您需要运行以下命令:
docker run -d\
--name influxdb-ch3 \
-p 8086:8086 \
influxdb:2.7.9-alpine
欲了解更多信息,您可以访问官方 Docker Hub 页面 hub.docker.com/_/influxdb
一旦influxdb-ch3容器启动并运行,您可以使用您喜欢的浏览器导航到 http://localhost:8086,并继续设置,例如用户名、密码、初始组织名称和初始存储桶名称。
对于本食谱,我们将使用国家海洋和大气管理局(NOAA)的水位样本数据,时间范围为 2019 年 8 月 17 日至 2019 年 9 月 17 日,数据来源为圣塔莫尼卡和科约特溪。
在数据浏览器 UI 中,您可以运行以下Flux查询来加载样本数据集:
import "influxdata/influxdb/sample"
sample.data(set: "noaaWater")
|> to(bucket: "tscookbook")
在之前的代码片段中,NOAA 数据集已加载到初始设置时创建的 tscookbook 存储桶中。
关于如何加载样本数据或其他提供的样本数据集的说明,请参阅 InfluxDB 官方文档 docs.influxdata.com/influxdb/v2/reference/sample-data/
TimescaleDB Docker 容器
要创建 TimescaleDB 容器,您需要运行以下命令:
docker run -d \
--name timescaledb-ch3 \
-p 5432:5432 \
-e POSTGRES_PASSWORD=password \
timescale/timescaledb:latest-pg16
欲了解更多信息,您可以访问官方 Docker Hub 页面 hub.docker.com/r/timescale/timescaledb
一旦timescaledb-ch3容器启动并运行,您可以使用与从关系数据库读取数据食谱中的准备工作部分相同的说明加载 MSFT.csv 文件。
注意,默认的用户名是 postgres,密码是您在 Docker 命令中设置的密码。

图 3. – DBeaver TimescaleDB/Postgres 连接设置(应该与图 3.1 类似)
由于 TimescaleDB 基于 PostgreSQL,它也默认使用 5432 端口。因此,如果你已经在本地运行一个默认使用 5432 端口的 PostgreSQL 数据库,你可能会遇到 TimescaleDB 的端口冲突问题。在这种情况下,你可以选择修改 Docker 运行配置并更改端口。
TDEngine Docker 容器 [待删除部分]
要创建一个 TDEngine 容器,你需要运行以下命令:
docker run -d \
--name tdengine-ch3 \
-p 6030-6060:6030-6060 \
-p 6030-6060:6030-6060/udp \
tdengine/tdengine:3.3.2.0
更多信息可以访问官方的 Docker Hub 页面 hub.docker.com/r/tdengine/tdengine
一旦 tdengine-ch3 容器启动并运行,你可以通过在容器 shell 中运行 taosBenchmark 命令来创建一个演示数据集。以下是从正在运行的容器内部访问 shell 并运行所需命令来安装和设置演示数据集的步骤:
docker exec -it tdengine-ch3 /bin/bash
>>
root@9999897cbeb4:~# taosBenchmark
一旦演示数据集创建完成,你可以退出终端。现在你可以使用 DBeaver 验证数据是否已创建。你可以使用与 从关系数据库读取数据 食谱中 准备工作 部分相同的说明。
请注意,默认用户名是 root,默认密码是 taosdata

图 3. – DBeaver TDEngine 连接设置
现在你应该能看到一个名为 test 的 数据库 被创建,并且一个名为 meters 的 超级表,它包含 10,000 个 子表,命名为 d0 到 d9999,每个表包含大约 10,000 行和四列(ts、current、voltage 和 phase)。你可能无法在 DBeaver 导航窗格中看到 meters 超级表,但如果你运行以下 SQL 查询 "SELECT COUNT(*) FROM test.meters;",它应该会输出 100,000,000 行(10,000 个子表乘以每个子表的 10,000 行)。
如何做到这一点…
本食谱将演示如何连接并与三种流行的时序数据库系统进行交互。
InfluxDB
我们将利用 Influxdb_client Python SDK 来访问 InfluxDB 2.x,它支持 pandas DataFrame 进行读取和写入功能。让我们开始吧:
- 首先,让我们导入必要的库:
from influxdb_client import InfluxDBClient
import pandas as pd
- 要使用
InfluxDBClient(url="http://localhost:8086", token=token)建立连接,你需要定义token、org和bucket变量:
token = "c5c0JUoz-\
joisPCttI6hy8aLccEyaflyfNj1S_Kff34N_4moiCQacH8BLbLzFu4qWTP8ibSk3JNYtv9zlUwxeA=="
org = "ts"
bucket = "tscookbook"
可以将桶看作是关系数据库中的数据库。
- 现在,你可以通过将
url、token和org参数传递给InlfuxDBClient()来建立连接:
client = InfluxDBClient(url="http://localhost:8086",
token=token,
org=org)
- 接下来,你将实例化
query_api:
query_api = client.query_api()
- 传递你的 Flux 查询,并使用
query_data_frame方法请求以 pandas DataFrame 格式返回结果:
query = f'''
from(bucket: "tscookbook")
|> range(start: 2019-09-01T00:00:00Z)
|> filter(fn: (r) =>
r._measurement == "h2o_temperature" and
r.location == "coyote_creek" and
r._field == "degrees"
)
|> movingAverage(n: 120)
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
'''
result = client.query_api().query_data_frame(org=org, query=query)
- 在前面的 Flux 脚本中,选择了度量
h2o_temparature,并且位置是coyote_creek。现在让我们检查一下 DataFrame。请注意以下输出中的数据类型:
result.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 3885 entries, 0 to 3884
Data columns (total 8 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 result 3885 non-null object
1 table 3885 non-null int64
2 _start 3885 non-null datetime64[ns, UTC]
3 _stop 3885 non-null datetime64[ns, UTC]
4 _time 3885 non-null datetime64[ns, UTC]
5 _measurement 3885 non-null object
6 location 3885 non-null object
7 degrees 3885 non-null float64
dtypes: datetime64ns, UTC, float64(1), int64(1), object(3)
memory usage: 242.9+ KB
- 如果你只想检索时间和温度列,你可以更新 Flux 查询,如下所示:
query = f'''
from(bucket: "tscookbook")
|> range(start: 2019-09-01T00:00:00Z)
|> filter(fn: (r) =>
r._measurement == "h2o_temperature" and
r.location == "coyote_creek" and
r._field == "degrees"
)
|> movingAverage(n: 120)
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
|> keep(columns: ["_time", "degrees"])
'''
result = client.query_api().query_data_frame( query=query)
result.head()
>>
result table _time degrees
0 _result 0 2019-09-01 11:54:00+00:00 64.891667
1 _result 0 2019-09-01 12:00:00+00:00 64.891667
2 _result 0 2019-09-01 12:06:00+00:00 64.816667
3 _result 0 2019-09-01 12:12:00+00:00 64.841667
4 _result 0 2019-09-01 12:18:00+00:00 64.850000
原始数据集包含 15,258 条观察数据,数据每 6 分钟收集一次,来源于两个站点(位置)。移动平均是基于 120 个数据点计算的。理解数据集的渐进性非常重要。最终的 DataFrame 包含 3885 条记录。
TimescaleDB
由于 TimescaleDB 基于 PostgreSQL,并且我们已经安装了 psycopg2,因此检索和查询数据的方式应该与示例 从关系数据库中读取数据 中使用的方法类似。
这里简要说明如何使用 pandas 的 from_sql 来实现:
- 导入 SQLAlchemy 和 pandas
import pandas as pd
from sqlalchemy import create_engine
- 使用正确的连接字符串创建 PostgreSQL 后端的引擎对象。
engine =\
create_engine("postgresql+psycopg2://postgres:password@localhost:5432/postgres")
- 最后,使用
read_sql方法将查询结果集检索到 pandas DataFrame 中:
query = "SELECT * FROM msft"
df = pd.read_sql(query,
engine,
index_col='date',
parse_dates={'date': '%Y-%m-%d'})
print(df.head())
TimescaleDB 提供了比 PostgreSQL 更多的优势,你将在 第五章 持久化时间序列数据到数据库 中探索其中的一些优势。然而,查询 TimescaleDB 的体验与熟悉 SQL 和 PostgreSQL 的用户类似。
TDEngine
对于这个示例,让我们更新配置文件 database.cfg,根据 技术要求 包括一个 [TDENGINE] 部分,如下所示:
[TDENGINE]
user=root
password=taosdata
url=http://localhost:6041
你将首先建立与 TDEngine 服务器的连接,然后对 taosBenchmark 演示数据集进行查询,该数据集在 Getting Read 部分中有所描述。
- 从导入所需的库开始。
import taosrest
import pandas as pd
- 你将创建一个 Python 字典,存储所有连接数据库所需的参数值,如
url、user和password。
import configparser
config = configparser.ConfigParser()
config.read('database.cfg')
params = dict(config['TDENGINE'])
- 建立与服务器的连接。
conn = taosrest.connect(**params)
- 运行以下查询,并使用连接对象 conn 的
query方法执行该查询。
query = """
SELECT *
FROM test.meters
WHERE location = 'California.LosAngles'
LIMIT 100000;
"""
results = conn.query(query)
- 你可以验证结果集中的行数和列名。
results.rows
>>
100000
results.fields
>>
[{'name': 'ts', 'type': 'TIMESTAMP', 'bytes': 8},
{'name': 'current', 'type': 'FLOAT', 'bytes': 4},
{'name': 'voltage', 'type': 'INT', 'bytes': 4},
{'name': 'phase', 'type': 'FLOAT', 'bytes': 4},
{'name': 'groupid', 'type': 'INT', 'bytes': 4},
{'name': 'location', 'type': 'VARCHAR', 'bytes': 24}]
results.data包含结果集中的值,但没有列标题。在将结果集写入 pandas DataFrame 之前,我们需要从results.fields捕获列名列表:
cols = [col['name'] for col in results.fields ]
df = pd.DataFrame(results.data, columns=cols)
df = df.set_index('ts')
df.info()
>>
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 100000 entries, 2017-07-14 05:40:00 to 2017-07-14 05:40:05.903000
Data columns (total 5 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 current 100000 non-null float64
1 voltage 100000 non-null int64
2 phase 100000 non-null float64
3 groupid 100000 non-null int64
4 location 100000 non-null object
dtypes: float64(2), int64(2), object(1)
memory usage: 4.6+ MB
它是如何工作的……
TimescaleDB 和 TDEngine 都使用 SQL 来查询数据,而 InfluxDB 使用他们的专有查询语言 Flux。
InfluxDB 1.8x 引入了 Flux 查询语言,作为 InfluxQL 的替代查询语言,后者与 SQL 更加相似。InfluxDB 2.0 引入了 bucket 的概念,数据存储在这里,而 InfluxDB 1.x 将数据存储在数据库中。
在这个示例中,我们从创建一个 InfluxDbClient 实例开始,这样我们就可以访问 query_api 方法,进而获得包括以下方法:
-
query()返回结果作为 FluxTable。 -
query_csv()返回结果作为 CSV 迭代器(CSV 读取器)。 -
query_data_frame()返回结果作为 pandas DataFrame。 -
query_data_frame_stream()返回一个 pandas DataFrame 流作为生成器。 -
query_raw()返回原始未处理的数据,格式为s字符串。 -
query_stream()类似于query_data_frame_stream,但它返回的是一个生成器流,其中包含FluxRecord。
在这个示例中,你使用了client.query_api()来获取数据,如下所示:
result = client.query_api().query_data_frame(org=org, query=query)
你使用了query_data_frame,它执行一个同步的 Flux 查询并返回一个你熟悉的 pandas DataFrame。
请注意,我们在 Flux 查询中必须使用pivot函数来将结果转换为适合 pandas DataFrame 的表格格式。
pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
让我们逐行分析前面的代码:
pivot()用于重塑数据,并将其从长格式转换为宽格式。
rowKey参数指定了哪一列用作每行的唯一标识符。在我们的示例中,我们指定了["_time"],因此每行将有一个唯一的时间戳。
columnKey参数指定了哪一列的值将用于在输出中创建新列。在我们的示例中,我们指定了["_field"]来从字段名称创建列。
valueColumn参数指定了哪个列包含数值,我们指定了"_value"来填充新列中的相应值。
还有更多…
在使用InfluxDB和influxdb-client时,有一个额外的参数可以用来创建 DataFrame 索引。在query_data_frame()中,你可以将一个列表作为data_frame_index参数的参数传入,如下面的示例所示:
result =\
query_api.query_data_frame(query=query,
data_frame_index=['_time'])
result['_value'].head()
>>
_time
2021-04-01 01:45:02.350669+00:00 64.983333
2021-04-01 01:51:02.350669+00:00 64.975000
2021-04-01 01:57:02.350669+00:00 64.916667
2021-04-01 02:03:02.350669+00:00 64.933333
2021-04-01 02:09:02.350669+00:00 64.958333
Name: _value, dtype: float64
这将返回一个带有DatetimeIndex(_time)的时间序列 DataFrame。
另见
-
如果你是 Flux 查询语言的新手,可以查看官方文档中的Flux 入门指南:
docs.influxdata.com/influxdb/v2.0/query-data/get-started/. -
请参考官方的InfluxDB-Client Python 库文档,地址为 GitHub:
github.com/influxdata/influxdb-client-python. -
要了解更多关于TDEngine Python 库的信息,请参阅官方文档:
docs.tdengine.com/cloud/programming/client-libraries/python/
第四章:4 将时间序列数据持久化到文件
加入我们的书籍社区,访问 Discord

在本章中,你将使用pandas库将你的时间序列 DataFrame持久化到不同的文件格式中,如CSV、Excel、Parquet和pickle文件。在对 DataFrame 进行分析或数据转换时,实际上是利用了 pandas 的内存分析能力,提供了极好的性能。然而,内存中的数据意味着它很容易丢失,因为它尚未被持久化到磁盘存储中。
在处理 DataFrame 时,你需要持久化数据以便将来取回、创建备份或与他人共享数据。pandas库附带了一套丰富的写入函数,可以将内存中的 DataFrame(或系列)持久化到磁盘上的不同文件格式中。这些写入函数使你能够将数据存储到本地驱动器或远程服务器位置,例如云存储文件系统,包括Google Drive、AWS S3、Azure Blob Storage和Dropbox。
在本章中,你将探索将数据写入不同的文件格式(本地存储)和云存储位置,如 Amazon Web Services(AWS)、Google Cloud 和 Azure。
以下是本章将涵盖的食谱:
-
使用
pickle进行时间序列数据序列化 -
写入 CSV 和其他分隔符文件
-
写入 Excel 文件
-
将数据存储到云存储(AWS、GCP 和 Azure)
-
写入大规模数据集
技术要求
在本章及之后的内容中,我们将广泛使用 pandas 2.2.2 版本(2023 年 4 月 10 日发布)。
在整个过程中,你将安装多个 Python 库,以与 pandas 协同工作。这些库在每个食谱的准备工作部分中都有突出说明。你还可以从 GitHub 仓库下载 Jupyter 笔记本(github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook)来跟随学习。你可以在这里下载本章使用的数据集:github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./tree/main/datasets/Ch4
使用 pickle 序列化时间序列数据
在 Python 中处理数据时,你可能希望将 Python 数据结构或对象(如 pandas DataFrame)持久化到磁盘,而不是将其保留在内存中。一个方法是将数据序列化为字节流,以便将其存储在文件中。在 Python 中,pickle模块是一种常见的对象序列化与反序列化方法(序列化的反过程),也被称为pickling(序列化)和unpickling(反序列化)。
准备工作
pickle模块是 Python 自带的,因此无需额外安装。
在这个食谱中,我们将探索两种常见的序列化数据的方法,这些方法通常被称为pickling。
你将使用由约翰·霍普金斯大学的系统科学与工程中心(CSSE)提供的 COVID-19 数据集,你可以从官方 GitHub 仓库下载该数据集,链接为:github.com/CSSEGISandData/COVID-19。请注意,约翰·霍普金斯大学自 2023 年 3 月 10 日起不再更新该数据集。
如何实现…
你将使用 pandas 的DataFrame.to_pickle()函数将数据写入pickle文件,然后使用pickle库直接探索另一种选择。
使用 pandas 写入 pickle 文件
你将从读取 COVID-19 时间序列数据到 DataFrame 开始,进行一些转换,然后将结果持久化到pickle文件中,以便未来分析。这应该类似于持久化仍在进行中的数据(就分析而言)的一种典型场景:
- 首先,让我们将 CSV 数据加载到 pandas DataFrame 中:
import pandas as pd
from pathlib import Path
file = \
Path('../../datasets/Ch4/time_series_covid19_confirmed_global.csv')
df = pd.read_csv(file)
df.head()
上述代码将显示 DataFrame 的前五行:

图 4.1:COVID-19 全球确诊病例的前五行
你可以从输出中观察到,这是一个宽格式 DataFrame,共有 1147 列,每列代表一个数据收集日期,从1/22/20到3/9/23。
- 假设分析的一部分是聚焦于美国,并且只使用 2021 年夏季(6 月、7 月、8 月和 9 月)收集的数据。你将通过应用必要的过滤器转换 DataFrame,然后将数据反向旋转,使日期显示在行中而不是列中(从宽格式转换为长格式):
# filter data where Country is United States
df_usa = df[df['Country/Region'] == 'US']
# filter columns from June to end of September
df_usa_summer = df_usa.loc[:, '6/1/21':'9/30/21']
# unpivot using pd.melt()
df_usa_summer_unpivoted = \
pd.melt(df_usa_summer,
value_vars=df_usa_summer.columns,
value_name='cases',
var_name='date').set_index('date')
df_usa_summer_unpivoted.index = \
pd.to_datetime(df_usa_summer_unpivoted.index, format="%m/%d/%y")
- 检查
df_usa_summer_unpivotedDataFrame 并打印前五条记录:
df_usa_summer_unpivoted.info()
>>
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 122 entries, 2021-06-01 to 2021-09-30
Data columns (total 1 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 cases 122 non-null int64
dtypes: int64(1)
memory usage: 1.9 KB
df_usa_summer_unpivoted.head()
>>
cases
date
2021-06-01 33407540
2021-06-02 33424131
2021-06-03 33442100
2021-06-04 33459613
2021-06-05 33474770
你已对数据集进行了筛选,并将其从宽格式的 DataFrame 转换为长格式的时间序列 DataFrame。
- 假设你现在对数据集已经满意,准备将数据集进行 pickling(序列化)。你将使用
DataFrame.to_pickle()函数将 DataFrame 写入covid_usa_summer_2020.pkl文件:
output = \
Path('../../datasets/Ch4/covid_usa_summer_2021.pkl')
df_usa_summer_unpivoted.to_pickle(output)
Pickling 保留了 DataFrame 的结构。当你再次加载 pickle 数据时(反序列化),你将恢复 DataFrame 的原始结构,例如,带有DatetimeIndex类型。
- 使用
pandas.read_pickle()读取 pickle 文件并检查 DataFrame:
unpickled_df = pd.read_pickle(output)
unpickled_df.info()
>>
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 122 entries, 2021-06-01 to 2021-09-30
Data columns (total 1 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 cases 122 non-null int64
dtypes: int64(1)
memory usage: 1.9 KB
从之前的示例中,你能够使用pandas.read_pickle()反序列化数据到 DataFrame 中,并保留之前所做的所有转换和数据类型。
使用 pickle 库写入 pickle 文件
Python 自带pickle库,你可以导入并使用它来序列化(pickle)对象,使用dump(写入)和load(读取)。在接下来的步骤中,你将使用pickle.dump()和pickle.load()来序列化并反序列化df_usa_summer_unpivoted DataFrame。
- 导入
pickle库:
import pickle
- 然后,您可以使用
dump()方法将df_usa_summer_unpivotedDataFrame 持久化:
file_path = \
Path('../../datasets/Ch4/covid_usa_summer_2021_v2.pkl')
with open(file_path, "wb") as file:
pickle.dump(df_usa_summer_unpivoted, file)
请注意,使用的模式是 “wb”,因为我们是以二进制模式写入(以原始字节写入)。
- 您可以使用
load()方法读取文件并检查 DataFrame。请注意,在以下代码中,导入的对象是一个 pandas DataFrame,尽管您使用的是pickle.load()而不是Pandas.read_pickle()。这是因为 pickling 保留了模式和数据结构:
with open(file_path, "rb") as file:
df = pickle.load(file)
type(df)
>>
pandas.core.frame.DataFrame
请注意,使用的模式是 “rb”,因为我们是以二进制模式读取(作为原始字节读取)。
它是如何工作的……
在 Python 中,Pickling 是将任何 Python 对象序列化的过程。更具体地说,它使用一种二进制序列化协议将对象转换为二进制信息,这是一种不可人类读取的格式。该协议允许我们重新构建(反序列化)被 pickle 的文件(二进制格式),使其恢复到原始内容而不丢失宝贵的信息。如前面的示例所示,我们确认时间序列 DataFrame 在重新构建(反序列化)时,能够恢复到其精确的形式(模式)。
pandas 的 DataFrame.to_pickle() 函数有两个额外的参数,重要的是需要了解第一个参数是 compression,该参数在其他写入函数中也可用,如 to_csv()、to_json() 和 to_parquet() 等。
在 DataFrame.to_pickle() 函数的情况下,默认的压缩值设置为 infer,这让 pandas 根据提供的文件扩展名来确定使用哪种压缩模式。在前面的示例中,我们使用了 DataFrame.to_pickle(output),其中 output 被定义为 .pkl 文件扩展名,如 covid_usa_summer_2020.pkl。如果将其更改为 covid_usa_summer_2020.zip,则输出将是存储在 ZIP 格式中的压缩二进制序列化文件。您可以尝试以下示例:
zip_output =\
Path('../../datasets/Ch4/covid_usa_summer_2021.zip')
# Write the Dataframe
df_usa_summer_unpivoted.to_pickle(zip_output)
# Read the Dataframe
pd.read_pickle(zip_output)
其他支持的压缩模式包括 gzip、bz2、tar 和 xz。
第二个参数是 protocol。默认情况下,DataFrame.to_pickle() 写入函数使用最高协议,截至本文编写时,该协议设置为 5。根据 Pickle 文档,当进行 pickle 时,有六个(6)不同的协议可供选择,从协议版本 0 到最新的协议版本 5。
在 pandas 之外,您可以使用以下命令检查最高协议配置是什么:
pickle.HIGHEST_PROTOCOL
>> 5
同样,默认情况下,pickle.dump() 使用 HIGHEST_PROTOCOL 值,如果没有提供其他值的话。该构造如下所示:
with open(output, "wb") as file:
pickle.dump(df_usa_summer_unpivoted,
file,
pickle.HIGHEST_PROTOCOL)
with open(output, "wb") as file:
pickle.dump(df_usa_summer_unpivoted,
file,
5)
前面两个代码片段是等效的。
还有更多……
Pickling(二进制序列化方法)的一个优点是我们几乎可以 pickle 大多数 Python 对象,无论是 Python 字典、机器学习模型、Python 函数,还是更复杂的数据结构,如 pandas DataFrame。然而,某些对象(如 lambda 和嵌套函数)存在一些限制。
让我们来看一下如何将一个函数及其输出进行序列化。你将创建一个covid_by_country函数,该函数需要三个参数:要读取的 CSV 文件、回溯的天数和国家。该函数将返回一个时间序列 DataFrame。接着你将对函数、函数的输出以及其图形进行序列化:
def covid_by_country(file, days, country):
ts = pd.read_csv(file)
ts = ts[ts['Country/Region'] == country]
final = ts.iloc[:, -days:].sum()
final.index = pd.to_datetime(final.index,
format="%m/%d/%y")
return final
file = \
Path('../../datasets/Ch4/time_series_covid19_confirmed_global.csv')
us_past_120_days = covid_by_country(file, 200, 'US')
plot_example = \
us_past_120_days.plot(title=f'COVID confirmed case for US',
xlabel='Date',
ylabel='Number of Confirmed Cases');
该函数将输出以下图形:

图 4.:– covid_by_country 函数的输出
在将对象序列化之前,你可以通过添加额外的信息来进一步增强内容,以提醒你内容的含义。在以下代码中,你将序列化函数以及返回的 DataFrame,并使用 Python 字典封装附加信息(称为元数据):
from datetime import datetime
metadata = {
'date': datetime.now(),
'data': '''
COVID-19 Data Repository by the
Center for Systems Science and Engineering (CSSE)
at Johns Hopkins University'
''',
'author': 'Tarek Atwan',
'version': 1.0,
'function': covid_by_country,
'example_code' : us_past_120_days,
'example_code_plot': plot_example
}
file_path = Path('../../datasets/Ch4/covid_data.pkl')
with open(file_path, 'wb') as file:
pickle.dump(metadata, file)
为了更好地理解它是如何工作的,你可以加载内容并使用pickle.load()进行反序列化:
with open(output, 'rb') as file:
content = pickle.load(file)
content.keys()
>>
dict_keys(['date', 'data', 'author', 'version', 'function', 'example_df', 'example_plot'])
你可以按照以下代码检索并使用该函数:
file_path =\
Path('../../datasets/Ch4/time_series_covid19_confirmed_global.csv')
loaded_func = content['function']
loaded_func(file_path, 120, 'China').tail()
>>
2023-03-05 4903524
2023-03-06 4903524
2023-03-07 4903524
2023-03-08 4903524
2023-03-09 4903524
dtype: int64
你也可以检索之前为美国存储的 DataFrame:
loaded_df = content['example_df']
loaded_df.tail()
>>
2023-03-05 103646975
2023-03-06 103655539
2023-03-07 103690910
2023-03-08 103755771
2023-03-09 103802702
dtype: int64
你也可以加载你刚刚存储的图形视图。以下代码将展示类似于图 4.2中的图形:
loaded_plot = content['example_plot']
loaded_plot.get_figure()
上述示例展示了序列化如何有助于存储对象及附加的元数据信息。这在存储一个正在进行的工作或执行多个实验并希望跟踪它们及其结果时非常有用。在机器学习实验中也可以采用类似的方法,因为你可以存储模型及与实验和其输出相关的任何信息。
另见
-
有关
Pandas.DataFrame.to_pickle的更多信息,请访问此页面:pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_pickle.html。 -
有关 Python Pickle 模块的更多信息,请访问此页面:
docs.python.org/3/library/pickle.html。
写入 CSV 及其他分隔符文件
在本例中,你将导出一个 DataFrame 为 CSV 文件,并利用DataFrame.to_csv()写入函数中的不同参数。
准备工作
该文件已提供在本书的 GitHub 代码库中,你可以在这里找到:github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook。你将使用的文件名为movieboxoffice.csv,首先读取该文件以创建你的 DataFrame。
为了准备本例,你将使用以下代码将文件读取为一个 DataFrame:
import pandas as pd
from pathlib import Path
filepath = Path('../../datasets/Ch4/movieboxoffice.csv')
movies = pd.read_csv(filepath,
header=0,
parse_dates=[0],
index_col=0,
usecols=['Date',
'Daily'],
date_format="%d-%b-%y")
movies.info()
>>
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 128 entries, 2021-04-26 to 2021-08-31
Data columns (total 1 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Daily 128 non-null object
dtypes: object(1)
memory usage: 2.0+ KB
你现在有了一个以DatetimeIndex为索引的时间序列 DataFrame。
如何做……
使用 pandas 将 DataFrame 写入 CSV 文件非常简单。DataFrame 对象可以访问许多写入方法,如 .to_csv,你将在接下来的步骤中使用这个方法:
- 你将使用 pandas DataFrame 写入方法来将 DataFrame 持久化为 CSV 文件。该方法有多个参数,但至少你需要传递文件路径和文件名:
output = Path('../../datasets/Ch4/df_movies.csv')
movies.to_csv(output)
默认情况下,创建的 CSV 文件是 逗号分隔的。
- 要更改分隔符,可以使用
sep参数并传入不同的参数。在以下代码中,你将创建一个管道符(|)分隔的文件:
output = Path('../../datasets/Ch4/piped_df_movies.csv')
movies.to_csv(output, sep='|')
- 读取管道分隔的文件并检查生成的 DataFrame 对象:
movies_df = pd.read_csv(output, sep='|')
movies_df.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 128 entries, 0 to 127
Data columns (total 2 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Date 128 non-null object
1 Daily 128 non-null object
dtypes: object(2)
memory usage: 2.1+ KB
从上面的输出可以看出,读取 CSV 文件时丢失了一些信息。例如,原始 DataFrame “movies” 中的 Date 列实际上不是列,而是 DatetimeIndex 类型的索引。当前的 DataFrame “movies_df” 并没有 DatetimeIndex 类型的索引(现在的索引是 RangeIndex 类型,仅为行号的范围)。这意味着你需要配置 read_csv() 函数并传递必要的参数,以便正确解析文件(这与读取 pickle 文件的情况不同,正如前面的示例中所演示的,使用 pickle 序列化时间序列数据)。
一般来说,CSV 文件格式不会保留索引类型或列数据类型信息。
它是如何工作的……
DataFrame.to_csv() 的默认行为是根据默认的 sep 参数(默认为 ",")写入一个 逗号分隔的 CSV 文件。你可以通过传递不同的分隔符来覆盖这个默认行为,例如制表符 ("\t")、管道符 ("|") 或分号 (";")。
以下代码展示了不同 分隔符 及其表示方式:
# tab "\t"
Date DOW Daily Avg To Date Day Estimated
2019-04-26 Friday 157461641 33775 157461641 1 False
2019-04-27 Saturday 109264122 23437 266725763 2 False
2019-04-28 Sunday 90389244 19388 357115007 3 False
# comma ","
Date,DOW,Daily,Avg,To Date,Day,Estimated
2019-04-26,Friday,157461641,33775,157461641,1,False
2019-04-27,Saturday,109264122,23437,266725763,2,False
2019-04-28,Sunday,90389244,19388,357115007,3,False
# semicolon ";"
Date;DOW;Daily;Avg;To Date;Day;Estimated
2019-04-26;Friday;157461641;33775;157461641;1;False
2019-04-27;Saturday;109264122;23437;266725763;2;False
2019-04-28;Sunday;90389244;19388;357115007;3;False
# pipe "|"
Date|DOW|Daily|Avg|To Date|Day|Estimated
2019-04-26|Friday|157461641|33775|157461641|1|False
2019-04-27|Saturday|109264122|23437|266725763|2|False
2019-04-28|Sunday|90389244|19388|357115007|3|False
还有更多内容……
注意,在上面的示例中,逗号分隔的字符串值没有被双引号("")包围。如果我们的字符串对象包含逗号(,)并且我们将其写入逗号分隔的 CSV 文件,会发生什么呢?让我们看看 pandas 如何处理这个场景。
在以下代码中,我们将创建一个 person DataFrame:
import pandas as pd
person = pd.DataFrame({
'name': ['Bond, James', 'Smith, James', 'Bacon, Kevin'],
'location': ['Los Angeles, CA', 'Phoenix, AZ', 'New York, NY'],
'net_worth': [10000, 9000, 8000]
})
print(person)
>>
name location net_worth
0 Bond, James Los Angeles, CA 10000
1 Smith, James Phoenix, AZ 9000
2 Bacon, Kevin New York, NY 8000
现在,将 DataFrame 导出为 CSV 文件。你将指定 index=False 来忽略导出的索引(行号):
person.to_csv('person_a.csv', index=False)
如果你检查 person_a.csv 文件,你会看到以下表示方式(注意 pandas 添加的双引号):
name,location,net_worth
"Bond, James","Los Angeles, CA",10000
"Smith, James","Phoenix, AZ",9000
"Bacon, Kevin","New York, NY",8000
to_csv() 函数有一个 quoting 参数,默认值为 csv.QUOTE_MINIMAL。这个默认值来自 Python 的 csv 模块,它是 Python 安装的一部分。QUOTE_MINIMAL 参数只会为包含特殊字符的字段加上引号,例如逗号(",")。
csv 模块提供了四个常量,我们可以将它们作为参数传递给 to_csv() 函数中的 quoting 参数。这些常量包括以下内容:
-
csv.QUOTE_ALL:为所有字段加上引号,无论是数字型还是非数字型 -
csv.QUOTE_MINIMAL:to_csv()函数中的默认选项,用于引用包含特殊字符的值。 -
csv.QUOTE_NONNUMERIC:引用所有非数字字段 -
csv.QUOTE_NONE:不引用任何字段
为了更好地理解这些值如何影响输出的 CSV,你将在以下示例中测试传递不同的引用参数。这是通过使用person DataFrame 来完成的:
import csv
person.to_csv('person_b.csv',
index=False,
quoting=csv.QUOTE_ALL)
person.to_csv('person_c.csv',
index=False,
quoting=csv.QUOTE_MINIMAL)
person.to_csv('person_d.csv',
index=False,
quoting= csv.QUOTE_NONNUMERIC)
person.to_csv('person_e.csv',
index=False,
quoting= csv.QUOTE_NONE, escapechar='\t')
现在,如果你打开并检查这些文件,你应该能看到以下表示:
person_b.csv
"name","location","net_worth"
"Bond, James","Los Angeles, CA","10000"
"Smith, James","Phoenix, AZ","9000"
"Bacon, Kevin","New York, NY","8000"
person_c.csv
name,location,net_worth
"Bond, James","Los Angeles, CA",10000
"Smith, James","Phoenix, AZ",9000
"Bacon, Kevin","New York, NY",8000
person_d.csv
"name","location","net_worth"
"Bond, James","Los Angeles, CA",10000
"Smith, James","Phoenix, AZ",9000
"Bacon, Kevin","New York, NY",8000
person_e.csv
name,location,net_worth
Bond, James,Los Angeles , CA,10000
Smith, James,Phoenix , AZ,9000
Bacon, Kevin,New York, NY,8000
注意,在前面的示例中,使用csv.QUOTE_NONE时,你必须为escapechar参数提供额外的参数,否则会抛出错误。
另见
-
欲了解更多关于
Pandas.DataFrame.to_csv()函数的信息,请参考此页面:pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_csv.html。 -
欲了解更多关于 CSV 模块的信息,请参考此页面:
docs.python.org/3/library/csv.html。
将数据写入 Excel 文件
在本配方中,你将把 DataFrame 导出为 Excel 文件格式,并利用DataFrame.to_excel()写入函数中可用的不同参数。
准备工作
在第二章的从 Excel 文件读取数据配方中,你需要安装openpyxl作为使用pandas.read_excel()读取 Excel 文件的引擎。在本配方中,你将使用相同的openpyxl作为使用DataFrame.to_excel()写入 Excel 文件的引擎。
要使用conda安装openpyxl,请运行以下命令:
>>> conda install openpyxl
你也可以使用pip:
>>> pip install openpyxl
该文件已提供在本书的 GitHub 仓库中,你可以在这里找到:github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook。文件名为movieboxoffice.csv。
为了准备这个配方,你将使用以下代码将文件读取到一个 DataFrame 中:
import pandas as pd
from pathlib import Path
filepath = Path('../../datasets/Ch4/movieboxoffice.csv')
movies = pd.read_csv(filepath,
header=0,
parse_dates=[0],
index_col=0,
usecols=['Date',
'Daily'],
date_format="%d-%b-%y")
如何操作…
要将 DataFrame 写入 Excel 文件,你需要提供包含filename和sheet_name参数的写入函数。文件名包含文件路径和名称。确保文件扩展名为.xlsx,因为你使用的是 openpyxl。
DataFrame.to_excel()方法将根据文件扩展名来决定使用哪个引擎,例如.xlsx或.xls。你也可以通过engine参数明确指定使用的引擎,示例如下:
- 确定文件输出的位置,并将文件路径、所需的工作表名称以及引擎传递给
DataFrame.to_excel()写入函数:
output = \
Path('../../datasets/Ch4/daily_boxoffice.xlsx')
movies.to_excel(output,
sheet_name='movies_data',
engine='openpyxl', # default engine for xlsx files
index=True)
前面的代码将在指定位置创建一个新的 Excel 文件。你可以打开并检查该文件,如下图所示:

图 4.3:来自 daily_boxoffice.xlsx 文件的示例输出
注意,工作表名称是movies_data。在 Excel 文件中,你会注意到Date列的格式与预期的不符。假设预期Date列是一个特定格式,比如MM-DD-YYYY。
使用
read_excel读取相同文件将正确读取Date列,符合预期。
- 为了实现这一点,你将使用另一个由 pandas 提供的类,
pandas.ExcelWriter类为我们提供了两个用于日期格式化的属性:datetime_format和date_format。这两个参数在使用xlsxwriter引擎时效果很好,但截至目前,openpyxl 集成存在一个已知的 bug。openpyxl 相较于 xlsxwriter 有几个优势,尤其是在追加现有 Excel 文件时。我们将利用 openpyxl 的number_format属性来修复这个问题。以下代码展示了如何实现这一点:
date_col = 'Date'
with pd.ExcelWriter(output,
engine='openpyxl',
mode='a',
if_sheet_exists='replace') as writer:
movies.to_excel(writer, sheet_name='movies_fixed_dates', index=True)
worksheet = writer.sheets['movies_fixed_dates']
for col in worksheet.iter_cols():
header = col[0] # capture headers
if header.value == date_col:
for row in range(2, # skip first row
worksheet.max_row+1):
worksheet.cell(
row,
header.column
).number_format='MM-DD-YYYY'
以下是新输出的表现形式。这是通过将MM-DD-YYYY传递给writer对象的datetime_format属性实现的:

图 4.4:使用 pd.ExcelWriter 和 number_format 将 Date 列格式更新为 MM-DD-YYYY
它是如何工作的……
DataFrame.to_excel()方法默认会创建一个新的 Excel 文件(如果文件不存在)或覆盖文件(如果文件已存在)。要向现有的 Excel 文件追加内容或写入多个工作表,你需要使用Pandas.ExcelWriter类。ExcelWriter()类有一个mode参数,可以接受"w"(写入)或"a"(追加)。截至目前,xlsxwriter 不支持追加模式,而 openpyxl 支持两种模式。
请记住,在ExcelWriter中,默认模式设置为"w"(写入模式),因此,如果未指定"a"(追加模式),将导致覆盖 Excel 文件(任何现有内容将被删除)。
此外,在使用追加模式(mode="a")时,你需要通过if_sheet_exists参数指定如何处理现有的工作表,该参数接受以下三种值之一:
-
error,会引发ValueError异常。 -
replace,它会覆盖现有的工作表。 -
new,创建一个具有新名称的新工作表。如果重新执行前面的代码并更新if_sheet_exists='new',那么将创建一个新的工作表并命名为movies_fixed_dates1。
还有更多……
如果你需要在同一个 Excel 文件中创建多个工作表,那么ExcelWriter可以帮助实现这一目标。例如,假设目标是将每个月的数据分开到自己的工作表中,并按月命名工作表。在下面的代码中,你将添加一个Month列并使用它按月拆分 DataFrame,使用groupby将每个组写入一个新工作表。
首先,让我们创建辅助函数sheet_date_format,将每个工作表中的Date列格式化为 MM-DD-YYYY 格式:
def sheet_date_format(sheet_name, writer, date_col):
worksheet = writer.sheets[sheet_name]
for col in worksheet.iter_cols():
header = col[0]
if header.value == date_col:
for row in range(2, worksheet.max_row+1):
worksheet.cell(
row,
header.column).number_format='MM-DD-YYYY'
接下来的代码将向 movies DataFrame 添加一个“Month”列,然后将每个月的数据写入独立的工作表,并为每个工作表命名为相应的月份名称:
movies['Month'] = movies.index.month_name()
output = Path('../../datasets/Ch4/boxoffice_by_month.xlsx')
with pd.ExcelWriter(output,
engine='openpyxl') as writer:
for month, data in movies.groupby('Month'):
data.to_excel(writer, sheet_name=month)
sheet_date_format(month, writer, date_col='Date')
上面的代码将创建一个名为boxoffice_by_month.xlsx的新 Excel 文件,并为每个月创建五个工作表,如下图所示:

图 4.5:movies DataFrame 中的每个月都被写入到各自的 Excel 工作表中
另见
pandas 的to_excel()方法和ExcelWriter类使将 DataFrame 写入 Excel 文件变得非常方便。如果您需要对 pandas DataFrame 以外的部分进行更精细的控制,您应该考虑使用已安装的openpyxl库作为读取/写入引擎。例如,openpyxl库有一个用于处理 pandas DataFrame 的模块(openpyxl.utils.dataframe)。一个例子是dataframe_to_rows()函数。
-
要了解更多关于
Pandas.DataFrame.to_excel()的信息,请参考pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_excel.html。 -
要了解更多关于
Pandas.ExcelWriter()的信息,请参考pandas.pydata.org/pandas-docs/stable/reference/api/pandas.ExcelWriter.html#pandas.ExcelWriter. -
要了解更多关于
openpyxl的信息,请参考openpyxl.readthedocs.io/en/stable/index.html。 -
要了解更多关于
openpyxl.utils.dataframe的信息,请参考openpyxl.readthedocs.io/en/stable/pandas.html#working-with-pandas-dataframes
将数据存储到云存储(AWS、GCP 和 Azure)
在本教程中,您将使用 pandas 将数据写入云存储,如 Amazon S3、Google Cloud Storage 和 Azure Blob Storage。多个 pandas 写入函数支持通过storage_options参数将数据写入云存储。
准备工作
在第二章的“从 URL 读取数据”中,您被要求安装boto3和s3fs来从 AWS S3 桶读取数据。在本教程中,除了需要的 Google Cloud Storage(gcsfs)和 Azure Blob Storage(adlfs)库外,您还将使用相同的库。
使用pip安装,您可以使用以下命令:
>>> pip install boto3 s3fs
>>> pip install google-cloud-storage gcsfs
>>> pip install adlfs azure-storage-blob azure-identity
使用conda安装,您可以使用以下命令:
>>> conda install -c conda-forge boto3 s3fs -y
>>> conda install -c conda-forge google-cloud-storage gcsfs -y
>>> conda install -c conda-forge adlfs azure-storage-blob azure-identity -y
您将使用我们在前一个教程中创建的boxoffice_by_month.xlsx文件,将数据写入 Excel 文件。该文件可在本书的 GitHub 仓库中找到,链接如下:github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook。
为了准备这个操作,你将使用以下代码将文件读取到一个 DataFrame 中:
import pandas as pd
from pathlib import Path
source = "../../datasets/Ch4/boxoffice_by_month.xlsx"
movies = pd.concat(pd.read_excel(source,
sheet_name=None,
index_col='Date',
parse_dates=True)).droplevel(0)
print(movies.head())
Daily Month
Date
2021-04-26 $125,789.89 April
2021-04-27 $99,374.01 April
2021-04-28 $82,203.16 April
2021-04-29 $33,530.26 April
2021-04-30 $30,105.24 April
请注意,movie DataFrame 有两列(Daily 和 Month),并且有一个 DatetimeIndex(Date)。
接下来,你将把 AWS、Google Cloud 和 Azure 的凭证存储在 Python 脚本外部的cloud.cfg配置文件中。然后,使用configparser读取并将值存储在 Python 变量中。你不希望将凭证暴露或硬编码在代码中:
# Example of configuration file "cloud.cfg file"
[AWS]
aws_access_key=<your_access_key>
aws_secret_key=<your_secret_key>
[GCP]
key_file_path=<GCPKeyFileexample.json>
[AZURE]
storage_account_key=<your_storageaccount_key>
然后,我们可以使用config.read()加载aws.cfg文件:
import configparser
config = configparser.ConfigParser()
config.read('cloud.cfg')
AWS_ACCESS_KEY = config['AWS']['aws_access_key']
AWS_SECRET_KEY = config['AWS']['aws_secret_key']
AZURE_ACCOUNT_KEY = config['AZURE']['storage_account_key']
GCP_KEY_FILE = config['GCP']['key_file_path']
如何实现…
多个 pandas 写入函数支持直接写入远程或云存储文件系统,例如 AWS 的s3://、Google 的gs://以及 Azure 的abfs://和az://协议。这些写入函数提供了storage_options参数,支持与远程文件存储系统的协作。这部分得益于 pandas 使用fsspec来处理非 HTTP(s)的系统 URL,例如每个云存储专用的 URL。对于每个云存储,你需要使用特定的文件系统实现,例如,AWS S3 使用s3fs,Google Cloud 使用gcsfs,Azure 使用adlfs。
storage_options参数接受一个 Python 字典,用于提供附加信息,如凭证、令牌或云提供商要求的任何信息,以键值对的形式提供。
使用 pandas 写入 Amazon S3
在本节中,你将使用 pandas 将movies DataFrame 写入tscookbook-private S3 桶,保存为 CSV 和 Excel 文件:
若干 pandas 写入函数,如to_csv、to_parquet和to_excel,允许你通过storage_accounts参数传递 AWS S3 特定的凭证(key和sercret),这些凭证在s3fs中有说明。以下代码展示了如何利用to_csv和to_excel将你的 movies DataFrame 写入tscookbook S3 桶,分别保存为movies_s3.csv和movies_s3.xlsx:
# Writing to Amazon S3
movies.to_csv('s3://tscookbook-private/movies_s3.csv',
storage_options={
'key': AWS_ACCESS_KEY,
'secret': AWS_SECRET_KEY
})
movies.to_excel('s3://tscookbook-private/movies_s3.xlsx',
storage_options={
'key': AWS_ACCESS_KEY,
'secret': AWS_SECRET_KEY
})
以下图示展示了tscookbook-private桶的内容:

图 4.6: 使用 pandas 成功写入 AWS S3 的 movies_s3.csv 和 movie_s3.xlsx
使用 pandas 写入 Google Cloud Storage
在本节中,你将使用 pandas 将movies DataFrame 写入 Google Cloud Storage 的tscookbook桶,保存为 CSV 和 Excel 文件:
在使用 Google Cloud 时,你将使用存储为 JSON 文件的服务帐户私钥。这个文件可以从 Google Cloud 生成并下载。在storage_options中,你将传递文件路径。以下代码展示了如何使用to_csv和to_excel将你的 movies DataFrame 写入tscookbook桶,分别保存为movies_gs.csv和movies_gs.xlsx:
# Writing to Google Cloud Storage
movies.to_csv('gs://tscookbook/movies_gs.csv',
storage_options={'token': GCP_KEY_FILE})
movies.to_excel('gs://tscookbook/movies_gs.xlsx',
storage_options={'token': GCP_KEY_FILE})
以下图示展示了tscookbook桶的内容:

图 4.7: 使用 pandas 成功写入 Google Cloud Storage 的 movies_gs.csv 和 movie_gs.xlsx
使用 pandas 向 Azure Blob 存储写入数据
在这一部分,你将使用 pandas 将movies DataFrame 以 CSV 文件的形式写入 Azure Blob 存储中的名为objects的容器:
在使用 Azure Blob 存储时,你可以使用abfs://或az://协议。在storage_options中,你将传递account_key,这是你在 Azure 存储账户中的 API 密钥。以下代码展示了如何利用to_csv将你的 movies DataFrame 写入objects容器。下面的三段代码是等效的,并展示了你需要传递的不同 URI 和storage_options:
# Writing to Azure Blob Storage
movies.to_csv("abfs://objects@tscookbook.dfs.core.windows.net/movies_abfs.csv",
storage_options={
'account_key': AZURE_ACCOUNT_KEY
})
movies.to_csv("az://objects@tscookbook.dfs.core.windows.net/movies_az.csv",
storage_options={
'account_key': AZURE_ACCOUNT_KEY
})
movies.to_csv("az://objects/movies_az2.csv",
storage_options={
'account_name': "tscookbook",
'account_key': AZURE_ACCOUNT_KEY
})
下图展示了objects容器的内容:

图 4.8:movies_abfs.csv、movies_az.csv 和 movie_az2.csv 成功写入 Azure Blob 存储,使用 pandas
它是如何工作的……
在前面的代码部分,我们使用了DataFrame.to_csv()和DataFrame.to_excel()方法,将数据写入 Amazon S3、Azure Blob 存储和 Google Cloud 存储。storage_options参数允许传递一个包含存储连接所需信息的键值对;例如,AWS S3 需要传递key和secret,GCP 需要token,而 Azure 需要account_key。
支持storage_options的 pandas DataFrame 写入函数示例包括:
-
Pandas.DataFrame.to_excel() -
Pandas.DataFrame.to_json() -
Pandas.DataFrame.to_parquet() -
Pandas.DataFrame.to_pickle() -
Pandas.DataFrame.to_markdown() -
Pandas.DataFrame.to_pickle() -
Pandas.DataFrame.to_stata() -
Pandas.DataFrame.to_xml()
还有更多……
为了更细粒度的控制,你可以使用 AWS(boto3)、Google Cloud(google-cloud-storage)或 Azure(azure-storage-blob)的特定 Python SDK 来写入数据。
首先,我们将把我们的电影 DataFrame 存储为 CSV 格式,以便将数据上传到不同的云存储服务。
data = movies.to_csv(encoding='utf-8', index=True)
注意,index=True是因为我们的日期列是索引,我们需要确保它在写入 CSV 文件时作为列被包含。
使用 boto3 库向 Amazon S3 写入数据
你将探索资源 API和客户端 API。资源 API 是一个更高级的抽象,它简化了代码并与 AWS 服务的交互。与此同时,客户端 API 提供了一个低级别的抽象,允许对 AWS 服务进行更细粒度的控制。
当使用资源 API 与boto3.resource("s3")时,你首先需要通过提供 S3 桶名称和对象键(文件名)来创建一个对象资源。一旦定义,你将可以访问多个方法,包括copy、delete、put、download_file、load、get和upload等。put方法将把一个对象添加到定义的 S3 桶中。
使用boto3.client("s3")客户端 API 时,你可以访问许多 Bucket 和 Object 级别的方法,包括create_bucket、delete_bucket、download_file、put_object、delete_object、get_bucket_lifecycle、get_bucket_location、list_buckets等。put_object方法将把一个对象添加到定义的 S3 存储桶中。
import boto3
bucket = "tscookbook-private"
# Using the Resource API
s3_resource = boto3.resource("s3",
aws_access_key_id = AWS_ACCESS_KEY,
aws_secret_access_key = AWS_SECRET_KEY)
s3_resource.Object(bucket, 'movies_boto3_resourceapi.csv').put(Body=data)
# Using the Client API
s3_client = boto3.client("s3",
aws_access_key_id = AWS_ACCESS_KEY,
aws_secret_access_key = AWS_SECRET_KEY)
s3_client.put_object(Body=data, Bucket=bucket, Key='movies_boto3_clientapi.csv')
使用 google-cloud-storage 库写入 Google Cloud Storage
你首先需要创建一个客户端对象,这是来自存储模块的 Client 类的一个实例。你将使用服务账户的 JSON 密钥文件进行身份验证。通过from_service_account_json方法指定该文件。你将使用bucket和blob方法创建一个引用,指向你希望放入 Google Storage 中tscookbook存储桶的 blob 对象。最后,你可以使用upload_from_string方法将数据上传到指定的 blob 对象中。
from google.cloud import storage
# Authenticate using the service account key
storage_client = storage.Client.from_service_account_json(GCP_KEY_FILE)
bucket_name = 'tscookbook'
file_path = 'movies_gsapi.csv'
blob = storage_client.bucket(bucket_name).blob(file_path)
blob.upload_from_string(data)
使用 azure-storage-blob 库写入 Azure Blob Storage
你将首先创建一个BlobServiceClient对象,并使用 Azure Storage Account API 密钥进行身份验证。然后,你将使用get_blob_client为指定的容器创建 blob 对象,并使用upload_blob方法将数据上传到指定的对象中。
from azure.storage.blob import BlobServiceClient
blob_service_client = BlobServiceClient(
account_url="https://tscookbook.blob.core.windows.net",
credential=AZURE_ACCOUNT_KEY)
blob_client = blob_service_client.get_blob_client(
container='objects',
blob='movies_blobapi.csv')
blob_client.upload_blob(data)
另见
若要了解更多关于如何使用 Python 管理云存储的信息,请查看这些流行库的官方文档
-
Amazon S3 (Boto3)
boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html -
Azure Blob Storage
learn.microsoft.com/en-us/azure/storage/blobs/storage-quickstart-blobs-python -
Google Cloud Storage
cloud.google.com/python/docs/reference/storage/latest
写入大数据集
在本示例中,你将探索不同文件格式的选择如何影响整体的写入和读取性能。你将探索 Parquet、优化行列式(ORC)和 Feather,并将它们的性能与其他流行的文件格式,如 JSON 和 CSV,进行比较。
这三种文件格式 ORC、Feather 和 Parquet 是列式文件格式,适用于分析需求,并且总体上显示出更好的查询性能。这三种文件格式也得到了 Apache Arrow(PyArrow)的支持,后者提供了内存中的列式格式,优化了数据分析性能。为了将这种内存中的列式数据持久化并存储,你可以使用 pandas 的to_orc、to_feather和to_parquet写入函数将数据持久化到磁盘。
Arrow 提供数据的内存表示,采用列式格式,而 Feather、ORC 和 Parquet 则允许我们将这种表示存储到磁盘中。
准备工作
在本示例中,您将使用来自(www.nyc.gov/site/tlc/about/tlc-trip-record-data.page)的纽约出租车数据集,我们将处理 2023 年的黄出租车行程记录。
在以下示例中,我们将使用这些文件之一,yellow_tripdata_2023-01.parquet,但您可以选择其他任何文件来跟随学习。在第二章的从 Parquet 文件读取数据示例中,您安装了PyArrow。以下是使用 Conda 或 Pip 安装 PyArrow 的说明。
要使用conda安装 PyArrow,运行以下命令:
conda install -c conda-forge pyarrow
要使用pip安装 PyArrow,运行以下命令:
pip install pyarrow
为了准备本示例,您将使用以下代码将文件读取到 DataFrame 中:
import pandas as pd
from pathlib import Path
file_path = Path('yellow_tripdata_2023-01.parquet')
df = pd.read_parquet(file_path, engine='pyarrow')
df.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 3066766 entries, 0 to 3066765
Data columns (total 19 columns):
# Column Dtype
--- ------ -----
0 VendorID int64
1 tpep_pickup_datetime datetime64[us]
2 tpep_dropoff_datetime datetime64[us]
3 passenger_count float64
4 trip_distance float64
5 RatecodeID float64
6 store_and_fwd_flag object
7 PULocationID int64
8 DOLocationID int64
9 payment_type int64
10 fare_amount float64
11 extra float64
12 mta_tax float64
13 tip_amount float64
14 tolls_amount float64
15 improvement_surcharge float64
16 total_amount float64
17 congestion_surcharge float64
18 airport_fee float64
dtypes: datetime64us, float64(12), int64(4), object(1)
memory usage: 444.6+ MB
如何实现
您将把 DataFrame 写入不同的文件格式,并随后比较压缩效率(文件大小)、写入和读取速度。
为了实现这一点,您需要创建一个返回文件大小的函数:
import os
def size_in_mb(file):
size_bytes = os.path.getsize(file)
size_m = size_bytes / (1024**2)
return round(size_m,2)
该函数将获取您创建的文件并返回文件大小(单位:MB)。os.path.getsize()将返回文件大小(单位:字节),而size_bytes / (1024**2)这一行将其转换为兆字节(MB)。
我们将把这些文件写入formats文件夹,以便稍后可以从该文件夹读取以评估读取性能。
写入为 JSON 和 CSV
您将使用DataFrame.to_json()方法写入一个yellow_tripdata.json文件:
%%time
df.to_json('formats/yellow_tripdata.json', orient='records')
size_in_mb('formats/yellow_tripdata.json')
>>
CPU times: user 4.63 s, sys: 586 ms, total: 5.22 s
Wall time: 5.24 s
1165.21
请注意,文件大小约为 1.16 GB,耗时约为 5.24 秒。
您将使用DataFrame.to_csv()方法写入一个yellow_tripdata.csv文件:
%%time
df.to_csv('formats/yellow_tripdata.csv', index=False)
size_in_mb('formats/yellow_tripdata.csv')
>>
CPU times: user 16.7 s, sys: 405 ms, total: 17.1 s
Wall time: 17.1 s
307.04
请注意,文件大小约为 307 MB,耗时约为 17.1 秒。
写入为 Parquet
to_parquet写入函数支持多种压缩算法,包括snappy、GZIP、brotli、LZ4、ZSTD。您将使用DataFrame.to_parquet()方法写入三个文件,以比较snappy、LZ4和ZSTD压缩算法:
%%time
df.to_parquet('formats/yellow_tripdata_snappy.parquet',
compression='snappy')
size_in_mb('formats/yellow_tripdata_snappy.parquet')
>>
CPU times: user 882 ms, sys: 24.2 ms, total: 906 ms
Wall time: 802 ms
59.89
%%time
df.to_parquet('formats/yellow_tripdata_lz4.parquet',
compression='lz4')
size_in_mb('formats/yellow_tripdata_lz4.parquet')
>>
CPU times: user 898 ms, sys: 20.4 ms, total: 918 ms
Wall time: 817 ms
59.92
%%time
df.to_parquet('formats/yellow_tripdata_zstd.parquet',
compression='zstd')
size_in_mb('formats/yellow_tripdata_zstd.parquet')
>>
CPU times: user 946 ms, sys: 24.2 ms, total: 970 ms
Wall time: 859 ms
48.95
注意,三种压缩算法产生相似的压缩结果(文件大小)和速度。
写入为 Feather
您将使用DataFrame.to_feather()方法,使用两个支持的压缩算法LZ4和ZSTD写入三个 feather 文件。最后一个文件格式将是未压缩的格式,以便进行比较:
%%time
df.to_feather('formats/yellow_tripdata_uncompressed.feather', compression='uncompressed')
size_in_mb('formats/yellow_tripdata_uncompressed.feather')
>>
CPU times: user 182 ms, sys: 75.5 ms, total: 257 ms
Wall time: 291 ms
435.84
%%time
df.to_feather('formats/yellow_tripdata_lz4.feather', compression='lz4')
size_in_mb('formats/yellow_tripdata_lz4.feather')
>>
CPU times: user 654 ms, sys: 42.1 ms, total: 696 ms
Wall time: 192 ms
116.44
%%time
df.to_feather('formats/yellow_tripdata_zstd.feather', compression='zstd', compression_level=3)
size_in_mb('formats/yellow_tripdata_zstd.feather')
>>
CPU times: user 1 s, sys: 39.2 ms, total: 1.04 s
Wall time: 243 ms
61.79
- 注意未压缩文件、使用 LZ4 和 ZSTD 压缩算法之间的文件大小差异。您可以进一步探索
compression_level来找到最佳输出。总体而言,LZ4 在写入和读取(压缩和解压缩速度)上提供了出色的性能。ZSTD 算法可能提供更高的压缩比,生成更小的文件,但其速度可能不如 LZ4。
写入为 ORC
类似于 Feather 和 Parquet 文件格式,ORC 支持不同的压缩算法,包括无压缩、snappy、ZLIB、LZ4 和 ZSTD。你将使用 DataFrame.to_orc() 方法写入三个 ORC 文件,以探索 ZSTD 和 LZ4 压缩算法,并将无压缩文件作为对比:
%%time
df.to_orc('formats/yellow_tripdata_uncompressed.orc',
engine_kwargs={'compression':'uncompressed'})
size_in_mb('formats/yellow_tripdata_uncompressed.orc')
>>
CPU times: user 989 ms, sys: 66.3 ms, total: 1.06 s
Wall time: 1.01 s
319.94
%%time
df.to_orc(' formats /yellow_tripdata_lz4.orc',
engine_kwargs={'compression':'lz4'})
size_in_mb('formats/yellow_tripdata_lz4.orc')
>>
CPU times: user 1 s, sys: 67.2 ms, total: 1.07 s
Wall time: 963 ms
319.65
%%time
df.to_orc('yellow_tripdata_zstd.orc',
engine_kwargs={'compression':'zstd'})
size_in_mb('formats/yellow_tripdata_zstd.orc')
>>
CPU times: user 1.47 s, sys: 46.4 ms, total: 1.51 s
Wall time: 1.42 s
53.58
注意,LZ4 算法在与无压缩版本进行比较时并未提供更好的压缩效果。ZSTD 算法确实提供了更好的压缩效果,但执行时间稍长。
它的工作原理…
通常,在处理需要完成转换后持久化到磁盘的大型数据集时,决定选择哪种文件格式会显著影响整体的数据存储策略。
例如,JSON 和 CSV 格式是人类可读的格式,几乎任何商业或开源的数据可视化或分析工具都可以处理这些格式。CSV 和 JSON 格式不支持大文件的压缩,会导致写入和读取操作的性能较差。另一方面,Parquet、Feather 和 ORC 是二进制文件格式(不可读),但支持多种压缩算法,并且是基于列的,这使得它们非常适合用于分析应用程序,具有快速的读取性能。
pandas 库通过 PyArrow 支持 Parquet、Feather 和 ORC,PyArrow 是 Apache Arrow 的 Python 封装。
还有更多内容…
你已经评估了不同文件格式的写入性能(及大小)。接下来,你将比较读取时间性能以及各种文件格式和压缩算法的效率。
为此,你将创建一个函数(measure_read_performance),该函数会读取指定文件夹中的所有文件(例如,formats 文件夹)。该函数将评估每个文件扩展名(例如,.feather、.orc、.json、.csv、.parquet),以确定应使用哪种 pandas 读取函数。然后,该函数会捕获每个文件格式的性能时间,附加结果,并返回一个按读取时间排序的包含所有结果的 DataFrame。
import pandas as pd
import os
import glob
import time
def measure_read_performance(folder_path):
performance_data = []
for file_path in glob.glob(f'{folder_path}/*'):
_, ext = os.path.splitext(file_path)
start_time = time.time()
if ext == '.csv':
pd.read_csv(file_path, low_memory=False)
elif ext == '.parquet':
pd.read_parquet(file_path)
elif ext == '.feather':
pd.read_feather(file_path)
elif ext == '.orc':
pd.read_orc(file_path)
elif ext == '.json':
pd.read_json(file_path)
end_time = time.time()
performance_data.append({'filename': file_path,
'read_time': end_time - start_time})
df = pd.DataFrame(performance_data)
return df.sort_values('read_time').reset_index(drop=True)
你可以通过指定文件夹(例如,formats 文件夹)来执行该函数,以显示最终结果:
results =\
measure_read_performance(folder_path='formats')
print(results)
>>
filename read_time
0 formats/yellow_tripdata_lz4.parquet 0.070845
1 formats/yellow_tripdata_snappy.parquet 0.072083
2 formats/yellow_tripdata_zstd.parquet 0.078382
3 formats/yellow_tripdata_lz4.feather 0.103172
4 formats/yellow_tripdata_zstd.feather 0.103918
5 formats/yellow_tripdata_uncompressed.feather 0.116974
6 formats/yellow_tripdata_zstd.orc 0.474430
7 formats/yellow_tripdata_uncompressed.orc 0.592284
8 formats/yellow_tripdata_lz4.orc 0.613846
9 formats/yellow_tripdata.csv 4.557402
10 formats/yellow_tripdata.json 14.590845
总体而言,读取性能的结果表明,Parquet 文件格式表现最佳,其次是 Feather,然后是 ORC。时间 read_time 以秒为单位。
另见
若要了解更多有关高效数据存储的文件格式,可以参考 pandas。
-
Parquet
-
Feather
-
ORC
-
Apache Arrow:
arrow.apache.org/overview/
第五章:5 将时间序列数据持久化到数据库
加入我们在 Discord 上的书籍社区

在完成一个数据分析任务后,通常会从源系统提取数据,进行处理、转换并可能建模,最后将结果存储到数据库中以实现持久化。你总是可以将数据存储在平面文件中或导出为 CSV,但在处理大量企业数据(包括专有数据)时,你需要一种更强大且安全的存储方式。数据库提供了多个优势:安全性(静态加密)、并发性(允许多个用户查询数据库而不影响性能)、容错性、ACID合规性、优化的读写机制、分布式计算和分布式存储。
在企业环境中,一旦数据被存储在数据库中,它可以跨不同部门共享;例如,财务、市场营销、销售和产品开发部门现在可以根据自己的需求访问存储的数据。此外,数据现在可以实现民主化,供不同角色的组织人员应用于各种用例,如业务分析师、数据科学家、数据工程师、市场分析师和商业智能开发人员。
在本章中,你将把时间序列数据写入数据库系统以实现持久化。你将探索不同类型的数据库(关系型和非关系型),并使用Python推送你的数据。
更具体地说,你将使用pandas库,因为你会通过使用 pandas 的DataFrame进行大部分的分析。你将学习如何使用 pandas 库将你的时间序列 DataFrame 持久化到数据库存储系统中。许多数据库提供 Python API 和连接器,最近,许多数据库已经支持 pandas DataFrame(用于读取和写入),因为它们的流行和主流应用。在本章中,你将使用关系型数据库、文档数据库、云数据仓库和专门的时间序列数据库。
本章的目的是让你通过与不同方法连接到这些数据库系统,亲身体验如何持久化时间序列 DataFrame。
以下是本章将涵盖的内容列表:
-
将时间序列数据写入关系型数据库
-
将时间序列数据写入 MongoDB
-
将时间序列数据写入 InfluxDB
-
将时间序列数据写入 Snowflake
写入数据库和权限
记住,当你安装数据库实例或使用云服务时,写入数据是直接的,因为你是所有者/管理员角色。
在任何公司中,这种情况在他们的数据库系统中并不适用。你必须与数据库的所有者、维护者以及可能的 IT 人员、数据库管理员或云管理员对接。在大多数情况下,他们可以允许你将数据写入沙盒或开发环境。然后,一旦完成,可能是同一个团队或另一个团队(如 DevOps 团队)会检查代码并评估性能,之后才会将代码迁移到质量保证(QA)/用户验收测试(UAT)环境。一旦进入该环境,业务部门可能会参与测试并验证数据,以便获得批准。最终,它可能会被推广到生产环境,以便所有人都可以开始使用数据。
技术要求
本章将广泛使用 pandas 2.2.2(于 2024 年 4 月 10 日发布)。
在我们的旅程中,你将安装多个 Python 库来与 pandas 一起使用。这些库在每个配方的准备部分中都有说明。你还可以从 GitHub 仓库下载 Jupyter notebooks,网址为 github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook 来跟着一起练习。
你应该参考 第三章 中的 技术要求 部分,从数据库中读取时间序列数据。这包括创建一个 配置文件,如
database.cfg。
本章中所有的配方将使用相同的数据集。该数据集基于 2019 年 1 月至 2023 年 12 月的亚马逊股票数据,通过 yfinance 库获取,并以 pandas DataFrame 的形式存储。
首先安装 yfinance 库,你可以通过 conda 安装,方法如下:
conda install -c conda-forge yfinance
你还可以通过 pip 安装,方法如下:
pip install yfinance
为了了解这个库的工作原理,你将从使用 yfinance 拉取亚马逊股票数据开始。
import yfinance as yf
amzn = yf.Ticker("AMZN")
amzn_hist = amzn.history(start="2019-01-01", end="2023-12-31")
amzn_hist.info()
>>
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 1258 entries, 2019-01-02 00:00:00-05:00 to 2023-12-29 00:00:00-05:00
Data columns (total 7 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Open 1258 non-null float64
1 High 1258 non-null float64
2 Low 1258 non-null float64
3 Close 1258 non-null float64
4 Volume 1258 non-null int64
5 Dividends 1258 non-null float64
6 Stock Splits 1258 non-null float64
dtypes: float64(6), int64(1)
memory usage: 78.6 KB
生成的 DataFrame 有七(7)列和 1258 行。它包含一个 DatetimeIndex,格式为 2019-01-02 00:00:00-05:00。我们将重点关注几列(Open、High、Low、Close 和 Volume),并将 DatetimeIndex 的日期时间格式更改为 YYYY-MM-DD:
amzn_hist.index = amzn_hist.index.strftime('%Y-%m-%d')
amzn_hist = amzn_hist[['Open', 'High', 'Low', 'Close', 'Volume']]
print(amzn_hist.head())
>>
Open High Low Close Volume
Date
2019-01-02 73.260002 77.667999 73.046501 76.956497 159662000
2019-01-03 76.000504 76.900002 74.855499 75.014000 139512000
2019-01-04 76.500000 79.699997 75.915497 78.769501 183652000
2019-01-07 80.115501 81.727997 79.459503 81.475502 159864000
2019-01-08 83.234497 83.830498 80.830498 82.829002 177628000
基于前面的示例,我们可以通过创建一个可以在本章中调用的函数来概括这种方法:
import yfinance as yf
def get_stock_data(ticker, start, end):
stock_data = yf.Ticker(ticker)
stock_data = stock_data.history(start=start, end=end)
stock_data.index = stock_data.index.strftime('%Y-%m-%d')
stock_data = stock_data[['Open', 'High', 'Low', 'Close', 'Volume']]
return stock_data
get_stock_data 函数将返回一个包含选定列和格式化 DatetimeIndex 的 pandas DataFrame。它需要三个输入:一个 ticker 符号,一个 start 日期和一个 end 日期。如果你想获取从 2024 年 1 月 1 日到今天的股票数据,只需将 end 参数传递为 None。下面是一个示例:
msft = get_stock_data('MSFT', '2024-01-01', None)
这将提供从 2024 年 1 月 1 日到请求时的最新数据的股票数据。
将时间序列数据写入关系型数据库(PostgreSQL 和 MySQL)
在这个配方中,你将把 DataFrame 写入 PostgreSQL 等关系型数据库。对于 SQLAlchemy Python 库支持的任何关系型数据库系统,这种方法都是一样的。你将体验到 SQLAlchemy 如何使得切换后端数据库(称为 dialect)变得简单,而无需更改代码。SQLAlchemy 提供的抽象层使得你能够使用相同的代码在任何支持的数据库之间切换,例如从 PostgreSQL 切换到 Amazon Redshift。
SQLAlchemy 支持的关系型数据库(方言)示例包括以下内容:
-
Microsoft SQL Server
-
MySQL/MariaDB
-
PostgreSQL
-
Oracle
-
SQLite
此外,还可以安装并使用外部方言与 SQLAlchemy 配合使用,以支持其他数据库(方言),如 Snowflake、Microsoft SQL Server 和 Google BigQuery。请访问 SQLAlchemy 的官方网站,查看可用的方言列表:docs.sqlalchemy.org/en/14/dialects/。
准备工作
你应参考第 3 章 中的配方“从关系型数据库中读取数据”,以回顾连接 PostgreSQL 的不同方式。
在此配方中,你将使用 yfinance Python 库来拉取股票数据。
要使用 conda 安装这些库,请运行以下命令:
>> conda install sqlalchemy psycopg
要使用 pip 安装这些库,请运行以下命令:
>> pip install sqlalchemy
>> pip install pyscopg
本书的 GitHub 仓库中提供了文件,你可以在这里找到:github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook。
如何操作……
在此配方中,你将使用 yfnance 库从 2019 年 1 月到 2023 年 12 月获取亚马逊的股票数据,并将其存入一个 pandas DataFrame 中,然后将该 DataFrame 写入 PostgreSQL 数据库中的表:
- 从调用 技术要求 部分中创建的
get_stock_data函数开始。
amzn_hist = get_stock_data('AMZN', '2019-01-01', '2023-12-31')
- 你需要创建一个 SQLAlchemy engine 对象。该引擎告诉 SQLAlchemy 和 pandas 我们计划与之交互的方言(后端数据库)以及运行中数据库实例的连接详情。利用
URL.create()方法,通过提供必要的参数(drivername、username、password、host、port和database)来创建一个格式正确的 URL 对象。这些参数存储在database.cfg文件中。
from sqlalchemy import create_engine, URL
from configparser import ConfigParser
config = ConfigParser()
config.read('database.cfg')
config.sections()
params = dict(config['POSTGRESQL'])
url = URL.create('postgresql+psycopg', **params)
print(url)
>>
postgresql+psycopg://postgres:***@127.0.0.1/postgres
现在,你可以将 url 对象传递给 create_engine:
engine = create_engine(url)
print(engine)
>>
Engine(postgresql+psycopg://postgres:***@127.0.0.1/postgres)
- 让我们将
amz_histDataFrame 写入 PostgreSQL 数据库实例中的新amzn表。这是通过使用DataFrame.to_sql()写入函数来实现的,该函数利用 SQLAlchemy 的功能将 DataFrame 转换为合适的表模式,并将数据转换为适当的 SQL 语句(如CREATE TABLE和INSERT INTO),这些语句特定于方言(后端数据库)。如果表不存在,在加载数据之前会创建一个新表;如果表已存在,你需要提供如何处理表的指令。这是通过if_exists参数来完成的,参数可以接受以下选项之一:'fail'、'replace'或'append'。
amzn_hist.to_sql('amzn',
engine,
if_exists='replace')
完成与前面的代码相同任务的另一种方法是利用with语句,这样你就无需管理连接。这通常是一个更优的做法。
with engine.connect() as connection:
hist.to_sql('amzn',
connection,
if_exists='replace')
一旦执行上述代码,一个新的amzn表会在默认的postgres数据库的 public 模式下创建(默认)。
你可以通过以下方式验证数据库中的内容:
from sqlalchemy import text
query = """
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'amzn'
);"""
with engine.connect() as conn:
result = conn.execute(text(query))
print(result.fetchone())
>>
(True,)
注意使用text()函数包裹我们的查询。text()构造了一个新的TextClause,用来表示文本 SQL 字符串。
- 通过查询
amzn表并统计记录数来确认数据已写入数据库:
query = "select count(*) from amzn;"
with engine.connect() as conn:
result = conn.execute(text(query))
result.fetchone()
>>
(1258,)
- 接下来,使用
get_stock_data请求额外的亚马逊股价数据,这次是 2024 年的数据(例如,2024 年 1 月 1 日到 2024 年 9 月 23 日),并将其附加到现有的amzn表中。在这里,你将利用to_sql()写入函数中的if_exists参数。
amzn_hist_2024 = get_stock_data('AMZN', '2024-01-01', None)
print(amzn_hist_2024.shape)
>>
(182, 5)
确保将append传递给if_exists参数,如以下代码所示:
with engine.connect() as connection:
amzn_hist_2024.to_sql('amzn',
connection,
if_exists='append')
- 统计记录的总数,以确保我们已经将 182 条记录附加到原来的 1258 条记录中。你将运行与之前相同的查询,如以下代码所示:
query = "select count(*) from amzn;"
with engine.connect() as conn:
result = conn.execute(text(query))
print(result.fetchone())
>>
(1440,)
确实,你可以观察到所有 1440 条记录都已写入amzn表。
它是如何工作的……
使用DataFrame.to_sql()写入函数,SQLAlchemy 在后台处理许多细节,比如创建表的模式、插入记录并提交到数据库。
使用 pandas 和 SQLAlchemy 向关系型数据库写入和读取数据非常相似。我们在第三章的从关系型数据库读取数据章节中讨论了使用 SQLAlchemy 读取数据。许多讨论的概念同样适用于这里。
我们总是从create_engine开始,并指定方言(后端数据库)。to_sql()函数会将 DataFrame 的数据类型映射到适当的 PostgreSQL 数据类型。使用对象关系映射器(ORM)如 SQLAlchemy 的优势在于,它提供了一个抽象层,使你无需担心将 DataFrame 模式转换为特定数据库模式。
在前面的例子中,你在 DataFrame.to_sql() 函数中使用了 if_exists 参数,并传递了两个不同的参数:
-
最初,你将值设置为
replace,这会在表存在时覆盖该表。如果我们将此覆盖操作转换为 SQL 命令,它会执行DROP TABLE,然后是CREATE TABLE。如果你已经有一个包含数据的表并打算向其中添加记录,这可能会很危险。因此,如果不传递任何参数,默认值会设置为fail。这种默认行为会在表已存在时抛出错误。 -
在食谱的第二部分,计划是将额外的记录插入到现有表中,并且你将参数从
replace更新为append。
当你使用 yfinance 拉取股票数据时,它会自动将 Date 字段指定为 DatetimeIndex。换句话说,Date 不是一列,而是一个索引。在 to_sql() 中,默认行为是将 DataFrame 的索引作为数据库中的一列写入,这由 index 参数控制。这个参数是布尔类型,默认值为 True,表示将 DataFrame 索引作为列写入。
另一个非常有用的参数是 chunksize。默认值为 None,表示一次性将 DataFrame 中的所有行写入数据库。如果你的数据集非常庞大,可以使用 chunksize 参数批量写入数据库;例如,设置 chunksize 为 500 会一次性批量写入 500 行数据。
with engine.connect() as connection:
amzn_hist.to_sql('amzn',
connection,
chunksize=500,
if_exists='append')
还有更多…
使用 pandas 的 read_sql、read_sql_table、read_sql_query 和 to_sql I/O 函数时,它们需要一个 SQLAlchemy 连接对象(SQLAlchemy 引擎)。要使用 SQLAlchemy 连接到目标数据库,你需要为特定的数据库(例如 Amazon Redshift、Google BigQuery、MySQL、MariaDB、PostgreSQL 或 Oracle)安装相应的 Python DBAPI(驱动程序)。这样你可以一次编写脚本,并且仍然能够与 SQLAlchemy 支持的其他方言(后端数据库)一起使用。为了演示这一点,我们将扩展最后一个例子。
Amazon Redshift 是一款流行的云数据仓库数据库,基于 PostgreSQL 架构,并在其基础上做了多项增强,包括列存储以加速分析查询。你将探索 SQLAlchemy 的简便性,以及其他将 pandas DataFrame 写入 Amazon Redshift 的选项。
使用 SQLAlchemy 向 Amazon Redshift 写入数据
这次你将使用相同的代码,但写入 Amazon Redshift 数据库。除了运行 MySQL 实例之外,唯一的要求是安装适用于的 Python DBAPI(驱动程序)。
Amazon Redshift。注意,sqlalchemy-redshift 需要 psycopg2。
要使用 conda 安装,请运行以下命令:
conda install -c conda-forge psycopg2 sqlalchemy-redshift
要使用 pip 安装,请运行以下命令:
pip install pip install psycopg2 sqlalchemy-redshift
你将使用相同的代码来操作 PostgreSQL;唯一的不同是 SQLAlchemy 引擎,它使用的是 Amazon Redshift 的 DBAPI。首先从配置文件中加载连接参数。在这个示例中,配置存储在 database.cfg 文件中。
[AWS]
host=yourendpoint
port=5439
database=dev
username=username
password=password
使用 ConfigParser 和 URL 提取参数并构建 URL:
from configparser import ConfigParser
config = ConfigParser()
config.read('database.cfg')
config.sections()
params = dict(config['AWS'])
from sqlalchemy import URL, create_engine
url = URL.create('redshift+psycopg2', **params)
print(url)
>>
redshift+psycopg2://awsuser:***@redshift-cluster-1.cltc17lacqp7.us-east-1.redshift.amazonaws.com:5439/dev
你现在可以使用以下代码创建引擎:
aws_engine = create_engine(url)
使用 yfinance 库创建一个基于过去 5 年 股票数据的新 amzn_hist DataFrame:
amzn = yf.Ticker("AMZN")
amzn_hist = amzn.history(period="5y")
amzn_hist = amzn_hist[['Open', 'High', 'Low', 'Close', 'Volume']]
在写入 DataFrame 之前,我们需要重置索引。这将使我们恢复 Date 列。我们这样做是因为 Amazon Redshift 不支持传统的索引,因为它是列式数据库(相反,你可以定义一个 排序键)。
amzn_hist = amzn_hist.reset_index()
with aws_engine.connect() as conn:
amzn_hist.to_sql('amzn',
conn,
if_exists='replace', index=False)
请注意前面的代码中的 index=False。这是因为 to_sql 会写入 DataFrame 中的索引对象,默认情况下 index=True。当你重置 DataFrame 索引时,它会将 DatetimeIndex 移动到 Date 列,并用 RangeIndex(从 0 到 1257)替换索引。使用 index=False 确保我们不会尝试将 RangeIndex 写入 Amazon Redshift。
最后,你可以验证写入的记录总数:
from sqlalchemy import text
query = "select count(*) from amzn;"
with aws_engine.connect() as conn:
result = conn.execute(text(query))
result.fetchone()
>>
(1258,)
使用 redshift_connector 写入 Amazon Redshift
在这个示例中,你将使用一个不同的库,即 redshift_connector。你首先需要安装该库。
你可以使用 conda 安装:
conda install -c conda-forge redshift_connector
你也可以使用 pip 安装它:
pip install redshift_connector
请注意,redshift_connector 需要一个 user 参数,这与 SQLAlchemy 需要 username 参数不同。为此,你可以在配置文件中创建一个新部分。下面是一个示例:
[AWS2]
host=yourendpoint
port=5439
database=dev
user=username
password=password
以下代码从 database.cfg 文件中读取参数,并将这些参数传递给 redshift_connector.connect() 来创建连接对象。
import redshift_connector
from configparser import ConfigParser
config = ConfigParser()
config.read('database.cfg')
config.sections()
params = dict(config['AWS2'])
conn = redshift_connector.connect(**params)
你将创建一个游标对象,它提供对 write_dataframe 方法的访问。
cursor = conn.cursor()
cursor.write_dataframe(amzn_hist, 'amzn')
最后,你将提交事务。
conn.commit()
请注意,write_dataframe 方法没有提供指定追加、替换/覆盖或失败行为的参数,正如你在 SQLAlchemy 中所看到的那样。write_dataframe 方法期望在 Amazon Redshift 中已存在的表进行追加。
使用 AWS SDK for pandas 写入 Amazon Redshift
awswrangler 库或 AWS SDK for pandas 可以轻松地与多个 AWS 服务(如 Athena、Glue、Redshift、Neptune、DynamoDB、EMR、S3 等)集成。
你可以使用 conda 安装该库:
conda install -c conda-forge awswrangler
你也可以使用 pip 安装:
pip install 'awswrangler[redshift]'
你可以利用在上一节中创建的 conn 对象,使用 redshift_connector 写入 Amazon Redshift。
import awswrangler as wr
wr.redshift.to_sql(
df=amzn_hist,
table='amzn',
schema='public',
con=conn,
mode='overwrite'
)
请注意,mode 参数支持三种(3)不同的选项:overwrite、append 或 upsert。
另请参见
这里有一些额外的资源:
-
若要了解更多关于
DataFrame.to_sql()函数的信息,可以访问pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_sql.html。 -
要了解更多关于SQLAlchemy的功能,你可以先阅读它们的功能页面:
www.sqlalchemy.org/features.html。 -
要了解
awswrangler,你可以访问他们的 GitHub 仓库:github.com/aws/aws-sdk-pandas
将时间序列数据写入 MongoDB
MongoDB是一个文档数据库系统,它以BSON格式存储数据。当你从 MongoDB 查询数据时,数据将以 JSON 格式呈现。BSON 与 JSON 类似,它是 JSON 的二进制编码格式。不过,BSON 不像 JSON 那样是人类可读的格式。JSON 非常适合传输数据,且与系统无关,而 BSON 则专为存储数据并与 MongoDB 相关联。
在这个实例中,你将学习如何将一个 pandas DataFrame 写入 MongoDB。
准备工作
你应该参考第三章中“从文档数据库读取数据”这一实例,以便复习连接 MongoDB 的不同方法。
在第三章中“从文档数据库读取数据”这一实例中,我们安装了pymongo。在本实例中,你将再次使用该库。
要通过conda安装,请运行以下命令:
$ conda install -c anaconda pymongo -y
要通过pip安装,请运行以下命令:
$ python -m pip install pymongo
本书的 GitHub 仓库中提供了该文件,你可以在此找到:github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook。
如何操作…
要将数据存储到 MongoDB 中,你需要创建一个数据库和一个集合。一个数据库包含一个或多个集合,这些集合类似于关系数据库中的表。一旦创建集合,你将以文档形式写入数据。集合包含文档,文档相当于关系数据库中的行。
- 首先,导入必要的库:
import pandas as pd
from pymongo import MongoClient
- 创建一个
MongoClient实例以建立与数据库的连接:
client = MongoClient('mongodb://localhost:27017')
- 创建一个名为
stock_data的新数据库,并创建一个名为daily_stock的时间序列集合。
首先,我们在 MongoDB 中创建一个常规集合:
db = client['stock_data']
collection = db.create_collection('amazon')
这将创建一个名为stock_data的新数据库,并创建一个名为 amazon 的集合。如果stock_data已存在,它将把amazon集合添加到现有数据库中。
然而,由于我们处理的是时间序列数据,我们可以通过创建一个时间序列集合来更高效地存储和查询数据。从 MongoDB 5.0 版本开始,时间序列集合已针对带时间戳的数据进行了优化。我们可以修改之前的代码来创建一个daily_stock时间序列集合。
db = client['stock_data']
ts = db.create_collection(
name="daily_stock",
timeseries={
"timeField": "Date",
"metaField": "symbol",
"granularity": "hours"
}
)
通过此次更新,我们现在使用时间序列集合,这提高了时间数据(如股票价格)的存储效率和查询性能。以后,我们将使用ts引用来与时间序列集合进行交互。
- 你将利用在技术要求部分创建的
get_stock_data函数,拉取从 2019 年 1 月 1 日到 2024 年 8 月 31 日的亚马逊股票数据:
amzn_hist = get_stock_data('AMZN', '2019-01-01', '2024-8-31')
- 在 pandas 中,我们以表格格式处理数据,其中每一列代表一个变量,每一行代表一个数据点。而 MongoDB 则将数据存储为类似 JSON 的格式(BSON),其中每个文档是一个独立的记录,可以包含时间戳、元数据和其他键值对。
在将数据插入 MongoDB 之前,你需要将 DataFrame 转换为一个字典列表,每个字典(或文档)表示一个股票数据点。每个字典将包含时间戳(Date)、股票信息(例如High、Low)和元数据(例如"ticker": "AMZN")。
你将探索两个选项:第一个选项使用to_dict()方法,第二个选项是遍历 DataFrame。
让我们来探索第一个选项:
metadata = {"ticker": "AMZN"}
amzn_hist['metadata'] = [metadata] * len(amzn_hist)
amzn_hist = amzn_hist.reset_index()
amzn_hist['Date'] = pd.to_datetime(amzn_hist['Date'])
amzn_records = amzn_hist.to_dict(orient='records')
amzn_records[0:2]
>>
[{'Date': Timestamp('2019-01-02 00:00:00'),
'Open': 73.26000213623047,
'High': 77.66799926757812,
'Low': 73.04650115966797,
'Close': 76.95649719238281,
'Volume': 159662000,
'metadata': {'ticker': 'AMZN'}},
{'Date': Timestamp('2019-01-03 00:00:00'),
'Open': 76.00050354003906,
'High': 76.9000015258789,
'Low': 74.85549926757812,
'Close': 75.01399993896484,
'Volume': 139512000,
'metadata': {'ticker': 'AMZN'}}]
在这里,我们假设所有数据都有相同的元数据信息(例如"ticker": "AMZN")。
to_dict()方法中orient参数的默认值是dict,它会生成一个字典,其格式为{column -> {index -> value}}。另一方面,使用
records作为值,会生成一个列表,它遵循[{column -> value}, … , {column -> value}]的模式。
现在,让我们来探索第二个选项,它提供了更多的灵活性,可以为特定字段添加数据或对单个记录进行转换,例如,基于不同的股票代码值:
amzn_hist = amzn_hist.reset_index()
amzn_records = []
for idx, row in amzn_hist.iterrows():
doc = {
"Date": pd.to_datetime(row['Date']),
"metadata": {"ticker": "AMZN"},
"High": row['High'],
"Low": row['Low'],
"Close": row['Close'],
"Open": row['Open'],
"Volume": row['Volume']
}
amzn_records.append(doc)
amzn_records[0:2]
>>
[{'Date': Timestamp('2019-01-02 00:00:00'),
'metadata': {'ticker': 'AMZN'},
'High': 77.66799926757812,
'Low': 73.04650115966797,
'Close': 76.95649719238281,
'Open': 73.26000213623047,
'Volume': 159662000},
{'Date': Timestamp('2019-01-03 00:00:00'),
'metadata': {'ticker': 'AMZN'},
'High': 76.9000015258789,
'Low': 74.85549926757812,
'Close': 75.01399993896484,
'Open': 76.00050354003906,
'Volume': 139512000}]
现在,你有了一个长度为1426的 Python 列表(每个记录是一个字典):
len(amzn_records)
>>
1426
- 现在,你已经准备好使用
insert_many()方法将数据写入时间序列daily_stock集合中:
result = ts.insert_many(amzn_records)
- 你可以使用以下代码验证数据库和集合是否已创建:
client.list_database_names()
>>
['admin', 'config', 'local', 'stock_data']
db.list_collection_names()
>>
['daily_stock', 'system.buckets.daily_stock', 'system.views']
- 接下来,拉取微软的股票数据(MSFT)并将其添加到同一个
daily_stock时间序列集合中。稍后,你将探索如何利用元数据在查询数据时区分不同的股票代码(如 AMZN 与 MSFT)。
msft_hist = get_stock_data('MSFT', '2019-01-01', '2024-8-31')
metadata = {"ticker": "MSFT"}
msft_hist['metadata'] = [metadata] * len(msft_hist)
msft_hist = msft_hist.reset_index()
msft_hist['Date'] = pd.to_datetime(msft_hist['Date'])
msft_records = msft_hist.to_dict(orient='records')
result = ts.insert_many(msft_records)
你可以通过查询数据库来检查写入的文档总数,如下面的代码所示:
ts.count_documents({})
>>
2852
- 现在,集合中包含了两个股票代码的数据。你可以使用元数据在查询中为每个代码进行筛选。你将首先查询
daily_stock集合,检索仅包含微软(MSFT)股票数据的记录。这时,metadata字段变得非常有用,允许你按股票代码进行筛选。让我们首先定义一个日期范围,然后仅查询 MSFT 的数据。
from datetime import datetime
# Define date range
start_date = datetime(2019, 1, 1)
end_date = datetime(2019, 1, 31)
# Query for MSFT stock data within the date range
results = ts.find({
"metadata.ticker": "MSFT",
"Date": {"$gte": start_date, "$lte": end_date}
})
# Convert the query results to a DataFrame
msft_df = (pd.DataFrame(results)
.set_index('Date')
.drop(columns=['_id', 'metadata']))
print(msft_df.head())
>>
Close High Open Volume Low
Date
2019-01-02 95.501335 96.096327 94.018571 35329300 93.442465
2019-01-03 91.988037 94.623014 94.538011 42579100 91.799146
2019-01-04 96.266327 96.814101 94.179125 44060600 93.433020
2019-01-07 96.389107 97.531873 95.992445 35656100 95.369122
2019-01-08 97.087975 98.192962 97.314637 31514400 96.058536
- 你还可以进行聚合计算每个股票代码的平均
Close价格:
msft_avg_close = ts.aggregate([
{"$group":
{"_id": "$metadata.ticker",
"avgClose":
{"$avg": "$Close"}}
}
])
for doc in msft_avg_close:
print(doc)
>>
{'_id': 'AMZN', 'avgClose': 133.4635473361022}
{'_id': 'MSFT', 'avgClose': 252.4193055419066}
它是如何工作的…
PyMongo提供了两个插入函数,用于将我们的记录作为文档写入集合。这些函数如下:
-
insert_one()会将一个文档插入到集合中。 -
insert_many()会将多个文档插入到集合中。
在前面的示例中,你使用了insert_many()并同时传入了要写入的数据作为文档。然而,在执行此操作之前,将 DataFrame 转换为符合[{column -> value}, … , {column -> value}]模式的字典列表格式是至关重要的。这是通过在to_dict() DataFrame 方法中使用orient='records'来完成的。
当文档插入数据库时,它们会被分配一个唯一的_id值。如果文档尚未拥有_id,MongoDB 会在插入操作中自动生成一个。你可以捕获生成的_id,因为插入函数会返回一个结果对象——对于单次插入是InsertOneResult,对于批量插入是InsertManyResult。以下代码演示了如何使用insert_one和InsertOneResult类来实现这一点:
one_record = amzn_records[0]
one_record
>>
{'Date': Timestamp('2019-01-02 00:00:00'),
'metadata': {'ticker': 'AMZN'},
'High': 77.66799926757812,
'Low': 73.04650115966797,
'Close': 76.95649719238281,
'Open': 73.26000213623047,
'Volume': 159662000}
result_id = ts.insert_one(one_record)
result_id
>>
InsertOneResult(ObjectId('66f2ed5efad8cbd88968d02e'), acknowledged=True)
返回的对象是InsertOneResult的一个实例;要查看实际的值,你可以使用insert_id属性:
result_id.inserted_id
>>
ObjectId('66f2ed5efad8cbd88968d02e')
如果你有分钟级的股票数据,你可以利用granularity属性,它可以是seconds、minutes或hours。
还有更多内容…
在前面的示例中,如果你运行以下代码查询数据库以列出可用的集合,你将看到三个集合:
db = client['stock_data']
db.list_collection_names()
>>
['daily_stock', 'system.buckets.daily_stock', 'system.views']
你创建了daily_stock集合,那么另外两个集合是什么呢?让我们首先探讨一下 MongoDB 中的桶模式。
桶模式是一种数据建模技术,用来优化数据在数据库中的存储方式。默认情况下,当你将 DataFrame 转换为字典列表时,实际上是将每个 DataFrame 记录(数据点)作为一个独立的 MongoDB 文档插入。这会在记录和文档之间创建一对一的映射。
然而,桶策略允许你将相关的数据点分组到一个文档中。例如,如果你有每小时的数据,你可以将它们分组到一个桶中,比如 24 小时周期,并将该时间范围内的所有数据存储在一个文档中。同样,如果我们有来自多个设备的传感器数据,你可以使用桶模式将数据(例如按设备 ID 和时间范围)分组,并将它们作为一个文档插入。这将减少数据库中文档的数量,提高整体性能,并简化查询。
当你创建一个时间序列集合时,MongoDB 会自动应用桶模式,以高效的格式存储数据。让我们分解一下:
-
daily_stock:这是你创建的主要时间序列集合。它充当视图,允许你使用标准的 MongoDB 操作与时间序列数据进行交互。 -
system.buckets.daily_stock:这是一个内部集合,MongoDB 使用桶模式存储实际的时间序列数据。MongoDB 会自动为时间序列集合实现这一策略,以提高存储和查询性能。它是如何工作的呢:-
文档会根据时间戳和元数据字段(例如,股票符号)被分组到“桶”中。
-
每个桶包含时间上接近的数据点,并共享相同的元数据值。
-
这种分桶策略显著减少了存储的文档数量,提高了查询效率并减少了磁盘使用。
-
-
system.views:这是一个系统集合,MongoDB 用来存储数据库中所有视图的信息,包括你的时间序列集合的视图。
为了更好地理解桶模式的应用,我们将通过创建一个新的集合(常规集合,而非时间序列集合),并按年和月将每日股票数据分桶:
db = client['stock_data']
bucket = db.create_collection(name='stock_bucket')
接下来,让我们创建一个新的 DataFrame,并添加两个额外的列:month 和 year:
amzn = yf.Ticker("AMZN")
amzn_hist = amzn.history(period="5y")
amzn_hist = amzn_hist[['Open',
'High',
'Low',
'Close',
'Volume']].reset_index()
amzn_hist['Date'] = pd.to_datetime(amzn_hist['Date'])
amzn_hist['month'] = amzn_hist['Date'].dt.month
amzn_hist['year'] = amzn_hist['Date'].dt.year
在前面的代码中,你向 DataFrame 添加了 month 和 year 列,并创建了一个名为 stocks_bucket 的新集合。在接下来的代码段中,你将循环遍历数据,并将按年和月分组的数据作为一个单一文档写入:
for year in amzn_hist['year'].unique():
for month in amzn_hist['month'].unique():
record = {}
record['month'] = int(month)
record['year'] = int(year)
record['symbol'] = 'AMZN'
try:
prices = amzn_hist[(amzn_hist['month'] == month) & (amzn_hist['year'] == year)]['Close'].values
record['price'] = [float(price) for price in prices]
except Exception as e:
print(f"Error processing data for {month}/{year}: {str(e)}")
continue
else:
bucket.insert_one(record)
在代码中,你遍历了唯一的年和月组合,然后为每个组合创建一个包含月、年、符号和收盘价列表的记录字典。然后,该记录被插入到 stock_bucket 集合中,有效地按月和年对数据进行了分桶。
为了说明文档数量的差异,请运行以下代码:
print('without bucketing: ',
db.daily_stock.count_documents({}))
print('with bucketing: ',
db.stock_bucket.count_documents({}))
>>
without bucketing: 2853
with bucketing: 72
请注意,stock_bucket 集合包含 72 个文档,代表按年和月分组的数据。
要查询 2024 年和 6 月的数据,并查看文档如何表示,请使用以下代码示例:
results = pd.DataFrame(bucket.find({'year':2024, 'month': 6}))
results['price'].to_dict()[0]
>>
[178.33999633789062,
179.33999633789062,
181.27999877929688,
185.0,
184.3000030517578,
187.05999755859375,
187.22999572753906,
186.88999938964844,
183.8300018310547,
183.66000366210938,
184.05999755859375,
182.80999755859375,
186.10000610351562,
189.0800018310547,
185.57000732421875,
186.33999633789062,
193.61000061035156,
197.85000610351562,
193.25]
你也可以使用 MongoDB Compass 运行相同的查询,结果应与图示中显示的类似:

图示 – 使用 MongoDB Compass 查询 stock_bucket 集合
另见
-
欲了解更多关于在 MongoDB 中存储时间序列数据和分桶的信息,你可以参考这篇 MongoDB 博客文章:
www.mongodb.com/blog/post/time-series-data-and-mongodb-part-2-schema-design-best-practices -
查看 MongoDB 手册,了解更多关于时间序列集合的信息
www.mongodb.com/docs/manual/core/timeseries-collections/
写入时间序列数据到 InfluxDB
在处理大规模时间序列数据时,如传感器或物联网(IoT)数据,你需要一种更高效的方式来存储和查询这些数据,以便进行进一步的分析。这就是时间序列数据库的优势所在,因为它们专门为处理复杂且非常大的时间序列数据集而构建。
在这个示例中,我们将以InfluxDB为例,演示如何写入时间序列数据库。
准备工作
你应该参考第三章“从数据库中读取时间序列数据”中的教程“从时间序列数据库中读取数据”,以便复习连接 InfluxDB 的不同方式。
你将使用ExtraSensory数据集,这是由加利福尼亚大学圣地亚哥分校提供的一个移动感应数据集:Vaizman, Y., Ellis, K., 和 Lanckriet, G. “从智能手机和智能手表识别复杂的人类背景”。IEEE Pervasive Computing, vol. 16, no. 4, 2017 年 10 月至 12 月, pp. 62-74. doi:10.1109/MPRV.2017.3971131
你可以在这里下载数据集:extrasensory.ucsd.edu/#download
数据集包含 60 个文件,每个文件代表一个参与者,并通过唯一标识符(UUID)来识别。每个文件包含 278 列:225 列(特征)、51 列(标签)和 2 列(时间戳和标签来源)。
本教程的目标是演示如何将时间序列 DataFrame 写入 InfluxDB。在这个教程中,选择了两列:时间戳(日期范围从2015-07-23到2016-06-02,共覆盖 152 天)和手表加速度计读数(以毫 G 为单位测量)。
在你可以在 Python 中与 InfluxDB 交互之前,你需要安装 InfluxDB Python 库。
你可以通过运行以下命令,使用pip安装该库:
$ pip install 'influxdb-client[ciso]'
要使用conda安装,请使用以下命令:
conda install -c conda-forge influxdb-client
如何操作…
你将通过读取ExtraSensory数据集中的一个文件(针对特定的 UUID)来开始这个教程,重点关注一个特征列——手表加速度计。你将进行一些数据转换,为将时间序列 DataFrame 写入 InfluxDB 做准备:
- 首先加载所需的库:
from influxdb_client import InfluxDBClient, WriteOptions
from influxdb_client.client.write_api import SYNCHRONOUS
import pandas as pd
from pathlib import Path
- 数据集由 60 个压缩 CSV 文件(
csv.gz)组成,你可以使用pandas.read_csv()读取这些文件。read_csv的默认compression参数设置为infer,这意味着 pandas 会根据文件扩展名推断使用哪种压缩或解压协议。文件扩展名为(gz),pandas 会使用这个扩展名来推断需要使用的解压协议。或者,你也可以通过compression='gzip'明确指定使用哪种压缩协议。
在以下代码中,你将读取这些文件中的一个,选择timestamp和watch_acceleration:magnitude_stats:mean两列,重命名这些列,最后,针对所有na(缺失)值执行回填操作:
path = Path('../../datasets/Ch5/ExtraSensory/')
file = '0A986513-7828-4D53-AA1F-E02D6DF9561B.features_labels.csv.gz'
columns = ['timestamp',
'watch_acceleration:magnitude_stats:mean']
df = pd.read_csv(path.joinpath(file),
usecols=columns,
compression='gzip')
df = df.bfill()
df.columns = ['timestamp','wacc']
df.shape
>>
(3960, 2)
从前面的输出中,你有来自那个文件的3960个传感器读数。
- 为了将数据写入 InfluxDB,你需要至少一个
measurement列和一个timestamp列。目前,时间戳是一个 Unix 时间戳(epoch),以秒为单位捕获,这是一个可接受的写入 InfluxDB 的数据格式。例如,2015-12-08 7:06:37 PM在数据集中以1449601597的形式存储。
InfluxDB 在磁盘上以纪元纳秒存储时间戳,但在查询数据时,InfluxDB 将数据显示为RFC3339 UTC 格式,以使其更易读。因此,在RFC3339中1449601597将表示为2015-12-08T19:06:37+00:00.000Z。请注意 InfluxDB 中的精度为纳秒。
在以下步骤中,您将把 Unix 时间戳转换为在pandas中更易读的格式,这也是 InfluxDB 中可接受的格式:
df['timestamp'] = pd.to_datetime(df['timestamp'],
origin='unix',
unit='s',
utc=True)
df.set_index('timestamp', inplace=True)
print(df.head())
>>
wacc
timestamp
2015-12-08 19:06:37+00:00 995.369977
2015-12-08 19:07:37+00:00 995.369977
2015-12-08 19:08:37+00:00 995.369977
2015-12-08 19:09:37+00:00 996.406005
2015-12-08 19:10:55+00:00 1034.180063
在上述代码中,unit参数设置为's'用于秒。这指示 pandas 基于起点计算秒数。origin参数默认设置为unix,因此转换将计算到 Unix 纪元开始的秒数。utc参数设置为True,这将返回一个UTC的DatetimeIndex类型。我们的 DataFrame 索引的dtype现在是datetime64[ns, UTC]。
您可以在第六章中的Chapter 6中的Working with Unix epoch timestamps中的食谱中了解有关 Unix 纪元时间戳的更多信息
- 接下来,您需要建立与运行的 InfluxDB 数据库实例的连接。您只需传递您的 API 读/写令牌即可。在写入数据库时,您需要指定 bucket 和组织名称:
bucket = "sensor"
org = "<yourorg>"
token = "<yourtoken>"
client = InfluxDBClient(url="http://localhost:8086",
token=token,
org=org)
- 初始化
write_api并配置WriterOptions。包括指定writer_type为SYNCHRONOUS,batch_size和max_retries,在失败之前:
writer = client.write_api(WriteOptions(SYNCHRONOUS,
batch_size=500,
max_retries=5))
writer.write(bucket=bucket,
record=df,
write_precision='ns',
data_frame_measurement_name='wacc',
data_frame_tag_columns=[])
- 要验证数据是否正确写入,您可以使用
query_data_frame方法查询数据库,如以下代码所示:
query = '''
from(bucket: "sensor")
|> range(start: 2015-12-08)
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
'''
result = client.query_api()
influx_df = result.query_data_frame(
org=org,
query=query,
data_frame_index='_time')
检查返回的 DataFrame:
Influx_df.info()
>>
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 3960 entries, 2015-12-08 19:06:37+00:00 to 2015-12-11 18:48:27+00:00
Data columns (total 6 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 result 3960 non-null object
1 table 3960 non-null int64
2 _start 3960 non-null datetime64[ns, UTC]
3 _stop 3960 non-null datetime64[ns, UTC]
4 _measurement 3960 non-null object
5 wacc 3960 non-null float64
dtypes: datetime64ns, UTC, float64(1), int64(1), object(2)
memory usage: 216.6+ KB
请注意 DataFrame 有两个datetime64[ns, UTC]类型的列。
- 现在您完成了,可以关闭您的写入对象并关闭客户端,如下所示:
writer.close()
client.close()
工作原理…
在使用write_api将 pandas DataFrame 写入 InfluxDB 之前,您需要在 InfluxDB 中定义几个必需的事项。包括以下内容:
-
测量:这些是您要跟踪的值。InfluxDB 每个数据点接受一个测量。
-
字段:我们不需要明确指定字段,因为未在标签定义中的任何列将被标记为字段。字段作为键值对存储的元数据对象。与标签不同,字段不被索引。
-
标签(可选):一个元数据对象,您可以指定要索引以提高查询性能的列。这也存储为键值对。
WriteAPI 支持 同步 和 异步 写入。此外,WriteAPI 在写入 InfluxDB 时还提供了多个选项(例如,行协议字符串、行协议字节、数据点结构、字典样式,以及对 pandas DataFrame 的支持)。在第三章的 从时间序列数据库读取数据 示例中,你使用了 query_data_frame() 方法,指定查询结果应该作为 pandas DataFrame 返回。
同样,write_api 在将 pandas DataFrame 写入 InfluxDB 时提供了额外的参数:
-
data_frame_measurement_name:用于写入pandasDataFrame 的测量名称 -
data_frame_tag_columns:作为标签的 DataFrame 列表;其余列将作为字段
还有更多内容…
在前面的示例中,我们需要手动使用 writer.close() 刷新数据,并使用 client.close() 终止连接。为了更好的资源管理(例如,自动关闭连接)和异常处理,你可以使用 with 语句,享受它带来的便利。以下示例展示了如何以更清晰、更高效的格式重写相同的代码:
with InfluxDBClient(url="http://localhost:8086", token=token) as client:
with client.write_api(WriteOptions(SYNCHRONOUS,
batch_size=500,
max_retries=5_000)) as writer:
writer.write(bucket=bucket,
org=org,
record=df,
write_precision='ns',
data_frame_measurement_name='wacc',
data_frame_tag_columns=[])
另见
-
要了解更多关于 InfluxDB 行协议的信息,请参阅他们的文档:
docs.influxdata.com/influxdb/v2.0/reference/syntax/line-protocol/。 -
要了解更多关于 InfluxDB 2.x Python API 的信息,请参阅官方文档:
docs.influxdata.com/influxdb/cloud/tools/client-libraries/python/。
将时间序列数据写入 Snowflake
Snowflake 已成为构建大数据分析的热门云数据库选项,因其可扩展性、性能以及 SQL 导向(列存储关系数据库)。
Snowflake 的 Python 连接器简化了与数据库的交互,无论是读取数据还是写入数据,特别是对 pandas DataFrame 的内建支持。在这个示例中,你将使用在 将时间序列数据写入 InfluxDB 示例中准备的传感器物联网数据集。这项技术适用于任何你打算写入 Snowflake 的 pandas DataFrame。
准备工作
你可以参考 第三章 中的 从 Snowflake 读取数据 示例,作为回顾,了解连接到 Snowflake 的不同方式。
对于 snowflake-connector-python 库,推荐的安装方法是使用 pip,这允许你安装诸如 pandas 等 额外 组件,如下所示:
pip install snowflake-sqlalchemy snowflake-snowpark-python
pip install "snowflake-connector-python[pandas]"
你也可以通过 conda 安装,但如果你想在使用 snowflake-connector-python 与 pandas 时,必须使用 pip 安装。
conda install -c conda-forge snowflake-sqlalchemy snowflake-snowpark-python
conda install -c conda-froge snowflake-connector-python
创建一个配置文件,例如 database.cfg,用于存储你的 Snowflake 连接信息,如下所示:
[SNOWFLAKE]
ACCOUNT=<YOURACCOUNT>
USER=<YOURUSERNAME>
PASSWORD= <YOURPASSWORD>
WAREHOUSE=COMPUTE_WH
DATABASE=TSCOOKBOOK
SCHEMA=CHAPTER5
ROLE=<YOURROLE>
使用 ConfigParser,提取 [SNOWFLAKE] 部分的内容,以避免暴露或硬编码你的凭证。读取 [SNOWFLAKE] 部分下的参数,并将其转换为 Python 字典,如下所示:
config = ConfigParser()
config.read('database.cfg)
config.sections()
params = dict(config['SNOWFLAKE'])
你将利用在技术要求部分创建的 get_stock_data 函数,拉取 Amazon 从 2019 年 1 月 1 日到 2024 年 8 月 31 日的股票数据:
amzn_hist = get_stock_data('AMZN', '2019-01-01', '2024-8-31')
amzn_hist DataFrame 没有 Date 列,而是有一个 DatetimeIndex。由于 API 不支持写入索引对象,你需要将索引转换为一列。
amzn_hist = amzn_hist.reset_index()
amzn_hist.shape
>>
(1426, 6)
在本食谱中,你将引用 amzn_hist DataFrame 和对象 params。
如何操作…
我们将探索三种方法和库来连接到 Snowflake 数据库。你将首先使用 Snowflake Python 连接器,然后探索 Snowflake SQLAlchemy,最后探索 Snowpark Python API。让我们开始吧。
使用 snowflake-connector-python(write_pandas)
本节中的食谱将利用 snowflake-connector-python 库来连接并将数据写入 Snowflake 数据库。
- 导入本食谱所需的库:
import pandas as pd
from snowflake import connector
from snowflake.connector.pandas_tools import pd_writer, write_pandas
pands_tools 模块提供了几个用于处理 pandas DataFrame 的函数,其中包括两个写入方法(write_pandas 和 pd_writer)。
write_pandas 是一个将 pandas DataFrame 写入 Snowflake 数据库的方法。背后,该函数会将数据存储为 Parquet 文件,将文件上传到 临时阶段,然后通过 COPY INTO 命令将数据从文件插入到指定的表中。
另一方面,pd_writer 方法是一个插入方法,用于通过 DataFrame.to_sql() 方法将数据插入到 Snowflake 数据库,并传递一个 SQLAlchemy 引擎。在本食谱后面的使用 SQLAlchemy 部分,你将探索 pd_writer。
- 建立与 Snowflake 数据库实例的连接,并创建一个游标对象:
con = connector.connect(**params)
cursor = con.cursor()
cursor 对象将用于执行 SQL 查询,以验证数据集是否已正确写入 Snowflake 数据库。
- 使用 writer_pandas 方法将 amzn_hist DataFrame 写入 Snowflake。该方法接受连接对象 con、DataFrame、目标表名及其他可选参数,如 auto_create_table 和 table_type 等。
success, nchunks, nrows, copy_into = write_pandas(
con,
amzn_hist,
auto_create_table=True,
table_name='AMAZON',
table_type='temporary')
使用 write_pandas 时,它返回一个元组。在之前的代码中,我们将元组解包为:success、nchunks、nrows 和 copy_into。让我们查看这些对象内部的值:
print('success: ', success)
print('number of chunks: ', nchunks)
print('number of rows: ', nrows)
print('COPY INTO output', copy_into)
>>
success: True
number of chunks: 1
number of rows: 1426
COPY INTO output [('ntporcytgv/file0.txt', 'LOADED', 1426, 1426, 1, 0, None, None, None, None)]
success 对象是一个布尔值(True 或 False),用于指示函数是否成功将数据写入指定的表中。nchunks 表示写入过程中的块数,在本例中,整个数据作为一个块写入。nrows 表示函数插入的行数。最后,copy_into 对象包含 COPY INTO 命令的输出。
注意 auto_create_table=True 的使用,如果没有设置为 True 并且表 AMAZON 在 Snowflake 中不存在,write_pandas 会抛出一个错误。当设置为 True 时,我们明确要求 write_pandas 创建该表。此外,如果表已经存在,你可以使用 overwrite=True 参数指定是否希望覆盖现有的表。
table_type 支持 Snowflake 中的 永久、临时 和 瞬态 表类型。该参数可以采用以下值:'temp'、'temporary' 和 'transient'。如果传递空字符串 table_type='',则会创建一个 永久 表(默认行为)。
- 你可以进一步验证所有 1426 条记录是否已写入临时表:
cursor.execute('SELECT count(*) FROM AMAZON;')
count = cursor.fetchone()[0]
print(count)
>>
1426
实际上,你已将所有 1426 条记录写入 Snowflake 中的 AMAZON 表。
使用 SQLAlchemy
本节中的示例将使用 snowflake-sqlalchemy 和 snowflake-connector-python 库来连接和将数据写入 Snowflake 数据库。
- 导入本示例所需的库:
import pandas as pd
from snowflake.connector.pandas_tools import pd_writer
from snowflake.sqlalchemy import URL
from sqlalchemy import create_engine
- 你将使用 Snowflake SQLAlchemy 库中的
URL函数来构建连接字符串并创建 SQLAlchemy 引擎,以便与 Snowflake 实例建立连接:
url = URL(**params)
engine = create_engine(url)
- 使用
to_sql()写入函数将数据框架写入 Snowflake 数据库。你需要传递一个 插入方法;在此情况下,你将传递pd_writer:
try:
amzn_hist.to_sql(
'amazon_alchemy',
engine,
index=False,
if_exists='replace'
)
except:
print('failed to write')
上述代码使用标准的 SQL INSERT 子句,每一行一个。Snowflake 连接器 API 提供了一个插入方法 pd_writer,你可以将其传递给 to_sql 方法中的方法参数,如下所示:
try:
amzn_hist.to_sql(
'amazon_alchemy',
engine,
index=False,
if_exists='replace',
method=pd_writer
)
except:
print('failed to write')
在幕后,pd_writer 函数将使用 write_pandas 函数将数据框架写入 Snowflake 数据库。
- 要读取并验证数据是否已写入,你可以使用
pandas.read_sql()来查询表:
pd.read_sql_table('amazon_alchemy',
con=engine).info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1426 entries, 0 to 1425
Data columns (total 6 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Date 1426 non-null object
1 Open 1426 non-null float64
2 High 1426 non-null float64
3 Low 1426 non-null float64
4 Close 1426 non-null float64
5 Volume 1426 non-null float64
dtypes: float64(5), object(1)
memory usage: 67.0+ KB
新的数据框架包含所有 1426 条记录以及预期的确切列数。
使用 snowflake-snowpark-python
本节中的示例将使用 Snowpark API 来写入 pandas 数据框架。
- 导入本示例所需的库:
from snowflake.snowpark import Session
import pandas as pd
- 通过与 Snowflake 数据库建立连接来创建一个会话。
session = Session.builder.configs(params).create()
- 在写入数据框架之前,你必须将 pandas 数据框架转换为 Snowpark 数据框架。
amzn_snowpark_df = session.create_dataframe(amzn_hist)
Snowpark 数据框架采用延迟计算,并提供了许多相对于 Panda 数据框架的优势。
- 要写入 Snowpark 数据框架,你可以使用
write和save_as_table方法:
amzn_snowpark_df.write.mode("overwrite").save_as_table("amazon_snowpark")
- 要读取并验证数据是否已写入,你可以使用
session.table来查询表:
amzn_df = session.table("amazon_snowpark")
如果你更习惯使用 pandas,你可以使用 to_pandas 方法将 Snowpark 数据框架转换为 pandas 数据框架,如下所示:
df = amzn_df.to_pandas()
df.shape
>>
(1426, 6)
数据框架包含所有 1426 条记录和预期的六(6)列。
它是如何工作的…
Snowflake Python API 提供了两种将 pandas 数据框架写入 Snowflake 的机制,这些机制包含在 pandas_tools 模块中:
from snowflake.connector.pandas_tools import pd_writer, write_pandas
在这个食谱中,你使用了 pd_writer 并将其作为插入方法传递给 DataFrame.to_sql() 写入函数。当在 to_sql() 中使用 pd_writer 时,你可以通过 if_exists 参数来改变插入行为,该参数有三个选项:
-
fail,如果表格已存在,则引发ValueError错误 -
replace,在插入新值之前会删除表格 -
append,将数据插入到现有表格中
如果表格不存在,SQLAlchemy 会为你创建表格,并将 pandas DataFrame 中的数据类型映射到 Snowflake 数据库中的相应数据类型。通过 pandas.read_sql() 使用 SQLAlchemy 引擎从 Snowflake 读取数据时也适用此规则。
请注意,pd_writer 在后台使用了 write_pandas 函数。它们的工作方式都是将 DataFrame 转储为 Parquet 文件,上传到临时阶段,最后通过 COPY INTO 将数据复制到表格中。
当你使用 Snowpark API 写入 DataFrame 时,你使用了 write.mode() 方法。mode() 方法接受不同的写入模式选项:
-
append:将 DataFrame 的数据追加到现有表格。如果表格不存在,它将被创建。 -
overwrite:通过删除旧表来覆盖现有表格。 -
truncate:通过截断旧表来覆盖现有表格。 -
errorifexists:如果表格已存在,则抛出异常错误。 -
ignore:如果表格已存在,则忽略该操作。
请记住,默认值是 errorifexists。
还有更多...
在 Snowpark 中有一个有用的方法,可以直接写入 pandas DataFrame,而无需将其转换为 Snowpark DataFrame:
snowpark_df = session.write_pandas(amzn_hist,
table_name="amazon_temp",
auto_create_table=True,
table_type="temp")
write_pandas 函数将 pandas DataFrame 写入 Snowflake,并返回一个 Snowpark DataFrame 对象。
另见
-
访问 Snowflake 文档,了解更多关于
write_pandas和pd_write方法的信息:docs.snowflake.com/en/user-guide/python-connector-api.html#write_pandas。 -
你可以在这里了解更多关于
pandas DataFrame.to_sql()函数的信息:pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_sql.html。 -
要了解更多关于 Snowpark API 中
write_pandas方法的信息,请参阅官方文档:docs.snowflake.com/en/developer-guide/snowpark/reference/python/1.22.1/snowpark/api/snowflake.snowpark.Session.write_pandas
第六章:6 在 Python 中处理日期和时间
加入我们的 Discord 书籍社区

时间序列数据的核心是时间。时间序列数据是按顺序和定期时间间隔捕捉的一系列观测值或数据点。在 pandas 的 DataFrame 上下文中,时间序列数据有一个按顺序排列的DatetimeIndex类型的索引,如你在前面的章节中所见。DatetimeIndex提供了一个简便且高效的数据切片、索引和基于时间的数据分组方式。
熟悉在时间序列数据中操作日期和时间是时间序列分析和建模的基本组成部分。在本章中,你将找到一些常见场景的解决方案,帮助你在处理时间序列数据中的日期和时间时得心应手。
Python 有几个内置模块用于处理日期和时间,如datetime、time、calendar和zoneinfo模块。此外,Python 还有一些流行的库进一步扩展了日期和时间的处理能力,例如dateutil、pytz和arrow等。
本章将介绍datetime模块,但你将转向使用pandas进行更复杂的日期和时间操作,以及生成带有DatetimeIndex序列的时间序列 DataFrame。此外,pandas库包含多个继承自上述 Python 模块的日期和时间相关类。换句话说,你无需导入额外的日期/时间 Python 库。
你将会接触到 pandas 中的类,如Timestamp、Timedelta、Period和DateOffset。你会发现它们之间有很多相似性——例如,pandas 的Timestamp类相当于 Python 的Datetime类,且在大多数情况下可以互换。类似地,pandas.Timedelta等同于 Python 的datetime.timedelta对象。pandas库提供了一个更简洁、直观和强大的接口,帮助你处理大多数日期和时间操作,无需额外导入模块。使用 pandas 时,你将享受一个包含所有时间序列数据处理所需工具的库,轻松应对许多复杂的任务。
以下是我们将在本章中讨论的解决方案列表:
-
使用
DatetimeIndex -
向
DateTime提供格式参数 -
使用 Unix 纪元时间戳
-
处理时间差
-
转换带有时区信息的
DateTime -
处理日期偏移
-
处理自定义工作日
在实际场景中,你可能并不会使用所有这些技巧或技术,但了解这些选项是至关重要的,尤其是在面对某些特定场景时,需要调整或格式化日期。
技术要求
在本章及之后的内容中,我们将广泛使用 pandas 2.1.3(发布于 2023 年 11 月 10 日)。这适用于本章中的所有示例。
请提前加载这些库,因为你将在本章中贯穿使用它们:
import pandas as pd
import numpy as np
import datetime as dt
你将在接下来的内容中使用 dt、np 和 pd 别名。
你可以从 GitHub 仓库下载 Jupyter 笔记本,github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./tree/main/code/Ch6 来进行跟随操作。
处理 DatetimeIndex
pandas 库提供了许多选项和功能,可以简化在处理时间序列数据、日期和时间时繁琐的任务。
在 Python 中处理时间序列数据时,通常将数据加载到具有 DatetimeIndex 类型索引的 pandas DataFrame 中。作为索引,DatetimeIndex 类扩展了 pandas DataFrame 的功能,使其能够更高效、智能地处理时间序列数据。这个概念在第二章《从文件中读取时间序列数据》和第三章《从数据库中读取时间序列数据》中已经多次展示。
在完成本示例后,你将充分理解 pandas 提供的丰富日期功能,以处理数据中几乎所有日期/时间的表示方式。此外,你还将学习如何使用 pandas 中的不同函数将类似日期的对象转换为 DatetimeIndex。
如何做到这一点……
在本示例中,你将探索 Python 的 datetime 模块,并了解 Timestamp 和 DatetimeIndex 类以及它们之间的关系。
- 为了理解 Python 的
datetime.datetime类与 pandas 的Timestamp和DatetimeIndex类之间的关系,你将创建三个不同的datetime对象,表示日期2021, 1, 1。然后,你将比较这些对象以获得更好的理解:
dt1 = dt.datetime(2021,1,1)
dt2 = pd.Timestamp('2021-1-1')
dt3 = pd.to_datetime('2021-1-1')
检查日期时间表示:
print(dt1)
print(dt2)
print(dt3)
>>
2021-01-01 00:00:00
2021-01-01 00:00:00
2021-01-01 00:00:00
检查它们的数据类型:
print(type(dt1))
print(type(dt2))
print(type(dt3))
>>
<class 'datetime.datetime'>
<class 'pandas._libs.tslibs.timestamps.Timestamp'>
<class 'pandas._libs.tslibs.timestamps.Timestamp'>
最后,让我们看看它们的比较:
dt1 == dt2 == dt3
>> True
isinstance(dt2, dt.datetime)
>> True
isinstance(dt2, pd.Timestamp)
>> True
isinstance(dt1, pd.Timestamp)
>> False
从前面的代码中可以看到,pandas 的 Timestamp 对象等同于 Python 的 Datetime 对象:
issubclass(pd.Timestamp, dt.datetime)
>> True
请注意,dt2 是 pandas.Timestamp 类的一个实例,而 Timestamp 类是 Python 的 dt.datetime 类的子类(但反之不成立)。
- 当你使用
pandas.to_datetime()函数时,它返回了一个Timestamp对象。现在,使用pandas.to_datetime()处理一个列表并检查结果:
dates = ['2021-1-1', '2021-1-2']
pd_dates = pd.to_datetime(dates)
print(pd_dates)
print(type(pd_dates))
>>
DatetimeIndex(['2021-01-01', '2021-01-02'], dtype='datetime64[ns]', freq=None)
<class 'pandas.core.indexes.datetimes.DatetimeIndex'>
有趣的是,输出现在是 DatetimeIndex 类型,它是使用你之前使用的相同 pandas.to_datetime() 函数创建的。此前,当对单个对象使用相同的函数时,结果是 Timestamp 类型,但当作用于列表时,它生成了一个 DatetimeIndex 类型的序列。你将执行另一个任务,以便更清楚地理解。
打印出 pd_dates 变量中的第一个元素(切片):
print(pd_dates[0])
print(type(pd_dates[0]))
>>
2021-01-01 00:00:00
<class 'pandas._libs.tslibs.timestamps.Timestamp'>
从前面的输出中,你可以推测出两个类之间的关系:DatetimeIndex 和 Timestamp。DatetimeIndex 是一个 Timestamp 对象的序列(列表)。
- 现在你已经知道如何使用
pandas.to_datetime()函数创建DatetimeIndex,让我们进一步扩展,看看你还能使用这个函数做些什么。例如,你将看到如何轻松地将不同的datetime表示形式(包括字符串、整数、列表、pandas 系列或其他datetime对象)转换为DatetimeIndex。
让我们创建一个 dates 列表:
dates = ['2021-01-01',
'2/1/2021',
'03-01-2021',
'April 1, 2021',
'20210501',
np.datetime64('2021-07-01'), # numpy datetime64
datetime.datetime(2021, 8, 1), # python datetime
pd.Timestamp(2021,9,1) # pandas Timestamp
]
使用 pandas.to_datetime() 解析列表:
parsed_dates = pd.to_datetime(
dates,
infer_datetime_format=True,
errors='coerce'
)
print(parsed_dates)
>>
DatetimeIndex(['2021-01-01', '2021-02-01', '2021-03-01', '2021-04-01', '2021-05-01', '2021-07-01', '2021-08-01', '2021-09-01'],
dtype='datetime64[ns]', freq=None)
请注意,to_datetime() 函数如何正确解析不同字符串表示形式和日期类型的整个列表,如 Python 的 Datetime 和 NumPy 的 datetime64。类似地,你也可以直接使用 DatetimeIndex 构造函数,如下所示:
pd.DatetimeIndex(dates)
这将产生类似的结果。
DatetimeIndex对象提供了许多有用的属性和方法,用于提取附加的日期和时间属性。例如,你可以提取day_name、month、year、days_in_month、quarter、is_quarter_start、is_leap_year、is_month_start、is_month_end和is_year_start。以下代码展示了如何做到这一点:
print(f'Name of Day : {parsed_dates.day_name()}')
print(f'Month : {parsed_dates.month}')
print(f'Month Name: {parsed_dates.month_name()}')
print(f'Year : {parsed_dates.year}')
print(f'Days in Month : {parsed_dates.days_in_month}')
print(f'Quarter {parsed_dates.quarter}')
print(f'Is Quarter Start : {parsed_dates.is_quarter_start}')
print(f'Days in Month: {parsed_dates.days_in_month}')
print(f'Is Leap Year : {parsed_dates.is_leap_year}')
print(f'Is Month Start : {parsed_dates.is_month_start}')
print(f'Is Month End : {parsed_dates.is_month_end}')
print(f'Is Year Start : {parsed_dates.is_year_start}')
上述代码产生了以下结果:
Name of Day : Index(['Friday', 'Monday', 'Monday', 'Thursday', 'Saturday', 'Thursday',
'Sunday', 'Wednesday'],
dtype='object')
Month : Index([1, 2, 3, 4, 5, 7, 8, 9], dtype='int32')
Month Name: Index(['January', 'February', 'March', 'April', 'May', 'July', 'August',
'September'],
dtype='object')
Year : Index([2021, 2021, 2021, 2021, 2021, 2021, 2021, 2021], dtype='int32')
Days in Month : Index([31, 28, 31, 30, 31, 31, 31, 30], dtype='int32')
Quarter Index([1, 1, 1, 2, 2, 3, 3, 3], dtype='int32')
Is Quarter Start : [ True False False True False True False False]
Days in Month: Index([31, 28, 31, 30, 31, 31, 31, 30], dtype='int32')
Is Leap Year : [False False False False False False False False]
Is Month Start : [ True True True True True True True True]
Is Month End : [False False False False False False False False]
Is Year Start : [ True False False False False False False False]
这些属性和方法在转换你的时间序列数据集以进行分析时非常有用。
它是如何工作的……
pandas.to_datetime() 是一个强大的函数,可以智能地解析不同的日期表示形式(如字符串)。正如你在之前的 第 4 步 中看到的那样,字符串示例,如 '2021-01-01'、'2/1/2021'、'03-01-2021'、'April 1, 2021' 和 '20210501',都被正确解析。其他日期表示形式,如 'April 1, 2021' 和 '1 April 2021',也可以通过 to_datetime() 函数解析,我将留给你探索更多可以想到的其他示例。
to_datetime 函数包含了 errors 参数。在以下示例中,你指定了 errors='coerce',这指示 pandas 将无法解析的任何值设置为 NaT,表示缺失值。在第七章《处理缺失数据》的数据质量检查部分,你将进一步了解 NaT。
pd.to_datetime(
dates,
infer_datetime_format=True,
errors='coerce'
)
在 pandas 中,有不同的表示方式来表示缺失值——np.NaN 表示缺失的数值 (Not a Number),而 pd.NaT 表示缺失的 datetime 值 (Not a Time)。最后,pandas 的 pd.NA 用于表示缺失的标量值 (Not Available)。
to_datetime 中的 errors 参数可以接受以下三种有效的字符串选项:
-
raise,意味着它将抛出一个异常(error out)。 -
coerce不会导致抛出异常。相反,它将替换为pd.NaT,表示缺失的日期时间值。 -
ignore同样不会导致抛出异常。相反,它将保留原始值。
以下是使用 ignore 值的示例:
pd.to_datetime(['something 2021', 'Jan 1, 2021'],
errors='ignore')
>> Index(['something 2021', 'Jan 1, 2021'], dtype='object')
当 errors 参数设置为 'ignore' 时,如果 pandas 遇到无法解析的日期表示,它将不会抛出错误。相反,输入值将按原样传递。例如,从前面的输出中可以看到,to_datetime 函数返回的是 Index 类型,而不是 DatetimeIndex。此外,索引序列中的项是 object 类型(而不是 datetime64)。在 pandas 中,object 类型表示字符串或混合类型。
此外,你还探索了如何使用内置属性和方法提取额外的日期时间属性,例如:
-
day_name(): 返回星期几的名称(例如,星期一,星期二)。
-
month: 提供日期的月份部分,作为整数(1 到 12)。
-
month_name(): 返回月份的完整名称(例如,1 月,2 月)。
-
year: 提取日期的年份部分,作为整数。
-
days_in_month: 给出给定日期所在月份的天数。
-
quarter: 表示该日期所在年的季度(1 到 4)。
-
is_quarter_start: 布尔值,指示该日期是否为季度的第一天(True 或 False)。
-
is_leap_year: 布尔值,指示该日期年份是否为闰年(True 或 False)。
-
is_month_start: 布尔值,指示该日期是否为该月份的第一天(True 或 False)。
-
is_month_end: 布尔值,指示该日期是否为该月份的最后一天(True 或 False)。
-
is_year_start: 布尔值,指示该日期是否为该年份的第一天(True 或 False)。
还有更多内容……
生成 DatetimeIndex 的另一种方式是使用 pandas.date_range() 函数。以下代码提供了一个起始日期和生成的周期数,并指定了每日频率 D:
pd.date_range(start=‘2021-01-01’, periods=3, freq=‘D’)
>>
DatetimeIndex([‘2021-01-01’, ‘2021-01-02’, ‘2021-01-03’], dtype=‘datetime64[ns]’, freq=‘D’)
pandas.date_range() 至少需要提供四个参数中的三个——start、end、periods 和 freq。如果没有提供足够的信息,将会抛出 ValueError 异常,并显示以下信息:
ValueError: Of the four parameters: start, end, periods, and freq, exactly three must be specified
让我们探索使用 date_range 函数所需的不同参数组合。在第一个示例中,提供开始日期、结束日期,并指定每日频率。该函数将始终返回一个等间隔的时间点范围:
pd.date_range(start=‘2021-01-01’,
end=‘2021-01-03’,
freq=‘D’)
>>
DatetimeIndex([‘2021-01-01’, ‘2021-01-02’, ‘2021-01-03’], dtype=‘datetime64[ns]’, freq=‘D’)
在第二个示例中,提供开始日期和结束日期,但不提供频率,而是提供周期数。请记住,该函数将始终返回一个等间隔的时间点范围:
pd.date_range(start=‘2021-01-01’,
end=‘2021-01-03’,
periods=2)
>>
DatetimeIndex([‘2021-01-01’, ‘2021-01-03’], dtype=‘datetime64[ns]’, freq=None)
pd.date_range(start=‘2021-01-01’,
end=‘2021-01-03’,
periods=4)
>>
DatetimeIndex([‘2021-01-01 00:00:00’, ‘2021-01-01 16:00:00’,
’2021-01-02 08:00:00’, ‘2021-01-03 00:00:00’],
dtype=‘datetime64[ns]’, freq=None)
在以下示例中,提供结束日期和返回的周期数,并指定每日频率:
pd.date_range(end=‘2021-01-01’, periods=3, freq=‘D’)
DatetimeIndex([‘2020-12-30’, ‘2020-12-31’, ‘2021-01-01’], dtype=‘datetime64[ns]’, freq=‘D’)
请注意,如果信息足够生成等间隔的时间点并推断缺失的参数,pd.date_range() 函数最少可以接受两个参数。以下是只提供开始和结束日期的示例:
pd.date_range(start=‘2021-01-01’,
end=‘2021-01-03’)
>>
DatetimeIndex([‘2021-01-01’, ‘2021-01-02’, ‘2021-01-03’], dtype=‘datetime64[ns]’, freq=‘D’)
请注意,pandas 能够使用开始和结束日期构造日期序列,并默认采用每日频率。这里有另一个例子:
pd.date_range(start=‘2021-01-01’,
periods=3)
>>
DatetimeIndex([‘2021-01-01’, ‘2021-01-02’, ‘2021-01-03’], dtype=‘datetime64[ns]’, freq=‘D’)
通过 start 和 periods,pandas 有足够的信息来构造日期序列,并默认采用每日频率。
现在,这里有一个例子,它缺乏足够的信息来生成序列,并且会导致 pandas 抛出错误:
pd.date_range(start=‘2021-01-01’,
freq=‘D’)
>>
ValueError: Of the four parameters: start, end, periods, and freq, exactly three must be specified
请注意,仅凭开始日期和频率,pandas 并没有足够的信息来构造日期序列。因此,添加 periods 或 end 日期中的任何一个即可。
让我们总结从生成 DatetimeIndex 到提取 datetime 属性的所有内容。在以下示例中,您将使用 date_range() 函数创建一个包含日期列的 DataFrame。然后,您将使用不同的属性和方法创建附加列:
df = pd.DataFrame(pd.date_range(start=‘2021-01-01’,
periods=5), columns=[‘Date’])
df[‘days_in_month’] = df[‘Date’].dt.days_in_month
df[‘day_name’] = df[‘Date’].dt.day_name()
df[‘month’] = df[‘Date’].dt.month
df[‘month_name’] = df[‘Date’].dt.month_name()
df[‘year’] = df[‘Date’].dt.year
df[‘days_in_month’] = df[‘Date’].dt.days_in_month
df[‘quarter’] = df[‘Date’].dt.quarter
df[‘is_quarter_start’] = df[‘Date’].dt.is_quarter_start
df[‘days_in_month’] = df[‘Date’].dt.days_in_month
df[‘is_leap_year’] = df[‘Date’].dt.is_leap_year
df[‘is_month_start’] = df[‘Date’].dt.is_month_start
df[‘is_month_end’] = df[‘Date’].dt.is_month_end
df[‘is_year_start’] = df[‘Date’].dt.is_year_start
df
上述代码应生成以下 DataFrame:

图 6.1 一个包含 5 行和 12 列的时间序列 DataFrame
Series.dt访问器请注意,在前面的代码示例中,当处理 pandas Series 的 datetime 对象时,使用了
.dt访问器。pandas 中的.dt访问器是用于访问 Series 的各种 datetime 属性的一个属性。在之前的例子中,您使用.dt访问了df[‘Date’]Series 的 datetime 属性。
另见:
要了解更多关于 pandas 的 to_datetime() 函数和 DatetimeIndex 类的信息,请查看以下资源:
-
pandas.DatetimeIndex文档:pandas.pydata.org/docs/reference/api/pandas.DatetimeIndex.html -
pandas.to_datetime文档:pandas.pydata.org/docs/reference/api/pandas.to_datetime.html
提供格式参数给 DateTime
在处理从不同数据源提取的数据集时,您可能会遇到以字符串格式存储的日期列,无论是来自文件还是数据库。在之前的例子中,使用 DatetimeIndex,您探索了 pandas.to_datetime() 函数,它可以通过最小的输入解析各种日期格式。然而,您可能希望有更精细的控制,以确保日期被正确解析。例如,接下来您将了解 strptime 和 strftime 方法,并查看如何在 pandas.to_datetime() 中指定格式化选项,以处理不同的日期格式。
在本例中,您将学习如何将表示日期的字符串解析为 datetime 或 date 对象(datetime.datetime 或 datetime.date 类的实例)。
如何操作…
Python 的 datetime 模块包含 strptime() 方法,用于从包含日期的字符串创建 datetime 或 date 对象。您将首先探索如何在 Python 中做到这一点,然后将其扩展到 pandas 中:
- 让我们探索一些示例,使用
datetime.strptime解析字符串为datetime对象。您将解析四种不同表示方式的2022 年 1 月 1 日,它们会生成相同的输出 –datetime.datetime(2022, 1, 1, 0, 0):
dt.datetime.strptime('1/1/2022', '%m/%d/%Y')
dt.datetime.strptime('1 January, 2022', '%d %B, %Y')
dt.datetime.strptime('1-Jan-2022', '%d-%b-%Y')
dt.datetime.strptime('Saturday, January 1, 2022', '%A, %B %d, %Y')
>>
datetime.datetime(2022, 1, 1, 0, 0)
请注意,输出是一个 datetime 对象,表示年份、月份、日期、小时和分钟。您可以仅指定日期表示方式,如下所示:
dt.datetime.strptime('1/1/2022', '%m/%d/%Y').date()
>>
datetime.date(2022, 1, 1)
现在,您将获得一个 date 对象,而不是 datetime 对象。您可以使用 print() 函数获取 datetime 的可读版本:
dt_1 = dt.datetime.strptime('1/1/2022', '%m/%d/%Y')
print(dt_1)
>>
2022-01-01 00:00:00
- 现在,让我们比较一下使用
datetime.strptime方法与pandas.to_datetime方法的差异:
pd.to_datetime('1/1/2022', format='%m/%d/%Y')
pd.to_datetime('1 January, 2022', format='%d %B, %Y')
pd.to_datetime('1-Jan-2022', format='%d-%b-%Y')
pd.to_datetime('Saturday, January 1, 2022', format='%A, %B %d, %Y')
>>
Timestamp('2022-01-01 00:00:00')
同样,您可以使用 print() 函数获取 Timestamp 对象的字符串(可读)表示:
dt_2 = pd.to_datetime('1/1/2022', format='%m/%d/%Y')
print(dt_2)
>>
2022-01-01 00:00:00
- 使用
pandas.to_datetime()相较于 Python 的datetime模块有一个优势。to_datetime()函数可以解析多种日期表示方式,包括带有最少输入或规格的字符串日期格式。以下代码解释了这个概念;请注意省略了format:
pd.to_datetime('Saturday, January 1, 2022')
pd.to_datetime('1-Jan-2022')
>>
Timestamp('2022-01-01 00:00:00')
请注意,与需要整数值或使用 strptime 方法解析字符串的 datetime 不同,pandas.to_datetime() 函数可以智能地解析不同的日期表示方式,而无需指定格式(大多数情况下是如此)。
它是如何工作的…
在本教程中,您使用了 Python 的 datetime.datetime 和 pandas.to_datetime 方法来解析字符串格式的日期。当使用 datetime 时,您需要使用 dt.datetime.strptime() 函数来指定字符串中日期格式的表示方式,并使用格式代码(例如 %d、%B 和 %Y)。
例如,在 datetime.strptime('1 January, 2022', '%d %B, %Y') 中,您按确切顺序和间距提供了 %d、%B 和 %Y 格式代码,以表示字符串中的格式。让我们详细解析一下:

图 6.2 – 理解格式
-
%d表示第一个值是一个零填充的数字,表示月份中的日期,后面跟一个空格,用于显示数字与下一个对象之间的间隔。 -
%B用于表示第二个值代表月份的完整名称。请注意,这后面跟着逗号(,),用于描述字符串中准确的格式,例如"January,"。因此,在解析字符串时,匹配格式至关重要,必须包含任何逗号、破折号、反斜杠、空格或使用的任何分隔符。 -
为了遵循字符串格式,逗号(
,)后面有一个空格,接着是%Y,表示最后一个值代表四位数的年份。
格式指令
记住,你总是使用百分号(
%)后跟格式代码(一个字母,可能带有负号)。这称为格式化指令。例如,小写的y,如%y,表示年份22(没有世纪),而大写的Y,如%Y,表示年份2022(包含世纪)。以下是可以在strptime()函数中使用的常见 Python 指令列表:docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes。
回想一下,你使用了pandas.to_datetime()来解析与dt.datetime.strptime()相同的字符串对象。最大不同之处在于,pandas 函数能够准确地解析字符串,而无需显式提供格式参数。这是使用 pandas 进行时间序列分析的诸多优势之一,尤其是在处理复杂日期和datetime场景时。
还有更多内容…
现在你已经知道如何使用pandas.to_datetime()将字符串对象解析为datetime。那么,让我们看看如何将包含日期信息的字符串格式的 DataFrame 列转换为datetime数据类型。
在下面的代码中,你将创建一个小的 DataFrame:
df = pd.DataFrame(
{'Date': ['January 1, 2022', 'January 2, 2022', 'January 3, 2022'],
'Sales': [23000, 19020, 21000]}
)
df
>>
Date Sales
0 January 1, 2022 23000
1 January 2, 2022 19020
2 January 3, 2022 21000
df.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 3 entries, 0 to 2
Data columns (total 2 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Date 3 non-null object
1 Sales 3 non-null int64
dtypes: int64(1), object(1)
memory usage: 176.0+ bytes
要更新 DataFrame 以包含 DatetimeIndex,你需要将Date列解析为datetime,然后将其作为索引分配给 DataFrame:
df['Date'] = pd.to_datetime(df['Date'])
df.set_index('Date', inplace=True)
df.info()
>>
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 3 entries, 2022-01-01 to 2022-01-03
Data columns (total 1 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Sales 3 non-null int64
dtypes: int64(1)
memory usage: 48.0 bytes
注意现在索引是DatetimeIndex类型,并且 DataFrame 中只有一列(Sales),因为Date现在是索引。
另请参见
要了解更多关于pandas.to_datetime的信息,请访问官方文档页面:pandas.pydata.org/docs/reference/api/pandas.to_datetime.html。
使用 Unix 纪元时间戳
纪元时间戳,有时称为Unix 时间或POSIX 时间,是一种常见的以整数格式存储datetime的方式。这个整数表示自参考点以来经过的秒数,对于基于 Unix 的时间戳,参考点是1970 年 1 月 1 日午夜(00:00:00 UTC)。这个任意的日期和时间代表了基准,从0开始。所以,我们每经过一秒钟就增加 1 秒。
许多数据库、应用程序和系统将日期和时间存储为数字格式,这样在数学上更容易处理、转换、递增、递减等。请注意,在 Unix 纪元 的情况下,它是基于 UTC(协调世界时),UTC 代表 Universal Time Coordinated。使用 UTC 是构建全球应用程序时的明确选择,它使得存储日期和时间戳以标准化格式变得更加简单。这也使得在处理日期和时间时,无需担心夏令时或全球各地的时区问题。UTC 是航空系统、天气预报系统、国际空间站等使用的标准国际时间。
你在某个时刻会遇到 Unix 纪元时间戳,想要更好地理解数据,你需要将其转换为人类可读格式。这就是本节要介绍的内容。再次提醒,你将体验使用 pandas 内置函数处理 Unix 纪元时间戳的简便性。
如何操作……
在我们开始将 Unix 时间转换为可读的 datetime 对象(这部分比较简单)之前,首先让我们对将日期和时间存储为数字对象(浮点数)这一概念有一些直观的理解:
- 你将使用
time模块(Python 的一部分)来请求当前的秒级时间。这将是从纪元开始以来的秒数,对于 Unix 系统来说,纪元从 1970 年 1 月 1 日 00:00:00 UTC 开始:
import time
epoch_time = time.time()
print(epoch_time)
print(type(epoch_time))
>>
1700596942.589581
<class 'float'>
- 现在,复制你得到的数字值并访问
www.epoch101.com。该网站应该会显示你当前的纪元时间。如果你向下滚动,可以粘贴该数字并将其转换为人类可读格式。确保点击 秒,如图所示:

图 6.3:将 Unix 时间戳转换为 GMT 和本地时间的可读格式
注意,GMT 格式是 Tue, 21 Nov 2023 20:02:22 GMT,而我的本地格式是 Wed Nov 22 2023, 00:02:22 GMT+0400 (海湾标准时间)。
- 让我们看看 pandas 如何转换纪元时间戳。这里的便利之处在于,你将使用你现在应该已经熟悉的相同的
pandas.to_datetime()函数,因为你在本章的前两部分中已经用过它。这就是使用 pandas 的众多便利之一。例如,在以下代码中,你将使用pandas.to_datetime()来解析 Unix 纪元时间1700596942.589581:
import pandas as pd
t = pd.to_datetime(1700596942.589581, unit='s')
print(t)
>>
2023-11-21 20:02:22.589581056
注意需要将单位指定为秒。输出与 图 6.3 中的 GMT 格式相似。
-
如果你希望
datetime具有时区感知功能——例如,美国/太平洋时区——可以使用tz_localize('US/Pacific')。不过,为了获得更精确的转换,最好分两步进行:-
使用
tz_localize('UTC')将时区不敏感的对象转换为 UTC。 -
然后,使用
tz_convert()将其转换为所需的时区。
-
以下代码展示了如何将时间转换为太平洋时区:
t.tz_localize('UTC').tz_convert('US/Pacific')
>>
Timestamp(2023-11-21 12:02:22.589581056-0800', tz='US/Pacific')
- 让我们把这些内容整合在一起。你将把包含
datetime列(以 Unix 纪元格式表示)的 DataFrame 转换为人类可读的格式。你将通过创建一个包含 Unix 纪元时间戳的新 DataFrame 开始:
df = pd.DataFrame(
{'unix_epoch': [1641110340, 1641196740, 1641283140, 1641369540],
'Sales': [23000, 19020, 21000, 17030]}
)
df
>>
unix_epoch Sales
0 1641110340 23000
1 1641196740 19020
2 1641283140 21000
3 1641369540 17030
- 创建一个新列,命名为
Date,通过将unix_epoch列解析为datetime(默认为 GMT),然后将输出本地化为 UTC,并转换为本地时区。最后,将Date列设为索引:
df['Date'] = pd.to_datetime(df['unix_epoch'], unit='s')
df['Date'] = df['Date'].dt.tz_localize('UTC').dt.tz_convert('US/Pacific')
df.set_index('Date', inplace=True)
df
>> unix_epoch Sales
Date
2022-01-01 23:59:00-08:00 1641110340 23000
2022-01-02 23:59:00-08:00 1641196740 19020
2022-01-03 23:59:00-08:00 1641283140 21000
2022-01-04 23:59:00-08:00 1641369540 17030
注意,由于Date列是datetime类型(而不是DatetimeIndex),你必须使用Series.dt访问器来访问内建的方法和属性。最后一步,你将datetime转换为DatetimeIndex对象(即 DataFrame 索引)。如果你还记得本章中的操作 DatetimeIndex示例,DatetimeIndex对象可以访问所有datetime方法和属性,而无需使用dt访问器。
- 如果你的数据是按天分布的,并且没有使用时间的需求,那么你可以仅请求日期,如以下代码所示:
df.index.date
>>
array([datetime.date(2022, 1, 1), datetime.date(2022, 1, 2), datetime.date(2022, 1, 3), datetime.date(2022, 1, 4)], dtype=object)
注意,输出只显示日期,不包括时间。
它是如何工作的……
理解 Unix 纪元时间在从事需要精确和标准化时间表示的技术领域时尤为重要。
到目前为止,你使用了pandas.to_datetime()将字符串格式的日期解析为datetime对象,并通过利用格式属性(请参阅提供格式参数给 DateTime的示例)。在本例中,你使用了相同的函数,但这次没有提供格式值,而是传递了一个值给unit参数,如unit='s'。
unit参数告诉 pandas 在计算与纪元起始的差异时使用哪种单位。在此示例中,请求的是秒。然而,还有一个重要的参数你通常不需要调整,即origin参数。例如,默认值为origin='unix',表示计算应基于 Unix(或 POSIX)时间,起始时间为1970-01-01 00:00:00 UTC。
下面是实际代码的样子:
pd.to_datetime(1635220133.855169, unit='s', origin='unix')
>>
Timestamp('2021-10-26 03:48:53.855169024')
你可以修改origin,使用不同的参考日期来计算日期时间值。在以下示例中,unit 被指定为天,origin 设置为2023 年 1 月 1 日:
pd.to_datetime(45, unit='D', origin='2023-1-1')
>>
Timestamp('2023-02-15 00:00:00')
还有更多内容……
如果你希望将datetime值存储为 Unix 纪元时间,可以通过减去1970-01-01,然后用1秒进行整除来实现。Python 使用/作为除法运算符,//作为整除运算符返回整除结果,%作为取余运算符返回除法的余数。
从创建一个新的 pandas DataFrame 开始:
df = pd.DataFrame(
{'Date': pd.date_range('01-01-2022', periods=5),
'order' : range(5)}
)
df
>>
Date order
0 2022-01-01 0
1 2022-01-02 1
2 2022-01-03 2
3 2022-01-04 3
4 2022-01-05 4
你可以按如下方式进行转换:
df['Unix Time'] = (df['Date'] - pd.Timestamp("1970-01-01")) // pd.Timedelta("1s")
df
>>
Date order Unix Time
0 2022-01-01 0 1640995200
1 2022-01-02 1 1641081600
2 2022-01-03 2 1641168000
3 2022-01-04 3 1641254400
4 2022-01-05 4 1641340800
你现在已经生成了 Unix 时间戳。实现相似结果有多种方法。上述示例是 pandas 推荐的方法,详情请参阅此链接:pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#from-timestamps-to-epoch。
你将在下一个食谱中学到更多关于Timedelta的知识。
另请参见
要了解更多关于pandas.to_datetime的信息,请访问官方文档页面:pandas.pydata.org/docs/reference/api/pandas.to_datetime.html。
使用时间差进行操作
在处理时间序列数据时,你可能需要对datetime列进行一些计算,比如加减操作。例子包括将 30 天加到购买datetime上,以确定产品的退货政策何时到期或保修何时结束。例如,Timedelta类使得通过在不同的时间范围或增量(如秒、天、周等)上加减时间,得出新的datetime对象成为可能。这包括时区感知的计算。
在本节中,你将探索 pandas 中两种捕获日期/时间差异的实用方法——pandas.Timedelta 类和 pandas.to_timedelta 函数。
如何操作……
在本节中,你将使用假设的零售商店销售数据。你将生成一个销售数据框,其中包含商店购买的商品及其购买日期。然后,你将使用Timedelta类和to_timedelta()函数探索不同的场景:
- 首先导入
pandas库并创建一个包含两列(item和purchase_dt)的数据框,这些列会标准化为 UTC 时间:
df = pd.DataFrame(
{
'item': ['item1', 'item2', 'item3', 'item4', 'item5', 'item6'],
'purchase_dt': pd.date_range('2021-01-01', periods=6, freq='D', tz='UTC')
}
)
df
上述代码应输出一个包含六行(items)和两列(item 和 purchase_dt)的数据框:

图 6.4:包含购买商品和购买日期时间(UTC)数据的数据框
- 添加另一个
datetime列,用于表示过期日期,设为购买日期后的 30 天:
df['expiration_dt'] = df['purchase_dt'] + pd.Timedelta(days=30)
df
上述代码应向数据框中添加一个第三列(expiration_dt),该列设置为购买日期后的 30 天:

图 6.5:更新后的数据框,增加了一个反映过期日期的第三列
- 现在,假设你需要创建一个特殊的退货延长期,这个时间设置为从购买日期起 35 天、12 小时和 30 分钟:
df['extended_dt'] = df['purchase_dt'] +\
pd.Timedelta('35 days 12 hours 30 minutes')
df
上述代码应向数据框中添加一个第四列(extended_dt),根据额外的 35 天、12 小时和 30 分钟,反映新的日期时间:

图 6.6:更新后的 DataFrame,新增第四列日期时间列,反映扩展后的日期
- 假设你被要求将时区从 UTC 转换为零售商店总部所在时区,也就是洛杉矶时区:
df.iloc[:,1:] = df.iloc[: ,1:].apply(
lambda x: x.dt.tz_convert('US/Pacific')
)
df
在将时区从 UTC 转换为美国/太平洋时区(洛杉矶)后,你将会覆盖datetime列(purchased_dt、expiration_dt和extended_dt)。DataFrame 的结构应该保持不变——六行四列——但数据现在看起来有所不同,如下图所示:

图 6.7:更新后的 DataFrame,所有日期时间列都显示洛杉矶(美国/太平洋时区)时间
- 最后,你可以计算扩展后的到期日期与原始到期日期之间的差异。由于它们都是
datetime数据类型,你可以通过简单的两个列之间的减法来实现这一点:
df['exp_ext_diff'] = (
df['extended_dt'] - df['expiration_dt']
)
df
最终的 DataFrame 应该会有一个第五列,表示扩展日期与到期日期之间的差异:

图 6.8:更新后的 DataFrame,新增第五列
由于 pandas 内置了处理时间序列数据和datetime的功能,这些类型的转换和计算变得更加简化,无需任何额外的库。
它是如何工作的…
时间差可以帮助捕捉两个日期或时间对象之间的差异。在 pandas 中,pandas.Timedelta类等同于 Python 的datetime.timedelta类,行为非常相似。然而,pandas 的优势在于它包含了大量用于处理时间序列数据的类和函数。这些内置的 pandas 函数在处理 DataFrame 时通常更简洁、高效。让我们做个简短实验,展示 pandas 的Timedelta类是 Python timedelta类的子类:
import datetime as dt
import pandas as pd
pd.Timedelta(days=1) == dt.timedelta(days=1)
>> True
让我们验证一下pandas.Timedelta是否是datetime.timedelta的一个实例:
issubclass(pd.Timedelta, dt.timedelta)
>> True
dt_1 = pd.Timedelta(days=1)
dt_2 = dt.timedelta(days=1)
isinstance(dt_1, dt.timedelta)
>> True
isinstance(dt_1, pd.Timedelta)
>> True
Python 的datetime.timedelta类接受这些参数的整数值——days、seconds、microseconds、milliseconds、minutes、hours 和 weeks。另一方面,pandas.Timedelta接受整数和字符串,如下代码所示:
pd.Timedelta(days=1, hours=12, minutes=55)
>> Timedelta('1 days 12:55:00')
pd.Timedelta('1 day 12 hours 55 minutes')
>> Timedelta('1 days 12:55:00')
pd.Timedelta('1D 12H 55T')
>> Timedelta('1 days 12:55:00')
一旦你定义了Timedelta对象,就可以将其用于对date、time或datetime对象进行计算:
week_td = pd.Timedelta('1W')
pd.to_datetime('1 JAN 2022') + week_td
>> Timestamp('2022-01-08 00:00:00')
在前面的示例中,week_td表示一个 1 周的Timedelta对象,可以将其加到(或减去)datetime中,得到时间差。通过添加week_td,你增加了 1 周。如果你想增加 2 周呢?你也可以使用乘法:
pd.to_datetime('1 JAN 2022') + 2*week_td
>> Timestamp('2022-01-15 00:00:00')
还有更多…
使用pd.Timedelta非常简单,并且使得处理大规模时间序列 DataFrame 变得高效,无需导入额外的库,因为它已内置于 pandas 中。
在之前的如何实现...部分,你创建了一个 DataFrame,并基于timedelta计算添加了额外的列。你也可以将timedelta对象添加到 DataFrame 中,并通过它的列进行引用。最后,让我们看看它是如何工作的。
首先,让我们构建与之前相同的 DataFrame:
import pandas as pd
df = pd.DataFrame(
{
'item': ['item1', 'item2', 'item3', 'item4', 'item5', 'item6'],
'purchase_dt': pd.date_range('2021-01-01', periods=6, freq='D', tz='UTC')
}
)
这应该会生成一个如图 6.4所示的 DataFrame。现在,你将添加一个包含Timedelta对象(1 周)的新列,然后使用该列对purchased_dt列进行加减操作:
df['1 week'] = pd.Timedelta('1W')
df['1_week_more'] = df['purchase_dt'] + df['1 week']
df['1_week_less'] = df['purchase_dt'] - df['1 week']
df
上述代码应该会生成一个带有三个额外列的 DataFrame。1 week列包含Timedelta对象,因此,你可以引用该列来计算所需的任何时间差:

图 6.9:更新后的 DataFrame,新增三个列
让我们检查 DataFrame 中每一列的数据类型:
df.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 6 entries, 0 to 5
Data columns (total 5 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 item 6 non-null object
1 purchase_dt 6 non-null datetime64[ns, UTC]
2 1 week 6 non-null timedelta64[ns]
3 1_week_more 6 non-null datetime64[ns, UTC]
4 1_week_less 6 non-null datetime64[ns, UTC]
dtypes: datetime64ns, UTC, object(1), timedelta64ns
memory usage: 368.0+ bytes
请注意,1 week列是一个特定的数据类型,timedelta64(我们的Timedelta对象),它允许你对 DataFrame 中的date、time和datetime列进行算术运算。
在与 DatetimeIndex 一起工作的食谱中,你探讨了pandas.date_range()函数来生成一个带有DatetimeIndex的 DataFrame。该函数基于开始时间、结束时间、周期和频率参数,返回一系列等间隔的时间点。
同样,你也可以使用pandas.timedelta_range()函数生成具有固定频率的TimedeltaIndex,它与pandas.date_range()函数的参数相似。下面是一个快速示例:
df = pd.DataFrame(
{
'item': ['item1', 'item2', 'item3', 'item4', 'item5'],
'purchase_dt': pd.date_range('2021-01-01', periods=5, freq='D', tz='UTC'),
'time_deltas': pd.timedelta_range('1W 2 days 6 hours', periods=5)
}
)
df
输出结果如下:

图 6.10:包含 Timedelta 列的 DataFrame
另见
-
若要了解更多关于
pandas.timedelta_range()函数的信息,请参考官方文档:pandas.pydata.org/docs/reference/api/pandas.timedelta_range.html。 -
若要了解更多关于
pandas.Timedelta类的信息,请访问官方文档:pandas.pydata.org/docs/reference/api/pandas.Timedelta.html。
转换带有时区信息的 DateTime
在处理需要关注不同时区的时间序列数据时,事情可能会变得不可控且复杂。例如,在开发数据管道、构建数据仓库或在系统之间集成数据时,处理时区需要在项目的各个利益相关者之间达成一致并引起足够的重视。例如,在 Python 中,有几个专门用于处理时区转换的库和模块,包括 pytz、dateutil 和 zoneinfo 等。
让我们来讨论一个关于时区和时间序列数据的启发性示例。对于跨越多个大陆的大型公司来说,包含来自全球不同地方的数据是很常见的。如果忽略时区,将很难做出基于数据的商业决策。例如,假设你想确定大多数客户是早上还是晚上访问你的电子商务网站,或者是否顾客在白天浏览,晚上下班后再进行购买。为了进行这个分析,你需要意识到时区差异以及它们在国际范围内的解读。
如何操作...
在这个示例中,你将处理一个假设场景——一个小型数据集,代表从全球各地不同时间间隔访问网站的数据。数据将被标准化为 UTC,并且你将处理时区转换。
- 你将首先导入
pandas库并创建时间序列 DataFrame:
df = pd.DataFrame(
{
'Location': ['Los Angeles',
'New York',
'Berlin',
'New Delhi',
'Moscow',
'Tokyo',
'Dubai'],
'tz': ['US/Pacific',
'US/Eastern',
'Europe/Berlin',
'Asia/Kolkata',
'Europe/Moscow',
'Asia/Tokyo',
'Asia/Dubai'],
'visit_dt': pd.date_range(start='22:00',periods=7, freq='45min'),
}).set_index('visit_dt')
df
这将生成一个 DataFrame,其中 visit_dt 是 DatetimeIndex 类型的索引,两个列 Location 和 tz 表示时区:

图 6.11:以 UTC 时间为索引的 DataFrame
- 假设你需要将这个 DataFrame 转换为与公司总部东京所在的时区相同。你可以通过对 DataFrame 使用
DataFrame.tz_convert()来轻松完成,但如果这样做,你将遇到TypeError异常。这是因为你的时间序列 DataFrame 并不具有时区感知能力。所以,你需要先使用tz_localize()将其本地化,才能使其具备时区感知能力。在这种情况下,你将其本地化为 UTC:
df = df.tz_localize('UTC')
- 现在,你将把 DataFrame 转换为总部所在的时区(
东京):
df_hq = df.tz_convert('Asia/Tokyo')
df_hq
DataFrame 索引 visit_dt 将转换为新的时区:

图 6.12:DataFrame 索引转换为总部所在时区(东京)
请注意,你能够访问 tz_localize() 和 tz_convert() 方法,因为 DataFrame 的索引类型是 DatetimeIndex。如果不是这种情况,你将会遇到 TypeError 异常,错误信息如下:
TypeError: index is not a valid DatetimeIndex or PeriodIndex
- 现在,你将把每一行本地化到适当的时区。你将添加一个新列,反映基于访问网站用户位置的时区。你将利用
tz列来完成这个操作:
df['local_dt'] = df.index
df['local_dt'] = df.apply(lambda x: pd.Timestamp.tz_convert(x['local_dt'], x['tz']), axis=1)
df
这应该会生成一个新的列local_dt,该列基于visit_dt中的 UTC 时间,并根据tz列中提供的时区进行转换:

图 6.13:更新后的 DataFrame,local_dt基于每次访问的本地时区
你可能会问,如果没有tz列怎么办?在哪里可以找到正确的tz字符串?这些被称为时区(TZ)数据库名称。它们是标准名称,你可以在 Python 文档中找到这些名称的一个子集,或者你可以访问这个链接查看更多信息:en.wikipedia.org/wiki/List_of_tz_database_time_zones。
它是如何工作的…
将时间序列的 DataFrame 从一个时区转换到另一个时区,可以使用DataFrame.tz_convert()方法,并提供像US/Pacific这样的时区字符串作为参数。在使用DataFrame.tz_convert()时,有几个假设需要记住:
-
DataFrame 应该具有
DatetimeIndex类型的索引。 -
DatetimeIndex需要具备时区感知功能。
你使用了DataFrame.tz_localize()函数来使索引具备时区感知功能。如果你在处理不同的时区和夏令时,建议标准化为UTC,因为 UTC 始终一致且不变(无论你身在何处,或者是否应用了夏令时)。一旦使用 UTC,转换到其他时区非常简单。
我们首先在前面的步骤中将数据本地化为 UTC 时间,然后分两步将其转换为不同的时区。你也可以通过链式调用这两个方法一步完成,如以下代码所示:
df.tz_localize('UTC').tz_convert('Asia/Tokyo')
如果你的索引已经具备时区感知功能,那么使用tz_localize()将会引发TypeError异常,并显示以下信息:
TypeError: Already tz-aware, use tz_convert to convert
这表示你不需要再次本地化它。相反,只需将其转换为另一个时区即可。
还有更多…
看着图 6.12中的 DataFrame,很难立刻判断时间是上午(AM)还是下午(PM)。你可以使用strftime格式化datetime(我们在提供格式参数给 DateTime的例子中讨论过)。
你将构建相同的 DataFrame,将其本地化为 UTC 时间,然后转换为总部所在的时区,并应用新的格式:
df = pd.DataFrame(
{
'Location': ['Los Angeles',
'New York',
'Berlin',
'New Delhi',
'Moscow',
'Tokyo',
'Dubai'],
'tz': ['US/Pacific',
'US/Eastern',
'Europe/Berlin',
'Asia/Kolkata',
'Europe/Moscow',
'Asia/Tokyo',
'Asia/Dubai'],
'visit_dt': pd.date_range(start='22:00',periods=7, freq='45min'),
}).set_index('visit_dt').tz_localize('UTC').tz_convert('Asia/Tokyo')
我们已经将这些步骤结合起来,生成的 DataFrame 应该类似于图 6.12中的数据。
现在,你可以更新格式,使用YYYY-MM-DD HH:MM AM/PM的模式:
df.index = df.index.strftime('%Y-%m-%d %H:%M %p')
df
索引将从格式/布局的角度进行更新。然而,它仍然是基于东京时区的时区感知,并且索引仍然是DatetimeIndex。唯一的变化是datetime布局:

图 6.14 – 更新后的 DataFrame 索引,基于提供的日期格式字符串进行格式化
我相信你会同意,这样做能更轻松地向用户展示,快速确定访问是上午还是下午。
另见
要了解更多关于 tz_convert 的信息,你可以阅读官方文档 pandas.pydata.org/docs/reference/api/pandas.Series.dt.tz_convert.html 和 https://pandas.pydata.org/docs/reference/api/pandas.Timestamp.tz_convert.html。
处理日期偏移
在处理时间序列时,了解你所处理的数据以及它如何与正在解决的问题相关联至关重要。例如,在处理制造或销售数据时,你不能假设一个组织的工作日是周一到周五,或者它是否使用标准的日历年或财年。你还应该考虑了解任何假期安排、年度停产以及与业务运营相关的其他事项。
这时,偏移量就派上用场了。它们可以帮助将日期转换为对业务更有意义和更具相关性的内容。它们也可以帮助修正可能不合逻辑的数据条目。
我们将在这个示例中通过一个假设的例子,展示如何利用 pandas 偏移量。
如何做…
在本示例中,你将生成一个时间序列 DataFrame 来表示一些生产数量的每日日志。该公司是一家总部位于美国的公司,希望通过分析数据来更好地理解未来预测的生产能力:
- 首先导入
pandas库,然后生成我们的 DataFrame:
np.random.seed(10)
df = pd.DataFrame(
{
'purchase_dt': pd.date_range('2021-01-01', periods=6, freq='D'),
'production' : np.random.randint(4, 20, 6)
}).set_index('purchase_dt')
df
>>
production
purchase_dt
2021-01-01 13
2021-01-02 17
2021-01-03 8
2021-01-04 19
2021-01-05 4
2021-01-06 5
- 让我们添加星期几的名称:
df['day'] = df.index.day_name()
df
>>
production day
purchase_dt
2021-01-01 13 Friday
2021-01-02 17 Saturday
2021-01-03 8 Sunday
2021-01-04 19 Monday
2021-01-05 4 Tuesday
2021-01-06 5 Wednesday
在处理任何数据时,始终理解其背后的业务背景。没有领域知识或业务背景,很难判断一个数据点是否可以接受。在这个情境中,公司被描述为一家总部位于美国的公司,因此,工作日是从周一到周五。如果有关于周六或周日(周末)的数据,未经与业务确认,不应该做任何假设。你应该确认是否在特定的周末日期有生产例外情况。此外,意识到 1 月 1 日是节假日。经过调查,确认由于紧急例外,生产确实发生了。业务高层不希望在预测中考虑周末或节假日的工作。换句话说,这是一个一次性、未发生的事件,他们不希望以此为基础建立模型或假设。
- 公司要求将周末/节假日的生产数据推迟到下一个工作日。在这里,您将使用
pandas.offsets.BDay(),它表示工作日:
df['BusinessDay'] = df.index + pd.offsets.BDay(0)
df['BDay Name'] = df['BusinessDay'].dt.day_name()
df
>>
production day BusinessDay BDay Name
purchase_dt
2021-01-01 13 Friday 2021-01-01 Friday
2021-01-02 17 Saturday 2021-01-04 Monday
2021-01-03 8 Sunday 2021-01-04 Monday
2021-01-04 19 Monday 2021-01-04 Monday
2021-01-05 4 Tuesday 2021-01-05 Tuesday
2021-01-06 5 Wednesday 2021-01-06 Wednesday
由于周六和周日是周末,它们的生产数据被推迟到下一个工作日,即周一,1 月 4 日。
- 让我们执行一个汇总聚合,按工作日添加生产数据,以更好地理解此更改的影响:
df.groupby(['BusinessDay', 'BDay Name']).sum()
>>
production
BusinessDay BDay Name
2021-01-01 Friday 137
2021-01-04 Monday 44
2021-01-05 Tuesday 4
2021-01-06 Wednesday 5
现在,周一被显示为该周最具生产力的一天,因为它是节假日后的第一个工作日,并且是一个长周末之后的工作日。
- 最后,企业提出了另一个请求——他们希望按月(
MonthEnd)和按季度(QuarterEnd)跟踪生产数据。您可以再次使用pandas.offsets来添加两列新数据:
df['QuarterEnd'] = df.index + pd.offsets.QuarterEnd(0)
df['MonthEnd'] = df.index + pd.offsets.MonthEnd(0)
df['BusinessDay'] = df.index + pd.offsets.BDay(0)
>>
production QuarterEnd MonthEnd BusinessDay
purchase_dt
2021-01-01 13 2021-03-31 2021-01-31 2021-01-01
2021-01-02 17 2021-03-31 2021-01-31 2021-01-04
2021-01-03 8 2021-03-31 2021-01-31 2021-01-04
2021-01-04 19 2021-03-31 2021-01-31 2021-01-04
2021-01-05 4 2021-03-31 2021-01-31 2021-01-05
2021-01-06 5 2021-03-31 2021-01-31 2021-01-06
现在,您已经有了一个 DataFrame,它应该能满足大多数企业的报告需求。
它是如何工作的…
使用日期偏移量使得根据特定规则增减日期或将日期转换为新的日期范围成为可能。pandas 提供了多种偏移量,每种偏移量都有其规则,可以应用到您的数据集上。以下是 pandas 中常见的偏移量列表:
-
BusinessDay或Bday -
MonthEnd -
BusinessMonthEnd或BmonthEnd -
CustomBusinessDay或Cday -
QuarterEnd -
FY253Quarter
要查看更全面的列表及其描述,请访问文档:pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#dateoffset-objects。
在 pandas 中应用偏移量与进行加法或减法一样简单,如以下示例所示:
df.index + pd.offsets.BDay()
df.index - pd.offsets.BDay()
还有更多…
根据我们的示例,您可能已经注意到,在使用 BusinessDay(BDay)偏移量时,它并未考虑到新年假期(1 月 1 日)。那么,如何既考虑到新年假期,又考虑到周末呢?
为了实现这一点,pandas 提供了两种处理标准节假日的方法。第一种方法是定义自定义节假日。第二种方法(在适用时)使用现有的节假日偏移量。
让我们从现有的偏移量开始。以新年为例,您可以使用 USFederalHolidayCalendar 类,它包含了标准节假日,如新年、圣诞节以及其他美国特定的节假日。接下来,我们来看看它是如何工作的。
首先,生成一个新的 DataFrame,并导入所需的库和类:
import pandas as pd
from pandas.tseries.holiday import (
USFederalHolidayCalendar
)
df = pd.DataFrame(
{
'purchase_dt': pd.date_range('2021-01-01', periods=6, freq='D'),
'production' : np.random.randint(4, 20, 6)
}).set_index('purchase_dt')
USFederalHolidayCalendar 有一些假期规则,您可以使用以下代码检查:
USFederalHolidayCalendar.rules
>>
[Holiday: New Years Day (month=1, day=1, observance=<function nearest_workday at 0x7fedf3ec1a60>),
Holiday: Martin Luther King Jr. Day (month=1, day=1, offset=<DateOffset: weekday=MO(+3)>),
Holiday: Presidents Day (month=2, day=1, offset=<DateOffset: weekday=MO(+3)>),
Holiday: Memorial Day (month=5, day=31, offset=<DateOffset: weekday=MO(-1)>),
Holiday: July 4th (month=7, day=4, observance=<function nearest_workday at 0x7fedf3ec1a60>),
Holiday: Labor Day (month=9, day=1, offset=<DateOffset: weekday=MO(+1)>),
Holiday: Columbus Day (month=10, day=1, offset=<DateOffset: weekday=MO(+2)>),
Holiday: Veterans Day (month=11, day=11, observance=<function nearest_workday at 0x7fedf3ec1a60>),
Holiday: Thanksgiving (month=11, day=1, offset=<DateOffset: weekday=TH(+4)>),
Holiday: Christmas (month=12, day=25, observance=<function nearest_workday at 0x7fedf3ec1a60>)]
要应用这些规则,您将使用 CustomerBusinessDay 或 CDay 偏移量:
df['USFederalHolidays'] = df.index + pd.offsets.CDay(calendar=USFederalHolidayCalendar())
df
输出结果如下:

图 6.15 – 添加到 DataFrame 中的 USFederalHolidays 列,识别新年假期
自定义假期选项将以相同的方式工作。你需要导入Holiday类和nearest_workday函数。你将使用Holiday类来定义你的具体假期。在本例中,你将确定新年规则:
from pandas.tseries.holiday import (
Holiday,
nearest_workday,
USFederalHolidayCalendar
)
newyears = Holiday("New Years",
month=1,
day=1,
observance=nearest_workday)
newyears
>>
Holiday: New Years (month=1, day=1, observance=<function nearest_workday at 0x7fedf3ec1a60>)
类似于你如何将USFederalHolidayCalendar类应用于CDay偏移量,你将把你的新newyears对象应用于Cday:
df['NewYearsHoliday'] = df.index + pd.offsets.CDay(calendar=newyears)
df
你将获得以下输出:

图 6.16 – 使用自定义假期偏移量添加的 NewYearsHoliday 列
如果你对nearest_workday函数以及它如何在USFederalHolidayCalendar规则和你的自定义假期中被使用感到好奇,那么以下代码展示了它的工作原理:
nearest_workday(pd.to_datetime('2021-1-3'))
>>
Timestamp('2021-01-04 00:00:00')
nearest_workday(pd.to_datetime('2021-1-2'))
>>
Timestamp('2021-01-01 00:00:00')
如所示,函数主要判断一天是否为工作日,然后根据判断结果,它将使用前一天(如果是星期六)或后一天(如果是星期日)。nearest_workday还有其他可用规则,包括以下几种:
-
Sunday_to_Monday -
Next_Monday_or_Tuesday -
Previous_Friday -
Next_monday
参见
欲了解更多有关pandas.tseries.holiday的详细信息,你可以查看实际的代码,它展示了所有的类和函数,可以作为一个很好的参考,访问github.com/pandas-dev/pandas/blob/master/pandas/tseries/holiday.py。
使用自定义工作日
不同地区和领土的公司有不同的工作日。例如,在处理时间序列数据时,根据你需要进行的分析,了解某些交易是否发生在工作日或周末可能会产生差异。例如,假设你正在进行异常检测,并且你知道某些类型的活动只能在工作时间内进行。在这种情况下,任何超出这些时间范围的活动都可能触发进一步的分析。
在本示例中,你将看到如何定制一个偏移量以适应你的需求,特别是在进行依赖于已定义的工作日和非工作日的分析时。
如何操作…
在本示例中,你将为总部位于约旦安曼的公司创建自定义的工作日和假期。在约旦,工作周是从星期日到星期四,而星期五和星期六是为期两天的周末。此外,他们的国庆节(一个假期)是在每年的 5 月 25 日:
- 你将从导入 pandas 并定义约旦的工作日开始:
import pandas as pd
from pandas.tseries.holiday import AbstractHolidayCalendar, Holiday
from pandas.tseries.offsets import CustomBusinessDay
jordan_workdays = "Sun Mon Tue Wed Thu"
- 你将定义一个自定义类假期
JordanHolidayCalendar和一个新的rule用于约旦的独立日:
class JordanHolidayCalendar(AbstractHolidayCalendar):
rules = [
Holiday('Jordan Independence Day', month=5, day=25)
]
- 你将定义一个新的
CustomBusinessDay实例,并包含自定义假期和工作日:
jordan_bday = CustomBusinessDay(
holidays=JordanHolidayCalendar().holidays(),
weekmask=jordan_workdays)
- 你可以验证规则是否已正确注册:
jordan_bday.holidays[53]
>>
numpy.datetime64('2023-05-25')
jordan_bday.weekmask
>>
'Sun Mon Tue Wed Thu'
- 现在,你可以使用
pd.date_range生成从 2023 年 5 月 20 日开始的 10 个工作日。生成的日期将排除周末(周五和周六)以及独立日假期(5 月 25 日):
df = pd.DataFrame({'Date': pd.date_range('12-1-2021', periods=10, freq=dubai_uae_bday )})
- 为了更容易判断功能是否按预期工作,添加一个新列来表示星期名称:
df['Day_name'] = df.Date.dt.day_name()
df
生成的时间序列应该具有新的自定义工作日,并包含约旦的节假日规则。

图 6.17:基于阿联酋自定义工作日和节假日生成的时间序列
在图 6.17中,你可以看到自定义的工作日从周日到周一,除了 5 月 25 日(星期四),因为这是约旦的国庆日,所有该日期被跳过。
这个示例可以进一步扩展,涵盖不同国家和节假日,以适应你正在进行的分析类型。
它是如何工作的……
这个配方基于“处理日期偏移量”配方,但重点是自定义偏移量。pandas 提供了几种偏移量,可以使用自定义日历、节假日和weekmask。这些包括以下内容:
-
CustomBusinessDay或Cday -
CustomBusinessMonthEnd或CBMonthEnd -
CustomBusinessMonthBegin或CBMonthBegin -
CustomBusinessHour
它们的行为就像其他偏移量;唯一的区别是,它们允许你创建自己的规则。
在这个配方中,你导入了CustomBusinessDay类,创建了它的一个实例来为工作日创建自定义频率,考虑了周末和节假日。以下是你使用的代码:
jordan_bday = CustomBusinessDay(
holidays=JordanHolidayCalendar().holidays(),
weekmask=jordan_workdays
)
这也等同于以下代码:
jordan_bday = pd.offsets.CustomBusinessDay(
holidays=JordanHolidayCalendar().holidays(),
weekmask=jordan_workdays,
)
请注意,在定义工作日时,使用的是一串缩写的星期名称。这被称为weekmask,在自定义星期时,pandas 和 NumPy 都使用它。在 pandas 中,你还可以通过扩展AbstractHolidayCalendar类并包含特定的日期或节假日规则来定义自定义假期日历,正如前面关于约旦独立日的代码所展示的那样。
还有更多内容……
让我们扩展前面的示例,并向 DataFrame 中添加自定义工作时间。这将是另一个自定义偏移量,你可以像使用CustomBusinessDay一样使用它:
jordan_bhour = pd.offsets.CustomBusinessHour(
start="8:30",
end="15:30",
holidays=JordanHolidayCalendar().holidays(),
weekmask=jordan_workdays)
在这里,你应用了相同的规则,自定义的holidays和weekmask用于自定义工作日,确保自定义的工作时间也遵守定义的工作周和节假日。你通过提供start和end时间(24 小时制)来定义自定义工作时间。
以下是你刚刚创建的自定义工作时间的使用示例:
start_datetime = '2023-05-24 08:00'
end_datetime = '2023-05-24 16:00'
business_hours = pd.date_range(start=start_datetime, end=end_datetime, freq=jordan_bhour)
print(business_hours)
>>
DatetimeIndex(['2023-05-24 08:30:00', '2023-05-24 09:30:00',
'2023-05-24 10:30:00', '2023-05-24 11:30:00',
'2023-05-24 12:30:00', '2023-05-24 13:30:00',
'2023-05-24 14:30:00'],
dtype='datetime64[ns]', freq='CBH')
请注意,像CustomBusinessDay和CustomBusinessHour这样的自定义偏移量可以应用于无时区感知和有时区感知的日期时间对象。然而,是否需要将 DataFrame 设置为时区感知取决于具体的使用案例。在需要时,你可以使用tz.localize()或tz.convert()将 DataFrame 设置为时区感知(例如,先本地化再转换到你的时区),然后再应用自定义偏移量以获得更好的结果。
另请参见
-
要了解更多关于 pandas 的
CustomBusinessDay,你可以阅读官方文档:pandas.pydata.org/docs/reference/api/pandas.tseries.offsets.CustomBusinessDay.html -
要了解更多关于 pandas 的
CustomBusinessHours,你可以阅读官方文档:pandas.pydata.org/docs/reference/api/pandas.tseries.offsets.CustomBusinessHour.html
第七章:7 处理缺失数据
加入我们的书籍社区,访问 Discord

作为数据科学家、数据分析师或业务分析师,您可能已经发现,指望获得完美的干净数据集是过于乐观的。然而,更常见的情况是,您正在处理的数据存在缺失值、错误数据、重复记录、数据不足或数据中存在异常值等问题。
时间序列数据也不例外,在将数据输入任何分析或建模流程之前,您必须先对数据进行调查。理解时间序列数据背后的业务背景对于成功检测和识别这些问题至关重要。例如,如果您处理的是股票数据,其背景与 COVID 数据或传感器数据有很大不同。
拥有这种直觉或领域知识将使您能够预见分析数据时的期望结果以及哪些结果是可以接受的。始终尝试理解数据背后的业务背景。例如,数据最初为什么要被收集?数据是如何收集的?数据是否已经应用了某些业务规则、逻辑或变换?这些修改是在数据采集过程中应用的,还是内置在生成数据的系统中?
在发现阶段,这些前期知识将帮助您确定最佳的方法来清理和准备数据集,以进行分析或建模。缺失数据和异常值是数据清理和准备过程中需要处理的两个常见问题。您将在第八章《使用统计方法检测异常值》和第十四章《使用无监督机器学习检测异常值》中深入探讨异常值检测。本章将探索通过插补和插值技术处理缺失数据的方法。
以下是本章将涵盖的配方列表:
-
执行数据质量检查
-
使用 pandas 进行单变量插补处理缺失数据
-
使用 scikit-learn 进行单变量插补处理缺失数据
-
使用多变量插补处理缺失数据
-
使用插值处理缺失数据
技术要求
您可以从 GitHub 仓库下载 Jupyter 笔记本和所需的数据集,以便跟随教程:
-
Jupyter 笔记本:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./blob/main/code/Ch7/Chapter%207.ipynb -
数据集:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./tree/main/datasets/Ch7
在本章及后续章节中,您将广泛使用 pandas 2.1.3(2023 年 11 月 10 日发布)。另外,您还将使用四个额外的库:
-
numpy (1.26.0)
-
matplotlob (3.8.1)
-
statsmodels (0.14.0)
-
scikit-learn (1.3.2)
-
SciPy (1.11.3)
如果您使用pip,则可以通过终端使用以下命令安装这些包:
pip install matplotlib numpy statsmodels scikit-learn scipy
如果您使用conda,则可以通过以下命令安装这些包:
conda install matplotlib numpy statsmodels scikit-learn scipy
在本章中,将广泛使用两个数据集进行插补和插值操作:CO2 排放数据集和电子商店点击流数据集。点击流数据集的来源为在线购物的点击流数据,来自UCI 机器学习库,您可以在这里找到:
archive.ics.uci.edu/ml/datasets/clickstream+data+for +online+shopping
CO2 排放数据集的来源为Our World in Data的年度CO2 排放报告,您可以在此处找到:ourworldindata.org/co2-emissions。
为了演示,两个数据集已经被修改,去除了部分观测值(缺失数据)。提供了原始版本和修改版本,用于评估本章讨论的不同技术。
在本章中,您将遵循类似的步骤来处理缺失数据:将数据导入 DataFrame,识别缺失数据,对缺失数据进行插补,评估与原始数据的对比,最后可视化并比较不同的插补技术。
这些步骤可以转化为函数以便重用。您可以为这些步骤创建函数:一个用于将数据读入 DataFrame 的函数,一个用于使用 RMSE 评分评估的函数,以及一个用于绘制结果的函数。
首先加载将在本章中使用的标准库:
import pandas as pd
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
函数 1 – read_datasets
read_datasets函数接受文件夹路径、CSV 文件名和包含日期变量的列名。
read_datasets函数定义如下:
def read_dataset(folder, file, date_col=None, format=None, index=False):
'''
folder: is a Path object
file: the CSV filename in that Path object.
date_col: specify a column which has datetime
index_col: True if date_col should be the index
returns: a pandas DataFrame with a DatetimeIndex
'''
index_col = date_col if index is True else None
df = pd.read_csv(folder / file,
index_col=index_col,
parse_dates=[date_col],
date_format=format)
return df
函数 2 – plot_dfs
plot_dfs()函数接受两个 DataFrame:没有缺失数据的原始 DataFrame(df1,作为基准)和经过插补的 DataFrame(df2,用于对比)。该函数使用指定的响应列(col)创建多个时间序列子图。注意,插补后的 DataFrame 将包含额外的列(每个插补技术的输出列),绘图函数会考虑到这一点。通过遍历列来完成这一操作。该函数将为每个插补技术绘制图形以便视觉比较,并将在本章中多次使用。
这个plot_dfs函数定义如下:
def plot_dfs(df1, df2, col, title=None, xlabel=None, ylabel=None):
'''
df1: original dataframe without missing data
df2: dataframe with missing data
col: column name that contains missing data
'''
df_missing = df2.rename(columns={col: 'missing'})
columns = df_missing.loc[:, 'missing':].columns.tolist()
subplots_size = len(columns)
fig, ax = plt.subplots(subplots_size+1, 1, sharex=True)
plt.subplots_adjust(hspace=0.25)
fig.suptitle = title
df1[col].plot(ax=ax[0], figsize=(10, 12))
ax[0].set_title('Original Dataset')
ax[0].set_xlabel(xlabel)
ax[0].set_ylabel(ylabel)
for i, colname in enumerate(columns):
df_missing[colname].plot(ax=ax[i+1])
ax[i+1].set_title(colname.upper())
plt.show()
函数 3 – rmse_score
除了使用plot_dfs函数进行插补技术的视觉比较外,您还需要一种方法来数字化比较不同的插补技术(使用统计度量)。
这时,rmse_score 函数将派上用场。它接受两个 DataFrame:原始 DataFrame(df1)作为基准,以及要进行比较的填补后的 DataFrame(df2)。该函数允许你指定哪个列包含响应列(col),用于计算的基础。
rmse_score 函数定义如下:
def rmse_score(df1, df2, col=None):
'''
df1: original dataframe without missing data
df2: dataframe with missing data
col: column name that contains missing data
returns: a list of scores
'''
df_missing = df2.rename(columns={col: 'missing'})
columns = df_missing.loc[:, 'missing':].columns.tolist()
scores = []
for comp_col in columns[1:]:
rmse = np.sqrt(np.mean((df1[col] - df_missing[comp_col])**2))
scores.append(rmse)
print(f'RMSE for {comp_col}: {rmse}')
return scores
理解缺失数据
数据缺失可能有多种原因,例如意外的停电、设备意外断电、传感器损坏、调查参与者拒绝回答某个问题,或者出于隐私和合规性原因故意删除数据。换句话说,缺失数据是不可避免的。
通常,缺失数据是非常常见的,但有时在制定处理策略时并未给予足够重视。处理缺失数据行的一种方法是删除这些观察值(删除行)。然而,如果你的数据本就有限,这种方法可能不是一个好的策略。例如,若数据的收集过程复杂且昂贵,则删除记录的缺点在于,如果过早删除,你将无法知道缺失数据是由于审查(观察仅部分收集)还是由于偏差(例如,高收入参与者拒绝在调查中共享家庭总收入)造成的。
第二种方法可能是通过添加一列描述或标记缺失数据的列来标记缺失数据的行。例如,假设你知道在某一天发生了停电。此时,你可以添加“停电”来标记缺失数据,并将其与其他标记为“缺失数据”的缺失数据区分开来,如果其原因未知的话。
本章讨论的第三种方法是估算缺失的数据值。这些方法可以从简单的、初步的,到更复杂的技术,后者使用机器学习和复杂的统计模型。但你怎么衡量最初缺失数据的估算值的准确性呢?
处理缺失数据时有多种选择和措施需要考虑,答案并非那么简单。因此,你应该探索不同的方法,强调彻底的评估和验证过程,以确保所选方法最适合你的情况。在本章中,你将使用均方根误差(RMSE)来评估不同的填补技术。
计算 RMSE 的过程可以分为几个简单的步骤:首先,计算误差,即实际值与预测值或估计值之间的差异。这是针对每个观测值进行的。由于误差可能是负值或正值,为了避免零求和,误差(差异)会被平方。最后,将所有误差求和并除以观测值的总数来计算平均值。这会给你均方误差(MSE)。RMSE 只是 MSE 的平方根。
RMSE 方程可以写成:
在我们估算缺失观测值时,
是插补值,
是实际(原始)值,N是观测值的数量。
用于评估多重插补方法的 RMSE
我想指出,RMSE 通常用于衡量预测模型的性能(例如,比较回归模型)。通常,较低的 RMSE 是理想的;它告诉我们模型能够拟合数据集。简单来说,它告诉我们预测值与实际值之间的平均距离(误差)。你希望最小化这个距离。
在比较不同的插补方法时,我们希望插补的值尽可能接近实际数据,这些数据包含随机效应(不确定性)。这意味着我们并不寻求完美的预测,因此较低的 RMSE 分数不一定表示更好的插补方法。理想情况下,你希望找到一个平衡,因此在本章中,RMSE 与可视化结合使用,以帮助说明不同技术如何比较和工作。
提醒一下,我们有意删除了一些值(人为造成缺失数据),但保留了原始数据,以便在使用 RMSE 时进行对比。
执行数据质量检查
缺失数据是指在数据集中没有捕获或没有观察到的值。值可能会缺失于特定特征(列)或整个观测(行)。使用 pandas 加载数据时,缺失值将显示为NaN、NaT或NA。
有时,在给定的数据集中,缺失的观测值会被源系统中的其他值替换;例如,这可以是像99999或0这样的数字填充值,或者像missing或N/A这样的字符串。当缺失值被表示为0时,需要小心,并进一步调查以确定这些零值是否合法,还是缺失数据的标志。
在本教程中,你将探索如何识别缺失数据的存在。
准备工作
你可以从 GitHub 仓库下载 Jupyter 笔记本和所需的数据集。请参考本章的技术要求部分。
你将使用来自Ch7文件夹的两个数据集:clicks_missing_multiple.csv和co2_missing.csv。
如何操作…
pandas库提供了方便的方法来发现缺失数据并总结 DataFrame 中的数据:
- 通过
read_dataset()函数开始读取两个 CSV 文件(co2_missing.csv和clicks_missing.csv):
folder = Path('../../datasets/Ch7/')
co2_file = Path('co2_missing.csv')
ecom_file = Path('clicks_missing_multiple.csv')
co2_df = read_dataset(folder,
co2_file,
index=True,
date_col='year')
ecom_df = read_dataset(folder,
ecom_file,
index=True,
date_col='date')
ecom_df.head()
这将显示ecom_df DataFrame 的前五行:

图 7.1:ecom_df DataFrame 的前五行,显示了 NaN 和 NaT
上述代码的输出显示源数据集中有五个缺失值。NaN是 pandas 表示空数值的方式(NaN是Not a Number的缩写)。NaT是 pandas 表示缺失的Datetime值的方式(NaT是Not a Time的缩写)。
- 要统计两个 DataFrame 中的缺失值数量,你可以使用
DataFrame.isnull()或DataFrame.isna()方法。这会返回True(如果缺失)或False(如果不缺失)对于每个值。例如,要获取每一列缺失值的总数,你可以使用DataFrame.isnull().sum()或DataFrame.isna().sum()。
在 Python 中,布尔值(True或False)是整数的一个子类型。True等于1,False等于0。要验证这一概念,可以尝试以下操作:
isinstance(True, int)
>> True
int(True)
>> 1
现在,让我们获取每个 DataFrame 中缺失值的总数:
co2_df.isnull().sum()
>>
co2 25
dtype: int64
ecom_df.isnull().sum()
>>
price 1
location 1
clicks 14
dtype: int64
请注意,在上面的代码中同时使用了.isnull()和.isna()。它们可以互换使用,因为.isnull()是.isna()的别名。
- 在前一步中,
co2_df的year列和ecom_df的date列未包含在计数结果中。这是因为isnull()或isna()关注的是 DataFrame 的列,而不包括索引。我们的read_datasets()函数在技术要求部分将它们设置为索引列。一种简单的方法是将索引重置为列,如下所示:
co2_df.reset_index(inplace=True)
ecom_df.reset_index(inplace=True)
现在,如果你执行isnull().sum(),你应该能看到co2_df的年份列和ecom_df的日期列包含在计数结果中:
co2_df.isnull().sum()
>>
year 0
co2 25
dtype: int64
ecom_df.isnull().sum()
>>
date 4
price 1
location 1
clicks 14
dtype: int64
从结果来看,co2_df的co2列有25个缺失值,而ecom_df总共有20个缺失值(其中4个来自date列,1个来自price列,1个来自location列,14个来自clicks列)。
- 要获取整个
ecom_dfDataFrame 的总计,只需在语句末尾再链式调用.sum()函数:
ecom_df.isnull().sum().sum()
>> 20
类似地,对于co2_df,你可以再链式调用一个.sum()。
co2_df.isnull().sum().sum()
>> 25
- 如果你使用文本/代码编辑器(如 Excel 或 Jupyter Lab)检查
co2_missing.csv文件,并向下滚动到第 192-194 行,你会发现其中有一些字符串占位符值:NA、N/A和null:

图 7.2:co2_missing.csv 显示了由 pandas 转换为 NaN(缺失)的字符串值
图 7.2 显示了这三种字符串值。有趣的是,pandas.read_csv()将这三种字符串值解释为NaN。这是read_csv()的默认行为,可以通过na_values参数进行修改。要查看 pandas 如何表示这些值,可以运行以下命令:
co2_df[190:195]
这将产生以下输出:

图 7.3:pandas.read_csv()将 NA、N/A 和 null 字符串解析为 NaN 类型
- 如果你只需要检查 DataFrame 是否包含任何缺失值,请使用
isnull().values.any()。如果 DataFrame 中有任何缺失值,这将输出True:
ecom_df.isnull().values.any()
>> True
co2_df.isnull().values.any()
>> True
- 到目前为止,
isnull()帮助识别了 DataFrame 中的所有缺失值。但如果缺失值被掩盖或替换为其他占位符值,例如?或99999,会怎样呢?这些值会被跳过并视为缺失(NaN)值。在技术上,它们并不是空单元格(缺失),而是具有值的。另一方面,领域知识或先验知识告诉我们,CO2 排放数据集是按年测量的,应该具有大于 0 的值。
同样,我们期望点击流数据的点击次数是数值型的。如果该列不是数值型的,应该引发调查,看看为什么 pandas 不能将该列解析为数值型。例如,这可能是由于存在字符串值。
为了更好地了解 DataFrame 的模式和数据类型,可以使用DataFrame.info()来显示模式、总记录数、列名、列的数据类型、每列非缺失值的计数、索引数据类型和 DataFrame 的总内存使用情况:
ecom_df.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 135 entries, 0 to 134
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 date 132 non-null datetime64[ns]
1 price 134 non-null float64
2 location 134 non-null float64
3 clicks 121 non-null object
dtypes: datetime64ns, float64(2), object(1)
memory usage: 4.3+ KB
co2_df.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 226 entries, 0 to 225
Data columns (total 2 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 year 226 non-null datetime64[ns]
1 co2 201 non-null float64
dtypes: float64(1), int64(1)
memory usage: 3.7 KB
co2_df的汇总输出看起来合理,确认我们有25个缺失值(226 个总记录减去 221 个非空值,得到 25 个缺失值)在co2列中。
另一方面,ecom_df的汇总显示clicks列的数据类型为object(表示混合类型),而不是预期的float64。我们可以进一步通过基础的汇总统计信息进行调查。
- 要获取 DataFrame 的汇总统计信息,请使用
DataFrame.describe()方法:
co2_df.describe(include='all')
输出如下:

图 7.4:co2_df 的汇总统计信息,表明数据中存在零值
请注意使用include='all'来替代默认值include=None。默认行为是仅显示数字列的汇总统计信息。通过将值更改为'all',结果将包括所有列类型。
co2_df DataFrame 的摘要统计确认了我们在 co2 列下有零值(最小值 = 0.00)。正如之前所指出的,先验知识告诉我们,0 代表一个空值(或缺失值)。因此,零值需要被替换为 NaN,以便将这些值纳入插补过程。现在,查看 ecom_df 的摘要统计:
ecom_df.describe(include='all')
输出如下:

图 7.5:ecom_df 摘要统计,显示点击列中的 ? 值
如你所见,ecom_df DataFrame 的摘要统计表明,我们在 clicks 列下有一个 ? 值。这也解释了为什么 pandas 没有将该列解析为数值类型(因为存在混合类型)。类似地,? 值需要被替换为 NaN,以便将其视为缺失值并进行插补。
- 将
0和?的值转换为NaN类型。这可以通过使用DataFrame.replace()方法来完成:
co2_df.replace(0, np.NaN, inplace=True)
ecom_df.replace('?', np.NaN, inplace=True)
ecom_df['clicks'] = ecom_df['clicks'].astype('float')
为了验证,运行 DataFrame.isnull().sum(),你应该注意到缺失值的计数已经增加:
co2_df.isnull().sum()
>>
year 0
missing 35
dtype: int64
ecom_df.isnull().sum()
>>
date 4
price 1
location 1
clicks 16
dtype: int64
新的数字更好地反映了两个 DataFrame 中实际缺失值的数量。
它是如何工作的……
在使用 pandas.read_csv() 读取 CSV 文件时,默认行为是识别并解析某些字符串值,例如 NA、N/A 和 null,并将其转换为 NaN 类型(缺失值)。因此,一旦这些值变为 NaN,CSV 阅读器就可以基于剩余的非空值将 co2 列解析为 float64(数值型)。
这可以通过两个参数来实现:na_values 和 keep_default_na。默认情况下,na_values 参数包含一个字符串列表,这些字符串会被解释为 NaN。该列表包括 #N/A、#N/A N/A、#NA、-1.#IND、-1.#QNAN、-NaN、-nan、1.#IND、1.#QNAN、<NA>、N/A、NA、NULL、NaN、n/a、nan 和 null。
你可以通过提供额外的值给 na_values 参数来将其附加到此列表中。此外,keep_default_na 默认设置为 True,因此使用(附加)na_values 时,会与默认列表一起用于解析。
如果你将 keep_default_na 设置为 False,而没有为 na_values 提供新的值,则不会将任何字符串(如 NA、N/A 和 null)解析为 NaN,除非你提供自定义列表。例如,如果将 keep_default_na 设置为 False 且未为 na_values 提供值,则整个 co2 列会被解析为 string(对象类型),任何缺失值将以字符串的形式出现;换句话说,它们会显示为 '',即一个空字符串。
这是一个例子:
co2_df = pd.read_csv(folder/co2_file,
keep_default_na=False)
co2_df.isna().sum()
>>
year 0
co2 0
dtype: int64
co2_df.shape
>> (226, 2)
请注意,我们没有丢失任何数据(226 条记录),但是没有显示任何 NaN(或缺失)值。我们来检查一下 DataFrame 的结构:
co2_df.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 226 entries, 0 to 225
Data columns (total 2 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 year 226 non-null datetime64[ns]
1 co2 226 non-null object
dtypes: datetime64ns, object(1)
memory usage: 3.7+ KB
注意 co2 列的 dtype 发生了变化。我们再检查一下从索引 190 到 195 的数据:
co2_df[190:195]
输出如下:

图 7.6:没有 NaN 解析的 co2_df DataFrame 输出
最后,你可以检查缺失值是如何处理的:
co2_df.iloc[132:139]
你会注意到所有七行都有空值(空字符串)。
在这个示例中,你探索了.isna()方法。一旦数据被读取到 DataFrame 或系列中,你可以访问.isna()和.isnull()这两个可互换的方法,如果数据缺失,它们会返回True,否则返回False。要获取每列的计数,我们只需链式调用.sum()函数,而要获取总计,则继续链式调用另一个.sum()函数:
co2_df.isnull().sum()
co2_df.isnull().sum().sum()
还有更多内容…
如果你知道数据中总是会包含?,并且应该将其转换为NaN(或其他值),你可以利用pd.read_csv()函数并更新na_values参数。这将减少在创建 DataFrame 后清理数据所需的步骤:
ecom_df = pd.read_csv(folder/ecom_file,
parse_dates=['date'],
na_values={'?'})
这将把所有的?替换为NaN。
另见
-
要了解有关
pandas.read_csv()中的na_values和keep_default_na参数的更多信息,请访问官方文档:pandas.pydata.org/docs/reference/api/pandas.read_csv.html -
要了解更多关于
DataFrame.isna()函数的信息,请访问官方文档:pandas.pydata.org/docs/reference/api/pandas.DataFrame.isna.html
使用 pandas 进行单变量插补处理缺失数据
通常,插补缺失数据有两种方法:单变量插补和多变量插补。本示例将探讨 pandas 中可用的单变量插补技术。
在单变量插补中,你使用单一变量中的非缺失值(比如某一列或特征)来填补该变量的缺失值。例如,如果你的数据集中的销售列有一些缺失值,你可以使用单变量插补方法,通过平均销售额来填补缺失的销售数据。在这里,使用了单一列(sales)来计算均值(来自非缺失值)进行填补。
一些基本的单变量插补技术包括以下内容:
-
使用均值进行填补。
-
使用最后一个观察值进行前向填补(前向填充)。这可以称为最后观察值向前填充(LOCF)。
-
使用下一个观察值进行向后填补(向后填充)。这可以称为下一个观察值向后填充(NOCB)。
你将使用两个数据集,通过不同的技术进行缺失数据插补,然后比较结果。
准备工作
你可以从 GitHub 仓库下载 Jupyter 笔记本和所需的数据集。请参考本章的技术要求部分。
你将使用 Ch7 文件夹中的四个数据集:clicks_original.csv,clicks_missing.csv,clicks_original.csv,和 co2_missing_only.csv。这些数据集可以从 GitHub 仓库获取。
如何执行……
你将从导入库开始,然后读取四个 CSV 文件。你将使用数据集的原始版本来比较插补结果,以便更好地理解它们的表现。作为比较度量,你将使用 RMSE 来评估每种技术,并通过可视化输出结果来直观比较插补结果:
- 使用
read_dataset()函数读取四个数据集:
co2_original = read_dataset(folder, 'co2_original.csv', 'year', index=True)
co2_missing = read_dataset(folder, 'co2_missing_only.csv', 'year', index=True)
clicks_original = read_dataset(folder, 'clicks_original.csv', 'date', index=True)
clicks_missing = read_dataset(folder, 'clicks_missing.csv', 'date', index=True)
- 可视化 CO2 数据框(原始数据与缺失数据),并指定包含缺失值的列(
co2):
plot_dfs(co2_original,
co2_missing,
'co2',
title="Annual CO2 Emission per Capita",
xlabel="Years",
ylabel="x100 million tons")
plot_dfs 函数将生成两个图:一个是原始的无缺失值的 CO2 数据集,另一个是带有缺失值的修改后的数据集。

图 7.7:CO2 数据集,展示缺失值与原始数据的比较
从 图 7.7 可以看到 CO2 水平随时间显著上升,并且在三个不同的地方存在缺失数据。现在,开始可视化点击流数据框:
plot_dfs(clicks_original,
clicks_missing,
'clicks',
title="Page Clicks per Day",
xlabel="date",
ylabel="# of clicks")
plot_dfs 函数将生成两个图:一个是原始的无缺失值的点击流数据集,另一个是带有缺失值的修改后的数据集。

图 7.8:点击流数据集,展示缺失值与原始数据的比较
注意,输出显示从 5 月 15 日到 5 月 30 日的数据缺失。你可以通过运行以下代码来确认这一点:
clicks_missing[clicks_missing['clicks'].isna()]
- 现在你准备进行第一次插补。你将使用
.fillna()方法,该方法有一个value参数,接受数字或字符串值,用来替代所有的NaN实例。此外,你还将使用.ffill()进行前向填充,使用.bfill()进行后向填充。
让我们利用 method 参数来插补缺失值,并将结果作为新列追加到数据框中。首先从 CO2 数据框开始:
co2_missing['ffill'] = co2_missing['co2'].ffill()
co2_missing['bfill'] = co2_missing['co2'].bfill()
co2_missing['mean'] = co2_missing['co2'].fillna(co2_missing['co2'].mean())
使用 rmse_score 函数获取得分:
_ = rmse_score(co2_original,
co2_missing,
'co2')
>>
RMSE for ffil: 0.05873012599267133
RMSE for bfill: 0.05550012995280968
RMSE for mean: 0.7156383637041684
现在,使用 plot_dfs 函数可视化结果:
plot_dfs(co2_original, co2_missing, 'co2')
上面的代码生成的结果如下:

图 7.9:CO2 数据框架中三种插补方法的比较
将 图 7.9 中的结果与 图 7.7 中的原始数据进行比较。注意,无论是 ffill 还是 bfill 都比使用 mean 方法产生了更好的结果。两种技术都具有较低的 RMSE 得分,并且视觉效果更好。
- 现在,在点击流数据框上执行相同的插补方法:
clicks_missing['ffil'] = clicks_missing['clicks'].ffill()
clicks_missing['bfill'] = clicks_missing['clicks'].bfill()
clicks_missing['mean'] = clicks_missing['clicks'].fillna(clicks_missing['clicks'].mean())
现在,计算 RMSE 得分:
_ = rmse_score(clicks_original,
clicks_missing,
'clicks')
>>
RMSE for ffil: 1034.1210689204554
RMSE for bfill: 2116.6840489225033
RMSE for mean: 997.7600138929953
有趣的是,对于 Clickstream 数据集,均值插补的 RMSE 分数最低,这与 CO2 数据集的结果形成对比。我们通过可视化结果来从另一个角度审视性能:
plot_dfs(clicks_original, clicks_missing, 'clicks')
你会得到如下图形:

图 7.10:三种插补方法在 Clickstream 数据框中的比较
比较 图 7.10 中的结果与 图 7.8 中的原始数据。请注意,通过对两个不同数据集(CO2 和 Clickstream)进行插补,处理缺失数据时并没有一个 放之四海而皆准的策略。相反,每个数据集都需要不同的策略。因此,你应该始终检查结果,并根据数据的性质调整输出与预期的匹配。
它是如何工作的……
使用 DataFrame.fillna() 是最简单的插补方法。在前一节中,你使用了 .fillna() 中的 value 参数,并传递了均值(一个标量数字值)来填充所有缺失值。
使用的其他选项包括 向后填充(.bfill()),该方法使用缺失位置后的下一个观测值,并向后填充空缺。此外,还使用了 向前填充(.ffill()),该方法使用缺失位置前的最后一个值,并向前填充空缺。
还有更多……
.fillna() 中的 value 参数也可以接受一个 Python 字典、一个 pandas Series 或一个 pandas DataFrame,而不仅仅是一个 标量。
使用 Python 字典
让我们通过另一个示例演示如何使用 Python 字典为多列插补缺失值。首先读取 clicks_missing_more.csv 文件:
clicks_missing = read_dataset(folder, 'clicks_missing_more.csv', 'date', index=True)
clicks_missing.isna().sum()
这应该会生成以下结果:
price 5
location 6
clicks 16
dtype: int64
这里有三列包含缺失值。我们可以使用字典来定义一个映射,其中每个 键值对 对应 clicks_missing 数据框中的一列。我们可以为不同的列定义不同的统计量(中位数、均值 和 众数)作为插补策略。以下代码展示了这一点:
values = {'clicks': clicks_missing['clicks'].median(),
'price': clicks_missing['clicks'].mean(),
'location': clicks_missing['location'].mode()}
clicks_missing.fillna(value=values, inplace=True)
clicks_missing.isna().sum()
前面的代码应该会生成以下结果,显示所有三列的缺失值都已填充。
price 0
location 0
clicks 0
dtype: int64
inplace=True 参数会直接修改 clicks_missing 数据框,意味着所做的更改会直接应用于该数据框。
使用另一个 DataFrame
你还可以使用另一个 pandas DataFrame(或 Series)来插补缺失值,前提是列名需要匹配,以便正确映射各列。在以下示例中,你将读取 clicks_missing_more.csv 文件和 clicks_original.csv 文件进行演示。
clicks_missing = read_dataset(folder, 'clicks_missing_more.csv', 'date', index=True)
clicks_original = read_dataset(folder, 'clicks_original.csv', 'date', index=True)
你将使用 clicks_original 数据框来插补 clicks_missing 数据框中的缺失值。
clicks_missing.fillna(value=clicks_original, inplace=True)
clicks_missing.isna().sum()
前面的代码应该会生成以下结果,显示所有三列的缺失值都已填充。
price 0
location 0
clicks 0
dtype: int64
另请参见
要了解更多关于DataFrame.fillna()的信息,请访问官方文档页面:pandas.pydata.org/docs/reference/api/pandas.DataFrame.fillna.html。
在以下的步骤中,你将执行类似的单变量插补,但这次使用的是Scikit-Learn库。
使用 scikit-learn 进行单变量插补处理缺失数据
Scikit-Learn是 Python 中非常流行的机器学习库。scikit-learn库提供了大量的选项,涵盖日常机器学习任务和算法,如分类、回归、聚类、降维、模型选择和预处理。
此外,库还提供了多种选项来进行单变量和多变量数据插补。
准备工作
你可以从 GitHub 存储库下载 Jupyter 笔记本和必需的数据集。请参考本章的技术要求部分。
本步骤将使用之前准备好的三个函数(read_dataset、rmse_score和plot_dfs)。
你将使用Ch7文件夹中的四个数据集:clicks_original.csv、clicks_missing.csv、co2_original.csv和co2_missing_only.csv。这些数据集可以从 GitHub 存储库下载。
如何操作…
你将从导入库开始,然后读取所有四个 CSV 文件:
- 你将使用
scikit-learn库中的SimpleImputer类来执行单变量插补:
from sklearn.impute import SimpleImputer
folder = Path('../../datasets/Ch7/')
co2_original = read_dataset(folder,
'co2_original.csv', 'year', index=True)
co2_missing = read_dataset(folder,
'co2_missing_only.csv', 'year', index=True)
clicks_original = read_dataset(folder,
'clicks_original.csv', 'date', index=True)
clicks_missing = read_dataset(folder,
'clicks_missing.csv', 'date', index=True)
SimpleImputer接受不同的strategy参数值,包括mean、median和most_frequent。让我们探索这三种策略,并看看它们的比较。为每种方法创建一个元组列表:
strategy = [
('Mean Strategy', 'mean'),
('Median Strategy', 'median'),
('Most Frequent Strategy', 'most_frequent')]
你可以循环遍历Strategy列表,应用不同的插补策略。SimpleImputer有一个fit_transform方法。它将两个步骤合并为一个:先拟合数据(.fit),然后转换数据(.transform)。
请记住,SimpleImputer接受 NumPy 数组,因此你需要使用Series.values属性,然后调用.reshape(-1, 1)方法来创建二维 NumPy 数组。简单来说,这样做的目的是将形状为(226, )的 1D 数组从.values转换为形状为(226, 1)的二维数组,即列向量:
co2_vals = co2_missing['co2'].values.reshape(-1,1)
clicks_vals = clicks_missing['clicks'].values.reshape(-1,1)
for s_name, s in strategy:
co2_missing[s_name] = (
SimpleImputer(strategy=s).fit_transform(co2_vals))
clicks_missing[s_name] = (
SimpleImputer(strategy=s).fit_transform(clicks_vals))
现在,clicks_missing和co2_missing这两个 DataFrame 已经有了三列额外的列,每一列对应实现的插补策略。
- 使用
rmse_score函数,你现在可以评估每个策略。从 CO2 数据开始。你应该获得如下输出:
_ = rmse_score(co2_original, co2_missing, 'co2')
>>
RMSE for Mean Strategy: 0.7156383637041684
RMSE for Median Strategy: 0.8029421606859859
RMSE for Most Frequent Strategy: 1.1245663822743381
对于 Clickstream 数据,你应该获得如下输出:
_ = rmse_score(clicks_original, clicks_missing, 'clicks')
>>
RMSE for Mean Strategy: 997.7600138929953
RMSE for Median Strategy: 959.3580492530756
RMSE for Most Frequent Strategy: 1097.6425985146868
注意,RMSE 策略排名在两个数据集之间的差异。例如,Mean策略在 CO2 数据上表现最好,而Median策略在 Clickstream 数据上表现最好。
- 最后,使用
plot_dfs函数绘制结果。从 CO2 数据集开始:
plot_dfs(co2_original, co2_missing, 'co2')
它会生成如下图表:

Figure 7.11: 比较 CO2 数据集的三种 SimpleImputer 策略
将 Figure 7.11 中的结果与 Figure 7.7 中的原始数据进行比较。对于 Clickstream 数据集,你应该使用以下内容:
plot_dfs(clicks_original, clicks_missing, 'clicks')
这应该绘制出所有三种策略:

Figure 7.12: 比较 Clickstream 数据集的三种 SimpleImputer 策略
将 Figure 7.12 中的结果与 Figure 7.8 中的原始数据进行比较。
SimpleImputer 提供了一些基本策略,对某些数据可能合适,对其他数据则不一定。这些简单的填充策略(包括前一节 使用 pandas 进行单变量填充处理缺失数据 中的方法)的优点在于它们快速且易于实现。
它是如何工作的…
你使用 SimpleImputer 类实现了三种简单策略来填补缺失值:均值、中位数和最频繁值(众数)。
这是一种单变量填充技术,意味着仅使用一个特征或列来计算均值、中位数和最频繁值。
SimpleImputer 类有三个参数是你需要了解的:
missing_values默认设置为nan,更具体地说是np.nan。NumPy 的nan和 pandas 的NaN很相似,正如下面的例子所示:
test = pd.Series([np.nan, np.nan, np.nan])
test
>>
0 NaN
1 NaN
2 NaN
dtype: float64
SimpleImputer 将填补所有 missing_values 的出现情况,你可以使用 pandas.NA、整数、浮点数或字符串值来更新它。
-
strategy默认为mean,接受字符串值。 -
fill_value可以用特定值替换所有missing_values的实例。这可以是字符串或数值。如果strategy设置为constant,则需要提供自定义的fill_value。
在 Scikit-Learn 中,用于预处理数据(如填补)的常见工作流程包括两个主要步骤:
-
拟合 Imputer:首先,你使用
.fit()方法对数据进行拟合。这一步涉及“训练” imputer,在填补的上下文中意味着从提供的数据中计算所需的统计数据(如均值、中位数等)。拟合过程通常在训练数据集上完成。 -
应用转换:在拟合后,使用
.transform()方法将 imputer 应用于数据。这一步骤实际上执行了填充操作,用计算出的统计数据替换缺失值。
在我们的示例中,这两个步骤被合并为一个,使用 .fit_transform() 方法。该方法首先对数据进行拟合(即计算所需的统计数据),然后立即应用转换(即替换缺失值)。在初始数据预处理阶段使用 .fit_transform() 是一种方便的方法。
此外,pandas 的 DataFrame 中,.fillna() 方法可以提供与 SimpleImputer 相同的功能。例如,mean 策略可以通过使用 pandas 的 DataFrame.mean() 方法,并将其传递给 .fillna() 来实现。
以下示例说明了这一点,并比较了 Scikit-Learn 和 pandas 的两个结果:
avg = co2_missing['co2'].mean()
co2_missing['pands_fillna'] = co2_missing['co2'].fillna(avg)
cols = ['co2', 'Mean Strategy', 'pands_fillna']
_ = rmse_score(co2_original, co2_missing[cols], 'co2')
>>
RMSE for Mean Strategy: 0.7156383637041684
RMSE for pands_fillna: 0.7156383637041684
注意你是如何与 scikit-learn 的 SimpleImputer 类实现相同的结果的。.fillna() 方法使得在整个 DataFrame 中进行插补变得更加容易(逐列处理)。例如,如果你有一个包含多个缺失数据列的 sales_report_data DataFrame,你可以通过一行代码 sales_report_data.fillna(sales_report_data.mean()) 来执行均值插补。
还有更多内容
scikit-learn 中的 SimpleImputer 的 add_indicator 选项是一个有用的功能,可以增强插补过程。它的作用是将一个 MissingIndicator 转换器添加到输出中(添加一个额外的二进制列,指示原始数据是否缺失,1 表示缺失,0 表示已观察到。这个功能可以用于将缺失信息编码为特征,从而提供额外的洞察)。
以下是如何使用 add_indicator=True 启用此功能的示例。在此示例中,你将使用 .fit() 方法,接着使用 .transform():
co2_missing = read_dataset(folder,
'co2_missing_only.csv', 'year', index=True)
co2_vals = co2_missing['co2'].values.reshape(-1,1)
imputer = SimpleImputer(strategy='mean', add_indicator=True)
imputer.fit(co2_vals)
imputer.get_params()
>>
{'add_indicator': True,
'copy': True,
'fill_value': None,
'keep_empty_features': False,
'missing_values': nan,
'strategy': 'mean'}
然后你可以使用 .transform() 方法,并将两列添加到原始的 co2_missing DataFrame 中:
co2_missing[['imputed', 'indicator']] = (imputer.transform(co2_vals))
co2_missing.head(5)
前面的代码应该会产生以下输出:

图 7.13:更新 co2_missing DataFrame,添加两列
注意如何添加了指示列,其中 0 表示原始观察值,1 表示缺失值。
参见
若要了解更多关于 scikit-learn 的 SimpleImputer 类,请访问官方文档页面:scikit-learn.org/stable/modules/generated/sklearn.impute.SimpleImputer.html#sklearn.impute.SimpleImputer。
到目前为止,你一直在处理单变量插补。一个更强大的方法是多变量插补,你将在接下来的示例中学习到这一方法。
使用多变量插补处理缺失数据
之前,我们讨论了填补缺失数据的两种方法:单变量 插补 和 多变量 插补。
正如你在之前的示例中看到的,单变量插补是使用一个变量(列)来替代缺失的数据,而忽略数据集中的其他变量。单变量插补技术通常实现起来更快、更简单,但在大多数情况下,多变量插补方法可能会产生更好的结果。
与使用单一变量(列)不同,在多元插补中,该方法使用数据集中的多个变量来插补缺失值。其理念很简单:让数据集中的更多变量参与进来,以提高缺失值的可预测性。
换句话说,单变量插补方法仅处理特定变量的缺失值,而不考虑整个数据集,专注于该变量来推导估算值。在多元插补中,假设数据集中的变量之间存在一定的协同作用,并且它们可以共同提供更好的估算值来填补缺失值。
在本示例中,你将使用 Clickstream 数据集,因为它有额外的变量(clicks,price 和 location 列),可以用来对 clicks 进行多元插补。
准备工作
你可以从 GitHub 仓库下载 Jupyter 笔记本和所需的数据集。请参考本章的 技术要求 部分。
此外,你还将利用本章前面定义的三个函数(read_dataset,rmse_score 和 plot_dfs)。
如何实现…
在这个示例中,你将使用 scikit-learn 进行多元插补。该库提供了 IterativeImputer 类,允许你传递一个回归模型来根据数据集中其他变量(列)预测缺失值:
- 首先,导入必要的库、方法和类:
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer
from sklearn.ensemble import ExtraTreesRegressor, BaggingRegressor
from sklearn.linear_model import ElasticNet, LinearRegression
from sklearn.neighbors import KneighborsRegressor
将两个 Clickstream 数据集加载到数据框中:
folder = Path('../../datasets/Ch7/')
clicks_original = read_dataset(folder,
'clicks_original.csv', 'date')
clicks_missing = read_dataset(folder,
'clicks_missing.csv', 'date')
- 使用
IterativeImputer,你可以测试不同的估算器。那么,让我们尝试不同的回归器并比较结果。创建一个要在IterativeImputer中使用的回归器(估算器)列表:
estimators = [
('bayesianRidge', BayesianRidge()),
('extra_trees', ExtraTreesRegressor(n_estimators=50)),
('bagging', BaggingRegressor(n_estimators=50)),
('elastic_net', ElasticNet()),
('linear_regression', LinearRegression()),
('knn', KNeighborsRegressor(n_neighbors=3))
]
- 遍历估算器并使用
.fit()在数据集上进行训练,从而构建不同的模型,最后通过在缺失数据的变量上应用.transform()进行插补。每个估算器的结果将作为新列附加到clicks_missing数据框中,以便进行得分并直观地比较结果:
clicks_vals = clicks_missing.iloc[:,0:3].values
for e_name, e in estimators:
est = IterativeImputer(
random_state=15,
estimator=e).fit(clicks_vals)
clicks_missing[e_name] = est.transform(clicks_vals)[: , 2]
- 使用
rmse_score函数评估每个估算器:
_ = rmse_score(clicks_original, clicks_missing, 'clicks')
这将打印出以下得分:
RMSE for bayesianRidge: 949.4393973455851
RMSE for extra_trees: 1577.3003394830464
RMSE for bagging: 1237.4433923801062
RMSE for elastic_net: 945.40752093431
RMSE for linear_regression: 938.9419831427184
RMSE for knn: 1336.8798392251822
观察到贝叶斯岭回归、弹性网和线性回归产生了相似的结果。
- 最后,绘制结果以便于不同估算器之间的可视化比较:
plot_dfs(clicks_original, clicks_missing, 'clicks')
输出结果如下:

图 7.14:使用迭代插补比较不同的估算器
比较 图 7.14 中的结果与 图 7.8 中的原始数据。
在本章开始时,我们讨论了使用 RMSE(均方根误差)来评估填补方法可能会有些误导。这是因为我们进行填补的目标不一定是为了得到“最佳”分数(即最小的 RMSE 值),就像在预测建模中我们所追求的那样。相反,填补的目标是以一种尽可能接近原始数据集的真实特性和分布的方式来填补缺失的数据。
尽管 RMSE 存在局限性,但在比较不同填补方法时,它仍然能提供有价值的洞察。它帮助我们理解哪种方法能更接近实际值地估算缺失值,基于可用数据。
然而,必须认识到,在真实数据的背景下,较低的 RMSE 并不总意味着更“准确”的填补。这是因为真实数据集通常包含噪音和随机性,而一些填补方法可能无法捕捉到这些特性,尤其是那些产生最低 RMSE 值的方法。像 BayesianRidge、ElasticNet 和 Linear Regression 这样的算法可能会产生较低的 RMSE 值,但可能会过度平滑数据(请参见图 7.14中的这三种估计器),未能反映真实数据集中固有的随机性和变异性。
后续,在使用填补后的数据建立预测模型(如预测模型)时,我们需要认识到填补值的某些不完美性是可以接受的。这是因为我们通常不知道缺失数据的真实性质,而我们的目标是创建一个能为模型训练和分析提供“足够好”代表性的数据集。本质上,目标是实现平衡——一种提供合理缺失值估计的填补方法,同时保持数据的整体特性,包括其随机性和变异性。
它是如何工作的……
R 语言中的 MICE 包启发了 IterativeImputer 类的设计,后者由 scikit-learn 库实现,用于执行 多元链式方程法填补(www.jstatsoft.org/article/view/v045i03)。IterativeImputer 与原始实现有所不同,你可以在这里阅读更多内容:scikit-learn.org/stable/modules/impute.html#id2。
请记住,IterativeImputer 仍处于实验模式。在下一节中,你将使用 statsmodels 库中的另一种 MICE 实现。
还有更多……
statsmodels 库中有一个 MICE 的实现,你可以用它来与 IterativeImputer 进行比较。这个实现更接近于 R 中的 MICE 实现。
你将使用相同的 DataFrame(clicks_original 和 clicks_missing),并将 statsmodels MICE 填补输出作为附加列添加到 clicks_missing DataFrame 中。
首先,加载所需的库:
from statsmodels.imputation.mice import MICE, MICEData, MICEResults
import statsmodels.api as sm
由于你的目标是插补缺失数据,你可以使用MICEData类来封装clicks_missing数据框。首先创建MICEData的实例,并将其存储在mice_data变量中:
# create a MICEData object
fltr = ['price', 'location','clicks']
mice_data = MICEData(clicks_missing[fltr],
perturbation_method='gaussian')
# 20 iterations
mice_data.update_all(n_iter=20)
mice_data.set_imputer('clicks', formula='~ price + location', model_class=sm.OLS)
MICEData为 MICE 插补准备数据集,并在循环中调用update_all()方法(执行 20 次)进行多次插补,每次基于数据集中的其他变量优化插补值。perturbation_method='gaussian'指定在插补过程中用于扰动缺失数据的方法。'gaussian'方法从正态(高斯)分布中添加噪声。
将结果存储在新列中,并命名为MICE。这样,你可以将得分与IterativeImputer的结果进行比较:
clicks_missing['MICE'] = mice_data.data['clicks'].values.tolist()
_ = rmse_score(clicks_original, clicks_missing, 'clicks')
>>
RMSE for bayesianRidge: 949.4393973455851
RMSE for extra_trees: 1577.3003394830464
RMSE for bagging: 1237.4433923801062
RMSE for elastic_net: 945.40752093431
RMSE for linear_regression: 938.9419831427184
RMSE for knn: 1336.8798392251822
RMSE for MICE: 1367.190103013395
最后,绘制结果进行最终比较。这将包括来自IterativeImputer的一些插补结果:
cols = ['clicks','bayesianRidge', 'bagging', 'knn', 'MICE']
plot_dfs(clicks_original, clicks_missing[cols], 'clicks')
输出如下:

图 7.15 比较 statsmodels MICE 实现与 scikit-learn IterativeImputer
将图 7.15中的结果与图 7.8中的原始数据进行比较。
总体而言,多元插补技术通常比单变量方法产生更好的结果。在处理具有更多特征(列)和记录的复杂时间序列数据集时,这一点尤其成立。尽管单变量插补器在速度和解释简便性方面更高效,但仍然需要平衡复杂性、质量和分析需求。
另见
-
要了解有关
IterativeImputer的更多信息,请访问官方文档页面:scikit-learn.org/stable/modules/generated/sklearn.impute.IterativeImputer.html#sklearn.impute.IterativeImputer。 -
要了解有关
statsmodelsMICE 实现的更多信息,请访问官方文档页面:www.statsmodels.org/dev/imputation.html。 -
一个有趣的库,
FancyImpute,最初启发了 scikit-learn 的IterativeImputer,提供了多种插补算法,你可以在此查看:github.com/iskandr/fancyimpute。
使用插值处理缺失数据
另一种常用的缺失值插补技术是插值。pandas 库提供了DataFrame.interpolate()方法,用于更复杂的单变量插补策略。
例如,插值方法之一是线性插值。线性插值可以通过在两个围绕缺失值的点之间画一条直线来填补缺失数据(在时间序列中,这意味着对于缺失的数据点,它会查看前一个过去值和下一个未来值,并在这两者之间画一条直线)。而多项式插值则尝试在两个点之间画一条曲线。因此,每种方法都会采用不同的数学运算来确定如何填充缺失数据。
pandas 的插值功能可以通过SciPy库进一步扩展,后者提供了额外的单变量和多变量插值方法。
在这个例子中,你将使用 pandas 的DataFrame.interpolate()函数来检查不同的插值方法,包括线性插值、多项式插值、二次插值、最近邻插值和样条插值。
准备工作
你可以从 GitHub 仓库下载 Jupyter 笔记本和必要的数据集。请参考本章的技术要求部分。
你将使用之前准备好的三个函数(read_dataset,rmse_score和plot_dfs)。
你将使用来自Ch7文件夹的四个数据集:clicks_original.csv,clicks_missing.csv,co2_original.csv和co2_missing_only.csv。这些数据集可以从 GitHub 仓库中获取。
操作步骤…
你将对两个不同的数据集进行多次插值,然后使用 RMSE 和可视化对比结果:
- 首先导入相关库并将数据读取到 DataFrame 中:
folder = Path('../../datasets/Ch7/')
co2_original = read_dataset(folder,
'co2_original.csv', 'year', index=True)
co2_missing = read_dataset(folder,
'co2_missing_only.csv', 'year', index=True)
clicks_original = read_dataset(folder,
'clicks_original.csv', 'date', index=True)
clicks_missing = read_dataset(folder,
'clicks_missing.csv', 'date', index=True)
- 创建一个待测试的插值方法列表:
linear,quadratic,nearest和cubic:
interpolations = [
'linear',
'quadratic',
'nearest',
'cubic'
]
- 你将遍历列表,使用
.interpolate()进行不同的插值操作。为每个插值输出附加一个新列,以便进行对比:
for intp in interpolations:
co2_missing[intp] = co2_missing['co2'].interpolate(method=intp)
clicks_missing[intp] = clicks_missing['clicks'].interpolate(method=intp)
- 还有两种附加的方法值得测试:样条插值和多项式插值。要使用这些方法,你需要为
order参数提供一个整数值。你可以尝试order = 2来使用样条插值方法,尝试order = 5来使用多项式插值方法。例如,样条插值方法的调用方式如下:.interpolate(method="spline", order = 2):
co2_missing['spline'] = \
co2_missing['co2'].interpolate(method='spline', order=2)
clicks_missing['spline'] = \
clicks_missing['clicks'].interpolate(method='spline',order=2)
co2_missing['polynomial'] = \
co2_missing['co2'].interpolate(method='polynomial',order=5)
clicks_missing['polynomial'] = \
clicks_missing['clicks'].interpolate(method='polynomial',order=5)
- 使用
rmse_score函数比较不同插值策略的结果。从 CO2 数据开始:
_ = rmse_score(co2_original, co2_missing, 'co2')
>>
RMSE for linear: 0.05507291327761665
RMSE for quadratic: 0.08367561505614347
RMSE for nearest: 0.05385422309469095
RMSE for cubic: 0.08373627305833133
RMSE for spline: 0.1878602347541416
RMSE for polynomial: 0.06728323553134927
现在,让我们检查 Clickstream 数据:
_ = rmse_score(clicks_original, clicks_missing, 'clicks')
>>
RMSE for linear: 1329.1448378562811
RMSE for quadratic: 5224.641260626975
RMSE for nearest: 1706.1853705030173
RMSE for cubic: 6199.304875782831
RMSE for spline: 5222.922993448641
RMSE for polynomial: 56757.29323647127
- 最后,进行可视化以更好地了解每种插值方法的效果。从 CO2 数据集开始:
cols = ['co2', 'linear', 'nearest', 'polynomial']
plot_dfs(co2_original, co2_missing[cols], 'co2')
这应该会绘制出所选列:

图 7.16:对 CO2 数据集的不同插值策略进行比较
将图 7.16中的结果与图 7.7中的原始数据进行对比。
线性和最近邻方法在缺失值填充效果上似乎类似。可以通过 RMSE 评分和图表看到这一点。
现在,创建 Clickstream 数据集的图表:
cols = ['clicks', 'linear', 'nearest', 'polynomial', 'spline']
plot_dfs(clicks_original, clicks_missing[cols], 'clicks')
这应该绘制所选列:

图 7.17: 比较 Clickstream 数据集上的不同插值策略
比较图 7.17中的结果与图 7.8中的原始数据。
从输出中,你可以看到当使用5作为多项式阶数时,polynomial方法如何夸大曲线。另一方面,Linear方法则尝试绘制一条直线。
值得注意的是,在实现的策略中,只有线性插值忽略了索引,而其他方法则使用数值索引。
它是如何工作的…
总体而言,插值技术通过检测相邻数据点(缺失点周围的数据)的模式,来预测缺失值应该是什么。最简单的形式是线性插值,它假设两个相邻数据点之间存在一条直线。另一方面,多项式则定义了两个相邻数据点之间的曲线。每种插值方法使用不同的函数和机制来预测缺失数据。
在 pandas 中,你将使用DataFrame.interpolate函数。默认的插值方法是线性插值(method = "linear")。还有其他参数可以提供更多控制,决定如何进行插值填充。
limit参数允许你设置填充的最大连续NaN数量。回想之前的配方,执行数据质量检查,Clickstream 数据集有16个连续的缺失值。你可以限制连续NaN的数量,例如设置为5:
clicks_missing['clicks'].isna().sum()
>> 16
example = clicks_missing['clicks'].interpolate(limit = 5)
example.isna().sum()
>> 11
仅填充了 5 个数据点,其余 11 个未填充。
还有更多…
其他库也提供插值,包括以下内容:
-
SciPy 提供了更广泛的选择,涵盖了单变量和多变量技术:
docs.scipy.org/doc/scipy/reference/interpolate.html. -
NumPy 提供了几种插值选项;最常用的是
numpy.interp()函数:numpy.org/doc/stable/reference/generated/numpy.interp.html?highlight=interp#numpy.interp.
另见
若要了解更多关于DataFrame.interpolate的信息,请访问官方文档页面:pandas.pydata.org/docs/reference/api/pandas.DataFrame.interpolate.html.
第八章:8 使用统计方法进行异常值检测
加入我们的书籍社区,参与 Discord 讨论

除了缺失数据,如第七章《缺失数据处理》所讨论的内容,您可能遇到的另一个常见数据问题是异常值。异常值可以是点异常值、集体异常值或上下文异常值。例如,点异常值发生在某个数据点偏离其余数据集时——有时被称为全局异常值。集体异常值是指一组观察值,它们与总体不同,并且不遵循预期的模式。最后,上下文异常值是指当某个观察值在特定条件或上下文下被认为是异常值时,例如偏离邻近数据点的情况。需要注意的是,对于上下文异常值,如果上下文发生变化,同一观察值可能不再被视为异常值。
在本章中,您将接触到一些实用的统计技术,包括参数方法和非参数方法。在第十四章《使用无监督机器学习进行异常值检测》中,您将深入了解基于机器学习和深度学习的更高级技术。
在文献中,您会发现另一个常用的术语,异常检测,它与异常值检测是同义的。识别异常或异常值的方式和技术是相似的;它们的区别在于上下文和识别后采取的措施。例如,金融交易中的异常交易可能被视为异常,并触发反欺诈调查,以防止其再次发生。在不同的上下文中,研究人员在检查保留与移除这些点的总体影响后,可能会选择简单地删除调查数据中的异常数据点。有时,您可能会决定保留这些异常值,如果它们是自然过程的一部分。换句话说,它们是合法的,并选择使用不受异常值影响的稳健统计方法。
另一个与异常值检测相关的概念是变点检测(CPD)。在变点检测中,目标是预测时间序列数据中的剧烈且有影响的波动(增加或减少)。变点检测包含特定技术,例如累积和(CUSUM)和贝叶斯在线变点检测(BOCPD)。变点检测在许多情况下至关重要。例如,当机器的内部温度达到某个点时,可能会发生故障,或者如果您想了解打折价格是否促进了销售增长时,这也很重要。异常值检测和变点检测之间的区别至关重要,因为有时您可能需要后者。当这两种学科相交时,根据上下文,突发的变化可能表明异常值(异常)的潜在存在。
本章中您将遇到的食谱如下:
-
重新采样时间序列数据
-
使用可视化方法检测异常值
-
使用 Tukey 方法检测异常值
-
使用 z-score 检测异常值
-
使用修改后的 z-score 检测异常值
-
使用其他非参数方法检测异常值
技术要求
你可以从 GitHub 仓库下载 Jupyter Notebooks 和所需的数据集:
-
Jupyter Notebook:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook-Second-Edition/blob/main/code/Ch8/Chapter%208.ipynb
在本章中,你将使用Numenta 异常基准(NAB)提供的数据集,它提供了用于异常值检测的基准数据集。有关 NAB 的更多信息,请访问他们的 GitHub 仓库:github.com/numenta/NAB。
纽约出租车数据集记录了特定时间戳下纽约市出租车乘客的数量。该数据包含已知的异常值,用于评估我们异常值检测器的性能。数据集包含10,320条记录,时间范围是2014 年 7 月 1 日到2015 年 5 月 31 日。这些观测值是以 30 分钟的间隔记录的,对应于freq = '30T'。
为了准备异常值检测步骤,首先加载你将在整个章节中使用的库:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from pathlib import Path
plt.rcParams[“figure.figsize”] = [16, 3]
将nyc_taxi.csv数据加载到 pandas DataFrame 中,因为它将在本章中贯穿始终:
file = Path(“../../datasets/Ch8/nyc_taxi.csv”)
nyc_taxi = pd.read_csv(folder / file,
index_col=‘timestamp’,
parse_dates=True)
nyc_taxi.index.freq = ‘30T’
你可以存储包含异常值的已知日期,这些异常值也被称为真实标签:
nyc_dates = [
”2014-11-01”,
”2014-11-27”,
”2014-12-25”,
”2015-01-01”,
”2015-01-27”
]
如果你研究这些日期以获得更多的见解,你会发现类似以下总结的信息:
-
2014 年 11 月 1 日,星期六,是在纽约马拉松赛之前,官方的马拉松赛事是在 2014 年 11 月 2 日,星期日。
-
2014 年 11 月 27 日,星期四,是感恩节。
-
2014 年 12 月 25 日,星期四,是圣诞节。
-
2015 年 1 月 1 日,星期四,是元旦。
-
2015 年 1 月 27 日,星期二,是北美暴风雪事件,在 2015 年 1 月 26 日至 1 月 27 日之间,所有车辆被命令停驶。
你可以绘制时间序列数据,以便对你将在异常值检测中使用的数据有一个直观的了解:
nyc_taxi.plot(title=“NYC Taxi”, alpha=0.6)
这应该生成一个频率为 30 分钟的时间序列:

图 8.1:纽约市出租车时间序列数据的图示
最后,创建一个plot_outliers函数,你将在整个过程中使用该函数:
def plot_outliers(outliers, data, method=‘KNN’,
halignment = ‘right’,
valignment = ‘bottom’,
labels=False):
ax = data.plot(alpha=0.6)
if labels:
for I in outliers’'valu’'].items():
plt.plot(i[0], i[1],’'r’')
plt.text(i[0], i[1], ‘'{i[0].date()’',
horizontalalignment=halignment,
verticalalignment=valignment)
else:
data.loc[outliers.index].plot(ax=ax, style’'r’')
plt.title(‘'NYC Taxi–- {method’')
plt.xlabel’'dat’'); plt.ylabel’'# of passenger’')
plt.legend(‘'nyc tax’'‘'outlier’'])
plt.show()
随着我们推进异常值检测的步骤,目标是看看不同的技术如何捕捉异常值,并将其与真实标签进行比较。
理解异常值
异常值的存在需要特别处理和进一步调查,不能草率决定如何处理它们。首先,你需要检测并识别它们的存在,这一章正是讲解这个内容。领域知识在确定这些被识别为异常值的点、它们对分析的影响以及如何处理它们方面可以发挥重要作用。
异常值可能表示由于过程中的随机变化(称为噪声)、数据输入错误、传感器故障、实验问题或自然变异等原因导致的错误数据。如果异常值看起来像是人工合成的,通常是不希望看到的,例如错误数据。另一方面,如果异常值是过程中的自然一部分,你可能需要重新考虑是否删除它们,而选择保留这些数据点。在这种情况下,你可以依赖于非参数统计方法,这些方法不对底层分布做假设。
一般来说,异常值会在基于强假设的模型构建过程中引发副作用;例如,数据来自高斯(正态)分布。基于假设底层分布的统计方法和测试称为参数方法。
处理异常值没有固定的协议,异常值的影响程度会有所不同。例如,有时你可能需要分别在有异常值和没有异常值的情况下测试你的模型,以了解它们对分析的整体影响。换句话说,并非所有异常值都是相同的,也不应当一视同仁。然而,正如前面所述,拥有领域知识在处理这些异常值时至关重要。
现在,在使用数据集构建模型之前,你需要测试是否存在异常值,以便进一步调查它们的重要性。识别异常值通常是数据清洗和准备过程的一部分,之后才会深入进行分析。
处理异常值的常见方法是删除这些数据点,使它们不成为分析或模型开发的一部分。或者,你也可以希望使用第七章,处理缺失数据中提到的类似技术,如插补和插值,来替换异常值。其他方法,如平滑数据,可能有助于最小化异常值的影响。平滑方法,如指数平滑法,在第十章,使用统计方法构建单变量时间序列模型中有讨论。你也可以选择保留异常值,并使用对它们影响更为坚韧的算法。
有许多知名的异常值检测方法。该研究领域正在发展,范围从基本的统计技术到更先进的方法,利用神经网络和深度学习。在统计方法中,你可以使用不同的工具,例如可视化工具(箱型图、QQ 图、直方图和散点图)、z 分数、四分位距(IQR)和图基围栏,以及诸如 Grubbs 检验、Tietjen-Moore 检验或广义极值学生化偏差(ESD)检验等统计检验。这些方法是基本的、易于解释且有效的。
在你的第一个实例中,你将会在深入异常值检测之前,学习一个重要的时间序列转换技术——重采样。
重采样时间序列数据
在时间序列数据中,一个典型的转换操作是重采样。该过程意味着改变数据的频率或粒度水平。
通常,你对时间序列生成的频率控制有限。例如,数据可能在小的时间间隔内生成和存储,如毫秒、分钟或小时。在某些情况下,数据可能是按较大的时间间隔生成的,如按天、按周或按月。
对时间序列进行重采样的需求可能来源于你的分析性质以及数据需要达到的粒度级别。例如,你可以拥有按天记录的数据,但分析要求数据以周为单位,因此你需要进行重采样。这个过程称为下采样。进行下采样时,你需要提供某种聚合方式,如均值、总和、最小值或最大值等。另一方面,一些情况下需要将数据从每日重采样为每小时。这一过程称为上采样。在上采样时,可能会引入空值行,你可以使用插补或插值技术来填充这些空值。详见第七章,处理缺失数据,其中详细讨论了插补和插值方法。
在这个实例中,你将会使用pandas库来探索如何进行重采样。
如何操作……
在这个实例中,你将使用技术要求部分中早些时候创建的nyc_taxis DataFrame。该数据捕获了30 分钟间隔内的乘客数量。
- 下采样数据至每日频率。当前,你有
10,320条记录,当你将数据重采样至每日时,你需要对数据进行聚合。在这个例子中,你将使用.mean()函数。这将把样本数减少到 215 条记录。术语下采样表示样本数量减少,具体来说,我们正在减少数据的频率。
检查原始 DataFrame 的前五行:
nyc_taxi.head()
>>
value
timestamp
2014-07-01 00:00:00 10844
2014-07-01 00:30:00 8127
2014-07-01 01:00:00 6210
2014-07-01 01:30:00 4656
2014-07-01 02:00:00 3820
重采样通过DataFrame.resample()函数来完成。对于按天重采样,你将使用'D'作为日期偏移规则,然后使用.mean()作为聚合方法:
df_downsampled = nyc_taxi.resample('D').mean()
df_downsampled.head()
>>
value
timestamp
2014-07-01 15540.979167
2014-07-02 15284.166667
2014-07-03 14794.625000
2014-07-04 11511.770833
2014-07-05 11572.291667
请注意,现在DatetimeIndex的频率为每日频率,且乘客数量现在反映的是每日平均数。检查第一个DatetimeIndex以查看其频率:
df_downsampled.index[0]
>>
Timestamp('2014-07-01 00:00:00', freq='D')
您还可以直接使用.freq属性检查频率:
df_downsampled.index.freq
>>
<Day>
检查下采样后的记录数:
df_downsampled.shape
>>
(215, 1)
事实上,您现在有215条记录。
- 再次重采样
nyc_taxi数据,这次使用 3 天频率。您可以使用偏移字符串'3D'来做到这一点。这次,请使用.sum()方法:
df_downsampled = nyc_taxi.resample('3D').sum()
df_downsampled.head()
>>
value
timestamp
2014-07-01 15540.979167
2014-07-02 15284.166667
2014-07-03 14794.625000
2014-07-04 11511.770833
2014-07-05 11572.291667
检查DatetimeIndex的频率:
df_downsampled.index.freq
>>
<3 * Days>
如果您使用df_downsampled.shape,您会注意到记录的数量减少到了 72 条。
- 现在,将频率改为三(3)个工作日。
pandas的默认值是从星期一到星期五。在第六章,处理日期和时间的使用自定义工作日的技巧中,您已经学习了如何创建自定义工作日。目前,您将使用默认的工作日定义。如果您查看上一步骤的输出,2014-07-13是星期天。使用'3B'作为DateOffset会将其推到下一个星期一,即2014-07-14:
df_downsampled = nyc_taxi.resample('3B').sum()
df_downsampled.head()
>>
value
timestamp
2014-07-01 745967
2014-07-04 1996347
2014-07-09 3217427
2014-07-14 3747009
2014-07-17 2248113
有趣的输出显示,它跳过了从 2014-07-04 到 2014-07-09 的 5 天时间,然后又从 2014-07-09 跳到 2014-07-14。原因是工作日规则,它规定了我们有两(2)天作为周末。由于该函数是日历感知的,它知道在第一次 3 天的增量之后会有周末,所以它会加上 2 天的周末来跳过它们,从而形成了一个 5 天的跳跃。从 2014-07-04 开始,跳到 2014-07-09,然后从 2017-07-09 跳到 2017-07-14。类似的,从 2014-07-17 跳到 2014-07-22,依此类推。
- 最后,让我们上采样数据,从 30 分钟间隔(频率)变为 15 分钟频率。这将会在每两个数据之间创建一个空条目(
NaN)。您将使用偏移字符串'T'表示分钟,因为'M'用于按月聚合:
df_upsampled = nyc_taxi.resample('15T').mean()
df_upsampled.head()
>>
value
timestamp
2014-07-01 00:00:00 10844.0
2014-07-01 00:15:00 NaN
2014-07-01 00:30:00 8127.0
2014-07-01 00:45:00 NaN
2014-07-01 01:00:00 6210.0
请注意,上采样会创建NaN行。与下采样不同,在上采样时,您需要提供如何填充NaN行的说明。您可能会想知道为什么我们这里使用了.mean()?简单的答案是,无论您是使用.sum()、.max()还是.min(),例如,结果都不会有所不同。在上采样时,您需要使用插补或插值技术来填充缺失的行。例如,您可以使用.fillna()指定插补值,或者使用.ffill()或.bfill()方法:
df_upsampled = nyc_taxi.resample('15T').ffill()
df_upsampled.head()
>>
value
timestamp
2014-07-01 00:00:00 10844
2014-07-01 00:15:00 10844
2014-07-01 00:30:00 8127
2014-07-01 00:45:00 8127
2014-07-01 01:00:00 6210
前五条记录显示了使用前向填充的方法。如需更多关于使用.fillna()、.bfill()或.ffill(),或一般插补的相关信息,请参考第七章,处理缺失数据。
总体而言,pandas 中的重采样非常方便和直接。当您想要改变时间序列的频率时,这是一个很实用的工具。
它是如何工作的…
DataFrame.resample() 方法允许你在指定的时间框架内对行进行分组,例如按天、周、月、年或任何 DateTime 属性。.resample() 的工作原理是通过使用 DatetimeIndex 和提供的频率对数据进行分组,因此,此方法特定于时间序列 DataFrame。
.resample() 函数的工作方式与 .groupby() 函数非常相似;不同之处在于,.resample() 专门用于时间序列数据,并按 DatetimeIndex 进行分组。
还有更多...
在进行降采样时,你可以一次提供多个聚合操作,使用 .agg() 函数。
例如,使用'M'表示按月,你可以为 .agg() 函数提供一个你想要执行的聚合操作列表:
nyc_taxi.resample('M').agg(['mean', 'min',
'max', 'median', 'sum'])
这应该生成一个包含五列的 DataFrame,每个聚合方法对应一列。索引列和timestamp列将按月进行分组:

图 8.2:使用 .agg() 方法进行多重聚合
请注意,'M' 或月频率的默认行为是在月末(例如 2014-07-31)。你也可以改为使用 'MS' 来设置为月初。例如,这将生成 2014-07-01(每月的第一天)。
另请参见
要了解更多关于 pandas DataFrame.resample() 的信息,请访问官方文档:pandas.pydata.org/docs/reference/api/pandas.DataFrame.resample.html。
使用可视化方法检测异常值
使用统计技术检测异常值有两种常见方法:参数性方法和非参数性方法。参数性方法假设你知道数据的基础分布。例如,如果你的数据遵循正态分布。另一方面,非参数性方法则没有这样的假设。
使用直方图和箱型图是基本的非参数技术,可以提供数据分布和异常值存在的洞察。更具体地说,箱型图,也叫做 箱线图,提供了五个数值概述:最小值、第一四分位数(25 百分位数)、中位数(50 百分位数)、第三四分位数(75 百分位数)和 最大值。在箱线图中,须的扩展范围有不同的实现方式,例如,须可以延伸到 最小值 和 最大值。在大多数统计软件中,包括 Python 的 matplotlib 和 seaborn 库,须会延伸到所谓的 Tukey's Lower and Upper Fences。任何超出这些边界的数据点都被认为是潜在的异常值。在 使用 Tukey 方法检测异常值 这一配方中,你将深入了解实际的计算和实现。现在,让我们关注分析中的可视化部分。
在这个例子中,你将使用seaborn,这是另一个基于matplotlib的 Python 可视化库。
准备工作
你可以从 GitHub 仓库下载 Jupyter Notebook 和所需的数据集。请参考本章的技术要求部分。你将使用在技术要求部分中加载的 nyc_taxi 数据框。
你将使用 seaborn 版本 0.11.2,这是截至目前的最新版本。
要使用 pip 安装 seaborn,请使用以下命令:
pip install seaborn
要使用 conda 安装 seaborn,请使用以下命令:
conda install seaborn
如何操作...
在本例中,你将探索来自 seaborn 的不同图表,包括 histplot()、displot()、boxplot()、boxenplot() 和 violinplot()。
你会注意到,这些图表传达了相似的故事,但在视觉上,每个图表的呈现方式不同。最终,在调查数据时,你将对这些图表中的某些图表产生偏好,用于自己的分析:
- 首先,导入
seaborn库,开始探索这些图表如何帮助你检测异常值:
Import seaborn as sns
sns.__version__
>> '0.13.1'
- 回想一下图 8.1,
nyc_taxi数据框包含每 30 分钟记录的乘客数量。请记住,每个分析或调查都是独特的,因此你的方法应该与解决的问题相匹配。这也意味着你需要考虑数据准备方法,例如,确定需要对数据应用哪些转换。
对于本例,目标是找出哪些天存在异常观察值,而不是哪一天的哪个时间段,因此你将重采样数据以按天进行分析。你将首先使用 mean 聚合来下采样数据。尽管这样的转换会平滑数据,但由于 mean 对异常值敏感,因此你不会失去太多与查找异常值相关的细节。换句话说,如果某个特定时间段内存在极端异常值(一天有 48 个时间段),mean 仍然会保留这一信息。
在此步骤中,你将使用 .resample() 方法将数据下采样到每日频率,并使用 ‘D’ 作为偏移字符串。这将把观察值的数量从 10,320 减少到 215(即 10320/48 = 215):
tx = nyc_taxi.resample('D').mean()
- 绘制新的
tx数据框,并使用真实标签作为参考。你将调用你在技术要求部分中创建的plot_outliers函数:
known_outliers= tx.loc[nyc_dates]
plot_outliers(known_outliers, tx, 'Known Outliers')
这应该会生成一个带有 X 标记的时间序列图,标记出已知的异常值。

图 8.3:使用真实标签(异常值)对纽约出租车数据进行下采样后的绘图
- 现在,让我们从第一个图开始,使用
histplot()函数检查你的时间序列数据:
sns.histplot(tx)
这应该会生成以下结果:

图 8.4:显示极端每日平均乘客乘车次数的直方图
在图 8.4中,标记为1、2、3、4和5的观测值似乎代表了极端的乘客数值。回想一下,这些数字表示的是重采样后的平均每日乘客数量。你需要问自己的是,这些观测值是否是离群值。直方图的中心接近 15,000 每日平均乘客数量。这应该让你质疑接近 20,000 的极端值(标签 5)是否真的那么极端。类似地,标记为3和4的观测值(因为它们接近分布的尾部),它们是否真的是极端值?那么标记为1和2的观测值呢?它们的平均乘客数量分别为 3,000 和 8,000 每日乘客,似乎比其他值更极端,可能是实际的离群值。再一次,确定什么是离群值、什么不是离群值需要领域知识和进一步分析。没有具体的规则,你会发现在本章中,一些被普遍接受的规则实际上是任意的、主观的。你不应急于得出结论。
- 你可以使用
displot()绘制类似的图表,它具有一个kind参数。kind参数可以取三个值之一:hist表示直方图,kde表示核密度估计图,ecdf表示经验累积分布函数图。
你将使用displot(kind='hist')来绘制一个类似于图 8.4中的直方图:
sns.displot(tx, kind='hist', height=3, aspect=4)
- 箱型图提供的信息比直方图更多,可以更好地帮助识别离群值。在箱型图中,超出须状线或边界的观测值被认为是潜在的离群值。须状线表示上界和下界的视觉边界,这是数学家约翰·图基于 1977 年提出的。
以下代码展示了如何使用seaborn创建箱型图:
sns.boxplot(tx['value'], orient='h')
以下图表显示了潜在的离群值:

图 8.5:显示可能的离群值超出边界(须状线)的箱型图
箱体的宽度(Q1到Q3)称为四分位数范围(IQR),其计算方式为 75th 和 25th 百分位数的差值(Q3 – Q1)。下边界的计算公式为Q1 - (1.5 x IQR),上边界为Q3 + (1.5 x IQR)。任何小于下边界或大于上边界的观测值都被视为潜在的异常值。有关更多信息,请参阅使用 Tukey 方法检测异常值一文。boxplot()函数中的whis参数默认为1.5(1.5 倍 IQR),控制上下边界之间的宽度或距离。较大的值意味着较少的观测值会被视为异常值,而较小的值则会让非异常值看起来超出边界(更多的异常值)。
sns.boxplot(tx['value'], orient='h', whis=1.5)
- seaborn中有两种额外的箱型图变种(
boxenplot()和violinplot())。它们提供与箱型图相似的见解,但呈现方式不同。boxen 图(在文献中称为字母值图)可以视为对常规箱型图的增强,旨在解决它们的一些不足之处,具体描述见论文Heike Hofmann, Hadley Wickham & Karen Kafadar (2017) Letter-Value Plots: Boxplots for Large Data, Journal of Computational and Graphical Statistics, 26:3, 469-477。更具体地说,boxen(字母值)图更适合用于处理较大数据集(用于显示数据分布的观测值较多,更适合区分较大数据集中的异常值)。seaborn实现的boxenplot基于该论文。
以下代码展示了如何使用seaborn创建 boxen(字母值)图:
sns.boxenplot(tx['value'], orient='h')
这将生成类似于图 8.5中箱型图的图形,但箱体会延伸超出四分位数(Q1、Q2和Q3)。25th 百分位数位于 14,205 每日平均乘客数的位置,75th 百分位数位于 16,209 每日平均乘客数的位置。

图 8.6:纽约市每日出租车乘客的 boxen(字母值)图
百分位值
你是否好奇我如何确定 25th、50th 和 75th 百分位的精确值?
你可以通过
describe方法为 DataFrame 或序列获取这些值。例如,如果你运行tx.describe(),你应该能看到一张描述性统计表,其中包括数据集的计数、均值、标准差、最小值、最大值、25th、50th 和 75th 百分位数值。
在图 8.6中,你可以获得超越分位数的乘客分布的额外洞察。换句话说,它扩展了箱形图以显示额外的分布,从而为数据的尾部提供更多的洞察。理论上,这些框可以一直延伸,以容纳所有数据点,但为了显示异常值,需要有一个停止点,这个点称为深度。在seaborn中,这个参数被称为k_depth,它可以接受一个数值,或者你可以指定不同的方法,如tukey、proportion、trustworthy或full。例如,k_depth=1的数值将显示与图 8.5中的箱形图相似的框(一个框)。作为参考,图 8.6显示了使用Tukey方法确定的四个框,这是默认值(k_depth="tukey")。使用k_depth=4将产生相同的图形。
这些方法在Heike Hofmann、Hadley Wickham & Karen Kafadar(2017)的参考论文中有所解释。要探索不同的方法,你可以尝试以下代码:
for k in ["tukey", "proportion", "trustworthy", "full"]:
sns.boxenplot(tx['value'], orient='h', k_depth=k, orient='h')
plt.title(k)
plt.show()
这将产生四个图形;请注意,每种方法确定的框数不同。回顾一下,你也可以按数值指定k_depth:

图 8.7:seaborn 中 boxenplot 函数可用的不同 k_depth 方法
- 现在,最后的变体是提琴图,你可以使用
violinplot函数来显示:
sns.violinplot(tx['value'], orient='h')
这将产生一个介于箱形图和核密度估计(KDE)之间的混合图。核是一个估算概率密度函数的函数,较大的峰值(较宽的区域),例如,表示大多数点集中所在的区域。这意味着一个数据点出现在该区域的概率较高,而在较薄的区域,概率则较低。

图 8.8:纽约市每日出租车乘客的提琴图
请注意,图 8.8显示了整个数据集的分布。另一个观察点是峰值的数量;在这种情况下,我们有一个峰值,这使得它成为一个单峰分布。如果有多个峰值,我们称之为多峰分布,这应该引发对数据的进一步调查。KDE 图将提供与直方图类似的洞察,但其曲线更加平滑。
它是如何工作的...
在本食谱中,我们介绍了几种图表,它们有助于可视化数据的分布并显示异常值。通常,直方图很适合显示分布,但箱形图(及其变体)在异常值检测方面要好得多。我们还探讨了箱线(字母值)图,它更适用于较大的数据集,比普通的箱形图更为合适。
还有更多...
在 seaborn 中,histplot() 和 displot() 都支持通过参数 kde 添加核密度估计(KDE)图。以下代码片段应该会生成一个相似的图形:
sns.histplot(tx, kde=True)
sns.displot(tx, kind='hist', height=3, aspect=4, kde=True)
这应该会生成一个结合直方图和 KDE 图的图形,如下所示:

图 8.9:带有 KDE 图的直方图
此外,另一个有用的可视化方法用于发现异常值是 滞后图。滞后图本质上是一个散点图,但与绘制两个变量以观察相关性不同,举个例子,我们将同一变量与其滞后版本进行比较。这意味着,它是一个使用相同变量的散点图,但 y 轴表示当前时刻 (t) 的乘客数量,x 轴显示的是前一个时刻 (t-1) 的乘客数量,这被称为 滞后。滞后参数决定了回溯的周期数;例如,滞后 1 表示回溯一个周期,滞后 2 表示回溯两个周期。在我们的重采样数据(降采样至每日)中,滞后 1 代表前一天。
pandas 库提供了 lag_plot 函数,你可以像下面的示例所示使用它:
from pandas.plotting import lag_plot
lag_plot(tx)
这应该会生成以下散点图:

图 8.10:纽约市日均出租车乘客的滞后图
被圈出的数据点突出显示了可能的异常值。有些点看起来比其他点更加极端。此外,你还可以看到乘客数量与其滞后版本(前一天)的线性关系,表明存在自相关性。回顾基础统计学中的相关性,相关性显示了两个独立变量之间的关系,因此你可以把自相关看作是一个变量在某一时刻 (t) 和它在前一时刻 (t-1) 的版本之间的相关性。更多内容请参见 第九章,探索性数据分析与诊断 和 第十章,使用统计方法构建单变量时间序列模型。
在 图 8.10 中,x 轴和 y 轴的标签可能会有些混淆,y 轴 被标记为 y(t+1)。实际上,这表示了我们之前描述的同一个意思:x 轴表示的是先前的值(预测变量),而 y 轴表示的是它的未来值 t+1。为了更清晰地理解,你可以像下面的代码所示,手动使用 seaborn 重现 lag_plot 所生成的可视化效果:
y = tx[1:].values.reshape(-1)
x = tx[:-1].values.reshape(-1)
sns.scatterplot(x=x, y=y)
这应该会生成一个与 图 8.10 相似的图形。
注意代码中,y 值从 t+1 开始(我们跳过了索引 0 处的值),直到最后一个观测值,而 x 值从索引 0 开始,到索引 -1(我们跳过了最后一个观测值)。这使得 y 轴上的值领先一个周期。
在下一个食谱中,我们将进一步探讨 IQR 和 Tukey fences,这两个概念我们在讨论箱形图时简要提到过。
另见
你可以通过seaborn文档了解我们使用的图表以及不同的选项。要了解更多信息,请访问相关网址:
-
对于箱型图(
boxplot),你可以访问seaborn.pydata.org/generated/seaborn.boxplot.html。 -
对于箱型图(
boxenplot),你可以访问seaborn.pydata.org/generated/seaborn.boxenplot.html。 -
对于小提琴图(
violinplot),你可以访问seaborn.pydata.org/generated/seaborn.violinplot.html#seaborn.violinplot。 -
对于直方图(
histplot),你可以访问seaborn.pydata.org/generated/seaborn.histplot.html。 -
对于分布图(
displot),你可以访问seaborn.pydata.org/generated/seaborn.displot.html#seaborn.displot。
使用 Tukey 方法检测异常值
这个示例将扩展前一个示例,使用可视化检测异常值。在图 8.5中,箱型图显示了四分位数,须状线延伸至上下边界。这些边界或围栏是使用 Tukey 方法计算得出的。
让我们在图 8.5的基础上,扩展一些其他组件的信息:

图 8.11:每日平均出租车乘客数据的箱型图
可视化图表非常有助于你对所处理数据的整体分布和潜在异常值有一个高层次的了解。最终,你需要通过编程识别这些异常值,以便隔离这些数据点,进行进一步的调查和分析。这个示例将教你如何计算 IQR,并定义落在 Tukey 围栏外的点。
如何操作...
大多数统计方法允许你发现超出某个阈值的极端值。例如,这个阈值可能是均值、标准差、第 10 或第 90 百分位数,或者其他你想要比较的值。你将通过学习如何获取基本的描述性统计量,特别是分位数,来开始这个示例。
- DataFrame 和 Series 都有
describe方法,用于输出总结性描述性统计量。默认情况下,它显示四分位数:第一四分位数,即第 25 百分位数,第二四分位数(中位数),即第 50 百分位数,第三四分位数,即第 75 百分位数。你可以通过向percentiles参数提供一个值列表来定制百分位数。以下代码演示了如何获取额外百分位数的值:
percentiles = [0, 0.05, .10, .25, .5, .75, .90, .95, 1]
tx.describe(percentiles= percentiles)
这应该生成以下 DataFrame:

图 8.12:包含自定义百分位数的每日出租车乘客数据描述性统计
分位数与四分位数与百分位数
这些术语可能会让人混淆,但本质上,百分位数和四分位数都是分位数。有时你会看到人们更宽松地使用百分位数,并将其与分位数交替使用。
四分位数将数据分为四个部分(因此得名),分别标记为Q1(第 25 百分位)、Q2(第 50 百分位或中位数)和Q3(第 75 百分位)。百分位数则可以取 0 到 100 之间的任何范围(在 pandas 中为 0 到 1,在 NumPy 中为 0 到 100),但最常见的是将数据分为 100 个部分。这些部分称为分位数。
这些名称基本上表示应用于数据分布的分割类型(分位数的数量);例如,四个分位数称为四分位数,两个分位数称为中位数,十个分位数称为十分位数,100 个分位数称为百分位数。
- NumPy 库还提供了
percentile函数,该函数返回指定百分位数的值。以下代码解释了如何使用该函数:
percentiles = [0, 5, 10, 25, 50, 75, 90, 95, 100]
np.percentile(tx, percentiles)
>>
array([ 4834.54166667, 11998.18125 , 13043.85416667, 14205.19791667,
15299.9375 , 16209.42708333, 17279.3 , 18321.61666667,
20553.5 ])
- 在图 8.11中,注意到大多数极端值和潜在异常值位于低界限以下(低界限计算公式为
Q1 – (1.5 x IQR))或位于高界限以上(高界限计算公式为Q3 + (1.5 x IQR))。IQR 是Q3与Q1的差值(IQR = Q3 – Q1),它决定了箱形图中箱体的宽度。这些上下界限被称为Tukey 界限,更具体地说,它们被称为内界限。外界限也有更低的Q1 - (3.0 x IQR)和更高的Q3 + (3.0 x IQR)界限。我们将重点关注内界限,并将任何超出这些界限的数据点视为潜在异常值。
你将创建一个名为iqr_outliers的函数,该函数计算 IQR、上界(内界限)、下界(内界限),然后过滤数据以返回异常值。这些异常值是任何低于下界或高于上界的数据点:
def iqr_outliers(data):
q1, q3 = np.percentile(data, [25, 75])
IQR = q3 - q1
lower_fence = q1 - (1.5 * IQR)
upper_fence = q3 + (1.5 * IQR)
return data[(data.value > upper_fence) | (data.value < lower_fence)]
- 通过传递
tx数据框来测试该函数:
outliers = iqr_outliers(tx)
outliers
>>
value
timestamp
2014-11-01 20553.500000
2014-11-27 10899.666667
2014-12-25 7902.125000
2014-12-26 10397.958333
2015-01-26 7818.979167
2015-01-27 4834.541667
这些日期(点)与图 8.5和图 8.11中根据 Tukey 界限识别的异常值相同。
- 使用技术要求部分中定义的
plot_outliers函数:
plot_outliers(outliers, tx, "Outliers using IQR with Tukey's Fences")
这应生成类似于图 8.3中的图表,只不过x标记是基于 Tukey 方法识别的异常值:

图 8.13:使用 Tukey 方法识别的每日平均出租车乘客数和异常值
比较图 8.13和图 8.3,你会看到这个简单的方法在识别已知的五个异常值中的四个方面做得非常好。此外,Tukey 方法还识别出了在2014-12-26和2015-01-26的两个额外的异常值。
它是如何工作的……
使用 IQR 和 Tukey’s fences 是一种简单的非参数统计方法。大多数箱线图实现使用 1.5x(IQR) 来定义上下界限。
还有更多……
使用 1.5x(IQR) 来定义异常值是很常见的;尽管有很多关于其理由的讨论,但这个选择仍然是任意的。你可以更改这个值进行更多实验。例如,在 seaborn 中,你可以通过更新 boxplot 函数中的 whis 参数来改变默认的 1.5 值。当数据符合高斯分布(正态分布)时,选择 1.5 是最有意义的,但这并不总是如此。一般来说,值越大,你捕获的异常值就越少,因为你扩展了边界(界限)。同样,值越小,更多的非异常值会被定义为异常值,因为你缩小了边界(界限)。
让我们更新 iqr_outliers 函数,接受一个 p 参数,这样你就可以尝试不同的值:
def iqr_outliers(data, p):
q1, q3 = np.percentile(data, [25, 75])
IQR = q3 - q1
lower_fence = q1 - (p * IQR)
upper_fence = q3 + (p * IQR)
return data[(data.value > upper_fence) | (data.value < lower_fence)]
在不同值上运行函数:
for p in [1.3, 1.5, 2.0, 2.5, 3.0]:
print(f'with p={p}')
print(iqr_outliers(tx, p))
print('-'*15)
>>
with p=1.3
value
timestamp
2014-07-04 11511.770833
2014-07-05 11572.291667
2014-07-06 11464.270833
2014-09-01 11589.875000
2014-11-01 20553.500000
2014-11-08 18857.333333
2014-11-27 10899.666667
2014-12-25 7902.125000
2014-12-26 10397.958333
2015-01-26 7818.979167
2015-01-27 4834.541667
---------------
with p=1.5
value
timestamp
2014-11-01 20553.500000
2014-11-27 10899.666667
2014-12-25 7902.125000
2014-12-26 10397.958333
2015-01-26 7818.979167
2015-01-27 4834.541667
---------------
with p=2.0
value
timestamp
2014-11-01 20553.500000
2014-12-25 7902.125000
2015-01-26 7818.979167
2015-01-27 4834.541667
---------------
with p=2.5
value
timestamp
2014-12-25 7902.125000
2015-01-26 7818.979167
2015-01-27 4834.541667
---------------
with p=3.0
value
timestamp
2014-12-25 7902.125000
2015-01-26 7818.979167
2015-01-27 4834.541667
---------------
最佳值将取决于你的数据以及你需要多敏感的异常值检测。
另请参见
要了解更多关于 Tukey’s fences 用于异常值检测的内容,可以参考这个维基百科页面:en.wikipedia.org/wiki/Outlier#Tukey's_fences。
我们将在接下来的配方中探索另一种基于 z-score 的统计方法。
使用 z-score 检测异常值
z-score 是一种常见的数据标准化变换。当你需要比较不同的数据集时,这种方法非常常见。例如,比较来自两个不同数据集的两个数据点相对于它们的分布会更容易。之所以能做到这一点,是因为 z-score 将数据标准化,使其围绕零均值居中,并且单位表示的是偏离均值的标准差。例如,在我们的数据集中,单位是按日计的出租车乘客数量(以千人计)。一旦应用了 z-score 变换,你将不再处理乘客数量,而是单位表示的是标准差,这告诉我们一个观测值距离均值有多远。以下是 z-score 的公式:
其中
是一个数据点(观测值),mu (
) 是数据集的均值,sigma (
) 是数据集的标准差。
请记住,z-score 是一种无损变换,这意味着你不会丢失诸如数据分布(数据形状)或观测之间关系的信息。唯一改变的是度量单位,它们正在被缩放(标准化)。
一旦使用 z-score 转换数据,您就可以选择一个阈值。所以,任何高于或低于该阈值的数据点(以标准差为单位)都被视为异常值。例如,您的阈值可以设置为离均值+3和-3标准差。任何低于-3或高于+3标准差的点可以视为异常值。换句话说,点离均值越远,它作为异常值的可能性就越大。
z-score 有一个主要的缺点,因为它是基于假设的参数统计方法。它假设数据服从高斯(正态)分布。那么,假设数据不是正态分布,您将需要使用修改版的 z-score,这将在下一个部分中讨论,使用修改版 z-score 检测异常值。
操作方法...
您将首先创建 zscore 函数,该函数接受一个数据集和一个阈值,我们称其为 degree。该函数将返回标准化后的数据和识别出的异常值。这些异常值是指任何高于正阈值或低于负阈值的点。
- 创建
zscore()函数来标准化数据,并根据阈值过滤掉极端值。请记住,阈值是基于标准差的:
def zscore(df, degree=3):
data = df.copy()
data['zscore'] = (data - data.mean())/data.std()
outliers = data[(data['zscore'] <= -degree) | (data['zscore'] >= degree)]
return outliers['value'], data
- 现在,使用
zscore函数并存储返回的对象:
threshold = 2.5
outliers, transformed = zscore(tx, threshold)
- 要查看 z-score 转换的效果,您可以绘制一个直方图。转换后的 DataFrame 包含两列数据,原始数据标记为
value,标准化数据标记为zscore:
transformed.hist()
这应该会生成两个直方图,分别对应两列数据:

图 8.14:原始数据和标准化数据分布对比的直方图
注意数据的形状没有变化,这也是为什么 z-score 被称为 无损转换 的原因。两者唯一的区别是尺度(单位)。
- 您使用阈值
2.5运行了zscore函数,意味着任何与均值相距 2.5 标准差的数据点(无论正负方向)都将被视为异常值。例如,任何高于+2.5标准差或低于-2.5标准差的数据点都将被视为异常值。打印出捕获在outliers对象中的结果:
print(outliers)
>>
timestamp
2014-11-01 20553.500000
2014-12-25 7902.125000
2015-01-26 7818.979167
2015-01-27 4834.541667
Name: value, dtype: float64
这种简单的方法成功地捕捉到了五个已知异常值中的三个。
- 使用在 技术要求 部分中定义的
plot_outliers函数:
plot_outliers(outliers, tx, "Outliers using Z-score")
这应该会生成类似于 图 8.3 中的图形,只不过 x 标记是基于使用 z-score 方法识别的异常值:

图 8.15:每日平均出租车乘客数和使用 z-score 方法识别的异常值
你需要多尝试几次,确定最佳阈值。阈值越大,你捕捉到的异常值就越少;阈值越小,更多的非异常值会被标记为异常值。
- 最后,让我们创建一个
plot_zscore函数,该函数接受标准化数据,并使用阈值线绘制数据。这样,你可以直观地看到阈值如何隔离极端值:
def plot_zscore(data, d=3):
n = len(data)
plt.figure(figsize=(8,8))
plt.plot(data,'k^')
plt.plot([0,n],[d,d],'r--')
plt.plot([0,n],[-d,-d],'r--')
使用阈值2.5运行该函数:
data = transformed['zscore'].values
plot_zscore(data, d=2.5)
这应该生成一个包含两条水平线的散点图:

图 8.16:基于阈值线的标准化数据和异常值的图示
四个被圈出的数据点代表了zscore函数返回的异常值。使用不同的阈值运行该函数,以更深入理解这个简单的技术。
它是如何工作的...
z 分数方法是一种非常简单且易于解释的方法。z 分数被解释为与均值的标准差单位距离,均值是分布的中心。由于我们从所有观测值中减去均值,本质上是在进行数据的均值中心化。我们还通过标准差进行除法,以标准化数据。
图 8.15几乎解释了这种方法的理解。一旦数据被标准化,使用标准差阈值就变得非常容易。如果数据没有标准化,可能很难基于日常乘客量来确定阈值。
还有更多...
z 分数是一种参数化方法,假设数据来自高斯(正态)分布。statsmodels库中有几种测试可以检测数据是否符合正态分布。这些测试中的一个是Kolmogorov-Smirnov检验。零假设是数据来自正态分布。该检验返回检验统计量和p值;如果p值小于0.05,你可以拒绝零假设(数据不服从正态分布)。否则,你将无法拒绝零假设(数据服从正态分布)。
你将使用来自statsmodels库的kstest_normal函数。为了让结果更易于解释,创建test_normal函数如下:
from statsmodels.stats.diagnostic import kstest_normal
def test_normal(df):
t_test, p_value = kstest_normal(df)
if p_value < 0.05:
print("Reject null hypothesis. Data is not normal")
else:
print("Fail to reject null hypothesis. Data is normal")
使用test_normal函数运行检验:
test_normal(tx)
>>
Reject null hypothesis. Data is not normal
正如预期的那样,该数据集不符合正态分布。在第九章《探索性数据分析与诊断》中,你将学习更多的正态性检验方法,具体内容在应用幂次变换配方中。但是要小心,这些检验通常在存在异常值时会失败。如果你的数据未通过正态性检验,那么可以使用《使用可视化检测异常值》一节中讨论的一些绘图方法,检查任何可能导致检验失败的异常值。
另请参见
要了解更多关于 z-score 和标准化的内容,可以参考这篇维基百科页面:en.wikipedia.org/wiki/Standard_score。
在接下来的方法中,你将探索一种与 z-score 非常相似的方法,这种方法对异常值更为鲁棒,并且更适合非正态数据。
使用修改版 z-score 检测异常值
在使用 z-score 检测异常值这一方法中,你体验了该方法的简单性和直观性。但是它有一个主要缺点:假设你的数据是正态分布的。
但是,如果你的数据不是正态分布的怎么办?幸运的是,存在一种修改版的 z-score,适用于非正态数据。常规 z-score 和修改版 z-score 之间的主要区别在于,我们用中位数代替均值:
其中
(tilde x)是数据集的中位数,MAD 是数据集的中位绝对偏差:
0.6745值是标准差单位,表示高斯分布中第 75 百分位数(Q3),用于作为归一化因子。换句话说,它用于近似标准差。这样,你从该方法中获得的单位是以标准差为度量的,类似于你如何解释常规 z-score。
你可以使用 SciPy 的百分位点函数(PPF),也称为累积分布函数(CDF)的反函数,来获得此值。只需为 PPF 函数提供一个百分位数,例如 75%,它将返回对应的下尾概率的分位数。
import scipy.stats as stats
stats.norm.ppf(0.75)
>>
0.6744897501960817
这是公式中使用的归一化因子。
最后,修改版 z-score 有时也被称为鲁棒 z-score。
如何实现...
总体而言,该方法的工作原理与使用常规 z-score 方法时的步骤完全相同。你将首先创建modified_zscore函数,该函数接收一个数据集和一个我们称之为degree的阈值,然后该函数将返回标准化后的数据以及识别出的异常值。这些异常值是指超出正阈值或低于负阈值的点。
- 创建
modified_zscore()函数来标准化数据,并根据阈值过滤掉极端值。回想一下,阈值是基于标准差的:
def modified_zscore(df, degree=3):
data = df.copy()
s = stats.norm.ppf(0.75)
numerator = s*(data - data.median())
MAD = np.abs(data - data.median()).median()
data['m_zscore'] = numerator/MAD
outliers = data[(data['m_zscore'] > degree) | (data['m_zscore'] < -degree)]
return outliers['value'], data
- 现在,使用
modified_zscore函数并存储返回的对象:
threshold = 3
outliers, transformed = modified_zscore (tx, threshold)
- 为了查看修改版 z-score 转换的效果,让我们绘制一个直方图。转换后的 DataFrame 包含两列数据,原始数据列标为
value,标准化数据列标为zscore。
transformed.hist()
这应该会生成两个直方图,分别对应两列数据:

图 8.17:比较原始和修改版 z-score 标准化数据分布的直方图
比较图 8.16与图 8.13中的结果。两种方法,z-score 和修改后的 z-score 方法,都没有改变数据的形状。不同之处在于缩放因子。
- 使用
modified_zscore函数,设置阈值为3,这意味着任何数据点距离中位数三倍标准差的距离(无论方向如何)都会被视为异常值。例如,任何高于+3标准差或低于-3标准差的数据点都将被视为异常值。打印出outliers对象中捕获的结果:
print(outliers)
>>
timestamp
2014-11-01 20553.500000
2014-11-27 10899.666667
2014-12-25 7902.125000
2014-12-26 10397.958333
2015-01-26 7818.979167
2015-01-27 4834.541667
Name: value, dtype: float64
有趣的是,修改后的 z-score 在捕捉五个已知异常值中的四个时表现得更好。
- 使用前面在技术要求部分定义的
plot_outliers函数:
plot_outliers(outliers, tx, "Outliers using Modified Z-score")
这应该会生成类似于图 8.3中的图表,只不过x标记是基于使用修改后的 z-score 方法识别的异常值:

图 8.18:使用修改后的 z-score 方法识别的每日平均出租车乘客及异常值
你需要调整阈值来确定最佳值。阈值越大,你捕获的异常值就越少;阈值越小,更多的非异常值会被标记为异常值。
- 最后,让我们创建一个
plot_m_zscore函数,接受标准化数据并绘制带有阈值线的数据。通过这种方式,你可以直观地看到阈值如何隔离极端值:
def plot_m_zscore(data, d=3):
n = len(data)
plt.figure(figsize=(8,8))
plt.plot(data,'k^')
plt.plot([0,n],[d,d],'r--')
plt.plot([0,n],[-d,-d],'r--')
使用阈值为3来运行该函数:
data = transformed['m_zscore'].values
plot_m_zscore(data, d=3)
这应该会生成一个带有两条水平线的散点图:

图 8.19:基于阈值线的标准化数据和异常值的图
六个圈中的数据点代表了由modified_score函数返回的异常值。使用不同的阈值运行此函数,以便对这种简单的技术有更深的直觉理解。
注意在图 8.19中,我们有一个数据点正好位于阈值线处。你认为这是异常值吗?通常,异常值检测时,你仍然需要进行尽职调查来检查结果。
它是如何工作的...
修改后的 z-score(稳健 z-score)方法与 z-score 方法非常相似,因为它依赖于定义标准差阈值。使得这种方法对异常值更加稳健的是它使用了中位数而不是均值。我们还使用了中位数绝对偏差(MAD)来代替标准差。
还有更多...
在前一个配方中,使用 z-score 检测异常值,我们使用了statsmodels中的kstest_normal来测试正态性。
另一个有用的图形是专门用来测试正态性并且有时可以帮助检测异常值的分位数-分位数图(QQ 图)。
你可以使用 SciPy 或 statsmodels 绘制 QQ 图。两者都会生成相同的图。以下代码展示了你如何使用其中任何一个绘图。
这展示了如何使用 SciPy 绘图:
import scipy
import matplotlib.pyplot as plt
res = scipy.stats.probplot(tx.values.reshape(-1), plot=plt)
这展示了如何使用statsmodels绘图:
from statsmodels.graphics.gofplots import qqplot
qqplot(tx.values.reshape(-1), line='s')
plt.show()
无论是 SciPy 还是statsmodels,都会生成以下图形:

图 8.20:QQ 图,比较出租车乘客数据与假设的正态分布
实线代表正态分布数据的参考线。如果你比较的数据是正态分布的,所有数据点将会落在这条直线上。在图 8.19中,我们可以看到分布几乎是正态的(虽然不完美),并且在分布的尾部存在一些问题。这与我们在图 8.16和图 8.13中看到的情况一致,显示大多数异常值位于底部尾部(低于-2标准差)。
另见
若想了解更多关于 MAD 的信息,你可以参考维基百科页面:en.wikipedia.org/wiki/Median_absolute_deviation。
第九章:9 探索性数据分析与诊断
加入我们在 Discord 上的书籍社区

到目前为止,我们已经涵盖了从不同来源提取数据的技术。这些内容在第二章,从文件读取时间序列数据和第三章,从数据库读取时间序列数据中已经讨论过了。第六章,在 Python 中处理日期和时间,以及第七章,处理缺失数据,介绍了几种有助于准备、清理和调整数据的技术。
你将继续探索额外的技术,以便更好地理解数据背后的时间序列过程。在建模数据或进行进一步分析之前,一个重要步骤是检查手头的数据。更具体地说,你需要检查一些特定的时间序列特征,如平稳性、趋势和季节性的影响、以及自相关等。这些描述你所处理的时间序列过程的特征需要与该过程背后的领域知识相结合。
本章将建立在你从前几章学到的知识基础上,帮助你为从第十章开始创建和评估预测模型做准备,构建单变量时间序列模型(使用统计方法)。
在本章中,你将学习如何可视化时间序列数据,如何将时间序列分解为其组成部分(趋势、季节性和残差随机过程),如何检验模型可能依赖的不同假设(如平稳性、正态性和同方差性),以及如何探索数据转换技术以满足其中的一些假设。
本章中你将遇到的食谱如下:
-
使用 pandas 绘制时间序列数据
-
使用 hvPlot 绘制交互式时间序列数据
-
分解时间序列数据
-
检测时间序列的平稳性
-
应用幂次变换
-
测试时间序列数据的自相关性
技术要求
你可以从 GitHub 仓库下载所需的 Jupyter 笔记本和数据集,进行跟随学习:
-
Jupyter 笔记本:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./blob/main/code/Ch9/Chapter%209.ipynb -
数据集:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./tree/main/datasets/Ch9
从本章起,我们将广泛使用 pandas 2.2.0(2024 年 1 月 20 日发布)。这适用于本章中的所有食谱。
我们将使用四个额外的库:
-
hvplot和PyViz -
seaborn -
matplotlib
如果你使用的是pip,你可以通过终端安装这些包,命令如下:
pip install hvplot seaborn matplotlib jupyterlab
如果你使用的是conda,你可以通过以下命令安装这些包:
conda install jupyterlab matplotlib seaborn
conda install -c pyviz hvplot
HvPlot 库将用于在 JupyterLab 中构建交互式可视化。如果你使用的是最新版本的 JupyterLab(jupyterlab >= 3.0),那么所有所需的扩展都会自动安装,因为它们已经捆绑在 pyviz_comms 包中。如果你使用的是较旧版本的 JupyterLab(jupyterlab < 3.0),那么你需要手动安装 jupyterlab_pyviz 扩展,具体步骤如下:
jupyter labextension install @pyviz/jupyterlab_pyviz
在本章中,你将使用三个数据集(Closing Price Stock Data、CO2 和 Air Passengers)。CO2 和 Air Passengers 数据集由 statsmodels 库提供。Air Passengers 数据集包含了 1949 年至 1960 年的每月航空乘客人数。CO2 数据集包含了 1958 年至 2001 年间,位于毛纳罗亚山的每周大气二氧化碳浓度。Closing Price Stock Data 数据集包括 2019 年 11 月到 2021 年 11 月的微软、苹果和 IBM 的股票价格。
为了开始,你需要加载数据集,并将其存储为 pandas DataFrame,并加载在整个过程中需要的任何库或方法:
import pandas as pd
from pathlib import Path
import matplotlib.pyplot as plt
from statsmodels.datasets import co2, get_rdataset
file = Path(‘../../datasets/Ch9/closing_price.csv’)
closing_price = pd.read_csv(file,
index_col=‘Date’,
parse_dates=True)
co2_df = co2.load_pandas().data
co2_df = co2_df.ffill()
air_passengers = get_rdataset(“AirPassengers”)
airp_df = air_passengers.data
airp_df.index = pd.date_range(‘1949’, ‘1961’, freq=‘M’)
airp_df.drop(columns=[‘time’], inplace=True)
现在,你应该已经有了三个数据框:airp_df、closing_price 和 co2_df。
使用 pandas 绘制时间序列数据
可视化是数据分析中的一个关键方面,尤其在处理时间序列数据时显得尤为重要。在前面的章节和示例中,你已经遇到过多次绘制数据的实例,这些实例对于突出特定点或得出关于时间序列的结论至关重要。可视化我们的时间序列数据使我们能够一眼识别出模式、趋势、异常值以及其他关键信息。此外,数据可视化有助于跨不同小组之间的沟通,并通过提供一个共同的平台促进各利益相关方(如商业专家和数据科学家)之间的建设性对话。
在时间序列分析以及机器学习领域,我们在探索性数据分析(EDA)中优先可视化数据,以全面理解我们正在处理的数据。在评估模型时,我们也依赖于可视化来比较它们的表现,并识别需要改进的地方。可视化在模型可解释性方面发挥着关键作用,使利益相关者能够理解模型如何进行预测。此外,在部署模型后,我们依赖可视化进行持续监控,寻找任何性能下降的迹象,如模型漂移。
pandas 库提供了内置的绘图功能,用于可视化存储在 DataFrame 或 Series 数据结构中的数据。在后台,这些可视化由 Matplotlib 库支持,后者也是默认选项。
pandas 库提供了许多方便的绘图方法。简单调用 DataFrame.plot() 或 Series.plot() 默认将生成折线图。你可以通过两种方式更改图表的类型:
-
使用
plot方法中的kind参数,如.plot(kind=),通过替换<charttype>为图表类型来指定绘图类型。例如,.plot(kind=“hist”)将绘制一个直方图,而.plot(kind=“bar”)将绘制柱状图。 -
或者,你可以扩展
plot方法。这可以通过链式调用特定的绘图函数来实现,例如.hist()或.scatter(),比如使用.plot.hist()或.plot.line()。
本配方将使用标准的 pandas .plot() 方法,并支持 Matplotlib 后端。
准备工作
你可以从 GitHub 仓库下载所需的 Jupyter 笔记本和数据集。请参考本章节的技术要求部分。
你将使用 Microsoft、Apple 和 IBM 的Closing Price Stock数据集,数据文件为closing_price.csv。
如何操作…
在这个配方中,你将探索如何绘制时间序列数据、改变主题、生成子图以及自定义输出的可视化效果:
- 在 pandas 中绘图可以通过简单地在 DataFrame 或 Series 名称后添加
.plot()来实现:
closing_price.plot();
这将生成一张线性图,它是kind参数的默认选项,类似于.plot(kind=“line”):

图 9.1:使用 pandas 绘制的多线时间序列图
你可以通过添加标题、更新轴标签以及自定义 x 和 y 轴刻度等方式进一步自定义图表。例如,添加标题和更新 y 轴标签,可以使用title和ylabel参数,如下所示:
start_date = ‘2019’
end_date = ‘2021’
closing_price.plot(
title=f’Closing Prices from {start_date} – {end_date}’,
ylabel= ‘Closing Price’);
- 如果你想看看价格之间的波动(上下浮动),一种简单的方法是标准化数据。为此,只需将每个股票的股价除以第一天的价格(第一行)。这将使所有股票具有相同的起点:
closing_price_n = closing_price.div(closing_price.iloc[0])
closing_price_n.plot(
title=f’Closing Prices from {start_date} – {end_date}’,
ylabel= ‘Normalized Closing Price’);
这将生成如下图表:

图 9.2:使用简单的标准化技术,使得价格波动更加直观易比对
从输出中可以观察到,现在这些线条的起点(原点)已经设置为1。图表显示了时间序列图中价格之间的偏差:
closing_price_n.head()
请注意,输出表格的第一行已设置为1.0:

图 9.3:标准化时间序列输出,所有序列的起点为 1
- 此外,Matplotlib 允许你更改图表的样式。为此,你可以使用
style.use()函数。你可以从现有模板中指定一个样式名称,或者使用自定义样式。例如,以下代码展示了如何从default样式切换到ggplot样式:
plt.style.use('ggplot')
closing_price_n.plot(
title=f'Closing Prices from {start_date} - {end_date}',
ylabel= 'Normalized Closing Price');
前面的代码应该生成相同数据内容的图表,但样式有所不同。

图 9.4:使用来自 Matplotlib 的 ggplot 风格
ggplot 风格的灵感来自于 R 中的 ggplot2 包。
您可以探索其他吸引人的样式:fivethirtyeight,灵感来自 fivethirtyeight.com,dark_background,dark-background,和 tableau-colorblind10。
要查看可用的样式表的完整列表,您可以参考 Matplotlib 文档:matplotlib.org/stable/gallery/style_sheets/style_sheets_reference.html
如果您想恢复到原始主题,可以指定 plt.style.use("default")。
- 您可以将您的图表保存为 jpeg、png、svg 或其他文件类型。例如,您可以使用
.savefig()方法将文件保存为plot_1.jpg文件,并指定更高的 dpi 分辨率以保证打印质量。默认的 dpi 值为 100:
plot = closing_price_n.plot(
title=f'Closing Prices from {start_date} - {end_date}',
ylabel= 'Norm. Price')
plot.get_figure().savefig('plot_1.jpg', dpi=300)
图表应作为 plot_1.jpg 图像文件保存在您的本地目录中。
它是如何工作的……
pandas 和 Matplotlib 库之间的协作非常好,双方有着集成并在 pandas 中增加更多绘图功能的雄心。
在 pandas 中,您可以通过向 kind 参数提供一个值来使用多种绘图样式。例如,您可以指定以下内容:
-
line用于常用于展示时间序列的折线图 -
bar或barh(水平)用于条形图 -
hist用于直方图 -
box用于箱形图 -
kde或density用于核密度估计图 -
area用于面积图 -
pie用于饼图 -
scatter用于散点图 -
hexbin用于六边形图
还有更多……
如前一节所示,我们将时间序列中的所有三列绘制在一个图中(同一图中的三条线图)。如果您希望每个符号(列)单独绘制呢?
这可以通过简单地将 subplots 参数设置为 True 来完成:
closing_price.plot(
subplots=True,
title=f'Closing Prices from {start_date} - {end_date}');
上述代码将在 DataFrame 中为每一列生成一个子图。使用 closing_price DataFrame,这将生成三个子图。

图 9.5:使用 pandas 子图功能
另见
若要了解更多有关 pandas 绘图和可视化的功能,请访问官方文档:pandas.pydata.org/pandas-docs/stable/user_guide/visualization.html
使用 hvPlot 绘制时间序列数据并进行交互式可视化
交互式可视化比静态图像更高效地分析数据。简单的交互,例如放大缩小或切片操作,可以揭示出更多有价值的见解,供进一步调查。
在本食谱中,我们将探索 hvPlot 库来创建交互式可视化。HvPlot 提供了一个高级 API 用于数据可视化,并且与多种数据源无缝集成,包括 pandas、Xarray、Dask、Polars、NetworkX、Streamlit 和 GeoPandas。利用 hvPlot 和 pandas 渲染交互式可视化需要最少的工作量,只需对原始代码做少量修改,就能创建动态可视化。我们将使用 'closing_price.csv' 数据集来探索该库在本食谱中的应用。
准备工作
你可以从 GitHub 仓库下载所需的 Jupyter 笔记本和数据集。请参阅本章的 技术要求 部分。
如何操作…
- 首先导入所需的库。注意,hvPlot 有一个 pandas 扩展,使得使用更为方便。这将允许你使用与前面示例相同的语法:
import pandas as pd
import hvplot.pandas
import hvplot as hv
closing_price_n = closing_price.div(closing_price.iloc[0])
使用 pandas 绘图时,你会使用 .plot() 方法,例如,closing_price_n.plot()。类似地,hvPlot 允许你通过将 .plot() 替换为 .hvplot() 来渲染交互式图表。如果你有内容密集的图表,这非常有用。你可以缩放到图表的特定部分,然后使用平移功能移动到图表的不同部分:
closing_price_n.hvplot(
title=f'Closing Prices from {start_date} - {end_date}')
通过将 .plot 替换为 .hvplot,你可以获得一个带有悬停效果的交互式可视化:

图 9.6:hvPlot 交互式可视化
同样的结果可以通过简单地切换 pandas 绘图后端来实现。默认后端是 matplotlib。要切换到 hvPlot,只需更新 backend='hvplot':
closing_price_n.plot(
backend='hvplot',
title=f'Closing Prices from {start_date} - {end_date}'
)
这应该会生成与图 9.6相同的图表。
注意右侧的小部件栏,其中包含一组交互模式,包括平移、框选缩放、滚轮缩放、保存、重置和悬停。

图 9.7:带有六种交互模式的小部件栏
- 你可以将每个时间序列按符号(列)分割成独立的图表。例如,将数据分为三列,每列对应一个符号(或股票代码):MSFT、AAPL 和 IBM。子图可以通过指定
subplots=True来完成:
closing_price.hvplot(width=300, subplots=True)
这应该会生成每列一个子图:

图 9.8:hvPlot 子图示例
你可以使用 .cols() 方法来更精确地控制布局。该方法允许你控制每行显示的图表数量。例如,.cols(1) 表示每行一个图表,而 .cols(2) 表示每行两个图表:
closing_price.hvplot(width=300, subplots=True).cols(2)
这应该会生成一个图表,第一行有两个子图,第二行有第三个子图,如下所示:

图 9.9:使用 .col(2) 每行两个列的示例 hvPlot
请记住,.cols() 方法仅在 subplots 参数设置为 True 时有效,否则会导致错误。
工作原理…
由于 pandas 被广泛使用,许多库现在都支持将 pandas DataFrame 和 Series 作为输入。此外,Matplotlib 和 hvPlot 之间的集成简化了与 pandas 一起使用的绘图引擎的更换过程。
HvPlot 提供了几种方便的选项来绘制你的 DataFrame:你可以轻松切换后端,使用 DataFrame.hvplot() 扩展 pandas 功能,或利用 hvPlot 的原生 API 进行更高级的可视化。
还有更多内容…
hvPlot 允许你使用两个算术运算符,+ 和 *,来配置图表的布局。
加号 (+) 允许你将两个图表并排显示,而乘号 (*) 则使你能够合并图表(将一个图表与另一个合并)。在以下示例中,我们将两个图表相加,使它们在同一行上并排显示:
(closing_price_n['AAPL'].hvplot(width=400) +
closing_price_n['MSFT'].hvplot(width=400))
这应该生成如下图所示的结果:

图 9.10:使用加法运算符并排显示两个图表
请注意,这两个图表将共享同一个小部件条。如果你对一个图表进行筛选或缩放,另一个图表将应用相同的操作。
现在,让我们看看如何通过乘法将两个图表合并成一个:
(closing_price_n['AAPL'].hvplot(width=500, height=300) *
closing_price_n['MSFT'].hvplot()).opts(legend_position='top_left')
上述代码应该生成一个结合了 AAPL 和 MSFT 的图表:

图 9.11:使用乘法运算符将两个图表合并为一个
最后,为了创建子组(类似于 "group by" 操作),其中每个组由不同的颜色表示,你可以使用下方演示的 by 参数:
closing_price['AAPL'].hvplot.line(by=['index.year'])
这段代码生成了一个如预期的折线图,并按年份分段(分组显示),如图 9.12 所示:

图 9.12:按子组(按年份)绘制的折线图。
鉴于我们的数据涵盖了三年,你将在图表中看到三种不同的颜色,每种颜色对应不同的年份,如图例所示。
另请参阅
有关 hvPlot 的更多信息,请访问其官方网站:hvplot.holoviz.org/。
时间序列数据的分解
在进行时间序列分析时,一个关键目标通常是预测,即构建一个能够做出未来预测的模型。在开始建模过程之前,至关重要的一步是提取时间序列的各个组成部分进行分析。这个步骤对整个建模过程中的决策至关重要。
一个时间序列通常由三个主要组成部分构成:趋势、季节性和残差随机过程。对于需要时间序列平稳的统计模型,可能需要估计并随之去除时间序列中的趋势和季节性成分。时间序列分解的技术和库通常提供趋势、季节性和残差随机过程的可视化表示和识别。
趋势成分反映了时间序列的长期方向,可能是向上、向下或水平的。例如,销售数据的时间序列可能显示出向上的趋势,表明销售随着时间的推移在增加。季节性是指在特定间隔内重复出现的模式,例如每年圣诞节前后的销售增长,这是一个随着假日季节的临近每年都会出现的模式。残差随机过程表示在去除趋势和季节性后,时间序列中剩余的部分,包含了数据中无法解释的变动性。
时间序列的分解是将其分离为三个组成部分的过程,并将趋势和季节性成分作为各自的模型来估计。分解后的组成部分可以根据它们之间的交互性质进行加法或乘法建模。
当你使用加法模型时,可以通过将所有三个组成部分相加来重建原始时间序列:

当季节性变化不随时间变化时,加法分解模型是合理的。另一方面,如果时间序列可以通过将这三部分相乘来重建,则使用乘法模型:

当季节性变化随时间波动时,使用乘法模型是合适的。
此外,你可以将这些成分分为可预测与不可预测的成分。可预测成分是稳定的、重复的模式,可以被捕捉和建模。季节性和趋势就是其中的例子。另一方面,每个时间序列都有一个不可预测的成分,它表现出不规则性,通常称为噪声,但在分解的上下文中被称为残差。
在本教程中,你将探索使用seasonal_decompose、季节-趋势分解(LOESS)(STL)和hp_filter方法来分解时间序列,这些方法在statsmodels库中可用。
准备工作
你可以从 GitHub 仓库下载所需的 Jupyter 笔记本和数据集。请参考本章节中的技术要求部分。
如何操作…
你将探索在 statsmodels 库中提供的两种方法:seasonal_decompose 和 STL。
使用 seasonal_decompose
seasonal_decompose函数依赖于移动平均法来分解时间序列。你将使用技术要求部分中的 CO2 和航空乘客数据集。
- 导入所需的库并设置
rcParams,使可视化图表足够大。通常,statsmodels 生成的图表较小。你可以通过调整rcParams中的figure.figsize来修复这个问题,使本食谱中的所有图表都应用相同的大小:
from statsmodels.tsa.seasonal import seasonal_decompose, STL
plt.rcParams["figure.figsize"] = [10, 5]
这将使所有图表的大小一致:宽度为 10 英寸,高度为 3 英寸(W x H)。
你可以应用诸如灰度主题之类的样式
plt.style.use('grayscale')
- 你可以使用
seasonal_decompose()函数分解这两个数据集。但在此之前,你应该绘制你的时间序列,以了解季节性是否表现出乘法或加法特征:
co2_df.plot(title=co2.TITLE);
这应该会显示一张显示 1960 年到 2000 年每周二氧化碳水平的线图,单位为百万分之一(ppm)。使用.plot()方法时,默认的图表类型是线图,参数为kind="line"。关于 pandas 绘图功能的更多信息,请参考使用 pandas 绘制时间序列数据的食谱。

图 9.13:显示上升趋势和恒定季节性变化的 CO2 数据集
co2_df数据展示了一个长期的线性趋势(向上),并且有一个以恒定速率重复的季节性模式(季节性变化)。这表明 CO2 数据集是一个加法模型。
同样,你可以探索airp_df数据框,观察航空乘客数据集中的季节性是否表现为乘法或加法行为:
airp_df['value'].plot(title=air_passengers['title']);
这应该生成一张显示 1949 年至 1960 年每月乘客数量的线图:

图 9.14:显示趋势和逐渐增加的季节性变化的航空乘客数据集
airp_df数据展示了一个长期的线性趋势和季节性变化(向上)。然而,季节性波动似乎也在增加,这表明是一个乘法模型。
- 对这两个数据集使用
seasonal_decompose。对于 CO2 数据,使用加法模型,而对于航空乘客数据,使用乘法模型:
co2_decomposed = seasonal_decompose(co2_df,model='additive')
air_decomposed = seasonal_decompose(airp_df,model='multiplicative')
co2_decomposed和air_decomposed都可以访问几种方法,包括.trend、.seasonal和.resid。你可以通过使用.plot()方法绘制所有三个组成部分:
air_decomposed.plot();
以下是结果图:

图 9.15:航空乘客的乘法分解为趋势、季节性和残差
让我们将结果图分解成四个部分:
-
这是我们正在分解的原始观察数据。
-
趋势组件显示出上升的方向。趋势指示是否存在正向(增加或上升)、负向(减少或下降)或恒定(没有趋势或水平)长期运动。
-
季节性组件显示季节性效应,表现为高低交替的重复模式。
-
最后,残差(有时称为噪声)组件显示去除趋势和季节性后的数据中的随机变化。在这种情况下,使用了乘法模型。
同样,你可以绘制 CO2 数据集的分解图:
co2_decomposed.plot();
这应该会生成以下图形:

图 9.16:CO2 加性分解为趋势、季节性和残差
- 在重建时间序列时,例如,在乘法模型中,你将会将三个组件相乘。为了演示这一概念,使用
air_decomposed,这是DecomposeResult类的一个实例。该类提供了seasonal、trend和resid属性以及.plot()方法。
在下面的代码中,你可以将这些组件相乘来重建时间序列:
(air_decomposed.trend *
air_decomposed.seasonal *
air_decomposed.resid).plot() ;
它输出如下图:

图 9.17:重建航空乘客时间序列数据集
使用 STL
另一种在statsmodels中的分解选项是STL。STL 代表使用 LOESS 的季节性-趋势分解,这是一种更先进的分解技术。在statsmodels中,STL类比seasonal_decompose函数需要更多的参数。你将使用的另外两个参数是seasonal和robust。seasonal参数用于季节性平滑器,并且只能接受大于或等于 7 的奇数整数值。同样,STL函数有一个趋势平滑器(trend参数)。
第二个参数是robust,它接受一个布尔值(True 或 False)。设置robust=True有助于消除异常值对季节性和趋势组件的影响。
- 你将使用
STL来分解co2_df数据框:
co2_stl = STL(
co2_df,
seasonal=13,
robust=True).fit()
co2_stl.plot(); plt.show()
这应该会生成与seasonal_decompose函数类似的子图,显示趋势、季节性和残差:

图 9.18:使用 STL 分解 CO2 数据集
比较图 9.16中的输出与图 9.18中的输出。
请注意,当你使用STL时,你提供了seasonal=13,因为数据具有年度季节性效应。季节性参数仅接受大于或等于 7 的奇数整数。
- 季节性分解和 STL 都生成
DecomposeResult类的实例,可以直接访问残差。你可以比较seasonal_decompose和 STL 的残差,如下所示:
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 4))
co2_stl.resid.plot(ax=ax1, title='Residual Plot from Seasonal Decomposition')
co2_decomposed.resid.plot(ax=ax2, title='Residual Plot from STL');
这应该会生成如下图,包含两个子图

图 9.19:比较季节分解与 STL 的残差图
你会注意到,残差图看起来不同,这表明两种方法使用不同的机制捕获了相似的信息。
它是如何工作的……
你使用了两种不同的时间序列分解方法。这两种方法都将时间序列分解为趋势、季节性和残差成分。
STL 类使用 LOESS 季节性平滑器,代表的是 局部加权散点图平滑。与 seasonal_decompose 相比,STL 在测量非线性关系方面更加稳健。另一方面,STL 假设成分是加性组合的,因此你不需要像 seasonal_decompose 那样指定模型。两种方法都可以从时间序列中提取季节性,以更好地观察数据的整体趋势。
seasonal_decompose 函数执行以下 简化 逻辑:
-
对时间序列数据进行平滑处理,以观察趋势。这是通过应用卷积滤波器来估计趋势。
-
一旦趋势被估计出来,它就会从时间序列中被移除(去趋势)。
-
剩下的去趋势时间序列会根据每个周期或季节组进行平均。季节性平均会将时间序列中对应每个季节的所有数据值取平均(例如,我们取每年的每个一月并对其进行平均)。
-
一旦季节性成分被估计出来,它就会被移除,剩下的就是残差。
STL 函数执行以下 简化 逻辑:
-
类似于
seasonal_decompose,时间序列通过平滑处理来估计趋势,但在 STL 中,这一过程是通过 LOESS 平滑来实现的。 -
一旦趋势被估计出来,它就会从时间序列中被移除(去趋势)。
-
对于季节性成分,STL 对去趋势后的数据应用了 Loess 平滑,但每个季节单独处理。
-
一旦季节性成分被估计出来,它就会被移除,剩下的就是残差。
还有更多……
霍德里克-普雷斯科特滤波器 是一种平滑滤波器,用于将短期波动(周期变化)与长期趋势分离。它在 statsmodels 库中实现为 hp_filter。
回顾一下,STL 和 seasonal_decompose 返回了三个成分(趋势、季节性和残差)。另一方面,hp_filter 只返回两个成分:周期成分和趋势成分。
首先从 statsmodels 库中导入 hpfilter 函数:
from statsmodels.tsa.filters.hp_filter import hpfilter
要将你的时间序列分解为趋势和周期成分,只需将你的时间序列 DataFrame 提供给 hpfilter 函数,如下所示:
co2_cyclic, co2_trend = hpfilter(co2_df)
hpfilter 函数返回两个 pandas Series:第一个 Series 是周期成分,第二个 Series 是趋势成分。将 co2_cyclic 和 co2_trend 并排绘制,以便更好地理解霍德里克-普雷斯科特滤波器从数据中提取了哪些信息:
fig, ax = plt.subplots(1,2, figsize=(16, 4))
co2_cyclic.plot(ax=ax[0], title='CO2 Cyclic Component')
co2_trend.plot(ax=ax[1], title='CO2 Trend Component');
这应该会在同一行中产生两个子图(并排显示),如所示:

图 9.20:使用赫德里克-普雷斯科特滤波器的周期性和趋势成分
请注意,hpfilter 得到的两个成分是加性的。换句话说,要重构原始时间序列,你需要将 co2_cyclic 和 co2_trend 相加。
(co2_cyclic + co2_trend).plot();

图 9.21:从 hpfilter 函数给出的趋势和周期成分重构 CO2 数据集
你可以将图 9.21 中从趋势和周期性成分重构的 CO2 图与图 9.13 中的原始 CO2 图进行比较。
另见
-
要了解更多关于
hpfilter()的信息,请访问官方文档页面:www.statsmodels.org/dev/generated/statsmodels.tsa.filters.hp_filter.hpfilter.html#statsmodels.tsa.filters.hp_filter.hpfilter。 -
要了解更多关于
seasonal_decompose()的信息,请访问官方文档页面:www.statsmodels.org/dev/generated/statsmodels.tsa.seasonal.seasonal_decompose.html。 -
要了解更多关于
STL()的信息,请访问官方文档页面:www.statsmodels.org/dev/generated/statsmodels.tsa.seasonal.STL.html#statsmodels.tsa.seasonal.STL。
检测时间序列的平稳性
一些时间序列预测技术假设时间序列过程是平稳的。因此,确定你正在处理的时间序列(无论是观察到的时间序列还是你所拥有的实现)是否来源于平稳或非平稳过程是至关重要的。
一个平稳的时间序列表明特定的统计性质随时间不会发生变化,保持稳定,这使得建模和预测过程更加容易。相反,非平稳过程由于其动态特性和随时间的变化(例如,存在趋势或季节性)更难建模。
定义平稳性的方式有多种;有些方法严格,可能在现实数据中无法观察到,称为强平稳性。与之相反,其他定义则在标准上更为宽松,能够在现实数据中观察到(或通过变换得到),称为弱平稳性。
在本教程中,出于实际考虑,平稳性一词指的是“弱”平稳性,即定义为具有恒定均值mu(),恒定方差sigma squared()和一致的协方差(或自相关)在相同距离的周期(lags)之间的时间序列。均值和方差作为常数有助于简化建模,因为你不需要将它们作为时间的函数来求解。
一般来说,具有趋势或季节性的时间序列可以认为是非平稳的。通常,通过图形直观识别趋势或季节性有助于判断时间序列是否平稳。在这种情况下,一个简单的折线图就足够了。但在本教程中,你将探索统计检验,帮助你从数值上识别时间序列是平稳还是非平稳。你将探索平稳性检验及使时间序列平稳的技术。
你将使用 statsmodels 库探索两种统计检验:扩展迪基-福勒(ADF)检验和克维亚特科夫-菲利普斯-施密特-辛(KPSS)检验。ADF 和 KPSS 都用于检验单变量时间序列过程中的单位根。需要注意的是,单位根只是时间序列非平稳的原因之一,但通常单位根的存在表明时间序列是非平稳的。
ADF 和 KPSS 都基于线性回归,并且是统计假设检验的一种。例如,ADF 的零假设表示时间序列中存在单位根,因此是非平稳的。另一方面,KPSS 的零假设相反,假设时间序列是平稳的。因此,你需要根据检验结果来判断是否可以拒绝零假设或未能拒绝零假设。通常,可以依赖返回的 p 值来决定是否拒绝或未能拒绝零假设。
请记住,ADF 和 KPSS 检验的解释不同,因为它们的零假设相反。如果 p 值小于显著性水平(通常为 0.05),则可以拒绝零假设,说明时间序列没有单位根,可能是平稳的。如果 KPSS 检验中的 p 值小于显著性水平,则表示可以拒绝零假设,表明该序列是非平稳的。
准备工作
你可以从 GitHub 仓库下载所需的 Jupyter notebook 和数据集。请参阅本章节的技术要求部分。
在本教程中,你将使用 CO2 数据集,该数据集之前已作为 pandas DataFrame 在本章节的技术要求部分加载。
如何操作…
除了通过时间序列图形的视觉解释来判断平稳性之外,一种更为具体的方法是使用单位根检验,例如 ADF KPSS 检验。
在图 9.13中,你可以看到一个上升的趋势和一个重复的季节性模式(年度)。然而,当存在趋势或季节性(在这种情况下,两者都有)时,这会使时间序列非平稳。并不是总能通过视觉轻松识别平稳性或其缺乏,因此,你将依赖统计检验。
你将使用 statsmodels 库中的 adfuller 和 kpss 检验,并在理解它们有相反的原假设的前提下解释它们的结果:
- 首先从 statsmodels 导入
adfuller和kpss函数:
from statsmodels.tsa.stattools import adfuller, kpss
为了简化对检验结果的解释,创建一个函数以用户友好的方式输出结果。我们将该函数称为 print_results:
def print_results(output, test='adf'):
pval = output[1]
test_score = output[0]
lags = output[2]
decision = 'Non-Stationary'
if test == 'adf':
critical = output[4]
if pval < 0.05:
decision = 'Stationary'
elif test=='kpss':
critical = output[3]
if pval >= 0.05:
decision = 'Stationary'
output_dict = {
'Test Statistic': test_score,
'p-value': pval,
'Numbers of lags': lags,
'decision': decision
}
for key, value in critical.items():
output_dict["Critical Value (%s)" % key] = value
return pd.Series(output_dict, name=test)
该函数接受 adfuller 和 kpss 函数的输出,并返回一个添加了标签的字典。
- 运行
kpss和adfuller检验。对于这两个函数,使用默认的参数值:
adf_output = adfuller(co2_df)
kpss_output = kpss(co2_df)
- 将两个输出传递给
print_results函数,并将它们合并为 pandas DataFrame,便于比较:
pd.concat([
print_results(adf_output, 'adf'),
print_results(kpss_output, 'kpss')
], axis=1)
这应该生成以下 DataFrame:

图 9.22:来自 ADF 和 KPSS 单位根检验的结果输出
对于 ADF,p 值为 0.96,大于 0.05,因此你不能拒绝原假设,因此时间序列是非平稳的。对于 KPSS,p 值为 0.01,小于 0.05,因此你拒绝原假设,因此时间序列是非平稳的。
接下来,你将探索六种使时间序列平稳的方法,如变换和差分。所涵盖的方法有一阶差分、二阶差分、减去移动平均、对数变换、分解和霍德里克-普雷斯科特滤波器。
本质上,通过去除趋势(去趋势化)和季节性效应可以实现平稳性。对于每个变换,你将运行平稳性检验,并比较不同方法的结果。为了简化解释和比较,你将创建两个函数:
-
check_stationarity接受一个 DataFrame,执行 KPSS 和 ADF 检验,并返回结果。 -
plot_comparison接受一个方法列表并比较它们的图表。该函数接受一个plot_type参数,因此你可以探索折线图和直方图。该函数调用check_stationarity函数,以捕捉子图标题的结果。
创建 check_stationarity 函数,这是对之前使用的 print_results 函数的简化重写:
def check_stationarity(df):
kps = kpss(df)
adf = adfuller(df)
kpss_pv, adf_pv = kps[1], adf[1]
kpssh, adfh = 'Stationary', 'Non-stationary'
if adf_pv < 0.05:
# Reject ADF Null Hypothesis
adfh = 'Stationary'
if kpss_pv < 0.05:
# Reject KPSS Null Hypothesis
kpssh = 'Non-stationary'
return (kpssh, adfh)
创建 plot_comparison 函数:
def plot_comparison(methods, plot_type='line'):
n = len(methods) // 2
fig, ax = plt.subplots(n,2, sharex=True, figsize=(20,10))
for i, method in enumerate(methods):
method.dropna(inplace=True)
name = [n for n in globals() if globals()[n] is method]
v, r = i // 2, i % 2
kpss_s, adf_s = check_stationarity(method)
method.plot(kind=plot_type,
ax=ax[v,r],
legend=False,
title=f'{name[0]} --> KPSS: {kpss_s}, ADF {adf_s}')
ax[v,r].title.set_size(20)
method.rolling(52).mean().plot(ax=ax[v,r], legend=False)
让我们实现一些使时间序列平稳或提取平稳成分的方法。然后,将这些方法合并成一个 Python 列表:
-
一阶差分:也称为去趋势化,它通过将时间
t的观测值减去时间t-1的前一个观测值来计算(![]()
在 pandas 中,可以使用
.diff()函数来实现,这个函数的默认参数是period=1。注意,差分后的数据将比原始数据少一个数据点(行),因此需要使用.dropna()方法:
first_order_diff = co2_df.diff().dropna()
- 二阶差分:如果存在季节性或一阶差分不足时,这非常有用。这本质上是进行两次差分——第一次差分去除趋势,第二次差分去除季节性趋势:
differencing_twice = co2_df.diff(52).diff().dropna()
- 从时间序列中减去移动平均(滚动窗口),使用
DataFrame.rolling(window=52).mean(),因为这是每周数据:
rolling_mean = co2_df.rolling(window=52).mean()
rolling_mean = co2_df - rolling
- 对数变换,使用
np.log(),是一种常见的技术,用于稳定时间序列的方差,有时足以使时间序列平稳。简单来说,它所做的就是用每个观测值的对数值替代原始值:
log_transform = np.log(co2_df)
- 使用时间序列分解方法去除趋势和季节性成分,例如
seasonal_decompose。从图 9.13来看,似乎该过程是加法性的。这是seasonal_decompose的默认参数,因此这里无需进行任何更改:
decomp = seasonal_decompose(co2_df)
seasonal_decomp = decomp.resid
让我们也添加 STL 作为另一种分解方法
co2_stl = STL(co2_df, seasonal=13,robust=True).fit()
stl_decomp = co2_stl.resid
- 使用霍德里克-普雷斯科特滤波器去除趋势成分,例如使用
hp_filter:
hp_cyclic, hp_trend = hpfilter(co2_df)
现在,让我们将这些方法组合成一个 Python 列表,然后将该列表传递给plot_comparison函数:
methods = [first_ord_diff, second_ord_diff,
diseasonalize, rolling_mean,
log_transform, seasonal_decomp,
stl_decomp, hp_cyclic]
plot_comparison(methods)
这应显示如图所示的 4 x 2 子图:

图 9.23:绘制不同方法使 CO2 时间序列平稳
通常,你不想对时间序列进行过度差分,因为一些研究表明,基于过度差分数据的模型准确性较低。例如,first_order_diff已经使时间序列平稳,因此无需进一步进行差分。换句话说,differencing_twice是不必要的。此外,注意到log_transform仍然是非平稳的。
注意中间线代表时间序列的平均值(移动平均)。对于平稳时间序列,均值应保持恒定,且更像是一条水平直线。
它是如何工作的…
平稳性是时间序列预测中的一个重要概念,尤其在处理金融或经济数据时尤为相关。我们之前将平稳性(弱平稳)定义为均值恒定、方差恒定、协方差一致。
如果时间序列是平稳的,则均值被认为是稳定且恒定的。换句话说,存在一种平衡,尽管值可能会偏离均值(高于或低于),但最终它总会回到均值。一些交易策略依赖于这一核心假设,正式称为均值回归策略。
statsmodels库提供了多个平稳性检验方法,例如adfuller和kpss函数。两者都被认为是单位根检验,用于确定是否需要差分或其他转换策略来使时间序列平稳。
记住,ADF 和 KPSS 检验基于不同的原假设。例如,adfuller和kpss的原假设是相反的。因此,你用来拒绝(或无法拒绝)原假设的 p 值在这两者之间的解读会有所不同。
在图 9.22中,测试返回了额外的信息,具体包括以下内容:
-
检验统计量值为 ADF 的 0.046 和 KPSS 的 8.18,均高于 1%的临界值阈值。这表明时间序列是非平稳的,确认你不能拒绝原假设。ADF 的临界值来自 Dickey-Fuller 表格。幸运的是,你不必参考 Dickey-Fuller 表格,因为所有提供 ADF 检验的统计软件/库都会在内部使用该表格。KPSS 也是如此。
-
p 值结果与检验统计量相关。通常,当 p 值小于 0.05(5%)时,你可以拒绝原假设。再次强调,在使用 ADF、KPSS 或其他平稳性检验时,确保理解原假设,以便准确解读结果。
-
滞后期数表示检验中自回归过程使用的滞后期数(ADF 和 KPSS)。在这两个检验中,使用了 27 个滞后期。由于我们的 CO2 数据是按周记录的,一个滞后期表示 1 周。因此,27 个滞后期代表我们数据中的 27 周。
-
使用的观测值数量是数据点的数量,排除了滞后期数。
-
最大化的信息准则基于autolag参数。默认值是
autolag="aic",对应赤池信息准则(AIC)。其他可接受的autolag参数值有bic(对应贝叶斯信息准则(BIC))和t-stat。
你还探索了一些时间序列去趋势(移除趋势)的技术,以使其平稳。例如,你使用了一级差分、分解和对数变换来去除趋势的影响。去趋势稳定了时间序列的均值,有时这就是使其平稳所需的全部。当你决定去趋势时,本质上是移除一个干扰因素,这样你就能专注于那些不那么明显的潜在模式。因此,你可以构建一个模型来捕捉这些模式,而不会被长期趋势(向上或向下的波动)所掩盖。一个例子是一级差分方法。
然而,在存在季节性模式的情况下,您还需要去除季节性效应,这可以通过季节性差分来完成。这是在去趋势的第一阶差分之外进行的;因此,它可以称为二阶差分、双重差分,或者称为“差分两次”,因为您首先使用差分去除趋势效应,然后再次去除季节性。这假设季节性差分不足以使时间序列平稳。您的目标是使用最少的差分,避免过度差分。您很少需要超过两次差分。
还有更多内容…
在本食谱的介绍部分,我们提到过,ADF 和 KPSS 都使用线性回归。更具体地说,普通最小二乘法(OLS)回归用于计算模型的系数。要查看 ADF 的 OLS 结果,您需要使用store参数并将其设置为True:
adf_result = adfuller(first_order_diff, store=True)
上面的代码将返回一个包含测试结果的元组。回归摘要将作为最后一项附加到元组中。元组中应该有四项:第一项,adf_result[0],包含t 统计量;第二项,adf_result[1],包含p 值;第三项,adf_result[2],包含 1%、5%和 10%区间的临界值。最后一项,adf_result[3],包含ResultStore对象。您也可以使用adf_result[-1]来访问最后一项,如以下代码所示:
adf_result[-1].resols.summary()
ResultStore对象让您可以访问.resols,其中包含.summary()方法。这将生成如下输出:

图 9.24:ADF OLS 回归摘要及前五个滞后项及其系数
另见
要了解更多关于平稳性和去趋势的内容,请访问官方 statsmodels 页面:www.statsmodels.org/dev/examples/notebooks/generated/stationarity_detrending_adf_kpss.html。
应用幂次变换
时间序列数据可能很复杂,数据中嵌入着您需要理解和分析的重要信息,以便确定构建模型的最佳方法。例如,您已经探索了时间序列分解,理解了趋势和季节性的影响,并测试了平稳性。在前面的食谱中,检测时间序列的平稳性,您研究了将数据从非平稳转变为平稳的技术。这包括去趋势的概念,旨在稳定时间序列的均值。
根据你所进行的模型和分析,可能需要对观察数据集或模型的残差进行额外假设检验。例如,检验同方差性(也拼作 homoscedasticity)和正态性。同方差性意味着方差在时间上是稳定的。更具体地说,它是残差的方差。当方差不是常数,而是随时间变化时,我们称之为异方差性(也拼作 heteroscedasticity)。另一个需要检验的假设是正态性;该特定观察值是否来自正态(高斯)分布?有时,你可能还需要检查残差的正态性,这通常是模型诊断阶段的一部分。因此,了解特定模型或技术所做的假设非常重要,这样你才能确定使用哪些测试以及针对哪个数据集进行测试。如果不进行这些检查,你可能会得到一个有缺陷的模型或一个过于乐观或悲观的结果。
此外,在本教程中,你将学习Box-Cox 变换,它可以帮助你转换数据,以满足正态性和同方差性的要求。Box-Cox 变换的形式如下:

图 9.25:Box-Cox 变换
Box-Cox 变换依赖于一个参数,lambda(
),并涵盖了对数变换和幂变换。如果
为 0,则得到自然对数变换;否则,则为幂变换。该方法是尝试不同的
值,然后测试正态性和同方差性。例如,SciPy 库有一个 boxcox 函数,你可以通过 lmbda 参数指定不同的
值(有趣的是,在实现中这样拼写,因为 lambda 是 Python 的保留关键字)。如果将 lmbda 参数设置为 None,该函数将为你找到最佳的 lambda(
)值。
准备工作
你可以从 GitHub 仓库下载所需的 Jupyter notebook 和数据集。请参考本章的技术要求部分。
在本教程中,你将使用空气乘客数据集,这个数据集已在本章技术要求部分作为 pandas DataFrame 加载。
你将使用 SciPy 和 statsmodels。
对于 pip 安装,请使用以下命令:
> pip install scipy
对于 conda 安装,请使用以下命令:
> conda install -c anaconda scipy
除了在技术要求部分中提到的准备工作外,你还需要导入在本教程中将使用到的常用库:
import numpy as np
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
import statsmodels.api as sm
为了让图表更大、更易读,可以使用以下命令设置固定的尺寸(20, 8)——宽度为 20 英寸,高度为 8 英寸:
plt.rcParams["figure.figsize"] = (20,8)
如何操作…
在本节中,你将扩展从上一节检测时间序列平稳性中学到的内容,并测试两个额外的假设:正态性和同方差性。
通常,平稳性是你需要关注的最关键假设,但熟悉额外的诊断技术将对你大有帮助。
有时,你可以通过图形来判断正态性和同方差性,例如,通过直方图或Q-Q 图。本节旨在教你如何在 Python 中编程执行这些诊断检验。此外,你还将了解White 检验和Breusch-Pagan Lagrange统计检验,用于同方差性。
对于正态性诊断,你将探索Shapiro-Wilk、D'Agostino-Pearson和Kolmogorov-Smirnov统计检验。总体而言,Shapiro-Wilk 倾向于表现最好,且能够处理更广泛的情况。
正态性检验
statsmodels 库和 SciPy 库有重叠的实现。例如,Kolmogorov-Smirnov 检验在 SciPy 中实现为ktest,而在 statsmodels 中实现为ktest_normal。在 SciPy 中,D'Agostino-Pearson 检验实现为normaltest,Shapiro-Wilk 检验实现为shapiro:
- 首先导入 SciPy 和 statsmodels 库提供的正态性检验:
from scipy.stats import shapiro, kstest, normaltest
from statsmodels.stats.diagnostic import kstest_normal
- 正态性诊断是一种基于原假设的统计检验,你需要确定是否可以接受或拒绝该假设。方便的是,以下你将实现的检验有相同的原假设。原假设声明数据是正态分布的;例如,当 p 值小于 0.05 时,你将拒绝原假设,表示时间序列不服从正态分布。让我们创建一个简单的函数
is_normal(),它将根据 p 值返回Normal或Not Normal:
def is_normal(test, p_level=0.05):
stat, pval = test
return 'Normal' if pval > 0.05 else 'Not Normal'
运行每个检验以检查结果:
normal_args = (np.mean(co2_df),np.std(co2_df))
print(is_normal(shapiro(co2_df)))
print(is_normal(normaltest(co2_df)))
print(is_normal(normal_ad(co2_df)))
print(is_normal(kstest_normal(co2_df)))
print(is_normal(kstest(co2_df,
cdf='norm',
args=(np.mean(co2_df), np.std(co2_df)))))
>>
Not Normal
Not Normal
Not Normal
Not Normal
Not Normal
检验结果显示数据不来自正态分布。你不需要运行那么多检验。例如,shapiro检验是一个非常常见且受欢迎的检验,你可以依赖它。通常,像任何统计检验一样,你需要阅读关于该检验实现的文档,以了解检验的具体内容。更具体地,你需要了解检验背后的原假设,以便决定是否可以拒绝原假设或未能拒绝原假设。
- 有时,你可能需要在模型评估和诊断过程中测试正态性。例如,你可以评估残差(定义为实际值和预测值之间的差异)是否符合正态分布。在第十章,使用统计方法构建单变量时间序列模型中,你将探索使用自回归和移动平均模型构建预测模型。目前,你将运行一个简单的自回归(AR(1))模型,演示如何将正态性检验应用于模型的残差:
from statsmodels.tsa.api import AutoReg
model = AutoReg(co2_df.dropna(), lags=1).fit()
你可以对残差运行shapiro检验。要访问残差,你可以使用.resid属性,如model.resid。这是你在第十章中构建的许多模型中的常见方法,使用统计方法构建单变量时间序列模型:
print(is_normal(shapiro(model.resid)))
>>
'Not Normal'
输出结果表明残差不是正态分布的。残差不正态分布这一事实本身不足以确定模型的有效性或潜在改进,但结合其他测试,这应该有助于你评估模型的优劣。这是你将在下一章进一步探讨的话题。
测试同方差性
你将测试模型残差的方差稳定性。这将是与之前正态性测试中使用的相同的 AR(1)模型:
- 让我们首先导入本章所需的方法:
from statsmodels.stats.api import (het_breuschpagan,
het_white)
- 你将对模型的残差进行同方差性测试。正如前面所述,理解这些统计测试背后的假设非常重要。原假设表示数据是同方差的,适用于这两个测试。例如,如果 p 值小于 0.05,你将拒绝原假设,这意味着时间序列是异方差的。
让我们创建一个小函数,命名为het_test(model, test),该函数接受一个模型和一个测试函数,并根据 p 值返回Heteroskedastic或Homoskedastic,以确定是否接受或拒绝原假设:
def het_test(model, test=het_breuschpagan):
lm, lm_pvalue, fvalue, f_pvalue = (
het_breuschpagan(model.resid,
sm.add_constant(
model.fittedvalues)
))
return "Heteroskedastic" if f_pvalue < 0.05 else "Homoskedastic"
- 从 Breusch-Pagan 拉格朗日乘数检验开始诊断残差。在 statsmodels 中,你将使用
het_breuschpagan函数,该函数接受resid(模型的残差)和exog_het(提供与残差异方差相关的原始数据,即解释变量):
het_test(model, test=het_breuschpagan)
>> 'Homoskedastic'
这个结果表明残差是同方差的,具有恒定方差(稳定性)。
- 一个非常相似的测试是怀特(White)的拉格朗日乘数检验。在 statsmodels 中,你将使用
het_white函数,它有两个参数,与你使用het_breuschpagan时相同:
het_test(model, test=het_white)
>> 'Homoskedastic'
两个测试都表明自回归模型的残差具有恒定方差(同方差)。这两个测试都估计了辅助回归,并使用了残差的平方以及所有解释变量。
请记住,正态性和同方差性是你在诊断模型时可能需要对残差进行的一些测试。另一个重要的测试是自相关性测试,相关内容将在接下来的章节中讨论,时间序列数据中的自相关性测试。
应用 Box-Cox 变换
Box-Cox 变换可以是一个有用的工具,熟悉它是很有帮助的。Box-Cox 将一个非正态分布的数据集转换为正态分布的数据集。同时,它稳定了方差,使数据呈同方差性。为了更好地理解 Box-Cox 变换的效果,你将使用包含趋势和季节性的航空乘客数据集:
- 从 SciPy 库中导入
boxcox函数:
from scipy.stats import boxcox
- 回顾本配方引言部分和 图 9.22,有一个 lambda 参数用于确定应用哪种变换(对数变换或幂变换)。使用默认的
lmbda参数值为None的boxcox函数,只需提供数据集以满足所需的x参数:
xt, lmbda = boxcox(airp_df['passengers'])
xts = pd.Series(xt, index=airp_df.index)
通过不提供 lmbda 的值并将其保持为 None,该函数将找到最佳的 lambda (
) 值。根据本配方的引言,你会记得在 boxcox 实现中 lambda 被拼写为 lmbda。该函数返回两个值,xt 用于保存变换后的数据,lmda 用于保存找到的最佳 lambda 值。
直方图可以直观地展示变换的影响:
fig, ax = plt.subplots(1, 2, figsize=(16,5))
airp_df.hist(ax=ax[0])
ax[0].set_title('Original Time Series')
xts.hist(ax=ax[1])
ax[1].set_title('Box-Cox Transformed');
这应该生成以下两个图:

图 9.26:Box-Cox 变换及其对分布的影响
第二个直方图显示数据已被变换,总体分布发生了变化。将数据集作为时间序列图进行分析会很有意思。
绘制两个数据集,以比较变换前后的效果:
fig, ax = plt.subplots(1, 2, figsize=(16,5))
airp_df.plot(ax=ax[0])
ax[0].set_title('Original Time Series')
xts.plot(ax=ax[1])
ax[1].set_title('Box-Cox Transformed');
这应该生成以下两个图:

图 9.27:Box-Cox 变换及其对时间序列数据的总体影响
注意观察变换后的数据集,季节性效应看起来比之前更稳定。
- 最后,构建两个简单的自回归模型,比较变换前后对残差的影响:
fig, ax = plt.subplots(1, 2, figsize=(16,5))
model_airp.resid.plot(ax=ax[0])
ax[0].set_title('Residuals Plot - Regular Time Series')
model_bx.resid.plot(ax=ax[1])
ax[1].set_title('Residual Plot - Box-Cox Transformed');
这应该生成以下两个图:

图 9.28:Box-Cox 变换及其对残差的影响
它是如何工作的…
Box-Cox 使我们能够将数据转换为正态分布且具有同方差性,它是一个包含对数变换和平方根变换等变换的幂变换家族的一部分。Box-Cox 是一种强大的变换方法,因为它支持根变换和对数变换,并且通过调整 lambda 值可以实现其他变换。
需要指出的是,
boxcox函数要求数据为正数。
还有更多…
AutoReg 模型有两个有用的方法:diagnostic_summary() 和 plot_diagnostics()。它们可以节省你编写额外代码的时间,测试模型残差的正态性、同方差性和自相关性。
以下代码展示了如何获取model_bx的诊断摘要:
print(model_bx.diagnostic_summary())
这应显示 Ljung-Box 自相关检验结果和模型残差的同方差性检验。

图 9.29:自相关的 diagnostic_summary
要获得视觉摘要,可以使用以下代码:
model_bx.plot_diagnostics(); plt.show()
.plot_diagnostics()函数将显示四个图表,您可以检查模型的残差。主要地,图表将显示残差是否从 Q-Q 图和直方图中呈现正态分布。此外,自相关函数图(ACF)将允许您检查自相关。您将在第十章的“绘制 ACF 和 PACF”一节中更详细地研究 ACF 图。

图 9.30:来自 plot_diagnostics()方法的输出
参见
要了解更多关于boxcox函数的信息,请访问官方 SciPy 文档:docs.scipy.org/doc/scipy/reference/generated/scipy.stats.boxcox.html。
测试时间序列数据中的自相关
自相关类似于统计学中的相关性(可以理解为高中学过的皮尔逊相关),用于衡量两个变量之间线性关系的强度,不同之处在于我们衡量的是滞后时间序列值之间的线性关系。换句话说,我们是在比较一个变量与其滞后的版本之间的关系。
在本节中,您将执行Ljung-Box 检验,检查是否存在直到指定滞后的自相关,以及它们是否显著偏离 0。Ljung-Box 检验的原假设表示前期滞后与当前期无关。换句话说,您正在检验自相关的不存在。
使用 statsmodels 中的acorr_ljungbox运行检验时,您需要提供一个滞后值。该检验将在所有滞后值(直到指定的最大滞后)上进行。
自相关测试是另一种有助于模型诊断的测试。如前面“应用幂变换”一节所讨论的那样,模型的残差需要进行假设检验。例如,当对残差进行自相关测试时,期望残差之间没有自相关。这确保了模型已经捕获了所有必要的信息。残差中的自相关可能表示模型未能捕捉到关键信息,需进行评估。
准备工作
您可以从 GitHub 仓库下载所需的 Jupyter 笔记本和数据集。请参考本章的技术要求部分。
你将使用来自 statsmodels 库的acorr_ljungbox。
如何操作…
你将使用存储在co2_df DataFrame 中的 CO2 数据集:
- 从 statsmodels 库加载
acorr_ljungbox:
from statsmodels.stats.diagnostic import acorr_ljungbox
- 由于数据不是平稳的(请回顾检测时间序列平稳性食谱),这次你将进行对数变换(对数差分):
co2_diff= np.log(co2_df).diff().dropna()
- 运行 Ljung-Box 检验。首先设置
lags=10:
acorr_ljungbox(co2_diff, lags=10, return_df=True)
这将打印出前 10 个滞后的结果。

图 9.31:自相关检验的前 10 个滞后
这表明,对于所有滞后期直到滞后 10,自检验统计量都显著(p 值 < 0.05),因此你可以拒绝原假设。拒绝原假设意味着你拒绝没有自相关的假设。
它是如何工作的…
acorr_ljungbox是一个累积自相关直到指定滞后的函数。因此,它有助于确定结构是否值得建模。
还有更多…
我们将对model_bx模型的残差使用 Ljung-Box 检验,该模型是在应用幂变换食谱中创建的:
acorr_ljungbox(model_bx.resid, return_df=True, lags=10)
这将打印出前 10 个滞后的结果:

图 9.32:自相关检验的前 10 个滞后,针对残差
从前面的例子中,p 值小于 0.05,因此你拒绝原假设,并且存在自相关。
另见
要了解更多关于acorr_ljungbox函数的信息,请访问官方文档:www.statsmodels.org/dev/generated/statsmodels.stats.diagnostic.acorr_ljungbox.html。
第十章:10 使用统计方法构建单变量时间序列模型
加入我们的书籍社区,加入 Discord

在第九章,探索性数据分析与诊断中,你已经接触了帮助你理解时间序列过程的几个概念。这些方法包括时间序列数据分解、检测时间序列的平稳性、应用幂次变换和测试时间序列数据的自相关性。这些技术将在本章讨论的统计建模方法中派上用场。
在处理时间序列数据时,可以根据你所处理的时间序列是单变量还是多变量、季节性还是非季节性、平稳还是非平稳、线性还是非线性,采用不同的方法和模型。如果你列出需要考虑和检查的假设——例如平稳性和自相关性——就会明显看出,为什么时间序列数据被认为是复杂和具有挑战性的。因此,为了建模这样一个复杂的系统,你的目标是获得一个足够好的近似,捕捉到关键的兴趣因素。这些因素会根据行业领域和研究目标的不同而有所变化,例如预测、分析过程或检测异常。
一些流行的统计建模方法包括指数平滑、自回归积分滑动平均(ARIMA)、季节性 ARIMA(SARIMA)、向量自回归(VAR)以及这些模型的其他变种,如 ARIMAX、SARIMAX、VARX 和 VARMA。许多实践者,如经济学家和数据科学家,仍然使用这些统计“经典”模型。此外,这些模型可以在流行的软件包中找到,如 EViews、MATLAB、Orange、KNIME 和 Alteryx,以及 Python 和 R 的库中。
在本章中,你将学习如何在 Python 中构建这些统计模型。换句话说,我只会简要介绍理论和数学,因为重点在于实现。如果你对这些模型的数学和理论感兴趣,我会在适当的地方提供参考文献,供你深入研究。
在本章中,我们将涵盖以下内容:
-
绘制 ACF 和 PACF
-
使用指数平滑法预测单变量时间序列数据
-
使用非季节性 ARIMA 预测单变量时间序列数据
-
使用季节性 ARIMA 预测单变量时间序列数据
-
使用 Auto_Arima 预测单变量时间序列数据
在深入这些方法之前,请特别注意即将到来的技术要求部分,在这一部分你将进行前期准备。这将消除任何干扰和重复编码,以便你可以专注于方法的核心目标以及每个实现背后的概念。
技术要求
你可以从本书的 GitHub 仓库下载 Jupyter Notebooks 和必要的数据集:
-
Jupyter Notebook:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./blob/main/code/Ch10/Chapter%2010.ipynb -
数据集:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./tree/main/datasets/Ch10
在开始学习本章的配方之前,请运行以下代码以加载将在全章中引用的数据集和函数:
- 首先导入本章所有配方中将共享的基本库:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import warnings
from statsmodels.tsa.api import (kpss, adfuller,
seasonal_decompose, STL)
from statsmodels.tools.eval_measures import rmspe, rmse
from sklearn.metrics import mean_absolute_percentage_error as mape
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from itertools import product
from pathlib import Path
warnings.filterwarnings('ignore')
plt.rcParams["figure.figsize"] = [12, 5]
plt.style.use('grayscale')
warnings.filterwarnings('ignore')
- 本章中您将使用两个数据集:
Life Expectancy from Birth和Monthly Milk Production。将这两个以 CSV 格式存储(life_expectancy_birth.csv和milk_production.csv)的数据集导入到 pandas DataFrame 中。每个数据集来自不同的时间序列过程,因此它们将包含不同的趋势或季节性。一旦导入数据集,您将得到两个名为life和milk的数据框:
life_file = Path('../../datasets/Ch10/life_expectancy_birth.csv')
milk_file = Path('../../datasets/Ch10/milk_production.csv')
life = pd.read_csv(life_file,
index_col='year',
parse_dates=True,
skipfooter=1)
milk = pd.read_csv(milk_file,
index_col='month',
parse_dates=True)
直观地检查数据,观察时间序列是否包含任何趋势或季节性。您可以随时返回本节中展示的图表作为参考:
fig, ax = plt.subplots(2, 1, figsize=(16, 12))
life.plot(title='Annual Life Expectancy',
legend=False, ax=ax[0])
milk.plot(title='Monthly Milk Production',
legend=False, ax=ax[1]);
这将显示两个时间序列图:

图 10.1:年度预期寿命和每月牛奶生产的时间序列图
上图展示了life expectancy 数据框的时间序列图,显示了一个积极(向上)的趋势,且没有季节性。该预期寿命数据包含了 1960 年至 2018 年(59 年)的每年出生时的预期寿命记录。原始数据集包含了各国的记录,但在本章中,您将使用全球的记录。
monthly milk production 数据框的时间序列图显示了一个积极(向上)的趋势,并且呈现出周期性(每年夏季)。该牛奶生产数据从 1962 年 1 月到 1975 年 12 月每月记录(共 168 个月)。季节性幅度和随时间变化的波动似乎保持稳定,表明其为加法模型。季节性分解明确了加法模型的水平、趋势和季节性,也能反映这一点。
如需深入了解季节性分解,请参考第九章中的季节性分解时间序列数据配方,位于探索性数据分析与诊断部分。
- 你需要将数据分割成
test和train数据集。你将在训练数据集上训练模型(拟合),并使用测试数据集来评估模型并比较预测结果。基于用于训练的数据所创建的预测称为样本内预测,而对未见数据(如测试集)进行的预测称为样本外预测。在评估不同模型时,你将使用样本外或测试集。
创建一个通用函数 split_data,根据测试拆分因子分割数据。这样,你也可以尝试不同的拆分方式。我们将在本章中引用这个函数:
def split_data(data, test_split):
l = len(data)
t_idx = round(l*(1-test_split))
train, test = data[ : t_idx], data[t_idx : ]
print(f'train: {len(train)} , test: {len(test)}')
return train, test
- 调用
split_data函数,将两个数据框分割为test和train数据集(开始时使用 15% 测试集和 85% 训练集)。你可以随时尝试不同的拆分因子:
test_split = 0.15
milk_train, milk_test = split_data(milk, test_split)
life_train, life_test = split_data(life, test_split)
>>
train: 143 , test: 25
train: 50 , test: 9
- 你将经常检查平稳性,因为这是你将构建的许多模型的一个基本假设。例如,在第九章,探索性数据分析与诊断,在检测时间序列平稳性的食谱中,我们讨论了检验平稳性的重要性以及使用扩展的迪基-富勒(Augmented Dickey-Fuller)检验。创建一个可以在本章中引用的函数,用于执行检验并解释结果:
def check_stationarity(df):
results = adfuller(df)[1:3]
s = 'Non-Stationary'
if results[0] < 0.05:
s = 'Stationary'
print(f"'{s}\t p-value:{results[0]} \t lags:{results[1]}")
return (s, results[0])
- 在某些食谱中,你将运行模型的多个变体,寻找最优配置,这一做法通常被称为超参数调优。例如,你可以训练一个 ARIMA 模型并使用不同的参数值,从而生成多个 ARIMA 模型的变体(多个模型)。
get_top_models_df 函数将比较不同的模型——例如,多个 ARIMA 模型——以选择最佳模型及其相关的参数集。get_top_models_df 函数将接受一个字典,包含生成的模型、相关的参数和每个模型的评分。它返回一个数据框,详细列出表现最好的模型,便于比较。该函数允许你指定返回的最佳模型数量和用于选择模型的 criterion,例如均方根百分比误差(RMSPE)、均方根误差(RMSE)、平均绝对百分比误差(MAPE)、赤池信息量准则(AIC)、修正赤池信息量准则(AICc)或贝叶斯信息量准则(BIC)。这些指标在确定最适合你的数据分析需求的模型时至关重要。
例如,默认情况下你可能会选择根据 AIC 分数评估模型,但如果 RMSPE 或 RMSE 更适合你的具体情况,你可以轻松切换到这些指标。这种灵活性确保你可以根据数据的分析需求和复杂性量身定制模型选择过程。
def get_top_models_df(scores, criterion='AIC', top_n=5):
sorted_scores = sorted(scores.items(),
key=lambda item: item[1][criterion])
top_models = sorted_scores[:top_n]
data = [v for k, v in top_models]
df = pd.DataFrame(data)
df['model_id'] = [k for k, v in top_models]
df.set_index('model_id', inplace=True)
return df
- 创建
plot_forecast函数,该函数接受你已经训练的模型对象、起始位置以及训练和测试数据集,生成一个将预测值与实际值进行比较的图表。当你深入本章的配方时,情况将变得更加清晰:
def plot_forecast(model, start, train, test):
forecast = pd.DataFrame(model.forecast(test.shape[0]),
index=test.index)
ax = train.loc[start:].plot(style='--')
test.plot(ax=ax)
forecast.plot(ax=ax, style = '-.')
ax.legend(['orig_train', 'orig_test', 'forecast'])
plt.show()
- 最后,创建一个
combinator工具函数,该函数接受一组参数值并返回这些选择的笛卡尔积。在进行超参数调优时,你将使用这个函数进行网格搜索。在网格搜索中,你会指定一组参数值的组合,分别训练多个模型,然后使用get_top_models_df函数评估最佳模型。例如,假设你的列表中包含三个不同参数的三个可能值。在这种情况下,combinator函数将返回一个包含 3x3 或九种可能组合的列表。当你深入本章的配方时,情况将变得更加清晰:
def combinator(items):
combo = [i for i in product(*items)]
return combo
我们可以像图 10.2中所示的那样表示整体流程,图中展示了你如何利用刚刚创建的函数。

图 10.2:本章利用已准备好的辅助函数创建的整体过程
现在,让我们深入了解这些配方。
在第一个配方中,你将接触到ACF和PACF图,它们用于评估模型拟合、检查平稳性,并确定本章中将使用的一些模型(如 ARIMA 模型)的阶数(参数)。
绘制 ACF 和 PACF
在构建任何统计预测模型之前,如自回归(AR)、移动平均(MA)、自回归移动平均(ARMA)、自回归积分移动平均(ARIMA)或季节性自回归积分移动平均(SARIMA),你需要确定最适合你数据的时间序列模型类型。此外,你还需要确定一些必需参数的值,这些参数称为阶数。更具体地说,这些包括自回归(AR)或移动平均(MA)成分的滞后阶数。这个过程将在本章的“使用 ARIMA 预测单变量时间序列数据”部分进一步探讨。例如,自回归移动平均(ARMA)模型表示为ARMA(p, q),其中'p'表示自回归阶数或 AR(p)成分,'q'表示移动平均阶数或 MA(q)成分。因此,ARMA 模型结合了 AR(p)和 MA(q)模型。
这些模型的核心思想基于这样一个假设:可以通过过去的数值来估计当前特定变量的值!。例如,在一个自回归模型(AR)中,假设当前值!,在时间!可以通过其过去的值(
)估算,直到p,其中p决定了我们需要回溯多少个时间步。如果!,这意味着我们必须使用前两个周期!来预测!。根据时间序列数据的粒度,p=2可能代表 2 小时、2 天、2 个月、2 季度或 2 年。
要构建 ARMA(p,q)模型,你需要提供p和q阶数(即滞后)。这些被视为超参数,因为它们是由你提供的,用于影响模型。
参数和超参数这两个术语有时被交替使用。然而,它们有不同的解释,你需要理解它们之间的区别。
参数与超参数
在训练 ARMA 或 ARIMA 模型时,结果将生成一组被称为系数的参数——例如,AR 滞后 1 的系数值或 sigma——这些系数是算法在模型训练过程中估算出来的,并用于进行预测。它们被称为模型的参数。
另一方面,(p,d,q)参数是 ARIMA(p,q,d)中的 AR、差分和 MA 的阶数。这些被称为超参数。它们在训练过程中提供,并影响模型生成的参数(例如系数)。这些超参数可以通过网格搜索等方法进行调优,以找到生成最佳模型的最佳参数组合。
现在,你可能会问,如何找到 AR 和 MA 模型的显著滞后值?
这就是自相关函数(ACF)和偏自相关函数(PACF)以及它们的图形发挥作用的地方。可以绘制 ACF 和 PACF 图,以帮助你识别时间序列过程是 AR、MA 还是 ARMA 过程(如果两者都存在),并识别显著的滞后值(对于p和q)。ACF 和 PACF 图都被称为相关图,因为这些图表示相关性统计。
ARMA 和 ARIMA 的区别,在于平稳性假设。ARIMA 中的d参数用于差分阶数。ARMA 模型假设过程是平稳的,而 ARIMA 模型则不假设,因为它处理差分问题。ARIMA 模型是一个更为广泛的模型,因为通过将差分因子d=0,它可以满足 ARMA 模型的需求。因此,ARIMA(1,0,1)就是ARMA(1,1)。
AR阶数与MA阶数
你将使用 PACF 图来估计 AR 阶数,并使用 ACF 图来估计 MA 阶数。ACF 和 PACF 图的纵轴(y 轴)显示从
-1到1的值,横轴(x 轴)表示滞后的大小。显著的滞后是任何超出阴影置信区间的滞后,正如你在图中看到的。
statsmodels 库提供了两个函数:acf_plot 和 pacf_plot。零滞后的相关性(对于 ACF 和 PACF)始终为 1(因为它表示第一个观测值与自身的自相关)。因此,这两个函数提供了 zero 参数,该参数接受一个布尔值。因此,为了在可视化中排除零滞后,可以传递 zero=False。
在 第九章,探索性数据分析与诊断 中,测试时间序列数据中的自相关 配方中,你使用了 Ljung-Box 测试来评估残差的自相关。在本例中,你还将学习如何使用 ACF 图来直观地检查 残差自相关。
如何操作…
在本例中,你将探索 statsmodels 库中的 acf_plot 和 pacf_plot。我们开始吧:
- 你将在本例中使用寿命预期数据。如 图 10.1 所示,由于存在长期趋势,数据不是平稳的。在这种情况下,你需要对时间序列进行差分(去趋势),使其平稳,然后才能应用 ACF 和 PACF 图。
从差分开始,然后创建不包含零滞后的图:
life_diff = life.diff().dropna()
fig, ax = plt.subplots(2,1, figsize=(12,8))
plot_acf(life_diff, zero=False, ax=ax[0])
plot_pacf(life_diff, zero=False, ax=ax[1]);
这将生成以下两个图:

图 10.3:差分后寿命预期数据的 ACF 和 PACF 图
如果你想查看更多滞后的计算 PACF 和 ACF,可以更新 lags 参数,如下所示:
fig, ax = plt.subplots(2,1, figsize=(12,8))
plot_acf(life_diff, lags=25, zero=False, ax=ax[0])
plot_pacf(life_diff, lags=25, zero=False, ax=ax[1]);
这将生成以下两个图:

图 10.4:前 25 个滞后的 ACF 和 PACF 图
ACF 图在滞后(阶数)1 处显示了一个显著的峰值。当滞后(垂直线)超出阴影区域时,表示显著性。阴影区域代表置信区间,默认设置为 95%。在 ACF 图中,只有第一个滞后显著,低于下置信区间,并且在此之后 迅速消失。其余的滞后均不显著。这表明是一个一阶移动平均 MA(1)。
PACF 图呈现 渐变 衰减并伴随震荡。通常,如果 PACF 显示渐变衰减,表示使用了移动平均模型。例如,如果你使用 ARMA 或 ARIMA 模型,一旦数据差分使其平稳后,它将表现为 ARMA(0, 1) 或 ARIMA(0, 1, 1),表示一阶差分,d=1。在 ARMA 和 ARIMA 模型中,AR 阶数为 p=0,MA 阶数为 q=1。
- 现在,让我们看看如何将 PACF 和 ACF 用于包含强烈趋势和季节性的更复杂的数据集。在图 10.1中,
月度牛奶生产图显示了年度季节性效应和一个正向上升的趋势,表示这是一个非平稳时间序列。它更适合使用 SARIMA 模型。在 SARIMA 模型中,你有两个部分:非季节性部分和季节性部分。例如,除了之前看到的表示非季节性部分的 AR 和 MA 过程(分别由小写p和q表示),你还会有季节性部分的 AR 和 MA 阶数,分别由大写P和Q表示。这个模型可以表示为SARIMA(p,d,q)(P,D,Q,S)。你将在使用季节性 ARIMA 预测单变量时间序列数据的食谱中了解更多关于 SARIMA 模型的内容。
为了使此类时间序列平稳,你需要从季节性差分开始,以去除季节性效应。由于观测是按月进行的,因此季节性效应是按年观察的(每 12 个月或每个周期):
milk_diff_12 = milk.diff(12).dropna()
- 使用你在本章早些时候创建的
check_stationarity函数,执行增广的迪基-富勒(Augmented Dickey-Fuller)检验以检查平稳性:
check_stationarity(milk_diff_12)
>> 'Non-Stationary p-value:0.16079880527711382 lags:12
- 差分后的时间序列仍然不是平稳的,因此你仍然需要进行第二次差分。这一次,你必须执行一阶差分(去趋势)。当时间序列数据包含季节性和趋势时,你可能需要进行两次差分才能使其平稳。将结果存储在
milk_diff_12_1变量中,并再次运行check_stationarity:
milk_diff_12_1 = milk.diff(12).diff(1).dropna()
check_stationarity(milk_diff_12_1)
>> 'Stationary p-value:1.865423431878876e-05
lags:11
太棒了——现在,你有了一个平稳过程。
- 绘制
milk_diff_12_1中平稳时间序列的 ACF 和 PACF 图:
fig, ax = plt.subplots(1,2)
plot_acf(milk_diff_12_1, zero=False, ax=ax[0], lags=36)
plot_pacf(milk_diff_12_1, zero=False, ax=ax[1], lags=36);
这应该会生成以下 ACF 和 PACF 图:

图 10.5: 经差分后的月度牛奶生产的 PACF 和 ACF 图
对于季节性阶数P和Q,你应该诊断在滞后s、2s、3s等位置的峰值或行为,其中s是一个季节中的周期数。例如,在牛奶生产数据中,s=12(因为一个季节有 12 个月)。然后,我们观察 12(s)、24(2s)、36(3s)等位置的显著性。
从自相关函数(ACF)图开始,滞后 1 处有一个显著的峰值,表示 MA 过程的非季节性阶数为q=1。滞后 12 处的峰值表示 MA 过程的季节性阶数为Q=1。注意,在滞后 1 之后有一个截断,然后在滞后 12 处有一个峰值,之后又是截断(没有其他显著的滞后)。这些现象表明该模型是一个移动平均模型:非季节性部分是 MA(1),季节性部分是 MA(1)。偏自相关函数(PACF)图也证实了这一点;在滞后 12、24 和 36 处的指数衰减表明这是一个 MA 模型。因此,SARIMA 模型应该是ARIMA (0, 1,1)(0, 1, 1, 12)。
尽管使用 ACF 和 PACF 图对于识别 ARIMA 阶数 p 和 q 很有用,但不应单独使用。在本章中,您将探索不同的技术,帮助您通过 AIC 和 BIC 等模型选择技术来确定阶数。
工作原理…
ACF 和 PACF 图可以帮助您理解过去观测值之间线性关系的强度以及在不同滞后期的显著性。
ACF 和 PACF 图显示出显著的自相关或部分自相关,超出了 置信区间。阴影部分表示置信区间,它由 pacf_plot 和 acf_plot 函数中的 alpha 参数控制。statsmodels 中 alpha 的默认值是 0.05(95% 置信区间)。显著性可以是任意方向;如果自相关强烈为正,它会接近 1(上方),如果强烈为负,则会接近 -1(下方)。
下表展示了一个示例指南,用于从 PACF 和 ACF 图中识别平稳的 AR 和 MA 阶数:

表 10.1:使用 ACF 和 PACF 图识别 AR、MA 和 ARMA 模型
还有更多…
在本食谱中,您使用了 ACF 和 PACF 图来估计应该为季节性和非季节性 ARIMA 模型使用哪些阶数(滞后)。
让我们看看 ACF 图如何用于诊断模型的 残差。检查模型的残差是评估模型的重要组成部分。这里的假设很简单:如果模型正确地捕捉到了所有必要的信息,那么残差中不应该包含任何在任意滞后期有相关性的点(即没有自相关)。因此,您会期望残差的 ACF 图显示出接近零的自相关。
让我们构建之前在本食谱中识别的季节性 ARIMA 模型 SARIMA(0,1,1)(0,1,1,12),然后使用 ACF 来诊断残差。如果模型捕捉到了时间序列中嵌入的所有信息,您会期望残差 没有自相关:
from statsmodels.tsa.statespace.sarimax import SARIMAX
model = SARIMAX(milk, order=(0,1,1),
seasonal_order=(0,1,1, 12)).fit(disp=False)
plot_acf(model.resid, zero=False, lags=36);
这将生成以下自相关图:

图 10.6:SARIMA 残差的自相关图
总体来说,SARIMA(0,1,1)(0,1,1,12) 很好地捕捉到了必要的信息,但仍然有提升的空间。存在一个显著的滞后期(在滞后=12 时,超出了置信阈值),表明残差中存在某些自相关。
您可以进一步调整模型并尝试不同的季节性和非季节性阶数。在本章及后续食谱中,您将探索一种网格搜索方法来选择最佳的超参数,以找到最优模型。
如果您想进一步诊断模型的残差,您可以使用 plot_diagnostics 方法进行:
model.plot_diagnostics(figsize=(12,7), lags=36);
这将生成以下图形:

图 10.7:使用 plot_diagnostics 方法进行的残差分析
请注意,生成的诊断图是基于标准化残差的,这是一种常见的技术,因为它使得在不同模型之间比较残差更加容易,因为它们是归一化的,并以标准差的形式表示。
你可以通过访问model.standardized_forecasts_error来复制相同的图表,如下所示:
plot_acf(model.standardized_forecasts_error.ravel(), lags=36,
title='Standardized Residuals ACF Plot');
和
pd.DataFrame(model.standardized_forecasts_error.ravel(),
index=milk.index).plot(title='Standardized Residuals Plot',
legend=False);
生成的两个图应类似于以下图形,展示标准化残差的自相关和时间序列模式:

图 10.8:SARIMA 模型标准化残差的 ACF 图

图 10.9:SARIMA 模型标准化残差图
图 10.6与图 10.8之间的差异是由于尺度归一化。标准化可以减少异常值的影响,因为残差被缩小。这就是为什么在图 10.8 中,尽管 Lag 12 的自相关在两张图中都可见,但它位于置信区间的边界,而在图 10.6中则明显不同。同时,标准化可能会放大一些较小的自相关,这些自相关在原始的 ACF 图中可能最初是不可见的。在整个食谱中,你将依赖于plot_diagnostics方法进行残差诊断。
另见
-
若要了解更多关于 ACF 图的内容,请访问官方文档
www.statsmodels.org/dev/generated/statsmodels.graphics.tsaplots.plot_acf.html. -
若要了解更多关于 PACF 图的内容,请访问官方文档
www.statsmodels.org/dev/generated/statsmodels.graphics.tsaplots.plot_pacf.html.
这样,你就知道在构建 ARIMA 模型及其变体(例如,ARMA 或 SARIMA 模型)时,如何使用 ACF 和 PACF 图。在接下来的食谱中,你将学习本章的第一个时间序列预测技术。
使用指数平滑法预测单变量时间序列数据
在本食谱中,你将探索使用statsmodels库的指数平滑技术,它提供了与 R 语言forecast包中的流行实现(如ets()和HoltWinters())类似的功能。在 statsmodels 中,指数平滑有三种不同的实现(类),具体取决于你所处理数据的性质:
-
SimpleExpSmoothing:当时间序列过程缺乏季节性和趋势时,使用简单指数平滑法。这也被称为单一指数平滑法。
-
Holt:Holt 指数平滑是简单指数平滑的增强版本,用于处理只包含趋势(但没有季节性)的时间序列过程。它被称为双指数平滑。
-
ExponentialSmoothing:Holt-Winters 指数平滑是 Holt 指数平滑的增强版本,用于处理同时具有季节性和趋势的时间序列过程。它被称为三重指数平滑。
你可以像下面这样导入这些类:
from statsmodels.tsa.api import (ExponentialSmoothing,
SimpleExpSmoothing,
Holt)
statsmodels 的实现遵循《预测:原理与实践》(作者:Hyndman, Rob J., and George Athanasopoulos)中的定义,你可以在这里参考:otexts.com/fpp3/expsmooth.html。
如何实现…
在这个示例中,你将对本章介绍的两个数据集进行指数平滑处理(技术要求)。由于Holt类和SimpleExpSmoothing类是ExponentialSmoothing类的简化版本,因此你可以使用后者来进行简单操作。与使用这三者不同,你可以使用ExponentialSmoothing类运行这三种不同类型,因为ExponentialSmoothing是更通用的实现。这种方法允许你使用单一更多功能的实现来管理不同类型的时间序列,无论它们是否表现出趋势、季节性或两者都有。让我们开始吧:
- 导入
ExponentialSmoothing类:
from statsmodels.tsa.api import ExponentialSmoothing
- 你将从寿命数据集开始,并使用
ExponentialSmoothing类。
ExponentialSmoothing 需要多个参数(称为超参数),可以分为两种类型:在构建模型时指定的参数和在拟合模型时指定的参数。
-
模型构建:
-
trend:选择从‘multiplicative’(别名‘mul’)、‘additive’(别名‘add’)或None中进行选择。 -
seasonal:选择从‘multiplicative’(别名‘mul’)、‘additive’(别名‘add’)或None中进行选择。 -
seasonal_periods:代表季节周期的整数;例如,对于月度数据使用 12,季度数据使用 4。 -
damped_trend:布尔值(True或False),指定趋势是否应该被阻尼。 -
use_boxcox:布尔值(True或False),确定是否应用 Box-Cox 变换。
-
-
模型拟合:
-
smoothing_level:浮点数,指定作为alpha的平滑水平的平滑因子。![]()
), 其有效值在 0 和 1 之间 (
![]()
).
-
smoothing_trend:浮点数,指定作为beta的趋势的平滑因子 (![]()
), 其有效值在 0 和 1 之间 (
![]()
).
-
smoothing_seasonal:浮点数,指定作为gamma的季节性趋势的平滑因子。![]()
), 其有效值在 0 和 1 之间 (
![]()
).
-
在后续的如何运作...部分,你将探索霍尔特-温特斯(Holt-Winters)的水平、趋势和季节性公式,以及这些参数是如何应用的。
- 创建一个包含不同超参数值组合的列表。这样,在下一步中,你可以在每次运行中评估不同的超参数值组合。从本质上讲,你将训练不同的模型,并在每次迭代中记录其得分。一旦每个组合都被评估,你将使用
get_top_models_df函数(来自技术要求部分)来确定表现最好的模型及其最佳超参数值,通过这个详尽的网格搜索过程。这个过程可能需要耗费一些时间,但幸运的是,有一种混合技术可以缩短搜索时间。
你可以使用ExponentialSmoothing类来找到alpha、beta和gamma的最佳值(
)。这种方法无需在网格中指定这些值(尽管如果你更愿意控制过程,仍然可以指定)。这种简化意味着你只需提供剩余超参数的值,如trend和seasonal。你可以通过使用seasonal_decompose()函数绘制它们的分解,初步判断这些组件是乘法型还是加法型。如果仍不确定,详尽的网格搜索仍然是一个可行的替代方案。
对于life数据框,只有trend,因此你只需要探索两个参数的不同值;即trend和damped:
trend = ['add', 'mul']
damped = [True, False]
life_ex_comb = combinator([trend, damped])
life_ex_comb
[('add', True), ('add', False), ('mul', True), ('mul', False)]
在这里,我们有两个参数,每个参数有两个不同的值,这为我们提供了 2x2 或四个总的组合来评估。
- 遍历组合列表,并在每次迭代中训练(拟合)一个不同的模型。将评估指标捕捉到字典中,以便稍后比较结果。你将捕捉到的示例得分包括 RMSE、RMSPE、MAPE、AIC 和 BIC 等。请记住,大多数自动化工具和软件会在后台使用 AIC 和 BIC 分数来确定最佳模型:
train = life_train.values.ravel()
y = life_test.values.ravel()
score = {}
for i, (t, dp) in enumerate(life_ex_comb):
exp = ExponentialSmoothing(train,
trend=t,
damped_trend=dp,
seasonal=None)
model = exp.fit(use_brute=True, optimized=True)
y_hat = model.forecast(len(y))
score[i] = {'trend':t,
'damped':dp,
'AIC':model.aic,
'BIC':model.bic,
'AICc':model.aicc,
'RMSPE': rmspe(y, y_hat),
'RMSE' : rmse(y, y_hat),
'MAPE' : mape(y, y_hat),
'model': model}
在前面的函数中,你使用life_train来训练不同的模型,使用life_test来评估错误度量,如 RMSPE、RMSE 和 MAPE。
要使用get_top_models_df函数获取前几个模型,只需传递得分字典。目前,保持默认标准设置为c=AIC以保持一致性:
model_eval = get_top_models_df(score, 'AIC', top_n=5)
get_top_models_df函数将返回一个 DataFrame,显示排名前五的模型(默认为 5),根据所选标准进行排名,例如在此案例中是 AIC 分数。DataFrame 不仅包含所有附加得分,还将模型实例本身存储在名为'model'的列中。
要查看排名和各种得分,你可以执行以下代码:
model_eval.iloc[:, 0:-1]
上述代码排除了最后一列,该列包含模型实例,因此显示的 DataFrame 包括 AIC、BIC、RMSE 等每个评估指标的列。

图 10.10:基于 AIC 分数排名的寿命预期数据的指数平滑模型
通常,对于基于信息准则(如 AIC、BIC 和 AICc)进行的模型选择,较低的值更好,表示模型拟合和复杂度之间的更优平衡。在我们的案例中,我们选择使用 AIC。如果您检查图 10.10 中的 DataFrame,会发现一些可以观察到的现象:
-
如果优先考虑信息准则(AIC、BIC、AICc),模型 1(趋势:加性,阻尼:False)会被视为最佳模型,因为它在所有三个信息准则中得分最低。该模型可能在模型复杂度和拟合度之间提供了最佳的折衷。
-
如果优先考虑误差指标(RMSPE、RMSE、MAPE),这些指标用于衡量预测精度,模型 0(趋势:加性,阻尼:True)会被认为是更优的,因为它的预测误差较小。
选择“获胜”模型将取决于您的具体目标和模型将被使用的上下文。如果您需要两者之间的平衡,您可能需要考虑其他因素或进一步验证,以决定选择模型 1 还是模型 0。
我们将继续使用 AIC 作为我们的选择标准。
- 存储在 DataFrame 中的模型是
HoltWintersResultsWrapper类的实例。您可以直接从 DataFrame 访问顶部模型,这样可以利用与该模型相关的其他方法和属性,如summary、predict和forecast。要提取并与第一行的获胜模型进行交互,请使用以下代码:
top_model = model_eval.iloc[0,-1]
您可以像下面这样访问 summary() 方法:
top_model.summary()
上述代码将生成一个总结输出,提供一个表格布局,详细展示模型——例如,使用的参数值和计算出的系数:

图 10.11:寿命预期数据的指数平滑总结
总结将显示关键的信息,例如通过拟合过程自动推导出的alpha(平滑水平)和beta(平滑趋势)的最佳值。
- 您可以使用
forecast方法预测未来的值,然后将结果与测试集(模型未见过的数据)进行比较。我们在本章的技术要求部分介绍的plot_forecast()函数将用于生成并绘制预测结果,同时显示测试数据。要执行此可视化,将存储在top_model中的模型对象与training和test数据集一起传递给plot_forecast():
plot_forecast(life_best_model, '2000', life_train, life_test)
plot_forecast函数中的start参数将数据从该点开始切片,以便更容易比较结果。可以将其视为聚焦于时间线的特定片段。例如,不是显示 1960 到 2018 年(59 个月)的数据,而是只请求从 2000 年开始的这一段数据。
这将生成一个图,其中 x 轴从 2000 年开始。应有三条线:一条线表示训练数据,另一条线表示测试数据,还有一条线表示预测值(预测值):

图 10.12:将指数平滑预测与生命预期数据集的实际数据进行对比
简单指数平滑的预测结果是延伸自训练数据的上升趋势的直线。
- 重复之前的过程,但使用
milk数据框。请记住,这里最重要的区别是添加了季节性参数。这意味着你将添加两个额外的超参数来进行评估——即seasonal和seasonal_periods。
为不同选项构建笛卡尔积。对于seasonal_periods,你可以探索三个周期——4、6 和 12 个月。这应该会给你提供 24 个需要评估的模型:
trend , damped= ['add', 'mul'], [True, False]
seasonal, periods = ['add' , 'mul'], [4, 6, 12]
milk_exp_comb = combinator([trend, damped, seasonal, periods])
循环遍历组合列表,训练多个模型并捕获它们的得分:
train = milk_train.values.ravel()
y = milk_test.values.ravel()
milk_model_scores = {}
for i, (t, dp, s, sp) in enumerate(milk_exp_comb):
exp = ExponentialSmoothing(train,
trend=t,
damped_trend=dp,
seasonal=s,
seasonal_periods=sp)
model = exp.fit(use_brute=True, optimized=True)
y_hat = model.forecast(len(y))
score[i] = {'trend':t,
'damped':dp,
'AIC':model.aic,
'BIC':model.bic,
'AICc': model.aicc,
'RMSPE': rmspe(y, y_hat),
'RMSE' : rmse(y, y_hat),
'MAPE' : mape(y, y_hat),
'model': model}
- 训练完成后,运行
get_top_models_df函数,根据 AIC 得分识别出最佳模型:
model_eval = get_top_models_df(score, 'AIC', top_n=5)
model_eval.iloc[:, 0:-1]
这将显示以下数据框:

图 10.13:基于 AIC 得分排名的牛奶生产数据前 5 个指数平滑模型
要从结果中确定获胜模型,通常会查看各种度量标准,如 AIC、BIC、AICc(信息准则)以及误差度量,如 RMSPE、RMSE 和 MAPE。AIC、BIC 和 AICc 的较低值表示模型在拟合优度和复杂性之间有更好的平衡。RMSPE、RMSE 和 MAPE 的较低值表示更好的预测精度。
如果你检查图 10.13 中的数据框,你会发现有一些观察结果:
如果优先考虑信息准则(AIC、BIC、AICc),模型 8(趋势:加性,阻尼:False)似乎是最佳模型,因为它在所有信息准则中具有最低的值。这表明它在很好地拟合数据和保持简单性之间提供了一个有利的平衡。
如果优先考虑误差度量(RMSPE、RMSE、MAPE),模型 2(趋势:加性,阻尼:True)在预测精度方面表现更好。该模型具有最低的误差率,表明它在列出的模型中最准确地预测了未来值。
选择“获胜”模型将取决于您的具体目标以及模型将用于的上下文。如果您需要在两种方法之间找到平衡,您可能需要考虑其他因素或进一步验证,以决定选择模型 8 还是模型 2。
我们将继续使用 AIC 作为选择标准。
您可以使用以下命令显示最佳模型的汇总信息:
top_model = model_eval.iloc[0,-1]
top_model.summary()
这应该生成一个汇总最佳模型的表格布局——例如,构建模型时使用的参数值和计算出的系数:

图 10.14:月度牛奶生产数据的指数平滑总结
请注意,趋势、季节性和季节周期的超参数值的最佳组合。最佳的 季节周期 为 12 个月或滞后期。汇总结果表将显示所有这些滞后的系数,并且这将是一个很长的列表。前面的截图仅显示了顶部部分。
此外,汇总还将显示关键的优化信息,如 alpha(平滑水平)、beta(平滑趋势)和 gamma(平滑季节性)的最佳值。
请记住,最佳模型是根据 AIC 分数选定的。因此,您应探索已捕捉到的不同指标,例如使用 get_top_models_df(score, 'MAPE', top_n=5)。
- 将您使用最佳模型的预测与测试数据进行比较:
plot_forecast(top_model, '1969', milk_train, milk_test);
这应该会生成一个从 1969 年开始的图表,展示三条线,分别表示训练数据、测试数据和预测(预测值):

图 10.15:绘制指数平滑预测与实际月度牛奶生产数据的对比
总体而言,模型有效地捕捉了趋势和季节性,且与测试集中的实际值高度吻合。
它是如何工作的……
平滑时间序列数据有多种方法,包括简单移动平均法、简单指数平滑法、霍尔特指数平滑法和霍尔特-温特指数平滑法。
移动平均模型将过去的值视为相同,而指数平滑模型则更加注重(加权)最近的观察数据。在指数平滑中,较早观察数据的影响逐渐减少(加权衰减),因此得名“指数”。这一方法基于这样一个逻辑假设:较新的事件通常比较旧的事件更为重要。例如,在日常时间序列中,昨天或前天的事件通常比两个月前的事件更为相关。
简单指数平滑(单一)的公式,适用于没有趋势或季节性的时间序列过程,如下所示:
这里,ExponentialSmoothing 类的目标是找到平滑参数 alpha 的最佳值(
)。在这个公式中,
代表当前时刻的期望(平滑)水平,
和
分别是当前时刻和之前时刻的平滑水平值,
是当前时刻的观测值(
)。alpha(
)参数至关重要,它作为水平平滑参数,决定了模型是否更信任过去(
)还是当前(
)。因此,当
趋近于零时,第一项(
)趋近于零,更多的权重会被放在过去;而当
趋近于一时,
项趋近于零,更多的权重则会放在当前。选择
的因素有很多,包括系统中的随机性程度。系数
的输出值决定了模型如何加权当前和过去的观测值来预测未来事件(
)。
这个解释与类似公式中所呈现的主题一致;虽然我们不会深入探讨每个细节,但整体概念保持一致。
Holt 的指数平滑(双重)公式包含了趋势(
)及其平滑参数 beta(
)。因此,一旦加入趋势,模型将输出两个系数的值——即 alpha 和 beta(
):
Holt-Winters 指数平滑(三重)公式同时包含趋势(
)和季节性(
)。以下公式展示了 乘法 季节性作为示例:
当使用 ExponentialSmoothing 寻找最佳
参数值时,它是通过最小化误差率(误差平方和 或 SSE)来实现的。因此,在每次循环中,你传入新参数值(例如,damped 为 True 或 False),模型通过最小化 SSE 来求解最优的
系数值。这个过程可以写成如下公式:
在一些教科书中,你会看到不同的字母用于表示水平、趋势和季节性,但公式的整体结构是相同的。
通常,指数平滑是一种快速且有效的技术,用于平滑时间序列以改善分析,处理异常值、数据插补和预测(预测)。
还有更多…
一个名为 Darts 的令人兴奋的库提供了一个 ExponentialSmoothing 类,它是基于 statsmodels 的 ExponentialSmoothing 类的封装。
要使用 pip 安装 Darts,请运行以下命令:
pip install darts
要使用 conda 安装,请运行以下命令:
conda install -c conda-forge -c pytorch u8darts-all
加载 ExponentialSmoothing 和 TimeSeries 类:
from darts.models import ExponentialSmoothing
from darts import TimeSeries
Darts 期望数据是 TimeSeries 类的一个实例,因此在使用它来训练模型之前,你需要先将 pandas DataFrame 转换为 TimeSeries。TimeSeries 类提供了 from_dataframe 方法,你将在其中使用:
model = ExponentialSmoothing(seasonal_periods=12)
ts = TimeSeries.from_dataframe(milk.reset_index(),
time_col='month', value_cols='production', freq='MS')
在创建 TimeSeries 对象时,你必须指定哪一列是日期,哪一列包含观测值(数据)。你可以使用 .fit() 方法训练模型。一旦训练完成,你可以使用 .predict() 方法进行预测。要绘制结果,可以使用 .plot() 方法:
train, test = split_data(ts, 0.15)
model.fit(train)
forecast = model.predict(len(test), num_samples=100)
train.plot()
forecast.plot(label='forecast', low_quantile=0.05, high_quantile=0.95)

图 10.16:使用 Darts 对每月牛奶生产数据进行 ExponentialSmoothing 预测
darts 库自动化了评估过程,以找到最佳配置(超参数)。Darts 的 ExponentialSmoothing 类是 statsmodels 的 ExponentialSmoothing 类的封装,这意味着你可以访问熟悉的方法和属性,例如 summary() 方法:
model.model.summary()
这应该会生成熟悉的 statsmodels 表格总结和优化后的参数值。作为挑战,请比较 Dart 的总结与 图 10.14 中的结果。尽管你会发现你达到了类似的结果,但使用 Darts 时付出的努力更少。它自动选择了 图 10.14 中确定的最佳超参数。
Darts 库还包含另一个有用的类,名为 StatsForecastAutoETS,其功能来源于 StatsForecast 库中的 AutoETS 实现。与传统的 ExponentialSmoothing 类相比,AutoETS 通常因其更快的性能而受到赞扬。
要探索 StatsForecastAutoETS 的功能,可以参考以下代码片段:
from darts.models import StatsForecastAutoETS
modelets = StatsForecastAutoETS(season_length=12)
modelets.fit(train)
etsforecast = modelets.predict(len(test))
train.plot()
etsforecast.plot(label='AutoETS');

图 10.17:使用 Darts 对每月牛奶生产数据进行 AutoETS 预测
你可以使用以下代码比较两个预测方法,ExponentialSmoothing 和 StatsForecastAutoETS:
forecast.plot(label='ExponentialSmoothing')
etsforecast.plot(label='StatsForecastAutoETS');

图 10.18:比较 AutoETS 和 ExponentialSmoothing
上面的线表示 ExponentialSmoothing 预测结果,而下面的线表示 StatsForecastAutoETS 预测结果。
指数平滑与 ETS
ETS 和指数平滑密切相关,因为它们都使用过去数据点的平滑方法来预测时间序列数据。然而,它们在方法上有所不同。指数平滑通过最小化平方误差和来估计参数,而 ETS 则通过最大化似然估计。此外,指数平滑提供点预测(预测值),ETS 也提供相同的点预测,但附带预测区间。
参见
要了解更多有关 ExponentialSmoothing 类的信息,您可以访问 statsmodels 的官方文档:www.statsmodels.org/dev/generated/statsmodels.tsa.holtwinters.ExponentialSmoothing.html。
您是否注意到在指数平滑中无需进行平稳性检验?指数平滑仅适用于非平稳时间序列(例如具有趋势或季节性的时间序列)。
在下一部分,在构建 ARIMA 模型时,您将进行平稳性检验,以确定差分阶数,并利用本章前面讨论过的 ACF 和 PACF 图。
使用 ARIMA 进行单变量时间序列数据预测
在本配方中,您将探索 ARIMA,使用 statsmodels 包进行实现。ARIMA 代表自回归集成滑动平均(Autoregressive Integrated Moving Average),它结合了三种主要成分:自回归或 AR(p) 模型、滑动平均或 MA(q) 模型和一个集成过程或 I(d),它对数据应用差分。
ARIMA 模型通过 p、d 和 q 参数来表征,非季节性时间序列的 ARIMA 模型用符号 ARIMA(p, d, q) 来描述。p 和 q 参数表示阶数或滞后;例如,AR 的阶数为 p,MA 的阶数为 q。它们被称为滞后,因为它们表示我们需要考虑的“过去”时期的数量。您可能还会遇到另一种关于 p 和 q 的称呼,即多项式的阶数。
ARIMA 模型通过差分(一种时间序列转换技术)来处理非平稳时间序列数据,从而使非平稳时间序列变为平稳。差分的阶数或集成阶数 d 是构建模型时需要选择的参数之一。有关平稳性的复习,请参阅 第九章,探索性数据分析与诊断中的检测时间序列平稳性配方。
虽然 ARIMA 模型通过利用集成因子 'd' 设计用于处理趋势,但它们传统上假设数据集中没有季节性。然而,如果季节性是一个因素,那么季节性 ARIMA(SARIMA)模型就是合适的替代方案,因为它扩展了 ARIMA,包含季节性差分。
准备工作
首先,从 statsmodels 库加载本配方所需的类和函数:
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.stats.diagnostic import acorr_ljungbox
如何操作……
不同的时间序列模型适用于各种类型的数据。因此,选择一个与数据集的特征以及你所解决的具体问题相符合的模型至关重要。在本例中,你将使用life数据框,它呈现出趋势但没有季节性。
你将结合视觉检查(使用 ACF 和 PACF 图)和统计检验,以便做出关于 AR 和 MA 模型成分(即p和q阶数)的明智决策。这些方法在第九章,探索性数据分析与诊断中已有介绍,包括自相关性检验、时间序列数据分解和时间序列平稳性检测的相关内容。让我们开始吧:
- 首先通过分解数据集,将其分为三个主要成分:趋势、季节性和残差(通常被认为是噪声)。你可以使用
seasonal_decompose函数来实现这一点。
decomposed = seasonal_decompose(life)
decomposed.plot();
你可以看到以下图表:

图 10.19:生命预期数据的分解
观察到分解结果显示数据集中有一个正向(上升)趋势。这表明数据随时间的一致增长。然而,数据中没有明显的季节性效应,这与我们对生命数据集的预期一致。
- 你需要首先对数据进行去趋势处理。进行一次差分,然后使用本章前面创建的
check_stationarity函数测试数据的平稳性:
check_stationarity(life)
>>
Non-Stationary p-value:0.6420882853800064 lags:2
life_df1 = life.diff().dropna()
check_stationarity(life_df1)
>>
Stationary p-value:1.5562189676003248e-14 lags:1
现在,数据是平稳的。p 值显著,可以拒绝原假设。请注意,diff()的默认periods值为1。通常,diff(periods=n)表示当前时期t的观测值与其滞后版本t-n之间的差异。对于diff(1)或diff(),滞后版本是t-1(例如,前一个月的观测值)。
你可以使用plot方法绘制差分后的时间序列数据:
life_df1.plot();
这将生成以下图表:

图 10.20:生命预期数据的一阶差分(去趋势)
接下来,你需要确定 ARIMA(p, d, q)模型的p和q阶数。
- ACF 和 PACF 图将帮助你估计 AR 和 MA 模型的合适
p和q值。对平稳化后的life_df1数据使用plot_acf和plot_pacf:
fig, ax = plt.subplots(1,2)
plot_acf(life_df1, ax=ax[0])
plot_pacf(life_df1, ax=ax[1]);
它会生成以下图表:

图 10.21:差分后的生命预期数据的 ACF 和 PACF 图
在前面的例子中,零滞后值被包含在图表中,以帮助你将其与过去的滞后期进行可视化比较。滞后期为 0 时,ACF 和 PACF 的值总是为 1;它们有时会被从图表中省略,因为它们并不提供有意义的信息。因此,更重要的是关注滞后期 1 及之后的滞后期,以确定它们的显著性。
ACF 图有助于识别 MA(q)组件的重要滞后。ACF 图在滞后 1 后显示截断,表示 MA(1)模型。相反,PACF 图有助于确定 AR(p)组件的重要滞后。你可以观察到滞后 1 后逐渐衰减并有振荡,表明在滞后 1 处是 MA 模型或 MA(1)。这表示没有 AR 过程,因此p阶数为零或 AR(0)。有关更多细节,请参考表 10.1。
MA(1)过程也叫做一阶移动平均过程,意味着当前值(在时间t时刻)受紧接其前一个值(在时间t-1时刻)的影响。
现在,你可以使用 p=0,q=1,d=1 的配置构建 ARIMA(p, d, q)模型,从而得到ARIMA(0,1,1)。通常,p 和 q 的最佳滞后值(阶数)并不一目了然,因此你需要通过评估多个 ARIMA 模型并使用不同的 p、d、q 参数来进行实验。这可以通过网格搜索等方法实现,类似于使用指数平滑法预测单变量时间序列数据中描述的方法。
- 在训练集
life_train上训练 ARIMA 模型,并查看模型的摘要。重要的是不要使用之前差分过的life_df1,因为 ARIMA 内部会根据d参数的值进行差分。在这个例子中,由于一阶差分(d=1)足以去趋势并使数据平稳,你将设置模型初始化中的d=1:
model = ARIMA(life_train, order=(0,1,1))
results = model.fit()
results.summary()
你将看到如下摘要:

图 10.22:ARIMA(0,1,1)模型在预期寿命数据上的摘要
注意,在模型摘要中提供了 AIC 和 BIC 评分。虽然这些评分很有用,但它们在用于比较多个模型时最有意义,因为它们有助于评估模型拟合度,同时惩罚过度复杂的模型。
在这个例子中,ARIMA 模型主要是一个 MA 过程,差分因子(d=1),摘要结果仅提供了 MA(1)组件的系数值。有关更多信息,请参阅如何工作…部分。
- 你需要验证模型的残差,以确定 ARIMA(0, 1, 1)模型是否充分捕捉了时间序列中的信息。你会假设模型预测的残差是随机的(噪声),并且不遵循任何模式。更具体地说,你期望残差中没有自相关。你可以从
acorr_ljungbox检验开始,然后查看残差的自相关函数(ACF)图。如果模型效果良好,你应该不期望看到任何自相关:
(acorr_ljungbox(results.resid,
lags=25,
return_df=True) < 0.05)['lb_pvalue'].sum()
>> 0
结果显示0,这是前 25 个滞后的结果的汇总,表示没有自相关。
也可以尝试查看 ACF 图:
plot_acf(results.resid, zero=False);
这应该生成一个 ACF 图。在这里,你应该期望图表不会显示出显著的滞后。换句话说,所有垂直线应该更接近零,或者对于所有滞后都应该为零:

图 10.23:显示残差没有自相关的 ACF 图
该图表确认了没有自相关的迹象(视觉上)。
- 你还可以检查残差的分布。例如,你应该期望残差符合正态分布,均值为零。你可以使用 QQ 图和核密度估计(KDE)图来观察分布并评估正态性。你可以使用
plot_diagnostics方法来实现:
results.plot_diagnostics();plt.show()
上面的代码将生成以下图表:

图 10.24:ARIMA(0,1,1)模型的视觉诊断
这些图表显示了与正态分布的轻微偏差。例如,一个完美的正态分布数据集将具有完美的钟形 KDE 图,并且所有点将在 QQ 图中完美对齐在直线上。
到目前为止,结果和诊断表明模型表现良好,尽管可能还有改进的空间。记住,构建 ARIMA 模型通常是一个迭代过程,涉及多轮测试和调整,以达到最佳结果。
- ARIMA 建模过程中的最后一步是预测未来的数值,并将这些预测与测试数据集进行比较,测试数据集代表的是未见过的或超出样本的数据。使用
plot_forecast()函数,这个函数是在本章技术要求部分中创建的:
plot_forecast(results, '1998', life_train, life_test)
执行此函数将生成一个图表,其中 x 轴从 1998 年开始。图表将显示三条线:实际数据分为两条线,一条是训练数据(orig_train),另一条是测试数据(orig_test),还有一条是forecast(预测值)。
这种视觉表现有助于评估 ARIMA 模型是否成功捕捉了数据集的潜在模式,以及它对未来数值的预测有多准确。

图 10.25:ARIMA(0,1,1)预测与实际预期寿命数据的对比
虚线(预测)似乎与预期的趋势不符,这与图 10.6 中的指数平滑模型结果不同,后者表现更好。为了解决这个问题,你可以运行多个具有不同(p, d, q)值的 ARIMA 模型,并比较它们的 RMSE、MAPE、AIC 或 BIC 分数,从而选择最佳拟合模型。你将在后续内容...部分探索这一选项。
它是如何工作的…
自回归模型或 AR(p) 是一个线性模型,利用前几个时间步的观测值作为回归方程的输入,来确定下一个步骤的预测值。因此,自回归中的auto部分表示自身,可以描述为一个变量对其过去版本的回归。典型的线性回归模型将具有以下方程:
这里,
是预测变量,
是截距,
是特征或独立变量,
是每个独立变量的系数。在回归分析中,你的目标是求解这些系数,包括截距(可以把它们当作权重),因为它们稍后会用于做预测。误差项,
,表示残差或噪声(模型中未解释的部分)。
将其与自回归方程进行比较,你将看到相似之处:
这是一个阶数为p的 AR 模型,表示为AR(p)。自回归模型与回归模型的主要区别在于预测变量是
,它是
当前时刻的值,
,而这些
变量是
的滞后(前)版本。在这个示例中,你使用了 ARIMA(0,1,1),即 AR(0),表示没有使用自回归模型。
与使用过去值的自回归模型不同,移动平均模型或 MA(q) 使用过去的误差(来自过去的估计)来进行预测:

将 AR(p) 和 MA(q) 模型结合会生成一个 ARMA(p,q) 模型(自回归滑动平均模型)。AR 和 ARMA 过程都假设时间序列是平稳的。然而,假设时间序列由于趋势的存在而不是平稳的,在这种情况下,除非进行一些转换,例如差分,否则不能在非平稳数据上使用 AR 或 ARMA 模型。这正是life数据的情况。
差分就是将当前值减去其前一个(滞后的)值。例如,差分阶数为一(lag=1)可以表示为
。在 pandas 中,你使用了diff方法,默认设置为periods=1。
ARIMA 模型通过添加一个集成(差分)因子来使时间序列平稳,从而改进了 ARMA 模型。
你利用了 ACF 图和 PACF 图来估计 AR 和 MA 模型的阶数。自相关函数衡量当前观测值与其滞后版本之间的相关性。ACF 图的目的是确定过去的观测值在预测中的可靠性。
另一方面,偏自相关函数(PACF)类似于自相关,但去除了干预观测值之间的关系。
ACF 与 PACF 通过示例对比
如果在滞后 1、2、3 和 4 处存在强相关性,这意味着滞后 1 的相关性度量会受到与滞后 2 的相关性的影响,滞后 2 又会受到与滞后 3 的相关性的影响,依此类推。
在滞后 1 处的自相关函数(ACF)度量将包括这些先前滞后的影响,如果它们是相关的。相比之下,在滞后 1 处的偏自相关函数(PACF)会去除这些影响,以测量当前观察值与滞后 1 之间的纯关系。
ARIMA 之所以流行的原因之一是它能够推广到其他更简单的模型,如下所示:
-
ARIMA(1, 0, 0) 是一个一阶自回归或 AR(1)模型
-
ARIMA(1, 1, 0) 是一个差分的一级自回归模型
-
ARIMA(0, 0, 1) 是一个一阶移动平均或 MA(1)模型
-
ARIMA(1, 0, 1) 是一个 ARMA(1,1)模型
-
ARIMA(0, 1, 1) 是一个简单的指数平滑模型
还有更多……
有时候,很难确定时间序列是 MA 过程还是 AR 过程,或者确定p或q的最优阶数(滞后值)。你可以参考下面这个例子,使用一种朴素的网格搜索方法,通过尝试不同的p,d和q组合来训练其他 ARIMA 模型,然后再选择最优模型。
在这里,你将利用你在技术要求部分创建的combinator()函数。你将训练多个 ARIMA 模型,并使用get_top_models_df()来找到最佳模型。作为起点,尝试对三个超参数(p,d,q)进行(0,1,2)组合。你将测试 3x3x3 或 27 个 ARIMA 模型:
pv, dv, qv = [list(range(3))]*3
vals = combinator([pv, dv, qv ])
score = {}
for i, (p, d, q) in enumerate(vals):
m = ARIMA(life_train, order=(p,d,q))
res = m.fit()
y = life_train.values.ravel()
y_hat = res.forecast(steps=len(y))
score[i] = {'order': (p,d,q),
'AIC':res.aic,
'RMSPE': rmspe(y, y_hat),
'BIC': res.bic,
'AICc':res.aicc,
'RMSE' : rmse(y, y_hat),
'MAPE' : mape(y, y_hat),
'model': res}
get_top_models_df(score, 'AIC')
这应该生成一个按 AIC 排序的 DataFrame。以下表格展示了前五个模型:

图 10.26:根据 AIC 得分排序的 27 个 ARIMA 模型的结果
你可以使用以下方法选择最佳模型:
best_m = get_top_models_df(score, 'AIC').iloc[0,-1]
如果你运行best_m.summary()来查看模型摘要,你会注意到它是一个ARIMA(0,2, 2)模型。这进一步确认了我们之前的观察,这实际上是一个移动平均(MA)过程,但我们错过了阶数。
赤池信息量准则(AIC)是一种衡量模型最大似然估计和模型简洁性之间平衡的指标。过于复杂的模型有时可能会过拟合,意味着它们看起来像是学习了,但一旦面对未见过的数据时,它们的表现会很差。AIC 得分随着参数数量的增加而受到惩罚,因为它们增加了模型的复杂性:

在这里,2k 被视为惩罚项。
贝叶斯信息准则(BIC)与 AIC 非常相似,但在模型的复杂性上有更高的惩罚项。通常,BIC 的惩罚项更大,因此它倾向于鼓励具有较少参数的模型,相较于 AIC。因此,如果你将排序或评估标准从 AIC 更改为 BIC,你可能会看到不同的结果。BIC 更偏向选择简单的模型:
在这里,
是最大似然估计,k 是估计的参数数量,n 是数据点的数量。
若要使用最佳模型绘制预测图表,可以运行以下命令:
plot_forecast(best_m, '1998', life_train, life_test);
这将产生以下图表:

图 10.26:ARIMA(0,2,2) 预测与实际的预期寿命数据对比
比较 图 10.25 中 ARIMA(0, 1, 1) 模型的输出和 图 10.26 中 ARIMA(0,2,2) 模型的输出。它们与图 10.12 中使用指数平滑法的结果相比如何?
在继续下一个示例之前,我们来对牛奶生产数据应用 ARIMA 模型,该数据与预期寿命数据不同,具有趋势和季节性:
pv, dv, qv = [list(range(3))]*3
vals = combinator([pv, dv, qv])
score = {}
for i, (p, d, q) in enumerate(vals):
m = ARIMA(milk_train, order=(p,d,q))
res = m.fit()
y = milk_test.values.ravel()
y_hat = res.forecast(steps=len(y))
score[i] = {'order': (p,d,q),
'AIC':res.aic,
'BIC': res.bic,
'AICc':res.aicc,
'RMSPE': rmspe(y, y_hat),
'RMSE' : rmse(y, y_hat),
'MAPE' : mape(y, y_hat),
'model': res}
model = get_top_models_df(score, 'AIC').iloc[0,-1]
plot_forecast(model, '1971', milk_train, milk_test);
运行代码并检查 model.summary() 输出的顶级模型后,你会发现它识别为 ARIMA(2,2,2)。结果的预测图可能整体上表现较差,如图 10.27 所示——预测与实际牛奶生产数据对比。

图 10.27:ARIMA(2,2,2) 预测与实际的牛奶生产数据对比
这个结果是预期中的,因为标准的 ARIMA 模型并不设计用来处理季节性。下一个示例将介绍 SARIMA(季节性 ARIMA),它更适合建模具有季节模式和趋势的时间序列数据。
另见
若要了解更多关于 ARIMA 类的信息,你可以访问 statsmodels 的官方文档 www.statsmodels.org/dev/generated/statsmodels.tsa.arima.model.ARIMA.html。
那么,具有趋势和季节性的 milk 数据怎么样呢?下一个示例将探索如何使用 SARIMA 模型处理此类数据。
FORECAST 与 PREDICT 方法的区别
在
plot_forecast函数中,我们使用了 forecast 方法。在 statsmodels 中,SARIMA 模型家族(如 ARMA 和 ARIMA)有两种方法可以进行预测:predict和forecast。
predict方法允许你同时进行样本内(历史)和样本外(未来)的预测,因此该方法需要start和end参数。另一方面,forecast方法仅接受 steps 参数,表示样本外预测的步数,从样本或训练集的末尾开始。
使用季节性 ARIMA 进行单变量时间序列数据预测
在本示例中,你将接触到一种增强型 ARIMA 模型,用于处理季节性,称为季节性自回归积分滑动平均模型或 SARIMA。与 ARIMA(p, d, q) 类似,SARIMA 模型也需要(p, d, q)来表示非季节性阶数。此外,SARIMA 模型还需要季节性成分的阶数,表示为(P, D, Q, s)。将两者结合,模型可以写成 SARIMA(p, d, q)(P, D, Q, s)。字母含义保持不变,字母的大小写表示不同的成分。例如,小写字母代表非季节性阶数,而大写字母代表季节性阶数。新参数s表示每个周期的步数——例如,对于月度数据,s=12,对于季度数据,s=4。
在 statsmodels 中,你将使用 SARIMAX 类来构建 SARIMA 模型。
在本示例中,你将使用milk数据,该数据包含趋势性和季节性成分。此数据已在技术要求部分准备好。
如何实现…
按照以下步骤操作:
- 首先导入必要的库:
from statsmodels.tsa.statespace.sarimax import SARIMAX
- 从图 10.1中,我们确定了季节性和趋势性都存在。我们还可以看到季节性效应是加性的。一个季节的周期或期数为 12,因为数据是按月收集的。这可以通过 ACF 图进行确认:
plot_acf(milk, lags=40, zero=False);
这应该会生成一个milk数据集的 ACF 图,并在特定滞后处显示明显的周期性波动:

图 10.28: ACF 图显示滞后 1、12 和 24 时有显著的峰值
注意,每 12 个月(滞后)会有一个重复的模式。如果这种模式不容易发现,可以尝试在差分数据后查看 ACF 图——例如,先对数据进行去趋势(一次差分),然后再绘制 ACF 图:
plot_acf(milk.diff(1).dropna(), lags=40, zero=False);
这应该会生成一个差分数据的 ACF 图,使季节性峰值更加明显:

图 10.29 – 差分后的 ACF 图显示在滞后 1、12、24 和 36 时有显著的峰值
你也可以提取季节性成分并用其生成 ACF 图,如下代码所示:
decomposed = seasonal_decompose(milk, period=12, model='multiplicative')
milk_s = decomposed.seasonal
plot_acf(milk_s, zero=False, lags=40);
ACF 图将显示经过分解后的季节性成分的自相关,并会讲述与图 10.28和图 10.29类似的故事。

图 10.30: 分解后的季节性成分的 ACF 图显示在滞后 1、12、24 和 36 时有显著的峰值
通常,处理月度数据时可以假设一个 12 个月的周期。例如,对于非季节性 ARIMA 部分,可以从d=1开始去趋势化,对于季节性 ARIMA 部分,可以将 D=1 作为起始值,前提是 s=12。
- 假设你不确定
d(非季节性差分)和D(季节性差分)的值,在这种情况下,你可以在差分后使用check_stationarity函数来判断季节性差分是否足够。通常,如果时间序列既有趋势又有季节性,你可能需要进行两次差分。首先进行季节性差分,然后进行一阶差分以去除趋势。
通过使用diff(12)进行季节性差分(去季节化),并测试这是否足以使时间序列平稳。如果不够,那么你需要继续进行一阶差分diff():
milk_dif_12 = milk.diff(12).dropna()
milk_dif_12_1 = milk.diff(12).diff(1).dropna()
sets = [milk, milk_dif_12, milk_dif_12_1]
desc = ['Original', 'Deseasonalize (Difference Once)', 'Differencing Twice']
fig, ax = plt.subplots(2,2, figsize=(20,10))
index, l = milk.index, milk.shape[0]
for i, (d_set, d_desc) in enumerate(zip(sets, desc)):
v, r = i // 2, i % 2
outcome, pval = check_stationarity(d_set)
d_set.plot(ax= ax[v,r], title=f'{d_desc}: {outcome}', legend=False)
pd.Series(d_set.mean().values.tolist()*l, index=index).plot(ax=ax[v,r])
ax[v,r].title.set_size(20)
ax[1,1].set_visible(False)
plt.show()
这应该会生成 2x2 子图(每行两个图),其中额外的子图是隐藏的:

图 10.31:原始数据、季节差分数据和两次差分数据的平稳性比较
- 现在,你需要估计非季节性(p, q)和季节性(P, Q)分量的 AR 和 MA 阶数。为此,你必须使用平稳数据上的 ACF 和 PACF 图,这些图可以在
milk_dif_12_1数据框中找到:
fig, ax = plt.subplots(1,2)
plot_acf(milk_dif_12_1, zero=False, lags=36, ax=ax[0], title=f'ACF - {d_desc}')
plot_pacf(milk_dif_12_1, zero=False, lags=36, ax=ax[1], title=f'PACF - {d_desc}')
plt.show()
这应该会在同一行中生成 ACF 和 PACF 图:

图 10.32:乳制品数据变为平稳后的 ACF 和 PACF 图
从自相关函数(ACF)图开始,可以看到在滞后 1 处有一个显著的峰值,表示 MA 过程的非季节性阶数。滞后 12 处的峰值表示 MA 过程的季节性阶数。注意,在滞后 1 之后有一个截断,接着是滞后 12 的峰值,然后又是另一个截断(之后没有其他显著的滞后)。这些都是移动平均模型的指示——更具体地说,是q=1和Q=1的阶数。
PACF 图也确认了这一点;在滞后 12、24 和 36 处的指数衰减表明是 MA 模型。在这里,季节性 ARIMA 将是 ARIMA(0, 1,1)(0, 1, 1, 12)。
- 根据最初提取的 AR 和 MA 阶数信息构建 SARIMA 模型。以下代码将在训练数据集上拟合一个 SARIMA(0, 1, 1)(0, 1, 1, 12)模型。请注意,结果可能与绘制 ACF 和 PACF中的结果有所不同,因为在该配方中数据没有被拆分,但在这里已经拆分:
sarima_model = SARIMAX(milk_train,
order=(0,1,1),
seasonal_order=(0,1,1,12))
model = sarima_model.fit(disp=0)
- 现在,使用
plot_diagnostics方法,它将在拟合模型后可用:
model.plot_diagnostics(figsize=(15,7));
这将提供四个图——一个标准化残差图,一个 QQ 图,一个 ACF 残差图和一个带有核密度图的直方图:

图 10.33:SARIMA(0,1,1)(0,1,1,12)诊断图
残差的 ACF 图(相关图)未显示自相关(忽略滞后 0 处的尖峰,因为它总是 1)。然而,直方图和 QQ 图显示残差并不符合完美的正态分布。与随机残差(无自相关)相比,这些假设并不关键。总体来说,结果非常有前景。
你可以使用summary方法获取摘要:
model.summary()
这应该以表格格式打印出关于模型的附加信息,包括季节性和非季节性成分的系数。回想一下,诊断图是基于标准化残差的。你可以绘制残差的 ACF 图而不进行标准化:
plot_acf(model.resid[1:])
这应该生成如下的 ACF 图,其中显示了阈值之外的一些滞后,表明存在自相关,并且有改进的潜力。

图 10.34:SARIMA(0,1,1)(0,1,1,12)的残差 ACF 图
- 使用
plot_forecast函数绘制 SARIMA 模型的预测,并与测试集进行比较:
plot_forecast(model, '1971', milk_train, milk_test);
这应该生成一个图表,x 轴从 1971 年开始:

图 10.35:使用 SARIMA(0,1,1)(0,1,1,12)的牛奶生产预测与实际生产对比
总体而言,SARIMA 模型在捕捉季节性和趋势效应方面表现得相当不错。你可以通过评估使用其他度量标准(例如 RMSE、MAPE 或 AIC 等)来迭代并测试(p, q)和(P, Q)的不同值。更多内容请参见还有更多……部分。
它是如何工作的……
SARIMA 模型通过加入季节性来扩展 ARIMA 模型,因此需要额外的一组季节性参数。例如,一个没有季节性成分的非季节性顺序为(1, 1, 1)的 SARIMA 模型会被指定为 SARIMA(1,1,1)(0,0,0,0),这本质上简化为 ARIMA(1, 1, 1)模型。为了处理季节性,你需要将季节性顺序设置为非零值。
在 statsmodels 中,SARIMAX类将 AR、MA、ARMA、ARIMA 和 SARIMA 模型进行了一般化,使你能够拟合适合自己时间序列数据的模型,无论其是否具有季节性成分。同样,正如在使用指数平滑法预测单变量时间序列数据一节中所讨论的,ExponentialSmoothing类作为SimpleExpSmoothing和Holt模型的广义实现。
还有更多……
类似于使用 ARIMA 预测单变量时间序列数据一节中的方法,你可以执行一个朴素的网格搜索,以评估不同的非季节性(p, d, q)和季节性(P, D, Q, s)参数组合,从而确定最佳的 SARIMA 模型。
这可以通过利用combinator()函数来实现,遍历所有可能的参数组合,在每次迭代中拟合一个 SARIMA 模型。要确定前 N 个模型,可以使用get_top_models_df函数。
例如,考虑测试所有组合,其中非季节性(p, d, q)参数分别取值(0,1,2),季节性(P, D, Q)参数分别取值(0,1),同时将s(季节周期)保持为 12。这种设置将测试总共(3x3x3x2x2x2)= 216 个 SARIMA 模型。虽然这种暴力(朴素)方法在计算上可能非常密集,但它仍然是有效的方法。像Auto_ARIMA这样的自动时间序列库通常也采用类似的穷举网格搜索来优化模型参数:
P_ns, D_ns, Q_ns = [list(range(3))]*3
P_s, D_s, Q_s = [list(range(2))]*3
vals = combinator([P_ns, D_ns, Q_ns, P_s, D_s, Q_s])
score = {}
for i, (p, d, q, P, D, Q) in enumerate(vals):
if i%15 == 0:
print(f'Running model #{i} using SARIMA({p},{d},{q})({P},{D},{Q},12)')
m = SARIMAX(milk_train,
order=(p,d,q),
seasonal_order=(P, D, Q, 12),
enforce_stationarity=False)
res = m.fit(disp=0)
y = milk_test.values.ravel()
y_hat = res.forecast(steps=len(y))
score[i] = {'non-seasonal order': (p,d,q),
'seasonal order': (P, D, Q),
'AIC':res.aic,
'AICc': res.aicc,
'BIC': res.bic,
'RMSPE': rmspe(y, y_hat),
'RMSE' : rmse(y, y_hat),
'MAPE' : mape(y, y_hat),
'model': res}
执行前面的代码后,它应该每 15 次迭代打印一次状态输出,如下所示:
Running model #0 using SARIMA(0,0,0)(0,0,0,12)
Running model #15 using SARIMA(0,0,1)(1,1,1,12)
Running model #30 using SARIMA(0,1,0)(1,1,0,12)
Running model #45 using SARIMA(0,1,2)(1,0,1,12)
Running model #60 using SARIMA(0,2,1)(1,0,0,12)
Running model #75 using SARIMA(1,0,0)(0,1,1,12)
Running model #90 using SARIMA(1,0,2)(0,1,0,12)
Running model #105 using SARIMA(1,1,1)(0,0,1,12)
Running model #120 using SARIMA(1,2,0)(0,0,0,12)
Running model #135 using SARIMA(1,2,1)(1,1,1,12)
Running model #150 using SARIMA(2,0,0)(1,1,0,12)
Running model #165 using SARIMA(2,0,2)(1,0,1,12)
Running model #180 using SARIMA(2,1,1)(1,0,0,12)
Running model #195 using SARIMA(2,2,0)(0,1,1,12)
Running model #210 using SARIMA(2,2,2)(0,1,0,12)
请注意enforce_stationarity=False参数,以避免在进行初始网格搜索时可能出现的LinAlgError。
要识别按 AIC 排序的前 5 个模型,可以运行get_top_models_df函数:
get_top_models_df(score, 'AIC')

图 10.36:按 AIC 排名的前 5 个 SARIMA 模型,针对牛奶生产数据
注意到前两个模型的 AIC 得分相似。通常,当两个模型的 AIC 得分相似时,较简单的模型更受偏好。例如,SARIMA(0,2,2)(0,1,1)模型比 SARIMA(2,2,2)(0,1,1)模型更简单。还有其他指标,如 AICc 和 BIC,在这种情况下,它们会更倾向于选择第二个模型(model_id=67),而不是第一个模型(model_id=211)。类似地,如果考虑 RMSPE 或 MAPE,你可能会选择不同的模型。
奥卡姆剃刀
奥卡姆剃刀原理表明,当多个模型产生相似的质量,换句话说,能同样良好地拟合数据时,应该偏好较简单的模型。这一原理在评估多个模型时尤其有用,比如在评估多个 SARIMA 模型时。如果一些模型脱颖而出成为强有力的候选者,通常来说,简单的模型更受青睐,假设最初这些模型是等可能的候选者。
要根据 BIC 得分对模型进行排序,请重新运行该函数:
get_top_models_df(score, 'BIC')
请查看下图中的结果(图 10.36),它展示了按 BIC 排名的前 5 个 SARIMA 模型,针对牛奶生产数据。

图 10.37:按 BIC 排名的前 5 个 SARIMA 模型,针对牛奶生产数据
比较图 10.36 和图 10.35 中的结果,你会注意到图 10.36 中的第三个模型(model_id = 35),即之前推导出来的 SARIMA(0,1,1)(0,1,1)模型。
现在,让我们根据 BIC 得分选择最佳模型:
best_model = get_top_models_df(score, BIC).iloc[0,-1]
最后,你可以使用plot_forecast函数将模型的预测结果与实际数据一起可视化:
plot_forecast(best_model, '1962', milk_train, milk_test);
这应该生成一个图表,x 轴从 1962 年开始,如图 10.37 所示:

图 10.38:使用 SARIMA(0,2,2)(0,1,1,12)的牛奶生产预测与实际生产对比
参见
要了解更多关于 SARIMAX 类的信息,可以访问 statsmodels 的官方文档:www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html。
使用auto_arima进行单变量时间序列预测
在这个示例中,你需要安装pmdarima,这是一个 Python 库,其中包括auto_arima——一个旨在自动化优化和拟合 ARIMA 模型的工具。Python 中的auto_arima实现灵感来源于 R 中forecast包中的流行auto.arima。
正如你在前面的示例中看到的,确定 AR 和 MA 组件的正确阶数可能会很具挑战性。虽然像检查 ACF 和 PACF 图这样的技术很有帮助,但找到最优模型通常需要训练多个模型——这一过程称为超参数调优,可能非常耗时费力。这正是auto_arima的亮点,它简化了这个过程。
与简单粗暴的手动网格搜索逐一尝试每种参数组合的方法不同,auto_arima使用了一种更高效的方法来寻找最优参数。auto_arima函数采用了一种逐步 算法,其速度更快、效率更高,优于完整的网格搜索或随机搜索:
-
默认情况下,使用
stepwise=True时,auto_arima会进行逐步搜索,逐步优化模型参数。 -
如果你设置
stepwise=False,它将执行全面的“蛮力”网格搜索,遍历所有参数组合。 -
使用
random=True时,它会执行随机搜索。
逐步 算法由 Rob Hyndman 和 Yeasmin Khandakar 于 2008 年在论文Automatic Time Series Forecasting: The forecast Package for R中提出,该论文发表在《统计软件杂志》27 卷 3 期(2008 年)(doi.org/10.18637/jss.v027.i03)。简而言之,逐步是一种优化技术,它更高效地利用了网格搜索。这是通过使用单位根检验和最小化信息准则(例如,赤池信息量准则(AIC)和最大似然估计(MLE)来实现的)。
此外,auto_arima还可以处理季节性和非季节性的 ARIMA 模型。对于季节性模型,设置seasonal=True以启用季节性参数(P、D、Q)的优化。
准备工作
在继续此示例之前,你需要安装pmdarima。
要使用pip安装,可以使用以下命令:
pip install pmdarima
要使用conda安装,可以使用以下命令:
conda install -c conda-forge pmdarima
你将使用在技术要求部分准备的“Milk Production”数据集。你将使用milk_train进行训练,使用milk_test进行评估。请回忆一下,数据包含了趋势和季节性,因此你将训练一个SARIMA模型。
如何做…
pmdarima库封装了statsmodels库,因此你会遇到熟悉的方法和属性。你将遵循类似的流程,首先加载数据,将数据拆分为训练集和测试集,训练模型,然后评估结果。这些步骤已在技术要求部分完成。
- 首先导入
pmdarima库
import pmdarima as pm
- 使用
pmdarima中的auto_arima函数来找到 SARIMA 模型的最佳配置。对 Milk Production 数据集的先验知识是从auto_arima获得最佳结果的关键。你知道数据存在季节性模式,因此需要为两个参数提供值:seasonal=True和m=12,其中m代表季节中的周期数。如果没有设置这些参数(seasonal和m),搜索将仅限于非季节性阶数(p, d, q)。
test参数指定用于检测平稳性并确定差分阶数(d)的单位根检验类型。默认的检验是kpss。你将把参数改为使用adf(以保持与之前配方一致)。类似地,seasonal_test用于确定季节性差分的阶数(D)。默认的seasonal_test是OCSB,你将保持不变:
auto_model = pm.auto_arima(milk_train,
seasonal=True,
m=12,
test='adf',
stepwise=True)
auto_model.summary()
摘要提供了所选 SARIMA 模型的详细配置,包括信息准则得分,如 AIC 和 BIC:

图 10.39:使用 auto_arima 选择的最佳 SARIMA 模型摘要
有趣的是,所选模型 SARIMA(0,1,1)(0,1,1,12)与在使用季节性 ARIMA 预测单变量时间序列数据配方中推导出的模型一致,在该配方中,你通过 ACF 和 PACF 图估算了非季节性阶数(p, q)和季节性阶数(P, Q)。
- 为了监控在逐步搜索过程中评估的每个模型配置的性能,在
auto.arima函数中启用trace=True参数:
auto_model = pm.auto_arima(milk_train,
seasonal=True,
m=12,
test='adf',
stepwise=True,
trace=True)
该设置将打印由逐步算法测试的每个 SARIMA 模型的 AIC 结果,如图 10.39 所示:

图 10.40:auto_arima 基于 AIC 评估不同的 SARIMA 模型
最佳模型是基于 AIC 选择的,AIC 由information_criterion参数决定。默认情况下,设置为aic,但可以更改为其他支持的准则之一:bic、hqic或oob。
在图 10.39中,两个突出的模型具有相似的 AIC 分数,但非季节性(p,q)阶数大相径庭。优选模型(标记为数字 1)缺少非季节性自回归 AR(p)成分,而是依赖于移动平均 MA(q)过程。相反,第二个突出模型(标记为数字 2)仅包含非季节性成分的 AR(p)过程。这表明,尽管auto_arima显著有助于模型选择,但仍需小心判断和分析,以有效解释和评估结果。
若要探索信息准则的选择如何影响模型选择,请将information_criterion更改为bic并重新运行代码:
auto_model = pm.auto_arima(milk_train,
seasonal=True,
m=12,
test='adf',
information_criterion='bic',
stepwise=True,
trace=True)

图 10.41:auto_arima 根据 BIC 评估不同的 SARIMA 模型
如图 10.40 所示,这将基于 BIC 从每次迭代中生成输出。值得注意的是,最终选择的模型与基于 AIC 从图 10.39 选择的模型相同。然而,请注意,第二个模型(标记为数字 2),尽管在图 10.39 中是一个接近的竞争者,但在 BIC 标准下已不再具有竞争力。
- 使用
plot_diagnostics方法评估模型的整体表现。这是你之前在 statsmodels 中使用过的相同方法。
auto_model.plot_diagnostics(figsize=(15,7));
这应该会生成选定的 SARIMA 模型的残差分析诊断图,如图 10.41 所示:

图 10.42:基于选定的 SARIMA 模型的残差分析诊断图
若要访问模型摘要,请使用summary方法。这将生成图 10.42,显示由auto_arima选择的 SARIMA 模型摘要:

图 10.43:基于 auto_arima 选择模型的 SARIMA 模型摘要
- 若要预测未来的周期,请使用
predict方法。你需要指定要预测的周期数:
n = milk_test.shape[0]
index = milk_test.index
ax = milk_test.plot(style='--', alpha=0.6, figsize=(12,4))
pd.Series(auto_model.predict(n_periods=n),
index=index).plot(style='-', ax=ax)
plt.legend(['test', 'forecast']);
生成的图形,如图 10.43 所示,比较了预测值与测试数据。

图 10.44:将 auto_arima 的预测与实际测试数据进行对比
你可以通过将return_conf_int参数从False更新为True来获得预测的置信区间。这将允许你使用 matplotlib 的fill_between函数绘制上下置信区间。默认的置信区间设置为 95%(alpha=0.05)。
以下代码使用predict方法,返回置信区间,并将预测值与测试集进行对比:
n = milk_test.shape[0]
forecast, conf_interval = auto_model.predict(n_periods=n,
return_conf_int=True,
alpha=0.05)
lower_ci, upper_ci = zip(*conf_interval)
index = milk_test.index
ax = milk_test.plot(style='--', alpha=0.6, figsize=(12,4))
pd.Series(forecast, index=index).plot(style='-', ax=ax)
plt.fill_between(index, lower_ci, upper_ci, alpha=0.2)
plt.legend(['test', 'forecast']);
这应该生成一个图,带有阴影区域,表示实际值落在此范围内的可能性。理想情况下,你会更倾向于选择较窄的置信区间范围。
阴影区域基于图 10.44 中所示的置信区间的上下界:

图 10.45:将来自 auto_arima 的预测与实际测试数据及置信区间进行对比的图示
请注意,预测线位于阴影区域的中间位置,表示上下界的均值。
以下代码检查预测值是否与置信区间的均值一致:
sum(forecast) == sum(conf_interval.mean(axis=1))
>> True
工作原理…
pmdarima 库中的 auto_arima 函数作为 statsmodels SARIMAX 类的封装器,旨在自动化识别最佳模型和参数的过程。此函数提供了三种主要方法来控制训练过程的优化,这些方法由 stepwise 和 random 参数决定:
-
朴素暴力网格搜索:通过设置
stepwise=False和random=False,对所有参数组合进行全面的网格搜索。该方法对每种组合进行详尽评估,虽然耗时,但非常彻底。 -
随机网格搜索:通过设置
stepwise=False和random=True来激活参数空间中的随机搜索。这种方法随机选择组合进行测试,尤其在大参数空间中,虽然是随机的,但仍然有效,且速度较快。 -
逐步搜索算法:默认启用
stepwise=True,此方法使用启发式算法逐步探索参数空间,基于前一步的 AIC 或 BIC 分数,逐个添加或减少每个参数。与全面网格搜索相比,它通常更快、更高效,因为它智能地缩小了模型范围,聚焦于最可能提供最佳拟合的模型。
还有更多…
pmdarima 库提供了大量有用的函数,帮助你做出明智的决策,使你能够更好地理解你所处理的数据。
例如,ndiffs 函数执行平稳性检验,以确定差分阶数 d,使时间序列变得平稳。检验包括增广迪基-富勒(adf)检验、克维亚茨科夫-菲利普斯-施密特-辛(kpss)检验和菲利普斯-佩龙(pp)检验。
同样,nsdiffs 函数有助于估算所需的季节性差分阶数(D)。该实现包括两个检验——奥斯本-崔-史密斯-比尔钦霍尔(ocsb)检验和卡诺瓦-汉森(ch)检验:
from pmdarima.arima.utils import ndiffs, nsdiffs
n_adf = ndiffs(milk, test='adf')
# KPSS test (the default in auto_arima):
n_kpss = ndiffs(milk, test='kpss')
n_pp = ndiffs(milk, test='pp')
n_ch = nsdiffs(milk, test='ocsb', m=10, max_D=12,)
n_ocsb = nsdiffs(milk, test='ch' , m=10, max_D=12,)
auto_arima函数通过设置各种参数的最小和最大约束,允许对模型评估过程进行详细控制。例如,你可以指定非季节性自回归阶数p或季节性移动平均Q的限制。以下代码示例演示了如何设置一些参数和约束条件:
model = pm.auto_arima(milk_train,
seasonal=True,
with_intercept=True,
d=1,
max_d=2,
start_p=0, max_p=2,
start_q=0, max_q=2,
m=12,
D=1,
max_D=2,
start_P=0, max_P=2,
start_Q=0, max_Q=2,
information_criterion='aic',
stepwise=False,
out_of_sample_siz=25,
test = 'kpss',
score='mape',
trace=True)
如果你运行前面的代码,auto_arima将根据你提供的约束条件,为每个参数值的组合创建不同的模型。由于stepwise被设置为False,这就变成了一种暴力的网格搜索,其中每种不同变量组合的排列都会逐一测试。因此,这通常是一个比较慢的过程,但通过提供这些约束条件,你可以提高搜索性能。
通过启用trace=True,可以显示测试的每个模型配置的 AIC 得分。完成后,应该会打印出最佳模型。
这里采取的方法,设置stepwise=False,应该与你在使用季节性 ARIMA 进行单变量时间序列预测教程中采取的方法类似,该教程位于更多内容...部分。
使用 Darts 的 AutoArima
在使用指数平滑进行单变量时间序列预测的教程中,你在更多内容...部分介绍了Darts库。
Darts 库提供了AutoArima类,它是pmdarima的auto_arima的一个轻量级封装。以下代码演示了如何利用 Darts 执行相同的功能:
model = AutoARIMA(seasonal=True,
m=12,
stepwise=True)
ts = TimeSeries.from_dataframe(milk_train.reset_index(),
time_col='month', value_cols='production', freq='MS')
darts_arima = model.fit(ts)
darts_forecast = model.predict(len(milk_test))
ts.plot(label='Training')
darts_forecast.plot(label='Forecast', linestyle='--');
这段代码生成的图表展示了预测结果,如图 10.45 所示:

图 10.46:使用 Darts 库的 AutoARIMA 进行预测
使用 Darts 的 StatsForecastAutoARIMA
Darts 还提供了一个Statsforecasts的包装器,封装了其 Auto_ARIMA,提供了比 AutoArima 可能更快的实现。以下代码演示了如何使用StatsForecastAutoARIMA来执行相同的功能:
from darts.models import StatsForecastAutoARIMA
model = StatsForecastAutoARIMA(season_length=12)
model.fit(ts)
pred = model.predict(len(milk_test))
ts.plot(label='Training')
darts_forecast.plot(label='AutoArima', linestyle='--');
pred.plot(label='StatsforecstsAutoArima');
这段代码生成的图表展示了 AutoARIMA 和 StatsForecastAutoARIMA 的预测对比,如图 10.47 所示:

图 10.47:使用 Darts 库的 StatsForecastAutoARIMA 进行预测
另见
若要了解有关auto_arima实现的更多信息,请访问官方文档:alkaline-ml.com/pmdarima/modules/generated/pmdarima.arima.auto_arima.html。
在下一个教程中,你将学习一种新的算法,该算法提供了一个更简单的 API 来进行模型调优和优化。换句话说,你需要关注的参数大大减少。
第十一章:11 种时间序列的附加统计建模技术
加入我们的 Discord 书籍社区

在第十章,使用统计方法构建单变量时间序列模型中,你学习了流行的预测技术,如指数平滑法、非季节性 ARIMA,和季节性 ARIMA。这些方法通常被称为经典的统计预测方法,具有快速、易实现和易解释的特点。
在本章中,你将深入学习基于上一章基础的额外统计方法。本章将介绍一些可以自动化时间序列预测和模型优化的库——Facebook(Meta)的Prophet库。此外,你将探索statsmodels的向量自回归(VAR)类,用于处理多变量时间序列,以及arch库,它支持用于金融数据波动性建模的GARCH模型。
本章的主要目标是让你熟悉自动化预测工具(如 Prophet),并介绍多变量时间序列建模的概念,使用 VAR 模型。你还将了解如何在金融时间序列中建模和预测波动性,这对于风险管理和财务决策至关重要。
在本章中,我们将涵盖以下食谱:
-
使用 Facebook Prophet 进行时间序列数据预测
-
使用 VAR 预测多变量时间序列数据
-
评估向量自回归(VAR)模型
-
使用 GARCH 预测金融时间序列数据中的波动性
技术要求
你可以从本书的 GitHub 仓库下载 Jupyter Notebooks 以便跟随学习,并下载本章所需的数据集:
-
Jupyter Notebooks:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./blob/main/code/Ch11/Chapter%2011.ipynb -
数据集:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./tree/main/datasets/Ch11
在本章的食谱中,你将使用一些常见的库。你可以通过以下代码提前导入它们:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from pathlib import Path
import warnings
warnings.filterwarnings('ignore')
plt.rc("figure", figsize=(16, 5))
使用 Facebook Prophet 进行时间序列数据预测
Prophet 库是一个流行的开源项目,最初由 Facebook(现为 Meta)开发,基于 2017 年的一篇论文,提出了一种时间序列预测算法,标题为《大规模预测》。该项目因其简单性、能够创建高效的预测模型、以及处理复杂季节性、假期效应、缺失数据和异常值的能力而广受欢迎。Prophet 自动化了许多设计预测模型的过程,同时提供了丰富的内置可视化功能。其他功能包括构建增长模型(如饱和预测)、处理趋势和季节性的 不确定性,以及检测变化点。
在本教程中,您将使用 Milk Production 数据集进行性能基准测试。这是 第十章,使用统计方法构建单变量时间序列模型 中介绍的相同数据集。使用相同的数据集有助于理解和比较不同的方法。
Prophet 是一个加性回归模型,可以处理非线性趋势,特别是在存在强烈季节性效应时。该模型将时间序列数据分解为三个主要组成部分:趋势、季节性和假期,形式如下所示:

其中:
-
是趋势函数,
-
代表周期性季节性函数,
-
考虑到假期的影响,
-
是残差误差项。
Prophet 使用贝叶斯推断自动调整和优化模型组件。其背后依赖于Stan,一个最先进的贝叶斯建模平台,当前的 Python 接口为 cmdstand 和 cmdstanpy(取代了早期的 PyStan)。最近的更新提高了与 Apple M1/M2 芯片的兼容性,并增强了模型自定义选项,例如调整假期处理和缩放输出。
准备工作
您可以在本书的 GitHub 仓库中找到 Jupyter 笔记本和必要的数据集。有关更多信息,请参阅本章的 技术要求 部分。我们为本教程使用 Prophet 1.0 版本。
在安装像 Prophet 这样的新库时,创建一个新的 Python 环境总是一个好主意。如果您需要快速回顾如何创建虚拟 Python 环境,可以查看 第一章,时间序列分析入门 中的 开发环境设置教程。该章节介绍了两种方法:使用 conda 和 venv。
例如,您可以像以下示例一样使用 conda 创建环境:
conda create -n prophet python=3.11 -y
上述代码将创建一个名为 prophet 的新虚拟环境。要使新的 prophet 环境在 Jupyter 中可见,可以运行以下代码:
python -m ipykernel install --user --name prophet --display-name "Prophet"
conda activate prophet
一旦新环境被激活,您就可以安装 Prophet 库。要使用 pip 安装,您可以运行以下命令:
pip install prophet
要使用 conda 安装,请使用以下命令:
conda install -c conda-forge prophet
如何做到这一点…
Prophet 要求输入数据为一个包含两个特定列的 pandas DataFrame:一个名为ds的datetime列和一个名为y的目标变量列——这是你希望预测的变量。Prophet 不能直接处理Datetimeindex。因此,确保这些列明确命名非常重要。如果数据包含超过两列,Prophet 只会识别ds和y,并默认忽略其他列。然而,如果你想添加额外的预测变量(回归器),可以使用add_regressor方法。如果这些列缺失或命名不正确,Prophet 会抛出错误。
为确保数据格式正确,请按照以下步骤操作:
- 首先读取
milk_productions.csv文件并重命名列为ds和y:
from prophet import Prophet
milk_file = Path('../../datasets/Ch11/milk_production.csv')
milk = pd.read_csv(milk_file, parse_dates=['month'])
milk.columns = ['ds', 'y']
milk.info()
>>
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 168 entries, 0 to 167
Data columns (total 2 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 ds 168 non-null datetime64[ns]
1 y 168 non-null int64
dtypes: datetime64ns, int64(1)
memory usage: 2.8 KB
- 将数据拆分为测试集和训练集。我们使用
90/10的比例进行拆分,代码如下:
idx = round(len(milk) * 0.90)
train = milk[:idx]
test = milk[idx:]
print(f'Train: {train.shape}')
print(f'Test: {test.shape}')
>>
Train: (151, 2)
Test: (17, 2)
- 你可以通过以下一行代码创建一个 Prophet 类的实例,并使用
fit方法在训练集上进行拟合。牛奶生产的时间序列是按月记录的,具有趋势和稳定的季节性波动(加性)。Prophet 的默认seasonality_mode是additive,所以保持原样即可:
from prophet import Prophet
model = Prophet().fit(train)
- 在你使用模型进行预测之前,需要进行一些设置。使用
make_future_dataframe方法将trainDataFrame 扩展到特定的预测期数,并按指定频率生成数据:
future = m_milk.make_future_dataframe(len(test), freq='MS')
这会将训练数据延长 17 个月(test集中的期数)。总之,你应该拥有与牛奶 DataFrame(训练和测试)中相同数量的期数。频率设置为月初,即freq='MS'。future对象只包含一个列ds,其类型为datetime64[ns],用于填充预测值:
len(milk) == len(future)
>> True
print(future.tail())
>>
ds
163 1975-08-01
164 1975-09-01
165 1975-10-01
166 1975-11-01
167 1975-12-01
- 使用
predict方法对futureDataFrame 进行预测。结果将是一个与forecast长度相同的 DataFrame,但现在会增加一些额外的列:
forecast = model.predict(future)
forecast.columns.tolist()
>>
['ds',
'trend',
'yhat_lower',
'yhat_upper',
'trend_lower',
'trend_upper',
'additive_terms',
'additive_terms_lower',
'additive_terms_upper',
'yearly',
'yearly_lower',
'yearly_upper',
'multiplicative_terms',
'multiplicative_terms_lower',
'multiplicative_terms_upper',
'yhat']
请注意,Prophet 返回了许多细节,帮助你了解模型的表现。重点是ds和预测值yhat。yhat_lower和yhat_upper分别表示预测的不确定性区间(yhat)。
model对象提供了两个绘图方法:plot和plot_components。首先使用plot方法来可视化 Prophet 的预测结果:
model.plot(forecast,
ylabel='Milk Production in Pounds',
include_legend=True);
这应该会生成一个 Prophet 预测图:图中的点代表训练数据点,点上的线代表历史数据的估计预测,线条延伸到训练点之外,反映未来的预测。

图 11.4 – 使用 Prophet 绘制预测图(历史与未来)
如果你只想展示超出训练集的预测期,可以使用以下代码:
predicted = model.predict(test)
model.plot(predicted,
ylabel='Milk Production in Pounds',
include_legend=True);
这里你只预测了测试数据集的长度。预测方法将仅捕获 ds 列(日期时间)。这将生成一个类似于图 11.4所示的图表,但它只会显示未来数据点(训练数据点之后)的预测线——即未来的预测。

图 11.5 – 使用 Prophet 绘制预测(历史和未来)
图 11.4 中的阴影区域表示不确定性区间。这由 forecast 数据框中的 yhat_lower 和 yhat_upper 列表示。
- 接下来,重要的图表涉及预测组件。使用
plot_components方法绘制这些组件:
model.plot_components(forecast);
子图的数量将取决于预测中已识别的组件数量。例如,如果包括了假期,那么将会显示holiday组件。在我们的示例中,将有两个子图:trend和yearly:

图 11.6 – 绘制显示趋势和季节性(年度)的组成部分
图 11.6 展示了训练数据的趋势和季节性。如果你查看图 11.6,你会看到一个持续上升的趋势,自 1972 年以来逐渐稳定(放缓)。此外,季节性模式显示出夏季时生产量的增加。
趋势图中的阴影区域表示估算趋势的不确定性区间。数据存储在 forecast 数据框的 trend_lower 和 trend_upper 列中。
- 最后,与样本外数据(测试数据)进行比较,看看模型的表现如何:
ax = test.plot(x='ds', y='y',
label='Actual',
style='-.',
figsize=(12,4))
predicted.plot(x='ds', y='yhat',
label='Predicted',
ax=ax,
title='Milk Production Actual vs Forecast');
将以下图表与第十章的图 10.43进行比较,看看Prophet与使用 auto_arima 获得的SARIMA模型有何不同:

图 11.7 – 将 Prophet 的预测与测试数据进行比较
注意,对于高度季节性的牛奶生产数据,模型表现非常出色。通常,Prophet 在处理强季节性时间序列数据时表现优异。
工作原理...
Prophet 精简了构建和优化时间序列模型的多个方面,但在开始时需要一些关键指令,以便 Prophet 正确调整模型。例如,在初始化模型时,你需要决定季节性效应是加法模型还是乘法模型。你还需要指定诸如数据频率(例如,对于按月数据的freq='MS')等参数,并使用 make_future_dataframe 方法来扩展预测范围。
当你实例化模型 model = Prophet() 时,Prophet 会使用默认的参数值,例如 yearly_seasonality='auto'、weekly_seasonality='auto' 和 daily_seasonality='auto'。这使得 Prophet 可以自动根据数据来确定需要包含的季节性分量。在牛奶生产数据集中,只检测到年度季节性,如下所示:
model.seasonalities
>>
OrderedDict([('yearly',
{'period': 365.25,
'fourier_order': 10,
'prior_scale': 10.0,
'mode': 'additive',
'condition_name': None})])
Prophet 还提供了不确定性区间来预测结果,这些区间受到三个因素的影响:
-
观测噪声:指的是无法通过模型解释的观测数据中的随机变化。
-
参数不确定性:指的是在估计模型参数时的的不确定性。例如,调整
mcmc_samples参数(马尔可夫链蒙特卡罗 或 MCMC 采样)来获取季节性分量的不确定性。默认值为零 (0)。 -
未来趋势不确定性:指的是基于历史数据对未来趋势变化的预期不确定性。例如,增加
changepoint_prior_scale参数值可以增加预测的不确定性。默认值设置为0.05。此外,区间宽度也可以通过interval_width参数进行调整(默认为 0.80 或 80%)。
默认情况下,uncertainty_samples 参数设置为 1000,这意味着 Prophet 会使用哈密顿蒙特卡罗(HMC)算法进行 1000 次模拟来估计不确定性。你可以调整此值以控制模拟次数,或者通过设置 uncertainty_samples=0 或 uncertainty_samples=False 来完全关闭不确定性估计。如果禁用不确定性样本,Prophet 会从预测结果中省略像 yhat_lower 和 yhat_upper 这样的不确定性区间。
model = Prophet(uncertainty_samples=False).fit(train)
forecast = model.predict(future)
forecast.columns.tolist()
>>
['ds', 'trend', 'additive_terms', 'yearly', 'multiplicative_terms', 'yhat']
Prophet 的优势在于它能够自动检测变化点,这些是趋势发生显著变化的时间点。默认情况下,Prophet 会在训练数据的前 80% 中识别 25 个潜在变化点。你可以通过调整 n_changepoints 参数来修改此行为,或者通过 changepoint_range 控制用于变化点检测的历史数据量,默认值为 0.8(即 80%)。
你可以通过模型的 changepoints 属性检查检测到的变化点。例如,以下代码显示前五个变化点:
model.changepoints.shape
>>
(25,)
model.changepoints.head()
>>
5 1962-06-01
10 1962-11-01
14 1963-03-01
19 1963-08-01
24 1964-01-01
Name: ds, dtype: datetime64[ns]
这些变化点也可以在图表上进行可视化。以下代码将变化点叠加到原始时间序列数据上:
ax = milk.set_index('ds').plot(figsize=(12,5))
milk.set_index('ds').loc[model.changepoints].plot(style='X', ax=ax)
plt.legend(['original data', 'changepoints']);
这应该会生成一个图表,展示原始时间序列及 25 个潜在变化点,显示 Prophet 识别的趋势变化时刻。

图 11.8 – Prophet 所识别的 25 个潜在变化点
这些潜在变化点是从训练数据的前 80% 中估算出来的。
在接下来的章节中,你将更详细地探索变化点检测。
还有更多内容…
要绘制捕捉趋势中影响较大的变化点,你可以使用add_changepoints_to_plot函数,如以下代码所示:
from prophet.plot import add_changepoints_to_plot
fig = model.plot(forecast, ylabel='Milk Production in Pounds')
add_changepoints_to_plot(fig.gca(), model, forecast);
这将生成类似于图 11.8的图表,但会有额外的变化点线和趋势线。图 11.9 中的 25 个变化点中有 10 个(垂直线是显著的变化点)。线性趋势线应与图 11.6中显示的趋势成分相同:

图 11.9 – 显示十个显著的变化点和趋势线
注意趋势线在识别出的变化点处的变化。这就是 Prophet 如何检测趋势变化的方式。由于应用了分段回归来构建趋势模型,趋势线并不是完全直线。当考虑分段线性模型时,你可以把它看作是多个线性回归线段(在显著变化点之间),然后将这些线段连接起来。这使得模型能够灵活地捕捉到趋势中的非线性变化,并进行未来预测。
Prophet 还包含交叉验证功能,以更好地评估模型在预测未来数据时的表现。交叉验证用于确保模型能够很好地泛化到未见过的数据,而不是出现过拟合或欠拟合的情况。此外,交叉验证还可以帮助你确定预测结果在多长时间内仍然可靠。
Prophet 提供了cross_validation和performance_metrics函数,允许你将数据拆分成训练集和测试集,并跨多个时段进行验证。这种方法通过在不同时间点进行预测并与实际值进行比较,帮助评估模型的准确性。
这是在 Prophet 中实现交叉验证的方法。
from prophet.diagnostics import cross_validation, performance_metrics
df_cv = cross_validation(model, initial='730 days', period='180 days', horizon='365 days')
df_cv.head()
>>
ds yhat yhat_lower yhat_upper y cutoff
0 1964-03-01 689.889300 685.612439 694.504671 688 1964-02-19
1 1964-04-01 701.435214 697.157285 706.019257 705 1964-02-19
2 1964-05-01 776.047139 771.707065 780.994528 770 1964-02-19
3 1964-06-01 735.045494 730.374821 739.547374 736 1964-02-19
4 1964-07-01 671.333097 666.625404 675.994830 678 1964-02-19
在前面的代码中,我们指定了以下参数:
initial:初始训练期的大小。在我们的代码中,我们指定了 730 天,大约是 2 年,或者是 24 个月,用于我们的月度牛奶生产数据。
period:进行预测时切分点之间的间隔。在这个例子中,我们指定了 180 天(大约 6 个月)。这意味着在初始训练后,Prophet 会按 6 个月的增量向前推进。
horizon:预测的时间跨度(预测未来的时间长度)。在这个例子中,我们指定了 365 天或 1 年。这意味着 Prophet 将在每个切分点之后的 12 个月(1 年)内进行预测。
在执行交叉验证之后,你可以使用performance_metrics函数评估模型的表现。
df_p = performance_metrics(df_cv)
print(df_p.iloc[: , 0:-1].head())
>>
horizon mse rmse mae mape mdape smape
0 41 days 226.788248 15.059490 12.300991 0.016356 0.016894 0.016345
1 42 days 220.336066 14.843721 11.849186 0.015699 0.015678 0.015694
2 45 days 214.385008 14.641892 11.647620 0.015503 0.015678 0.015503
3 46 days 207.646253 14.409936 11.380352 0.015170 0.014446 0.015164
4 47 days 242.132208 15.560598 12.179413 0.015953 0.014446 0.015986
该函数计算多个指标,如平均绝对误差(MAE)、均方根误差(RMSE)等。
你可以使用plot_cross_validation_metric函数来可视化交叉验证预测的表现:
from prophet.plot import plot_cross_validation_metric
fig = plot_cross_validation_metric(df_cv, metric='rmse');
这应该会绘制不同预测时段的 RMSE。

图 11.10 – 显示模型在不同预测期的 RMSE 曲线
对图形的解释表明,该模型在短期预测期内表现较好,因为 RMSE 相对较低。随着预测期的增加,RMSE 似乎普遍增加。通常,我们期望这些模型在短期预测(1-3 个月)内表现更好,误差更小。
另请参见
-
Prophet 支持 Python 和 R。有关 Python API 的更多信息,请访问以下文档:
facebook.github.io/prophet/docs/quick_start.html#python-api。 -
如果你有兴趣阅读关于 Prophet 算法的原始论文,可以通过此链接访问:
peerj.com/preprints/3190/。 -
交叉验证也可以用于微调模型的超参数。你可以在这里了解更多:
facebook.github.io/prophet/docs/diagnostics.html#hyperparameter-tuning
到目前为止,你一直在处理单变量时间序列。接下来的章节将教你如何处理多元时间序列。
使用 VAR 预测多元时间序列数据
在本章节中,你将探索用于处理多元时间序列的向量自回归(VAR)模型。在第十章,《使用统计方法构建单变量时间序列模型》中,我们讨论了 AR、MA、ARIMA 和 SARIMA 作为单变量单向模型的示例。而 VAR 则是双向且多元的。
VAR 与 AR 模型的比较
你可以将阶数为 p 的 VAR,或称VAR(p),视为单变量 AR(p) 的一种推广,用于处理多个时间序列。多个时间序列被表示为一个向量,因此命名为向量自回归。滞后为一(1)的 VAR 可以表示为 VAR(1),涉及两个或更多变量。
还有其他形式的多元时间序列模型,包括向量移动平均(VMA)、向量自回归移动平均(VARMA)和向量自回归积分移动平均(VARIMA),它们是对其他单变量模型的推广。在实践中,你会发现由于其简单性,VAR 使用得最多。VAR 模型在经济学中非常流行,但你也会在其他领域看到它的应用,如社会科学、自然科学和工程学。
多变量时间序列的前提是,当利用多个时间序列(或输入变量)而不是单一时间序列(单一变量)时,可以增强预测的能力。简而言之,当你有两个或更多的时间序列,并且它们彼此间有(或假设有)相互影响时,VAR 就可以被使用。这些通常被称为内生变量,其关系是双向的。如果变量或时间序列之间没有直接关系,或者我们不知道它们是否存在直接的影响,则称其为外生变量。
外生与内生变量
当你开始深入研究 VAR 模型时,你会遇到关于内生和外生变量的引用。从宏观角度来看,这两者是相互对立的,在
statsmodels中,它们分别被称为endog和exog。内生变量受到系统内其他变量的影响。换句话说,我们预期一个状态的变化会影响其他状态。有时,这些变量在机器学习文献中被称为依赖变量。你可以使用Granger 因果检验来确定多个内生变量之间是否存在这种关系。例如,在
statsmodels中,你可以使用grangercausalitytests。另一方面,外生变量位于系统外部,并且不直接影响其他变量。它们是外部影响因素。有时,这些变量在机器学习文献中被称为独立变量。
类似于 AR 模型,VAR 模型假设时间序列变量的平稳性。这意味着每个内生变量(时间序列)需要是平稳的。
为了说明 VAR 是如何工作的以及背后的数学公式,我们从一个简单的 VAR(1)模型开始,该模型包含两个(2)内生变量,表示为(
)。回顾一下第十章,使用统计方法构建单变量时间序列模型,AR(1)模型的形式如下:
一般来说,AR(p)模型是自我过去值的线性模型,其中(p)参数告诉我们应该追溯多远。现在,假设你有两个AR(1)模型,分别对应两个不同的时间序列数据。其形式如下:

然而,这两个模型是独立的,没有展示出任何相互关系或相互影响。如果我们创建这两个模型的线性组合(即自身过去值和另一个时间序列的过去值),我们将得到以下公式:

图 11.11 – 带滞后一期的 VAR 模型公式,或 VAR(1)
上述方程看起来可能很复杂,但最终,像 AR 模型一样,它仍然只是过去滞后值的线性函数。换句话说,在 VAR(1) 模型中,每个变量都将有一个滞后(1)的线性函数。当拟合 VAR 模型时,正如你将在本食谱中看到的那样,使用最小二乘法(OLS)方法来估计每个方程的 VAR 模型。
准备开始
在本食谱中,你将使用 pandas_datareader 库从 FRED(联邦储备经济数据)下载数据。相关文件也可以从本书的 GitHub 仓库下载。
使用conda安装:
conda install -c anaconda pandas-datareader
使用pip安装:
pip install pandas-datareader
如何操作……
在本食谱中,你将使用 pandas_datareader 库中的 FredReader() 函数来提取两个不同的时间序列数据集。正如 FRED 网站上所提到的,第一个符号 FEDFUNDS 是联邦基金有效利率,它“是存款机构之间互相过夜交易联邦基金(存放在联邦储备银行的余额)的利率”。简单来说,联邦基金有效利率影响借贷成本。它是由联邦公开市场委员会(FOMC)设定的目标利率,用于确定银行向其他机构收取的利率,特别是借出其储备余额中的多余现金的利率。 第二个符号是 unrate,代表失业率,它是指总劳动人口中正在积极寻找工作或愿意工作的失业人员的比例。
引用
美国联邦储备系统理事会,联邦基金有效利率 [FEDFUNDS],来自 FRED,圣路易斯联邦储备银行; https://fred.stlouisfed.org/series/FEDFUNDS,2024 年 10 月 6 日。
美国劳动统计局,失业率 [UNRATE],来自 FRED,圣路易斯联邦储备银行; https://fred.stlouisfed.org/series/UNRATE,2024 年 10 月 6 日。
按照以下步骤进行:
- 开始时加载必要的库并提取数据。注意,
FEDFUNDS和unrate都是按月报告的:
import pandas_datareader.data as web
import pandas as pd
from statsmodels.tsa.api import VAR,adfuller, kpss
from statsmodels.tsa.stattools import grangercausalitytests
- 使用
FredReader提取数据,FredReader封装了 FRED API,并返回一个 pandas DataFrame。对于FEDFUNDS和unrate符号,你将提取大约 34 年的数据(417 个月):
import pandas_datareader.data as web
start = "01-01-1990"
end = "01-09-2024"
economic_df = web.FredReader(
symbols=["FEDFUNDS", "unrate"],
start=start,
end=end).read()
file = '../../datasets/Ch11/economic_df.pickle'
economic_df.to_pickle(file)
将数据框存储为 pickle 对象,如前面的代码最后一行所示。这样,你就不需要再次调用 API 来重新运行示例。你可以使用 economic_df = pd.read_pickle(file) 读取 economic_df.pickle 文件。
- 检查数据,确保没有空值:
economic_df.isna().sum()
>>
FEDFUNDS 0
unrate 0
dtype: int64
- 将数据框的频率更改为月初(
MS),以反映数据存储的方式:
economic_df.index.freq = 'MS'
- 绘制数据集进行可视化检查和理解:
economic_df.plot(subplots=True); plt.show()
由于 subplots 设置为 True,这将为每一列生成两个子图:

图 11.12 – 绘制联邦基金有效利率和失业率
FEDFUND和unrate之间存在某种反向关系——随着FEDFUNDS的增加,unrate减少。从 2020 年开始,由于 COVID-19 疫情,出现了有趣的异常行为。我们可以进一步检查这两个变量之间的相关性:
correlation_matrix = economic_df.corr()
correlation_matrix
>>
FEDFUNDS unrate
FEDFUNDS 1.000000 -0.435171
unrate -0.435171 1.000000
FEDFUNDS和unrate之间的相关系数为-0.435,表明存在中等强度的负相关关系(反向相关)。这表明,随着联邦基金利率的上升,失业率趋向下降,反之亦然。
进一步,你可以执行互相关函数(CCF)分析FEDFUNDS和unrate之间不同滞后的相关性。CCF 有助于识别两个时间序列之间的滞后关系或时间依赖性。结果主要告诉我们一个系列是否领先或滞后于另一个系列。这并不是一个正式的因果性检验,你将在后续步骤中进行更深入的调查。
from statsmodels.graphics.tsaplots import plot_ccf
import numpy as np
lags = np.arange(-12, 13)
plot_ccf(economic_df['FEDFUNDS'], economic_df['unrate'], lags=lags)
plt.grid(True)

图 11.13 – 前后 12 个月的互相关函数图,便于更好的解释
图中显示了超出阴影置信区间的尖峰,这表明存在显著的负相关。这暗示着FEDFUNDS的变化可能与unrate的相反变化有关,且这些变化发生在同一时段内。
- VAR 模型中的一个重要假设是平稳性。两个变量(这两个内生时间序列)需要是平稳的。创建一个
check_stationarity()函数,该函数返回扩展型迪基-富勒(adfuller)检验的平稳性结果:
from statsmodels.tsa.stattools import adfuller
def check_stationarity(df):
adf_pv = adfuller(df)[1]
result = 'Stationary' if adf_pv < 0.05 else "Non-Stationary"
return result
使用check_stationarity函数评估每个内生变量(列):
for i in economic_df:
adf = check_stationarity(economic_df[i])
print(f'{i} adf: {adf}')
>>
FEDFUNDS adf: Stationary
unrate adf: Stationary
总体而言,这两个时间序列被证明是平稳的。
- 绘制ACF和PACF图,以便对每个变量及其所属的过程(例如 AR 或 MA 过程)获得直观理解:
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
for col in economic_df.columns:
fig, ax = plt.subplots(1,2, figsize=(18,4))
plot_acf(economic_df[col], zero=False,
lags=30, ax=ax[0], title=f'ACF - {col}')
plot_pacf(economic_df[col], zero=False,
lags=30, ax=ax[1], title=f'PACF - {col}');
这应该生成FEDFUNDS和unrate的 ACF 和 PACF:

图 11.11 – FEDFUNDS和unrate的 ACF 和 PACF 图
FEDFUNDS和unrate的 ACF 和 PACF 图表明我们正在处理一个自回归(AR)过程。ACF 图显示出逐渐缓慢衰减,而 PACF 图则显示出在滞后 1 之后的急剧截断。FEDFUNDS的 PACF 图在滞后 2 和滞后 3 时显示出略微显著的(超出阴影区域)滞后,而unrate的 PACF 图在滞后 24 时显示出一定的显著性。我们可以暂时忽略这些。
- 将数据分为训练集和测试集:
train = economic_df.loc[:'2022']
test = economic_df.loc['2023':]
print(f'Train: {len(train)}, Test: {len(test)}')
>>
Train: 396, Test: 21
-
你可以对数据进行缩放(标准化),尽管 VAR 模型并不要求标准化,且这两个时间序列的规模差异不大。在这一步中,你将进行缩放操作,仅用于演示目的,以展示如何进行此操作,并如何在后续步骤中反向转换结果以便进行解释。
在实现 VAR 时,是否需要对变量进行标准化(缩放)常常存在争议。然而,VAR 算法本身并不要求变量必须进行标准化,因为它是与规模无关的。在实践中,通常保持变量的原始单位,以保留系数和残差的可解释性,这样可以用有意义的术语(例如百分比点)来解释。然而,如果变量在规模上差异显著,则可以应用标准化,以便更容易比较系数并更有效地检测异常值。
如果你选择标准化(例如,使用Scikit-Learn中的
StandardScalar),需要注意,结果将以标准差为单位。你可以使用inverse_transform方法将数据还原到其原始单位,以便在解释结果时使用。这对于需要在原始变量规模的上下文中解释结果时非常有用。 -
使用
StandardScalar对数据进行缩放,通过fit方法拟合训练集。然后,使用transform方法对训练集和测试集应用缩放变换。变换将返回一个 NumPyndarray,然后你可以将其作为 pandas DataFrame 返回。这样,你可以更方便地绘制图形并进一步检查 DataFrame:
from sklearn.preprocessing import StandardScaler
scale = StandardScaler()
scale.fit(train)
train_sc = pd.DataFrame(scale.transform(train),
index=train.index,
columns=train.columns)
test_sc = pd.DataFrame(scale.transform(test),
index=test.index,
columns=test.columns)
- 那么,如何确定 VAR 模型的最优滞后期数(p)呢?幸运的是,
statsmodels中的 VAR 实现会自动选择最佳的 VAR 滞后期数。你只需要定义滞后的最大数量(阈值);模型将确定最小化每个四种信息准则得分的最佳p值:AIC、BIC、FPE和HQIC。select_order方法会计算每个滞后期数的得分,而summary方法会显示每个滞后的得分。这些结果将有助于在训练(fit)模型时指定算法应使用哪些信息准则:
model = VAR(endog=train_sc)
res = model.select_order(maxlags=10)
res.summary()
这应该显示所有10个滞后的结果。最低的得分会用*标记:
VAR Order Selection (* highlights the minimums)
==================================================
AIC BIC FPE HQIC
--------------------------------------------------
0 -0.2862 -0.2657 0.7511 -0.2780
1 -7.345 -7.283 0.0006461 -7.320
2 -7.874 -7.772 0.0003805 -7.833
3 -7.960 -7.817* 0.0003491 -7.903*
4 -7.960 -7.775 0.0003492 -7.887
5 -7.951 -7.726 0.0003523 -7.862
6 -7.967 -7.701 0.0003467 -7.861
7 -7.974* -7.667 0.0003443* -7.852
8 -7.957 -7.609 0.0003502 -7.819
9 -7.947 -7.557 0.0003539 -7.792
10 -7.931 -7.501 0.0003593 -7.761
--------------------------------------------------
res对象是LagOrderResults类的一个实例。你可以使用selected_orders属性打印每个信息准则的选定滞后期数,该属性返回最优滞后期数的字典,包括AIC、BIC、FPE和HQIC:
print(res.selected_orders)
>>
{'aic': 7, 'bic': 3, 'hqic': 3, 'fpe': 7}
在滞后期 7 时,AIC和FPE得分最低。另一方面,BIC和HQ得分在滞后期 3 时最低,表明该模型可能更简洁,滞后期数较少。
通常,BIC和HQIC倾向于偏好更简洁的模型(即滞后期数较少的模型),因为它们对模型复杂度施加更高的惩罚。相反,AIC和FPE则较为宽松,可能建议使用更高的滞后期数,因为它们对复杂度的惩罚较少。
在这种情况下,滞后 3似乎是更安全的选择,旨在追求简洁性并避免过拟合。然而,选择滞后 7将会得到一个更复杂的模型,可能捕捉到数据中的更多动态,但也带有更高的过拟合风险。
- 要训练模型,必须使用BIC分数(或其他你选择的标准)。你可以通过更新
ic参数来尝试不同的信息准则。maxlags参数是可选的——如果你留空,模型会根据所选信息准则自动确定最优滞后阶数,在此案例中为'bic'。
results = model.fit(ic='bic')
这将自动选择最小化 BIC 分数的滞后阶数(即滞后 3)。你可以通过设置ic='aic'来尝试其他标准,如 AIC。如果你更倾向于手动指定最大滞后阶数,可以使用maxlags参数。请记住,如果同时使用了maxlags和ic,则ic将优先考虑。
- 运行
results.summary()将打印详细的总结,包括每个自回归过程的回归结果。
总结中包括了方程数量(对应变量的数量)、信息准则(AIC、BIC、HQIC 和 FPE)及其他细节,如对数似然。这些指标有助于评估模型的整体拟合度和复杂度。
Summary of Regression Results
==================================
Model: VAR
Method: OLS
Date: Mon, 07, Oct, 2024
Time: 14:49:51
--------------------------------------------------------------------
No. of Equations: 2.00000 BIC: -7.83732
Nobs: 393.000 HQIC: -7.92278
Log likelihood: 466.565 FPE: 0.000342624
AIC: -7.97888 Det(Omega_mle): 0.000330737
--------------------------------------------------------------------
在总结的最后,有残差相关矩阵。该矩阵显示了每个方程中残差(误差)之间的相关性。理想情况下,你希望这些非对角线值尽可能接近零。较低的相关性表明模型已经捕捉到了大部分变量之间的关系,且残差中剩余的相关性较小。
Correlation matrix of residuals
FEDFUNDS unrate
FEDFUNDS 1.000000 -0.115904
unrate -0.115904 1.000000
FEDFUNDS和unrate之间的残差相关性为-0.1159,相对较小,表明模型很好地捕捉到了这两个变量之间的关系。
- 使用
k_ar属性存储 VAR 滞后阶数,以便在以后使用预测方法时能再次使用:
lag_order = results.k_ar
lag_order
>> 3
这显示选择的最优滞后阶数为 3。
最后,你可以使用forecast或forecast_interval方法进行预测。后者将返回预测值,以及上和下置信区间。这两种方法都需要过去的值和预测的步数。先前的值(past_y)将作为预测的初始值:
past_y = train_sc[-lag_order:].values
n = test_sc.shape[0]
forecast, lower, upper = results.forecast_interval(past_y, steps=n)
- 由于你对数据集应用了
StandardScalar,预测值已被缩放。你需要应用inverse_transform将它们转换回原始数据的尺度。你还可以将预测和置信区间转换为 pandas DataFrame,方便使用:
forecast_df = pd.DataFrame(scale.inverse_transform(forecast),
index=test_sc.index,
columns=test_sc.columns)
lower_df = pd.DataFrame(scale.inverse_transform(lower),
index=test_sc.index,
columns=test_sc.columns)
upper_df = pd.DataFrame(scale.inverse_transform(upper),
index=test_sc.index,
columns=test_sc.columns)
- 绘制实际值与预测值
unrate及其置信区间:
idx = test.index
plt.figure(figsize=(10, 6))
plt.plot(idx, test['unrate'], label='Actual unrate')
plt.plot(idx, forecast_df['unrate'], label='Forecasted unrate', linestyle='dashed')
plt.fill_between(idx, lower_df['unrate'], upper_df['unrate'], alpha=0.2, label='Confidence Interval')
plt.title('Actual vs Forecasted unrate with Confidence Intervals')
plt.legend()
plt.show()
这应该绘制实际的测试数据,unrate 的预测(中间虚线),以及上下置信区间:

图 11.12 – 绘制带有置信区间的 unrate 预测
在 VAR 模型中,所有变量都同时进行预测,因为该模型假设系统中所有变量的过去值都会对每个变量的未来值产生影响。因此,即使你只关心预测 unrate 来自 FEDFUNDS,该模型也会基于 unrate 预测 FEDFUNDS 的未来值。你可以像绘制 unrate 的预测一样,绘制 FEDFUNDS 的预测,如下所示:
idx = test.index
plt.figure(figsize=(10, 6))
plt.plot(idx, test['FEDFUNDS'], label='Actual FEDFUNDS')
plt.plot(idx, forecast_df['FEDFUNDS'], label='Forecasted FEDFUNDS', linestyle='dashed')
plt.fill_between(idx, lower_df['FEDFUNDS'], upper_df['FEDFUNDS'], alpha=0.2, label='Confidence Interval')
plt.title('Actual vs Forecasted FEDFUNDS with Confidence Intervals')
plt.legend()
plt.show()

图 11.13 – 绘制带有置信区间的 FEDFUNDS 预测
请记住,训练数据是设置到 2022 年底的,包括 2020 年 COVID-19 导致的重大经济冲击。因此,由于数据的巨大且突然而来的变化,模型的预测可能无法如预期般表现。
你可能需要调整训练-测试划分来适应这一事实,并对模型进行不同的实验。当处理像 COVID-19 这样的重要异常时,必须评估事件是一次性异常,其影响将逐渐消退,还是它代表了一种持久的变化,需要在模型中进行特殊处理。
在这里,领域知识变得至关重要:理解经济背景将帮助你决定是否将此类事件建模,或将其视为不应影响未来预测的异常值。
它是如何工作的…
向量自回归模型(VAR)在计量经济学中非常有用。在评估 VAR 模型的性能时,通常会报告几个重要的分析结果,例如Granger 因果关系检验、残差(或误差)分析和冲击响应分析。这些对理解变量之间的相互作用至关重要。你将在下一个章节中探索这些内容,评估向量自回归(VAR)模型。
在图 11.11中,两个变量的 VAR(1) 模型产生了两个方程。每个方程将一个变量的当前值建模为其自身滞后值和另一个变量滞后值的函数。矩阵符号显示了四个系数和两个常数。每个方程有两个系数,分别用于两个变量的滞后值(例如,对于和对于)。常数和代表每个方程的截距。
在本配方中,选择的模型是针对两个变量的 VAR(3)模型。VAR(1)和 VAR(3)之间的主要区别在于由于附加滞后项,复杂性有所增加。在 VAR(3)模型中,你将需要求解十二个系数(每个变量三个滞后期)和两个常数。每个方程都是使用OLS估计的,如results.summary()输出所示。
图 11.11中表示的两个函数显示了 VAR(1)中每个变量不仅受到自身过去值的影响,还受到第二个(内生)变量的过去值的影响。这是 VAR 模型和 AR 模型(仅考虑变量自身滞后)的一个关键区别。VAR 模型捕捉了多个变量之间随时间变化的动态交互。
在下一个配方中,既然模型已经拟合完成,你将花时间绘制 VAR 特定的输出——例如,脉冲响应(IRs)和预测误差方差分解(FEVD)——以更好地理解这些相互作用以及变量之间的影响。
在本配方中,我们专注于使用模型进行预测;接下来,我们将专注于理解因果关系,使用 Granger 因果关系检验等工具,并通过额外的诊断评估模型的整体性能。
VARIMA(向量 ARIMA)模型扩展了 VAR,用于通过加入差分来处理非平稳数据。我们在这里没有使用它,因为两个时间序列都是平稳的,所以不需要差分。在处理多个需要差分以实现平稳的非平稳变量时,可以考虑使用 VARIMA。
还有更多……
由于我们关注的是比较预测结果,观察包含两个内生变量的 VAR(3)模型是否比单变量 AR(3)模型表现更好是很有趣的。将多元 VAR(3)模型与更简单的 AR(3)(或 ARIMA(3,0,0))模型进行比较有助于评估第二个变量(FEDFUNDS)的加入是否改善了对unrate的预测。
你可以尝试为unrate时间序列拟合 AR(3)或 ARIMA(3,0,0)模型,并使用相同的滞后值以保持一致性。由于unrate序列是平稳的,因此无需进行差分。回想一下,从之前的活动中,lag_order等于 3。
from statsmodels.tsa.arima.model import ARIMA
model = ARIMA(train['unrate'],
order=(lag_order,0,0)).fit()
你可以使用model.summary()查看 ARIMA 模型的摘要。拟合模型后,你可以通过绘制诊断图表来评估残差,以检查模型拟合是否存在问题:
fig = model.plot_diagnostics(figsize=(12,6));
fig.tight_layout()
plt.show()
这将生成四个诊断子图:

图 11.14 – AR(3)或 ARIMA(3,0,0)模型诊断图输出
标准化残差图显示在 2020 年左右出现显著峰值,这可能是由于 COVID-19 的经济影响。根据 Q-Q 图,还可以观察到一些偏离正态分布的迹象,表明该模型可能未完全捕捉数据尾部行为。总体而言,AR 模型根据自相关图捕捉到了必要的信息。
现在,使用 AR(3)模型预测未来步骤,并将其与我们已经拟合并应用了inverse_transform的 VAR(3)模型进行比较:
# Forecast from AR(3) model
ar_forecast = pd.Series(model.forecast(n), index=test.index)
# Plot the forecast for AR(3) against the actual data
idx = test.index
plt.figure(figsize=(10, 4))
plt.plot(idx, test['unrate'], label='Actual unrate')
plt.plot(idx, ar_forecast, label='Forecasted unrate', linestyle='dashed')
plt.title('AR(3) model - Actual vs Forecasted unrate')
plt.legend()
plt.show()
这应该会生成以下图表:

图 11.15 – AR(3)预测与实际结果对比(测试集)
同样的图表也可以为 VAR(3)模型绘制,该模型考虑了FEDFUNDS对unrate的影响:
# plotting VAR(3) same code as before but without confidence intervals
idx = test.index
plt.figure(figsize=(10, 4))
plt.plot(idx, test['unrate'], label='Actual unrate')
plt.plot(idx, forecast_df['unrate'], label='Forecasted unrate', linestyle='dashed')
plt.title('VAR(3) model - Actual vs Forecasted unrate')
plt.legend()
plt.show()
这应该会生成以下图表:

图 11.16 – VAR(3)预测与实际结果对比(测试集)
最后,我们将计算均方根误差(RMSE)得分,以比较两个模型(VAR 和 AR)在测试数据上的表现:
from statsmodels.tools.eval_measures import rmse
rmse_var = rmse(test['FEDFUNDS'], forecast_df['unrate'])
print('VAR(3) RMSE = ', rmse_var)
rmse_ar = rmse(test['FEDFUNDS'], ar_forecast)
print('AR(3) RMSE = ', rmse_ar)
>>
VAR(3) RMSE = 0.9729655920788434
AR(3) RMSE = 0.7693416723850359
AR(3)模型相较于 VAR(3)模型具有更低的 RMSE,表明仅考虑unrate过去值的简单 AR(3)模型在此案例中表现更佳。包含unrate和FEDFUNDS的 VAR(3)模型并未显著提高unrate的预测精度。
根据结果,可能更倾向于选择 AR(3)模型,因为其复杂度较低且预测精度较高。然而,当变量之间存在更强的相互作用时,VAR 模型可以提供额外的洞察力和更精确的预测。
另见...
要了解有关 statsmodels 中 VAR 类的更多信息,请访问官方文档:www.statsmodels.org/dev/generated/statsmodels.tsa.vector_ar.var_model.VAR.html。
评估向量自回归(VAR)模型
在拟合 VAR 模型之后,下一步是评估模型如何捕捉不同内生变量(多个时间序列)之间的相互作用和动态关系。了解这些关系有助于您评估因果关系,变量如何相互影响,以及一个变量的冲击如何在系统中传播。
在本配方中,您将继续上一个配方《多元时间序列数据预测》中使用VAR的内容,并探索各种诊断工具以加深您对 VAR 模型的理解。具体来说,测试格兰杰因果关系,分析残差自相关函数(ACF)图,使用脉冲响应函数(IRF)以及进行预测误差方差分解(FEVD)。
这些评估步骤将帮助你理解系统中变量之间的因果关系和相互依赖关系,确保你的模型正确捕捉到潜在的动态。
如何操作...
以下步骤接着上一个步骤进行。如果你没有执行过这些步骤,可以运行附带的Jupyter Notebook中的代码来继续进行。你将专注于诊断之前创建的 VAR(3)模型:
- 首先,进行格兰杰因果关系检验,以确定一个时间序列是否可以用来预测另一个。在本例中,你需要找出
FEDFUNDS是否可以用来预测unrate。
根据之前的步骤,你已经使用BIC准则选择了3 个滞后期。然而,你可以根据数据的特性或具体的研究问题,调整滞后期的顺序,并在需要时测试更高的滞后期。
- 格兰杰因果关系检验在
statsmodels中通过grangercausalitytests()函数实现,该函数在每个过去的滞后期上执行四项检验。你可以通过maxlag参数控制滞后期的数量。格兰杰因果关系检验用于确定一个变量的过去值是否影响另一个变量。
格兰杰因果关系检验中的原假设是第二个变量(在本例中为FEDFUNDS)不对第一个变量(在本例中为unrate)产生格兰杰因果关系。换句话说,假设在影响或效应方面没有统计学意义。要测试FEDFUNDS是否影响unrate,你需要在应用检验之前交换列的顺序:
granger = grangercausalitytests(
x=economic_df[['unrate', 'FEDFUNDS']],
maxlag=3)
根据之前选择的 BIC 准则,测试的最大滞后期设为 3。输出将显示每个滞后期的检验统计量、p 值和自由度。重点关注p 值,以决定是否拒绝或接受原假设。
Granger Causality
number of lags (no zero) 1
ssr based F test: F=0.5680 , p=0.4515 , df_denom=413, df_num=1
ssr based chi2 test: chi2=0.5721 , p=0.4494 , df=1
likelihood ratio test: chi2=0.5717 , p=0.4496 , df=1
parameter F test: F=0.5680 , p=0.4515 , df_denom=413, df_num=1
Granger Causality
number of lags (no zero) 2
ssr based F test: F=21.9344 , p=0.0000 , df_denom=410, df_num=2
ssr based chi2 test: chi2=44.4039 , p=0.0000 , df=2
likelihood ratio test: chi2=42.1852 , p=0.0000 , df=2
parameter F test: F=21.9344 , p=0.0000 , df_denom=410, df_num=2
Granger Causality
number of lags (no zero) 3
ssr based F test: F=21.6694 , p=0.0000 , df_denom=407, df_num=3
ssr based chi2 test: chi2=66.1262 , p=0.0000 , df=3
likelihood ratio test: chi2=61.3477 , p=0.0000 , df=3
parameter F test: F=21.6694 , p=0.0000 , df_denom=407, df_num=3
由于滞后期 2 和 3 的 p 值都小于 0.05,这表明FEDFUNDS对unrate有格兰杰因果关系。换句话说,考虑到 2 个月或 3 个月的滞后期时,FEDFUNDS的过去值对unrate具有显著的预测能力。
- 接下来,你将探讨残差图。之前步骤中的
results对象results = model.fit(ic='bic')是VARResultsWrapper类型,与VARResults类相同,拥有相同的方法和属性。首先使用plot_acorr方法绘制残差的ACF图:
results.plot_acorr(resid=True)
plt.show();
这将生成四个图(2x2 图——每个变量各两个)来展示残差的自相关和交叉相关。回忆图 11.11,对于两个变量,你将有两个函数,这将转化为 2x2 的残差子图。如果有三个变量,则会有一个 3x3 的子图:

图 11.17 – 残差自相关和交叉相关图
不幸的是,图 11.17中的图表没有正确的标签。第一行的图表对应数据框架中的第一个变量(FEDFUNDS),第二行对应第二个变量(unrate)。
你需要观察残差中没有显著的自相关性,这在图 11.17中是成立的。这将确认 VAR(3)模型已经有效捕捉了变量之间的动态关系(FEDFUNDS 和 unrate),并且残差中没有模型未能捕捉和考虑的剩余模式。
- 如果你希望单独查看每个变量的残差,你可以通过提取
resid属性来实现。这将返回一个每个变量残差的 DataFrame。你可以使用标准的plot_acf函数来创建自相关函数(ACF)图:
for col in results.resid.columns:
fig, ax = plt.subplots(1,1, figsize=(10,2))
plot_acf(results.resid[col], zero=False,
lags=10, ax=ax, title=f'ACF - {col}')
这将生成两个 ACF 图——一个用于FEDFUNDS,另一个用于unrate。这将确认相同的发现——即残差中没有显著的自相关性。
- 接下来,你将进行脉冲响应函数(IRF)分析。IRF 帮助你可视化并理解一个变量的冲击如何随时间影响另一个变量(或其自身),这对于评估 VAR 模型中变量之间的动态相互作用至关重要。
你将使用irf方法分析系统对冲击的响应:
irf_output = results.irf()
irf_output.plot()
plt.show()
irf_output对象是IRAnalysis类型,可以访问plot()函数:

图 11.18 – 显示一个变量的单位变化对另一个变量的冲击响应
脉冲响应函数(IRF)分析计算动态脉冲响应和近似标准误差,并显示一个变量的冲击(或脉冲)对其他变量随时间(滞后)变化的影响。在VAR 模型中,所有变量相互影响,IRF 追踪一个变量变化对其他变量随时间的影响。
例如,FEDFUNDS → unrate(位于图 11.18的左下角)显示了
unrate如何对FEDFUNDS增加一个单位在 10 个滞后期中的反应。立刻可以观察到unrate有明显的急剧负响应,这表明联邦基金利率的提高会降低失业率。该效应持续存在,但随着时间的推移逐渐减弱,这与货币政策如何影响经济中的就业一致。从滞后期 2 到 滞后期 3,我们可以看到有一个稳定期,响应水平有所平稳,这就是滞后效应开始显现的地方;初始下降发生得很快,但总的调整需要更长时间,因为unrate仍然低于起始点。在滞后期 3 之后,我们看到unrate略微上升,这种逐步调整表明unrate开始缓慢恢复,但在若干期内仍未完全回到冲击前的水平。
另一方面,unrate → FEDFUNDS(图 11.18 的右上角)显示了一个相对较小且略微正向的响应。这表明失业率(unrate)的提高会导致FEDFUNDS略微上升,但该效应会随着时间的推移减弱。
如果你只想查看一个变量对另一个变量的响应(而不是所有子图),你可以指定 impulse 和 response 变量:
fig = irf_output.plot(impulse='FEDFUNDS', response='unrate', figsize=(5, 7))
fig.tight_layout()
plt.show()
- 绘制累积响应效应:
irf.plot_cum_effects()
plt.show()

图 11.19 – 累积脉冲响应函数图
累积响应图展示了冲击效果如何随着时间的推移逐渐累积。例如,FEDFUNDS → unrate 图(图 11.19 的左下角),你可以看到 FEDFUNDS 增加一个单位对unrate的累积效应会导致unrate持续下降。累积响应有助于你评估这些冲击的长期影响。
- 接下来,你将进入预测误差方差分解(FEVD),以量化系统中每个变量的预测误差方差有多少是由自身冲击和其他变量的冲击造成的。换句话说,你需要了解每个变量冲击的贡献。
你可以使用 fevd() 方法计算 FEVD:
fv = results.fevd()
你可以使用 fv.summay() 和 fv.plot() 方法探索 FEVD 结果。它们提供相似的信息:
fv.plot()
plt.show()
这将生成两个 FEVD 图(图 11.20),每个变量一个。

图 11.20 – FEDFUNDS 和 unrate 变量的 FEVD 图
x 轴表示从 0 到 9 的时期(滞后期),y 轴表示每个冲击对预测误差方差的贡献百分比(0 到 100%)。从视觉上看,每个冲击的贡献在每个时期的总条形中占据一定部分。
FEDFUNDS 图(顶部)显示,FEDFUNDS的整个预测误差方差由其自身的冲击解释。这意味着unrate的冲击对FEDFUNDS的预测误差方差没有影响。
另一方面,失业率图(底部)开始时显示,unrate的预测误差方差主要由其自身的冲击解释。然而,从滞后 4 期开始,FEDFUNDS的贡献开始增大。到滞后 9 期时,大约 30%的unrate方差可以归因于FEDFUNDS的冲击。这表明,在较长的时间范围内,FEDFUNDS成为unrate预测误差方差的更重要驱动因素。
它是如何工作的……
来自 statsmodels 的 VAR 实现提供了多个工具,帮助你理解不同时间序列(或内生变量)之间的动态关系。这包括格兰杰因果检验、脉冲响应函数(IRFs)和预测误差方差分解(FEVD)等方法。这些工具为你提供了不同的视角,帮助你理解系统中变量如何随时间互动,不论是通过因果关系还是一个变量的冲击如何影响其他变量。
除了理解变量之间的关系外,还提供了诊断工具(如残差分析和自相关图),帮助你评估模型拟合度,并判断是否需要进行调整(如模型阶数、差分或增加变量)。
还有更多……
在之前的使用 VAR 预测多元时间序列数据示例中,你手动创建了预测图。然而,results对象(即VARResults类的实例)提供了一种便捷的方式,使用plot_forecast方法可以快速绘制你的预测图。此函数会自动生成预测值及其置信区间。
n = len(test)
results.plot_forecast(steps=n, plot_stderr=True);
在这里,n是未来的步数。这个操作应该会生成两个子图,每个变量都有一个预测图,如下图所示:

图 11.21 – FEDFUNDS 和失业率的预测图
另见…
要了解更多关于VARResults类及其所有可用方法和属性的信息,请访问官方文档:www.statsmodels.org/dev/generated/statsmodels.tsa.vector_ar.var_model.VARResults.html。
使用 GARCH 模型预测金融时间序列数据的波动性
在处理金融时间序列数据时,一个常见的任务是衡量波动性,它代表了未来回报的不确定性。一般来说,波动性衡量的是在特定时期内回报的概率分布范围,通常通过方差或标准差(即方差的平方根)来计算。它被用作量化风险或不确定性的代理指标。换句话说,它衡量了金融资产回报围绕预期值的分散程度。更高的波动性意味着更高的风险。这有助于投资者理解他们能期望的回报水平,以及回报与预期值之间的差异频率。
我们之前讨论的大多数模型(如 ARIMA、SARIMA 和 Prophet)都专注于基于过去的值预测观察到的变量。然而,这些模型假设方差是恒定的(同方差性),并没有考虑方差随时间的变化(异方差性)。
在本教程中,你将处理另一种类型的预测:预测和建模方差随时间变化的情况。这就是所谓的波动性。一般而言,当存在不确定性时,波动性是衡量风险的一个重要指标,也是处理金融数据时的一个重要概念。
为了实现这一点,你将接触到一类与自回归条件异方差(ARCH)相关的新算法。ARCH 模型捕捉了方差随时间的变化,它将变化建模为来自过去时间点的平方误差项(创新)的函数。ARCH 的扩展是GARCH模型,代表广义自回归条件异方差。它通过增加一个移动平均成分来考虑过去的方差,从而提供了一个更灵活、更持久的波动性结构。
GARCH 在计量经济学中很受欢迎,金融机构广泛使用它来评估投资和市场风险。预测市场动荡和波动性与预测未来价格同样重要,许多交易策略——例如均值回归——都将波动性作为一个关键因素。
在继续之前,让我们分解一下一个典型 ARCH 模型的组成部分:
-
自回归 - 这是我们在第十章,使用统计方法构建单变量时间序列模型中探讨过的一个概念,意味着变量的当前值受到其过去值的影响。在 ARCH 模型中,这意味着当前的波动性(方差)受过去的平方误差项(创新)的影响。
-
异方差性 - 意味着模型在不同的时间点可能具有不同的幅度或波动性(方差随时间变化)。
-
条件性 - 由于方差(波动性)不是固定的,它依赖于过去的信息。换句话说,在某一时点的方差条件性地依赖于该序列中过去的值,意味着随着新信息的到来,波动性会被更新。
在本例中,您将创建一个GARCH 模型,其阶数为(p, q),其中p表示滞后方差的数量(GARCH 项),q表示滞后平方残差的数量(ARCH 项)。
使用arch Python 库,您将通过arch_model函数实现这一点。该函数中的参数可以根据 GARCH 模型的假设分为三个主要组件:
-
dist:控制创新项(残差)的分布假设,默认为'normal'。 -
mean:控制条件均值的模型,默认为'Constant',即假设均值为常数。 -
vol:控制波动率模型(条件方差),默认为'GARCH'。
在大多数文献中,q通常表示ARCH 阶数(滞后平方残差或创新项的数量),而p表示GARCH 阶数(滞后方差的数量)。然而,在
archPython 库中,p和q的角色是颠倒的。在
arch包中,p表示平方残差(ARCH组件)的滞后阶数,而q表示滞后方差(GARCH组件)的滞后阶数。这个区别在使用
arch库指定和解释 GARCH 模型时非常重要,因为它与学术文献中使用的常规符号有所不同。例如,在
arch_model中指定p=2和q=1,实际上会根据常规符号拟合一个GARCH(1, 2)模型(包含 1 个滞后方差和 2 个滞后残差)。
准备工作
在本例中,您将使用arch库,它包含了多个波动率模型以及金融计量经济学工具。该库的输出与 statsmodels 库的输出类似。撰写本文时,最新版本为7.1.0。
使用pip安装arch,请使用以下命令:
pip install arch
使用conda安装arch,请使用以下命令:
conda install -c conda-forge arch-py
操作步骤...
当使用arch库中的arch_model函数构建GARCH模型时,有几个关键参数:默认的分布是normal(dist='normal'),均值模型是常数(mean='Constant'),波动率模型是 GARCH(默认vol='GARCH')。根据上下文,您还可以指定其他均值模型,如自回归模型('AR')。同样,您也可以选择其他波动率模型,如'GARCH'、'ARCH'、'EGARCH'、'FIGARCH'和'APARCH'。您还可以选择不同的分布,如'gaussian'、't'、'studentst'、'skewstudent'。
本例中,您将使用微软的日收盘价数据集。请按照以下步骤进行:
- 首先加载本例所需的库:
from arch import arch_model
import pandas as pd
- 加载
MSFT.csv数据集:
msft = pd.read_csv('../../datasets/Ch11/MSFT.csv',
index_col='date',
usecols=['date', 'close'],
parse_dates=True)
- 你需要将每日股价转换为每日股价收益。这可以通过计算当前时间的价格与前一天的价格之间的比例来完成。在 pandas 中可以使用
DataFrame.pct_change()函数,之后乘以 100 将收益表示为百分比。
pct_change() 函数接受一个 periods 参数,默认值为 1。如果你想计算 30 天的收益率,那么你需要将该值改为 pct_change(periods=30):
msft['returns'] = 100 * msft.pct_change()
msft.dropna(inplace=True, how='any')
dropna 步骤是必要的,因为计算 returns 会对第一行产生 NaN。
- 你可以同时绘制每日股价和每日收益:
title = 'Microsoft Daily Closing Price and Daily Returns'
msft.plot(subplots=True,
title=title);
这将产生以下图表:

图 11.22 – 微软每日收盘价和日收益
- 将数据拆分为训练集和测试集。对于短期波动率预测,你将检查模型如何预测接下来几天的波动率。在拆分数据时,你将留出最后 5 天作为测试数据。
train = msft.returns[:-5]
test = msft.returns[-5:]
print(f'Train: {train.shape}')
print(f'Test: {test.shape}')
print(f'Train: {train.shape}')
print(f'Test: {test.shape}')
>>
Train: (1253,)
Test: (5,)
- 拟合一个GARCH(p, q) 模型。从简单的 GARCH(1,1) 模型开始,使用所有默认选项——即
mean='Constant',分布为dist='normal',波动率为vol='GARCH',p=1,q=1。GARCH(1,1) 是最常用的模型,也是波动率预测的一个很好的起点,因其简洁性和有效性在捕捉波动率集群方面表现出色。
model = arch_model(train,
p=1, q=1,
mean='Constant',
vol='GARCH',
dist='normal')
results = model.fit(update_freq=5)
>>
Iteration: 3, Func. Count: 22, Neg. LLF: 2419.4197011866704
Iteration: 6, Func. Count: 41, Neg. LLF: 2409.599553434422
Iteration: 9, Func. Count: 55, Neg. LLF: 2409.592672855605
Optimization terminated successfully (Exit mode 0)
Current function value: 2409.59267285418
Iterations: 9
Function evaluations: 55
Gradient evaluations: 9
拟合过程在九次(9)迭代中完成。使用 update_freq=3 会影响拟合过程中打印进度的频率。默认值是 1,意味着每次迭代都会打印输出。通过设置为三(3),我们每三次迭代打印一次输出。要打印总结,使用 summary() 方法:
Print(results.summary())
这将产生以下输出:

图 11.22 – GARCH(1,1) 模型的总结
GARCH(1,1) 模型的omega、alpha 和 beta 参数(符号)是通过最大似然方法估计的。系数的 p-value 表明它们是统计学上 显著的。
你可以通过从 results 对象中调用相应的属性来访问总结表中看到的几个组件——例如,results.pvalues,results.tvalues,results.std_err,或 results.params。
print(results.params)
>>
mu 0.144878
omega 0.055152
alpha[1] 0.095416
beta[1] 0.891086
Name: params, dtype: float64
- 接下来,你需要评估模型的表现。首先,使用
plot方法绘制标准化残差和条件波动率:
results.plot();
这应该产生以下图表:

图 11.23 – GARCH(1,1) 模型的诊断
如果模型拟合良好,那么标准化残差图应该呈现出白噪声的样子,表明残差中没有残余模式。该图似乎表现出随机性,没有明显的模式,表明拟合良好。条件波动率图显示了模型估计的时间变化波动率,反映了波动率随时间变化的情况。你可以看到反映市场状况的高波动和低波动时期。
绘制标准化残差的直方图。你可以使用std_resid属性来获取该数据:
results.std_resid.hist(bins=20)
plt.title('Standardized Residuals')

图 11.24 – GARCH 模型标准化残差的直方图
直方图表明标准化残差大致符合正态分布,尽管你可以使用额外的统计检验,如Jarque-Bera检验,进行更正式的正态性评估。
- 你还可以通过使用Ljung-Box 检验来测试自相关性,该检验检验无自相关性的原假设。在前 10 个滞后期中,使用
acorr_ljungbox。p 值大于 0.05 表示无法拒绝原假设,表明该滞后期没有显著的自相关性。
from statsmodels.stats.diagnostic import acorr_ljungbox
acorr_ljungbox(results.std_resid,
lags=10,
return_df=True)['lb_pvalue']
>>
1 0.050203
2 0.038038
3 0.077375
4 0.136003
5 0.195838
6 0.157237
7 0.201474
8 0.248204
9 0.153473
10 0.210838
Name: lb_pvalue, dtype: float64
来自 Ljung-Box 检验的 p 值表明,在大多数滞后期,标准化残差没有显著的自相关性。由于大多数 p 值大于 0.05,我们未能拒绝无自相关性的原假设,表明模型拟合良好。
- 要进行预测,请使用
forecast()方法。默认情况下,它会生成一步(1 步)预测。要获取n步预测,你需要更新horizon参数:
msft_forecast = results.forecast(horizon=test.shape[0])
- 要访问预测的未来方差(或波动率),请使用
msft_forecast对象中的variance属性:
forecast = msft_forecast.variance
print(forecast)
>>
h.1 h.2 h.3 h.4 h.5
date
2024-08-27 1.623692 1.656928 1.689714 1.722059 1.753967
你还可以评估预测的均值。回想一下,当你拟合模型时,你指定了均值为Constant。如果你检查均值,这一点将得到进一步验证:
print(msft_forecast.mean)
>>
h.1 h.2 h.3 h.4 h.5
date
2024-08-27 0.144878 0.144878 0.144878 0.144878 0.144878
这将在所有时间段中输出一个常数值0.144878。
工作原理……
GARCH(p,q)可以写成如下形式:
Omega、alpha 和 beta(
)是此处的参数。p阶通常被称为 GARCH 阶,而q被称为 ARCH 阶。
GARCH 模型的参数表示如下:
-
Omega:常数或基准方差
-
Alpha:滞后平方残差的系数(ARCH 项),衡量近期冲击对波动率的影响。
-
Beta:滞后方差的系数(GARCH 项),表示波动率随时间的持续性。
在
archPython 包中,p和q的角色被交换,p表示 ARCH 成分(滞后平方残差),q表示 GARCH 成分(滞后方差)。
你实现的 GARCH(1,1)模型可以写成如下形式:

时间序列中的创新与误差
在时间序列文献中,你会遇到创新这个术语,指的是无法通过过去的信息预测的意外和不可预测的新信息、冲击或误差。简而言之,你可以将创新视为每个时间步的预测误差或意外情况。虽然在机器学习中,我们通常称这些为预测误差,但在像 ARCH/GARCH 这样的时间序列模型中,我们使用创新这个术语来描述影响模型的新、出乎意料的信息。
还有更多...
之前,在实现 GARCH 模型时,你将均值设置为'Constant'。现在,让我们探讨将均值更改为'Zero'的影响,这实际上会将均值模型从方程中移除。
让我们从设置mean='Zero'开始:
model = arch_model(train,
p=1, q=1,
mean='Zero',
vol='GARCH',
dist='normal')
results = model.fit(disp=False)
这将生成一个表格格式的 GARCH 总结:

图 11.25 – 带零均值的 GARCH(1, 1)
请注意,在图 11.25 中,没有像图 11.21 中那样的均值模型。使用零均值在你想将波动性建模与均值分开时很常见。当你主要关注建模波动性,而不需要用于基础回报的均值模型时,这种方法非常有帮助。
另见
要了解更多关于arch库的信息,请访问官方文档:arch.readthedocs.io/en/latest/univariate/introduction.html。
第十二章:14 无监督机器学习中的异常值检测
加入我们的书籍社区,参与 Discord 讨论

在第八章,使用统计方法的异常值检测中,你探讨了使用参数和非参数统计技术来识别潜在的异常值。这些方法简单、可解释,而且相当有效。
异常值检测并非简单直观,主要是由于异常值的定义存在模糊性,这种定义取决于你的数据或你试图解决的问题。例如,尽管常见,但第八章中使用的某些阈值,使用统计方法的异常值检测,仍然是任意的,并非你必须遵循的规则。因此,拥有领域知识或能够访问主题专家(SMEs)对于正确判断异常值至关重要。
在本章中,你将会接触到基于机器学习的几种异常值检测方法。大多数机器学习异常值检测技术被视为无监督异常值检测方法,例如孤立森林(iForest)、无监督K-近邻算法(KNN)、局部异常因子(LOF)以及基于 Copula 的异常值检测(COPOD)等。
通常情况下,异常值(或异常情况)被认为是一种罕见现象(在本章稍后你会看到,这被称为污染率)。换句话说,你会假设在一个大型数据集中,只有一小部分数据是异常值。例如,1%的数据可能是潜在的异常值。然而,这种复杂性需要设计出能够发现数据模式的方法。无监督的异常值检测技术擅长于发现罕见现象中的模式。
在调查异常值之后,你将拥有一个历史标签数据集,允许你使用半监督的异常值检测技术。本章重点讲解无监督异常值检测。
在本章中,你将接触到PyOD库,它被描述为“一个全面且可扩展的 Python 工具包,用于检测多变量数据中的异常对象。”该库提供了一个广泛的实现集合,涵盖了异常值检测领域的流行算法和新兴算法,你可以在此阅读更多内容:github.com/yzhao062/pyod。
你将使用相同的纽约出租车数据集,这样可以更方便地比较本章不同机器学习方法和第八章,使用统计方法的异常值检测中的统计方法的结果。
本章中你将遇到的内容如下*:
-
使用KNN检测异常值
-
使用LOF检测异常值
-
使用iForest检测异常值
-
使用一类支持向量机(OCSVM)检测异常值
-
使用COPOD检测异常值
-
使用PyCaret检测异常值
技术要求
你可以从 GitHub 仓库下载所需的 Jupyter notebooks 和数据集:
-
Jupyter notebooks:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./blob/main/code/Ch14/Chapter%2014.ipynb -
数据集:
github.com/PacktPublishing/Time-Series-Analysis-with-Python-Cookbook./tree/main/datasets/Ch14
你可以使用 pip 或 Conda 安装 PyOD。对于 pip 安装,运行以下命令:
pip install pyod
对于 Conda 安装,运行以下命令:
conda install -c conda-forge pyod
为了准备异常值检测方法,首先加载你将在整个章节中使用的库:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from pathlib import Path
import warnings
warnings.filterwarnings('ignore')
plt.rcParams["figure.figsize"] = [16, 3]
将 nyc_taxi.csv 数据加载到 pandas DataFrame 中,因为它将在整个章节中使用:
file = Path("../../datasets/Ch14/nyc_taxi.csv")
nyc_taxi_2 = pd.read_csv(file,
index_col='timestamp',
parse_dates=True)
nyc_taxi_2.index.freq = '30T'
你可以存储包含异常值的已知日期,也称为真实标签:
nyc_dates = [
"2014-11-01",
"2014-11-27",
"2014-12-25",
"2015-01-01",
"2015-01-27"]
创建你将在整个方法中使用的 plot_outliers 函数:
def plot_outliers(outliers, data, method='KNN',
halignment = 'right',
valignment = 'top',
labels=False):
ax = data.plot(alpha=0.6)
if labels:
for i in outliers['value'].items():
plt.plot(i[0], i[1], 'v', markersize=8, markerfacecolor='none', markeredgecolor='k')
plt.text(i[0], i[1]-(i[1]*0.04), f'{i[0].strftime("%m/%d")}',
horizontalalignment=halignment,
verticalalignment=valignment)
else:
data.loc[outliers.index].plot(ax=ax, style='rX', markersize=9)
plt.title(f'NYC Taxi - {method}')
plt.xlabel('date'); plt.ylabel('# of passengers')
plt.legend(['nyc taxi','outliers'])
plt.show()
在进行异常值检测时,目标是观察不同技术如何捕捉异常值,并将其与真实标签进行比较,如下所示:
tx = nyc_taxi.resample('D').mean()
known_outliers = tx.loc[nyc_dates]
plot_outliers(known_outliers, tx, 'Known Outliers')
上述代码应生成一个时间序列图,其中标记 X 表示已知的异常值:

图 14.1:对下采样后的纽约出租车数据进行绘图,并附上真实标签(异常值)
PYOD 的训练与预测方法
像 scikit-learn 一样,PyOD 提供了熟悉的方法来训练模型并进行预测,方法包括:
model.fit()、model.predict()和model.fit_predict()。在这些方法中,我们将过程分为两步,首先使用
.fit()拟合模型(训练),然后使用.predict()进行预测。
除了 predict 方法,PyOD 还提供了两个附加方法:predict_proba 和 predict_confidence。
在第一个方法中,你将探索 PyOD 如何在幕后工作,并介绍基本概念,例如 contamination 的概念,以及如何使用 threshold_ 和 decision_scores_ 来生成二元标签(异常 或 正常)。这些概念将在接下来的方法中详细讨论。
使用 KNN 检测异常值
KNN 算法通常用于监督学习环境,其中先前的结果或输出(标签)是已知的。
它可以用来解决分类或回归问题。这个思想很简单;例如,你可以根据最近邻来分类一个新的数据点 Y。例如,如果 k=5,算法会通过与点 Y 的距离找到五个最近的邻居,并根据多数邻居的类别来确定 Y 的类别。如果五个邻居中有三个是蓝色,两个是红色,那么 Y 将被分类为蓝色。KNN 中的 K 是一个参数,你可以修改它以找到最佳值。
在离群点检测的情况下,算法的使用方式有所不同。由于我们事先不知道离群点(标签),KNN 以无监督的方式进行学习。在这种情况下,算法会为每个数据点找到最近的K个邻居,并计算它们的平均距离。与数据集其余部分的距离最远的点将被视为离群点,更具体地说,它们被认为是全局离群点。在这种情况下,距离成为确定哪些点是离群点的评分依据,因此 KNN 是一种基于邻近的算法。
一般来说,基于邻近的算法依赖于离群点与其最近邻之间的距离或接近度。在 KNN 算法中,最近邻的数量,k,是你需要确定的一个参数。PyOD 支持 KNN 算法的其他变种,例如,平均 KNN(AvgKNN),它使用与 KNN 的平均距离进行评分;以及中位数 KNN(MedKNN),它使用中位数距离进行评分。
如何实现...
在这个示例中,你将继续使用在技术要求部分创建的tx数据框,通过 PyOD 的KNN类来检测离群点:
- 首先加载
KNN类:
from pyod.models.knn import KNN
- 你需要熟悉一些参数来控制算法的行为。第一个参数是
contamination,一个数字(浮动)值,表示数据集中离群点的比例。这是 PyOD 中所有不同类别(算法)通用的参数。例如,contamination值为0.1表示你期望数据中 10%是离群点。默认值为contamination=0.1。contamination的值可以在0到0.5(即 50%)之间变化。你需要通过实验来调整contamination值,因为该值会影响用于确定潜在离群点的评分阈值,以及返回多少潜在离群点。你将在本章的它是如何工作的...部分深入了解这一点。
例如,如果你怀疑数据中的离群点比例为 3%,你可以将其作为contamination值。你可以尝试不同的contamination值,检查结果,并确定如何调整contamination水平。我们已经知道在 215 个观测值中有 5 个已知的离群点(约 2.3%),在这个示例中,你将使用 0.03(或 3%)。
第二个参数,特定于 KNN 的是method,其默认值为method='largest'。在本教程中,你将把它更改为mean(所有k邻居距离的平均值)。第三个参数,亦为 KNN 特有的是metric,它告诉算法如何计算距离。默认值是minkowski距离,但它可以接受来自 scikit-learn 或 SciPy 库的任何距离度量。最后,你需要提供邻居的数量,默认值为n_neighbors=5。理想情况下,你将希望使用不同的 KNN 模型并比较不同k值的结果,以确定最佳邻居数量。
- 使用更新后的参数实例化 KNN,然后训练(拟合)模型:
knn = KNN(contamination=0.03,
method='mean',
n_neighbors=5)
knn.fit(tx)
>>
KNN(algorithm='auto', contamination=0.05, leaf_size=30, method='mean',
metric='minkowski', metric_params=None, n_jobs=1, n_neighbors=5, p=2,
radius=1.0)
predict方法将为每个数据点生成二进制标签,1或0。值为1表示是异常值。将结果存储在 pandas Series 中:
predicted = pd.Series(knn.predict(tx),
index=tx.index)
print('Number of outliers = ', predicted.sum())
>>
Number of outliers = 6
- 过滤
predictedSeries,仅显示异常值:
outliers = predicted[predicted == 1]
outliers = tx.loc[outliers.index]
outliers
>>
Timestamp value
2014-11-01 20553.500000
2014-11-27 10899.666667
2014-12-25 7902.125000
2014-12-26 10397.958333
2015-01-26 7818.979167
2015-01-27 4834.541667
总体来看,结果很有前景;已识别出五个已知日期中的四个。此外,算法还识别了圣诞节后的那一天,以及 2015 年 1 月 26 日,那一天由于北美暴风雪,所有车辆都被命令驶离街道。
- 使用在技术要求部分创建的
plot_outliers函数来可视化输出,以获得更好的洞察:
plot_outliers(outliers, tx, 'KNN')
上述代码应生成类似于图 14.1的图表,不同之处在于x标记是基于使用 KNN 算法识别的异常值:

图 14.2:使用 KNN 算法识别的潜在异常值标记
要打印标签(日期)和标记,只需再次调用plot_outliers函数,但这次要设置labels=True:
plot_outliers(outliers, tx, 'KNN', labels=True)
上述代码应生成一个类似于图 14.2的图表,并附加文本标签。
工作原理...
KNN 算法的无监督方法计算一个观测值与其他邻近观测值的距离。PyOD 中 KNN 的默认距离是闵可夫斯基距离(p-范数距离)。你可以更改为不同的距离度量,例如使用euclidean或l2表示欧几里得距离,或使用manhattan或l1表示曼哈顿距离。可以使用metric参数来实现这一点,metric参数可以接受字符串值,例如metric='l2'或metric='euclidean',也可以是来自 scikit-learn 或 SciPy 的可调用函数。这是一个需要实验的参数,因为它影响距离的计算方式,而异常值分数正是基于此进行计算的。
传统上,当人们听到 KNN 时,他们立即认为它仅仅是一个监督学习算法。对于无监督 KNN,有三种常见的算法:ball tree、KD tree 和暴力搜索。PyOD 库支持这三种算法,分别为 ball_tree、kd_tree 和 brute。默认值设置为 algorithm="auto"。
PyOD 使用特定于每个算法的内部评分,对训练集中的每个观测值进行评分。decision_scores_ 属性将显示每个观测值的这些评分。较高的评分表示该观测值更有可能是异常值:
knn_scores = knn.decision_scores_
你可以将其转换为 DataFrame:
knn_scores_df = (pd.DataFrame(scores,
index=tx.index,
columns=['score']))
knn_scores_df
由于所有数据点都被评分,PyOD 会确定一个阈值来限制返回的异常值数量。阈值的大小取决于你之前提供的 污染 值(你怀疑的异常值比例)。污染值越高,阈值越低,因此返回的异常值越多。污染值越低,阈值会提高。
你可以使用模型在拟合训练数据后,从 threshold_ 属性中获取阈值。以下是基于 3% 污染率的 KNN 阈值:
knn.threshold_
>> 225.0179166666657
这是用来过滤显著异常值的值。以下是如何重现这一点的示例:
knn_scores_df[knn_scores_df['score'] >= knn.threshold_].sort_values('score', ascending=False)
输出结果如下:

图 14.3:显示来自 PyOD 的决策评分
请注意,最后一个观测值 2014-09-27 稍微高于阈值,但在你使用 predict 方法时并没有返回。如果使用污染阈值,你可以得到一个更好的截止点:
n = int(len(tx)*0.03)
knn_scores_df.nlargest(n, 'score')
另一个有用的方法是 predict_proba,它返回每个观测值是正常值和异常值的概率。PyOD 提供了两种方法来确定这些概率:linear 或 unify。这两种方法都会在计算概率之前对异常值评分进行缩放。例如,在 linear 方法的实现中,使用了 scikit-learn 的 MinMaxScaler 来缩放评分,然后再计算概率。unify 方法则使用 z-score(标准化)和 SciPy 库中的高斯误差函数(erf)(scipy.special.erf)。
你可以比较这两种方法。首先,使用 linear 方法计算预测概率,你可以使用以下代码:
knn_proba = knn.predict_proba(tx, method='linear')
knn_proba_df = (pd.DataFrame(np.round(knn_proba * 100, 3),
index=tx.index,
columns=['Proba_Normal', 'Proba_Anomaly']))
knn_proba_df.nlargest(n, 'Proba_Anomaly')
对于 unify 方法,你只需将 method='unify' 更新即可。
要保存任何 PyOD 模型,你可以使用 joblib Python 库:
from joblib import dump, load
# save the knn model
dump(knn, 'knn_outliers.joblib')
# load the knn model
knn = load('knn_outliers.joblib')
还有更多内容...
在之前的步骤中,当实例化 KNN 类时,你将计算异常值 评分 的 method 值更改为 mean:
knn = KNN(contamination=0.03,
method='mean',
n_neighbors=5)
让我们为 KNN 算法创建一个函数,通过更新 method 参数为 mean、median 或 largest,以训练模型并检查这些方法对决策评分的影响:
-
largest使用到 k 邻居的最大距离作为异常值评分。 -
mean使用与 k 邻居的距离的平均值作为异常值分数。 -
median使用与 k 邻居的距离的中位数作为异常值分数。
创建 knn_anomaly 函数,包含以下参数:data、method、contamination 和 k:
def knn_anomaly(df, method='mean', contamination=0.05, k=5):
knn = KNN(contamination=contamination,
method=method,
n_neighbors=5)
knn.fit(df)
decision_score = pd.DataFrame(knn.decision_scores_,
index=df.index, columns=['score'])
n = int(len(df)*contamination)
outliers = decision_score.nlargest(n, 'score')
return outliers, knn.threshold_
你可以通过不同的方法、污染度和 k 值来运行该函数进行实验。
探索不同方法如何生成不同的阈值,这会影响异常值的检测:
for method in ['mean', 'median', 'largest']:
o, t = knn_anomaly(tx, method=method)
print(f'Method= {method}, Threshold= {t}')
print(o)
前述代码应该会打印出每种方法的前 10 个异常值(污染度为 5%):

图 14.4:使用不同 KNN 距离度量比较决策分数
注意,前六个(表示 3% 污染度)对于三种方法是相同的。顺序可能会有所不同,各方法的决策分数也不同。请注意,各方法之间的差异在前六个之后更为明显,如 图 14.4 所示。
另见
查看以下资源:
-
要了解更多关于无监督 KNN 的内容,scikit-learn 库提供了关于其实现的很棒的解释:
scikit-learn.org/stable/modules/neighbors.html#unsupervised-nearest-neighbors。 -
要了解更多关于 PyOD KNN 和不同参数的内容,请访问官方文档:
pyod.readthedocs.io/en/latest/pyod.models.html?highlight=knn#module-pyod.models.knn。
使用 LOF 检测异常值
在前一个实例中,使用 KNN 检测异常值,KNN 算法通过观测点之间的距离来为检测异常值计算决策分数。与 KNN 距离较远的数据点可以被认为是异常值。总体来说,该算法在捕捉全局异常值方面表现不错,但对于那些远离周围点的数据点,可能无法很好地识别局部异常值。
这时,LOF(局部异常因子)就能解决这个问题。LOF 不使用邻近点之间的距离,而是通过密度作为基础来为数据点评分并检测异常值。LOF 被认为是一个基于密度的算法。LOF 的理念是,异常值会离其他数据点较远且更加孤立,因此会出现在低密度区域。
用一个例子来说明这个概念会更清楚:假设一个人站在小而忙碌的星巴克队伍中,每个人几乎都靠得很近;那么,我们可以说这个人处于高密度区域,更具体地说是高局部密度。如果这个人决定在停车场里等着,直到队伍稍微缓解,他就被孤立了,处于低密度区域,因此被认为是异常值。从排队的人角度来看,他们可能不知道车里的人,但车里的人能看到所有排队的人。所以,从他们的角度来看,车里的人被认为是不可接近的。我们称之为逆向可达性(从邻居的角度看你有多远,而不仅仅是你自己的视角)。
和 KNN 一样,你仍然需要定义用于最近邻居数量的k参数。最近邻居是基于观察值之间的距离进行识别的(想想 KNN),然后对每个邻近点计算局部可达密度(LRD或简称局部密度)。这个局部密度是用于比较第k个邻近观察值的评分,那些局部密度低于第k个邻居的点被视为异常值(它们离邻居的范围更远)。
如何操作……
在这个实例中,你将继续使用在技术要求部分创建的tx DataFrame,使用 PyOD 中的LOF类来检测异常值:
- 首先加载
LOF类:
from pyod.models.lof import LOF
- 你应该熟悉几个控制算法行为的参数。第一个参数是
contamination,它是一个数值(浮动型),表示数据集中异常值的比例。例如,0.1表示你预计 10%的数据是异常值。默认值是contamination=0.1。在本例中,你将使用0.03(3%)。
第二个参数是邻居的数量,默认为n_neighbors=5,类似于 KNN 算法。理想情况下,你会使用不同的k(n_neighbors)值运行不同的模型,并比较结果以确定最佳的邻居数。最后,metric参数指定用来计算距离的度量。可以使用 scikit-learn 或 SciPy 库中的任何距离度量(例如,欧几里得距离或曼哈顿距离)。默认值是闵可夫斯基距离,metric='minkowski'。由于闵可夫斯基距离是欧几里得距离(
)和曼哈顿距离(
)的推广,你会看到一个p参数。默认情况下,p=2表示欧几里得距离,而p=1表示曼哈顿距离。
- 通过更新
n_neighbors=5和contamination=0.03来实例化 LOF,同时保持其他参数为默认值。然后,训练(拟合)模型:
lof = LOF(contamination=0.03, n_neighbors=5)
lof.fit(tx)
>>
LOF(algorithm='auto', contamination=0.03, leaf_size=30, metric='minkowski',
metric_params=None, n_jobs=1, n_neighbors=5, novelty=True, p=2)
predict方法将为每个数据点输出1或0。值为1表示异常值。将结果存储在一个 pandas Series 中:
predicted = pd.Series(lof.predict(tx),
index=tx.index)
print('Number of outliers = ', predicted.sum())
>>
Number of outliers = 6
- 过滤预测的序列,仅显示异常值:
outliers = predicted[predicted == 1]
outliers = tx.loc[outliers.index]
outliers
>>
Timestamp value
2014-10-31 17473.354167
2014-11-01 20553.500000
2014-12-25 7902.125000
2014-12-26 10397.958333
2015-01-26 7818.979167
2015-01-27 4834.541667
有趣的是,它捕获了五个已知日期中的三个,但成功识别了感恩节后的那一天和圣诞节后的那一天为异常值。此外,10 月 31 日是星期五,那天是万圣节夜晚。
使用在技术要求部分创建的plot_outliers函数来可视化输出,以获得更好的洞察:
plot_outliers(outliers, tx, 'LOF')
前面的代码应生成一个类似于图 14.1的图表,只不过x标记是基于使用 LOF 算法识别的异常值:

图 14.5:使用 LOF 算法识别的潜在异常值标记
若要打印带有标记的标签(日期),只需再次调用plot_outliers函数,但这次传入labels=True:
plot_outliers(outliers, tx, 'LOF', labels=True)
前面的代码应生成一个类似于图 14.5的图表,并附加文本标签。
它是如何工作的...
LOF是一种基于密度的算法,它假设异常点比邻居更加孤立,且具有较低的局部密度得分。
LOF 类似于 KNN,因为我们在计算局部密度之前,测量邻居之间的距离。局部密度是决策得分的基础,你可以通过decision_scores_属性查看这些得分:
timestamp score
2014-11-01 14.254309
2015-01-27 5.270860
2015-01-26 3.988552
2014-12-25 3.952827
2014-12-26 2.295987
2014-10-31 2.158571
这些得分与图 14.3中的 KNN 得分非常不同。
若要更深入了解decision_得分、threshold_或predict_proba,请查看本章的第一篇教程——使用 KNN 检测异常值。
还有更多内容...
类似于 LOF,算法的另一个扩展是基于聚类的局部异常因子(CBLOF)。CBLOF 在概念上与 LOF 相似,因为它在计算得分以确定异常值时依赖于聚类大小和距离。因此,除了 LOF 中的邻居数(n_neighbors),我们现在有了一个新的参数,即聚类数(n_clusters)。
PyOD 中的默认聚类估计器clustering_estimator是 K 均值聚类算法。
你将使用 PyOD 中的 CBLOF 类,并保持大部分参数为默认值。更改n_clusters=8和contamination=0.03参数:
from pyod.models.cblof import CBLOF
cblof = CBLOF(n_clusters=4, contamination=0.03)
cblof.fit(tx)
predicted = pd.Series(lof.predict(tx),
index=tx.index)
outliers = predicted[predicted == 1]
outliers = tx.loc[outliers.index]
plot_outliers(outliers, tx, 'CBLOF')
前面的代码应生成一个类似于图 14.1的图表,只不过x标记是基于使用 CBLOF 算法识别的异常值:

图 14.6:使用 CBLOF 算法识别的潜在异常值标记
将图 14.6与图 14.5(LOF)进行比较,注意它们之间的相似性。
另见
要了解更多关于 LOF 和 CBLOF 算法的信息,你可以访问 PyOD 文档:
-
LOF:
pyod.readthedocs.io/en/latest/pyod.models.html#module-pyod.models.lof -
CBLOF:
pyod.readthedocs.io/en/latest/pyod.models.html#module-pyod.models.cblof
使用 iForest 检测异常值
iForest 与另一个流行的算法随机森林有相似之处。随机森林是一种基于树的监督学习算法。在监督学习中,你有现有的标签(分类)或值(回归)来表示目标变量。这就是算法学习的方式(它是监督学习)。
森林这个名称来源于算法工作原理的底层机制。例如,在分类中,算法随机采样数据来构建多个弱分类器(较小的决策树),这些分类器共同做出预测。最终,你得到一个由较小树(模型)组成的森林。这种技术优于单个可能会过拟合数据的复杂分类器。集成学习是多个弱学习者协作产生最优解的概念。
iForest,作为一种集成学习方法,是随机森林的无监督学习方法。iForest 算法通过随机划分(拆分)数据集成多个分区来隔离异常值。这个过程是递归进行的,直到所有数据点都属于一个分区。隔离一个异常值所需的分区数量通常比隔离常规数据点所需的分区数量要少。这个思路是,异常数据点距离其他点较远,因此更容易被分离(隔离)。
相比之下,正常的数据点可能会更接近较大的数据集,因此需要更多的分区(拆分)来隔离该点。因此,称之为隔离森林,因为它通过隔离来识别异常值。一旦所有的点都被隔离,算法会生成一个异常值评分。你可以把这些分区看作是创建了一个决策树路径。到达某个点的路径越短,异常的可能性越大。
如何实现...
在本例中,你将继续使用nyc_taxi数据框,利用 PyOD 库中的IForest类来检测异常值:
- 开始加载
IForest类:
from pyod.models.iforest import IForest
- 有一些参数是你应该熟悉的,以控制算法的行为。第一个参数是
contamination。默认值为contamination=0.1,但在本例中,你将使用0.03(3%)。
第二个参数是 n_estimators,默认为 n_estimators=100,即生成的随机树的数量。根据数据的复杂性,您可能希望将该值增大到更高的范围,如 500 或更高。从默认的小值开始,以了解基准模型的工作原理——最后,random_state 默认为 None。由于 iForest 算法会随机生成数据的划分,因此设置一个值有助于确保工作结果的可复现性。这样,当您重新运行代码时,能够获得一致的结果。当然,这个值可以是任何整数。
- 实例化
IForest并更新contamination和random_state参数。然后,将该类的新实例(iforest)拟合到重采样数据上,以训练模型:
iforest = IForest(contamination=0.03,
n_estimators=100,
random_state=0)
iforest.fit(nyc_daily)
>>
IForest(behaviour='old', bootstrap=False, contamination=0.03,
max_features=1.0, max_samples='auto', n_estimators=100, n_jobs=1,
random_state=0, verbose=0)
- 使用
predict方法来识别异常值。该方法会为每个数据点输出1或0。例如,值为1表示是一个异常值。
让我们将结果存储在一个 pandas Series 中:
predicted = pd.Series(iforest.predict(tx),
index=tx.index)
print('Number of outliers = ', predicted.sum())
>>
Number of outliers = 7
有趣的是,与之前的算法 使用 KNN 检测异常值 不同,iForest 检测到了 7 个异常值,而 KNN 算法检测到了 6 个异常值。
Filter the predicted Series to only show the outlier values:
outliers = predicted[predicted == 1]
outliers = tx.loc[outliers.index]
outliers
>>
timestamp value
2014-11-01 20553.500000
2014-11-08 18857.333333
2014-11-27 10899.666667
2014-12-25 7902.125000
2014-12-26 10397.958333
2015-01-26 7818.979167
2015-01-27 4834.541667
总体而言,iForest 捕获了已知的五个异常值中的四个。还有一些其他有趣的日期被识别出来,这些日期应触发调查,以确定这些数据点是否为异常值。例如,2014 年 11 月 8 日被算法检测为潜在异常值,而该日期并未被考虑在数据中。
- 使用 技术要求 部分中创建的
plot_outliers函数来可视化输出,以便更好地理解:
plot_outliers(outliers, tx, 'IForest')
上述代码应生成类似于 图 14.1 中的图表,唯一不同的是 x 标记基于使用 iForest 算法识别的异常值:

图 14.7:使用 iForest 算法标识潜在异常值的标记
要打印带有标记的标签(日期),只需再次调用 plot_outliers 函数,但这次将 labels=True:
plot_outliers(outliers, tx, 'IForest', labels=True)
上述代码应生成类似于 图 14.7 的图表,并且增加了文本标签。
它是如何工作的...
由于 iForest 是一种集成方法,您将创建多个模型(决策树学习器)。n_estimators 的默认值是 100。增加基础估计器的数量可能会提高模型的性能,但在某个程度上可能会影响计算性能。因此,您可以将估计器数量视为训练好的模型。例如,对于 100 个估计器,您实际上是创建了 100 个决策树模型。
还有一个值得一提的参数是bootstrap参数。默认值为False,它是一个布尔值。由于 iForest 会随机抽样数据,你有两个选择:带替换的随机抽样(称为自助抽样)或不带替换的随机抽样。默认行为是没有替换的抽样。
还有更多...
PyOD 中的 iForest 算法(IForest类)是 scikit-learn 中IsolationForest类的封装。这对上一食谱中使用的 KNN 也是如此,使用 KNN 检测异常值。
让我们进一步探索,使用 scikit-learn 实现 iForest 算法。你将使用fit_predict()方法作为一步训练和预测,这个方法也可以在 PyOD 的各种算法实现中找到:
from sklearn.ensemble import IsolationForest
sk_iforest = IsolationForest(contamination=0.03)
sk_prediction = pd.Series(sk_iforest.fit_predict(tx),
index=tx.index)
sk_outliers = sk_prediction[sk_prediction == -1]
sk_outliers = tx.loc[sk_outliers.index]
sk_outliers
>>
timestamp value
2014-11-01 20553.500000
2014-11-08 18857.333333
2014-11-27 10899.666667
2014-12-25 7902.125000
2014-12-26 10397.958333
2015-01-26 7818.979167
2015-01-27 4834.541667
结果是一样的。但请注意,与 PyOD 不同,识别出的异常值在标记时为-1,而在 PyOD 中,异常值被标记为1。
参见
PyOD 的 iForest 实现实际上是 scikit-learn 中IsolationForest类的封装:
-
要了解更多关于 PyOD iForest 和不同参数的信息,请访问他们的官方文档:
pyod.readthedocs.io/en/latest/pyod.models.html?highlight=knn#module-pyod.models.iforest。 -
要了解更多关于 scikit-learn 中
IsolationForest类的信息,你可以访问他们的官方文档页面:scikit-learn.org/stable/modules/generated/sklearn.ensemble.IsolationForest.html#sklearn-ensemble-isolationforest。
使用单类支持向量机(OCSVM)检测异常值
支持向量机(SVM) 是一种流行的有监督机器学习算法,主要用于分类,但也可以用于回归。SVM 的流行源于使用核函数(有时称为核技巧),例如线性、多项式、基于半径的函数(RBF)和 sigmoid 函数。
除了分类和回归,SVM 还可以以无监督的方式用于异常值检测,类似于 KNN。KNN 通常被认为是一种有监督的机器学习技术,但在异常值检测中它是以无监督的方式使用的,正如在使用 KNN 进行异常值检测食谱中所见。
如何做...
在这个食谱中,你将继续使用在技术要求部分创建的tx数据框,利用 PyOD 中的ocsvm类检测异常值:
- 首先加载
OCSVM类:
from pyod.models.ocsvm import OCSVM
- 有一些参数你应该了解,以控制算法的行为。第一个参数是
contamination。默认值为contamination=0.1,在这个食谱中,你将使用0.03(即 3%)。
第二个参数是 kernel,其值设为 rbf,你将保持其不变。
通过更新 contamination=0.03 来实例化 OCSVM,同时保持其余参数为默认值。然后,训练(拟合)模型:
ocsvm = OCSVM(contamination=0.03, kernel='rbf')
ocsvm.fit(tx)
>>
OCSVM(cache_size=200, coef0=0.0, contamination=0.03, degree=3, gamma='auto',
kernel='rbf', max_iter=-1, nu=0.5, shrinking=True, tol=0.001,
verbose=False)
predict方法将为每个数据点输出1或0。值为1表示异常值。将结果存储在一个 pandas Series 中:
predicted = pd.Series(ocsvm.predict(tx),
index=tx.index)
print('Number of outliers = ', predicted.sum())
>>
Number of outliers = 5
- 筛选预测的 Series,仅显示异常值:
outliers = predicted[predicted == 1]
outliers = tx.loc[outliers.index]
outliers
>>
timestamp value
2014-08-09 15499.708333
2014-11-18 15499.437500
2014-11-27 10899.666667
2014-12-24 12502.000000
2015-01-05 12502.750000
有趣的是,它捕捉到了五个已知日期中的一个。
- 使用技术要求部分中创建的
plot_outliers函数可视化输出,以便获得更好的洞察:
plot_outliers(outliers, tx, 'OCSVM')
上述代码应产生一个与图 14.1类似的图表,唯一不同的是 x 标记是基于 OCSVM 算法识别的异常值:

图 14.8:使用 OCSVM 标记每个异常点的折线图
当查看图 14.8中的图表时,不清楚为什么 OCSVM 识别出这些日期为异常值。RBF 核函数可以捕捉非线性关系,因此它应该是一个强健的核函数。
这个不准确的原因在于 SVM 对数据缩放敏感。为了获得更好的结果,您需要首先对数据进行标准化(缩放)。
- 让我们解决这个问题,先对数据进行标准化,然后再次运行算法:
from pyod.utils.utility import standardizer
scaled = standardizer(tx)
predicted = pd.Series(ocsvm.fit_predict(scaled),
index=tx.index)
outliers = predicted[predicted == 1]
outliers = tx.loc[outliers.index]
outliers
>>
timestamp value
2014-07-06 11464.270833
2014-11-01 20553.500000
2014-11-27 10899.666667
2014-12-25 7902.125000
2014-12-26 10397.958333
2015-01-26 7818.979167
2015-01-27 4834.541667
有趣的是,现在模型识别出了五个已知异常日期中的四个。
- 在新的结果集上使用
plot_outliers函数:
plot_outliers(outliers, tx, 'OCSVM Scaled'))
上述代码应生成一个更合理的图表,如下图所示:

图 14.9:使用标准化函数缩放数据后的 OCSVM
比较图 14.9和图 14.8中的结果,看看缩放如何在 OCSVM 算法识别异常值方面产生了显著差异。
工作原理...
PyOD 对 OCSVM 的实现是对 scikit-learn 中 OneClassSVM 实现的封装。
与 SVM 相似,OneClassSVM 对异常值以及数据的缩放非常敏感。为了获得合理的结果,在训练模型之前标准化(缩放)数据非常重要。
还有更多...
让我们探讨不同核函数在相同数据集上的表现。在下面的代码中,您将测试四种核函数:'linear'、'poly'、'rbf' 和 'sigmoid'。
回顾一下,当使用 SVM 时,您需要缩放数据。您将使用之前创建的缩放数据集:
for kernel in ['linear', 'poly', 'rbf', 'sigmoid']:
ocsvm = OCSVM(contamination=0.03, kernel=kernel)
predict = pd.Series(ocsvm.fit_predict(scaled),
index=tx.index, name=kernel)
outliers = predict[predict == 1]
outliers = tx.loc[outliers.index]
plot_outliers(outliers, tx, kernel, labels=True)
上述代码应生成每个核函数的图表,以便您可以直观地检查并比较它们之间的差异:

图 14.10:比较不同核函数与 OCSVM 的效果
有趣的是,每种核方法捕获的异常值略有不同。您可以重新运行之前的代码,通过传递 labels=True 参数来打印每个标记(日期)的标签。
参见
要了解有关 OCSVM 实现的更多信息,请访问官方文档:pyod.readthedocs.io/en/latest/pyod.models.html#module-pyod.models.ocsvm。
使用 COPOD 检测异常值
COPOD 是一个令人兴奋的算法,基于 2020 年 9 月发布的一篇论文,你可以在这里阅读:arxiv.org/abs/2009.09463。
PyOD 库提供了许多基于最新研究论文的算法,这些算法可以分为线性模型、基于邻近的模型、概率模型、集成模型和神经网络。
COPOD 属于概率模型,并被标记为 无参数 算法。它唯一的参数是 contamination 因子,默认为 0.1。COPOD 算法受统计方法启发,使其成为一个快速且高度可解释的模型。该算法基于 copula,一种通常用于建模相互独立的随机变量之间的依赖关系的函数,这些变量不一定服从正态分布。在时间序列预测中,copula 已被应用于单变量和多变量预测,这在金融风险建模中非常流行。copula 这个术语源自 copula 函数,它将单变量的边际分布连接(耦合)在一起,形成一个统一的多变量分布函数。
如何操作...
在本食谱中,您将继续使用 tx DataFrame,通过 PyOD 库中的 COPOD 类来检测异常值:
- 首先加载
COPOD类:
from pyod.models.copod import COPOD
- 您需要考虑的唯一参数是
contamination。通常,将该参数(用于所有异常值检测实现)视为一个阈值,用于控制模型的敏感性并最小化假阳性。由于这是一个由您控制的参数,理想情况下,您希望运行多个模型,实验出适合您用例的理想阈值。
如需了解更多关于 decision_scores_、threshold_ 或 predict_proba 的信息,请查看本章的第一个食谱,使用 KNN 检测异常值。
- 实例化
COPOD并将contamination更新为0.03。然后,在重新采样的数据上进行拟合,以训练模型:
copod = COPOD(contamination=0.03)
copod.fit(tx)
>>
COPOD(contamination=0.03, n_jobs=1)
- 使用
predict方法识别异常值。该方法将为每个数据点输出1或0。例如,1表示异常值。
将结果存储在 pandas Series 中:
predicted = pd.Series(copod.predict(tx),
index=tx.index)
print('Number of outliers = ', predicted.sum())
>>
Number of outliers = 7
异常值的数量与使用 iForest 获得的数量相匹配。
- 仅筛选预测的 Series,以显示异常值:
outliers = predicted[predicted == 1]
outliers = tx.loc[outliers.index]
outliers
>>
timestamp value
2014-07-04 11511.770833
2014-07-06 11464.270833
2014-11-27 10899.666667
2014-12-25 7902.125000
2014-12-26 10397.958333
2015-01-26 7818.979167
2015-01-27 4834.541667
与你迄今为止探索的其他算法相比,你会注意到使用 COPOD 捕获了一些有趣的异常点,这些异常点之前没有被识别出来。例如,COPOD 识别了 7 月 4 日——美国的国庆节(独立日)。那天恰好是周末(星期五是休息日)。COPOD 模型在 7 月 4 日和 7 月 6 日的整个周末期间捕获了异常。恰巧 7 月 6 日是由于纽约的一场棒球赛而成为一个有趣的日子。
- 使用技术要求部分中创建的
plot_outliers函数来可视化输出,以便获得更好的洞察:
plot_outliers(outliers, tx, 'COPOD')
上述代码应该生成一个类似于图 14.1的图表,唯一的区别是x标记基于使用 COPOD 算法识别的异常点:

图 14.11:使用 COPOD 算法识别的潜在异常点的标记
要打印带有标记的标签(日期),只需再次调用plot_outliers函数,但这次需要将labels=True:
plot_outliers(outliers, tx, 'COPOD', labels=True)
上述代码应该生成一个类似于图 14.11的图表,并加上文本标签。
它是如何工作的…
COPOD 是一个先进的算法,但它仍然基于概率建模和在数据中找到统计学上显著的极端值。使用 COPOD 的几项测试已经证明它在基准数据集上的卓越表现。使用 COPOD 的一个吸引力是它不需要调参(除了污染因子)。因此,作为用户,你无需担心超参数调优。
还有更多…
另一个简单且流行的概率算法是中位绝对偏差(MAD)。我们在第八章,使用统计方法进行异常值检测中探讨了 MAD,具体是在使用修改过的 z-score 进行异常值检测的食谱中,你是从零开始构建该算法的。
这是 PyOD 提供的一个类似实现,只有一个参数:阈值。如果你还记得第八章,使用统计方法进行异常值检测,阈值是基于标准差的。
以下代码展示了如何使用 PyOD 实现 MAD。你将使用threshold=3来复现你在第八章,使用统计方法进行异常值检测中的操作:
from pyod.models.mad import MAD
mad = MAD(threshold=3)
predicted = pd.Series(mad.fit_predict(tx),
index=tx.index)
outliers = predicted[predicted == 1]
outliers = tx.loc[outliers.index]
outliers
>>
timestamp value
2014-11-01 20553.500000
2014-11-27 10899.666667
2014-12-25 7902.125000
2014-12-26 10397.958333
2015-01-26 7818.979167
2015-01-27 4834.541667
这应该与你在第八章,使用统计方法进行异常值检测中,修改过的 z-score 实现的结果一致。
另见
若要了解更多关于 COPOD 及其在 PyOD 中的实现,请访问官方文档:pyod.readthedocs.io/en/latest/pyod.models.html?highlight=copod#pyod.models.copod.COPOD。
如果你有兴趣阅读COPOD: 基于 Copula 的异常值检测(2020 年 9 月发布)的研究论文,请访问 arXiv.org 页面:arxiv.org/abs/2009.09463。
使用 PyCaret 检测异常值
在本食谱中,你将探索PyCaret用于异常检测。PyCaret (pycaret.org) 被定位为“一个开源的、低代码的 Python 机器学习库,自动化机器学习工作流”。PyCaret 是 PyOD 的封装,你之前在食谱中使用过它进行异常检测。PyCaret 的作用是简化整个过程,用最少的代码进行快速原型设计和测试。
你将使用 PyCaret 来检查多种异常检测算法,类似于你在之前的食谱中使用过的算法,并查看 PyCaret 如何简化这个过程。
准备开始
探索 PyCaret 的推荐方式是为 PyCaret 创建一个新的虚拟 Python 环境,这样它可以安装所有所需的依赖项,而不会与当前环境发生任何冲突或问题。如果你需要快速回顾如何创建虚拟 Python 环境,请参考开发环境设置食谱,见第一章,时间序列分析入门。本章介绍了两种方法:使用conda和venv。
以下说明将展示使用conda的过程。你可以为环境命名任何你喜欢的名字;在以下示例中,我们将命名为pycaret:
>> conda create -n pycaret python=3.8 -y
>> conda activate pycaret
>> pip install "pycaret[full]"
为了使新的pycaret环境在 Jupyter 中可见,你可以运行以下代码:
python -m ipykernel install --user --name pycaret --display-name "PyCaret"
本食谱有一个单独的 Jupyter notebook,你可以从 GitHub 仓库下载:
如何操作...
在本食谱中,你将不会接触到任何新概念。重点是展示如何使用 PyCaret 作为实验的起点,并快速评估不同的模型。你将加载 PyCaret 并运行不同的异常检测算法:
- 从
pycaret.anomaly模块加载所有可用的函数:
from pycaret.anomaly import *
setup = setup(tx, session_id = 1, normalize=True)
上述代码应该生成一个表格摘要,如图 14.12 所示

图 14.12 – PyCaret 摘要输出
- 要打印可用的异常检测算法列表,可以运行
models():
models()
这应该会显示一个 pandas DataFrame,如下所示:

图 14.14: PyCaret 可用的异常检测算法
请注意,这些算法都来自 PyOD 库。如前所述,PyCaret 是 PyOD 和其他库(如 scikit-learn)的封装。
- 让我们将前八个算法的名称存储在列表中,以便稍后使用:
list_of_models = models().index.tolist()[0:8]
list_of_models
>>
['abod', 'cluster', 'cof', 'iforest', 'histogram', 'knn', 'lof', 'svm']
- 遍历算法列表并将输出存储在字典中,以便稍后在分析中引用。要在 PyCaret 中创建模型,你只需使用
create_model()函数。这类似于 scikit-learn 和 PyOD 中的fit()函数,用于训练模型。一旦模型创建完成,你可以使用该模型通过predict_model()函数预测(识别)离群值。PyCaret 将生成一个包含三列的 DataFrame:原始的value列,一个新的Anomaly列,存储结果为0或1,其中1表示离群值,另一个新的Anomaly_Score列,存储使用的得分(得分越高,表示越有可能是离群值)。
你只需要改变污染参数,以便与之前使用 PyOD 的配方匹配。在 PyCaret 中,污染参数被称为fraction,为了保持一致性,你需要将其设置为0.03或者 3%,即fraction=0.03:
results = {}
for model in list_of_models:
cols = ['value', 'Anomaly_Score']
outlier_model = create_model(model, fraction=0.03)
print(outlier_model)
outliers = predict_model(outlier_model, data=tx)
outliers = outliers[outliers['Anomaly'] == 1][cols]
outliers.sort_values('Anomaly_Score', ascending=False, inplace=True)
results[model] = {'data': outliers, 'model': outlier_model}
results字典包含每个模型的输出(一个 DataFrame)。
- 要打印每个模型的离群值,你可以简单地遍历字典:
for model in results:
print(f'Model: {model}')
print(results[model]['data'], '\n')
这将打印出每个模型的结果。以下是列表中的前两个模型作为示例:
Model: abod
value Anomaly_Score
timestamp
2014-11-01 20553.500000 -0.002301
2015-01-27 4834.541667 -0.007914
2014-12-26 10397.958333 -3.417724
2015-01-26 7818.979167 -116.341395
2014-12-25 7902.125000 -117.582752
2014-11-27 10899.666667 -122.169590
2014-10-31 17473.354167 -2239.318906
Model: cluster
value Anomaly_Score
timestamp
2015-01-27 4834.541667 3.657992
2015-01-26 7818.979167 2.113955
2014-12-25 7902.125000 2.070939
2014-11-01 20553.500000 0.998279
2014-12-26 10397.958333 0.779688
2014-11-27 10899.666667 0.520122
2014-11-28 12850.854167 0.382981
它是如何工作的…
PyCaret 是一个出色的自动化机器学习库,最近他们在时间序列分析、预测和离群值(异常值)检测方面不断扩展其功能。PyCaret 是 PyOD 的封装库,你在本章之前的配方中也使用了 PyOD。图 14.14展示了 PyCaret 支持的 PyOD 算法数量,这是 PyOD 更广泛算法列表的一个子集:pyod.readthedocs.io/en/latest/index.html#implemented-algorithms。
另请参见
要了解更多关于 PyCaret 离群值检测的信息,请访问官方文档:pycaret.gitbook.io/docs/get-started/quickstart#anomaly-detection。












浙公网安备 33010602011771号