时间序列索引指南-全-

时间序列索引指南(全)

原文:zh.annas-archive.org/md5/6b4bb1a3d7511bcccbc0a170909b1e6e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

您正在阅读的书的标题是时间序列索引,这应该暗示了其内容。

本书讨论和探索的时间序列索引称为 iSAX。iSAX 被认为是时间序列中最好的索引之一,这也是选择它的主要原因。除了将 iSAX 和 SAX 表示实现为 Python 3 包之外,本书还展示了如何在子序列级别处理时间序列以及如何理解学术论文中呈现的信息。

但本书并未止步于此,因为它提供了用于更好地了解您的时序数据的 Python 脚本,以及用于可视化时序数据和 iSAX 索引的代码,以便更好地理解数据以及特定 iSAX 索引的结构。

本书面向的对象

本书面向的开发者、研究人员和任何级别的大学学生,他们希望在后续级别处理时间序列,并在过程中使用现代时间序列索引。

虽然展示的代码是用 Python 3 编写的,但一旦您理解了代码背后的思想和概念,这些包和脚本可以轻松地移植到任何其他现代编程语言,如 Rust、Swift、Go、C、Kotlin、Java、JavaScript 等。

本书涵盖的内容

第一章时间序列与所需 Python 知识简介,主要介绍了您需要了解的基础知识,包括时间序列的重要性以及如何设置合适的 Python 环境来运行本书的代码并进行时间序列实验。

第二章实现 SAX,解释了 SAX 及其表示,并展示了用于计算时间序列或子序列的 SAX 表示的 Python 代码。它还展示了用于计算可以提供时间序列更高概述的统计量的 Python 脚本,并绘制时间序列数据的直方图。

第三章iSAX – 所需理论,介绍了 iSAX 索引构建和使用背后的理论,并展示了如何通过大量可视化逐步手动构建一个小 iSAX 索引。

第四章iSAX - 实现,介绍了开发一个用于创建适合内存的 iSAX 索引的 Python 包,并展示了如何将这个 Python 包付诸实践的 Python 脚本。

第五章连接和比较 iSAX 索引,展示了如何使用isax包创建的 iSAX 索引,以及如何连接和比较它们。本章最后讨论了测试 Python 代码的主题。最后,我们展示了如何为isax包编写一些简单的测试。

第六章可视化 iSAX 索引,主要介绍了如何使用 JavaScript 编程语言和 JSON 格式通过各种类型的可视化来可视化 iSAX 索引。

第七章使用 iSAX 近似 MPdist,介绍了如何使用 iSAX 索引来近似计算两个时间序列之间的矩阵轮廓向量和 MPdist 距离。

第八章结论和下一步行动,如果您对时间序列或数据库非常感兴趣,它将提供有关下一步要查找什么和哪里的指导,建议研究经典书籍和研究论文。

为了最大限度地利用本书

本书需要一台装有相对较新 Python 3 安装和本地安装 Python 包能力的 UNIX 机器。这包括运行 macOS 和 Linux 最新版本的任何机器。所有代码都在 Microsoft Windows 机器上进行了测试。

我们建议您使用用于 Python 包、依赖和环境管理的软件来拥有稳定的 Python 3 环境。我们使用 Anaconda,但任何类似的工具都可以正常工作。

最后,如果您真的想最大限度地利用本书,那么您需要尽可能多地与提供的 Python 代码进行实验,创建自己的 iSAX 索引和可视化,也许还可以将代码移植到不同的编程语言中。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Time-Series-Indexing。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/Pzq1j

使用的约定

本书中使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们将使用以下滑动窗口大小进行实验:162561024409616384。”

代码块应如下设置:

def query(ISAX, q):
    global totalQueries
    totalQueries = totalQueries + 1
    Accesses = 0
    # Create TS Node

当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:

    # Query iSAX for TS1
    for idx in range(0, len(ts1)-windowSize+1):
        currentQuery = ts1[idx:idx+windowSize]
        found, ac = query(i1, currentQuery)
        if found == False:
            print("This cannot be happening!")
            return

任何命令行输入或输出都应如下编写:

$ ./accessSplit.py -s 8 -c 32 -t 500 -w 16384 500k.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 16384 Threshold: 500 Default Promotion: False
OVERFLOW: 01111_10000_10000_01111_10000_01111_10000_01111
Number of splits: 6996
Number of subsequence accesses: 19201125

小贴士或重要注意事项

看起来像这样。

联系我们

我们欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件 customercare@packtpub.com 与我们联系,并在邮件主题中提及书名。

勘误: 尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果你在这本书中发现了错误,我们非常感谢你能向我们报告。请访问 www.packtpub.com/support/errata 并填写表格。

盗版: 如果你在网上遇到任何形式的我们作品的非法副本,我们非常感谢你能提供位置地址或网站名称。请通过 mailto:copyright@packtpub.com 与我们联系,并提供材料的链接。

如果你有兴趣成为作者: 如果你在一个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问 authors.packtpub.com

分享你的想法

一旦你阅读了 时间序列索引,我们非常乐意听到你的想法!请点击此处直接跳转到该书的亚马逊评论页面并分享你的反馈。

你的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载这本书的免费 PDF 副本

感谢你购买这本书!

你喜欢在路上阅读,但又无法携带你的印刷书籍到处走吗?

你的电子书购买是否与你的选择设备不兼容?

别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠不会就此停止,你还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。

按照以下简单步骤获取这些好处:

  1. 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781838821951

  1. 提交你的购买证明

  2. 就这些!我们将直接将你的免费 PDF 和其他好处发送到你的电子邮件。

第一章:时间序列与所需 Python 知识简介

这是您正在阅读的本书的第一章。尽管通常第一章节包含您可能想要跳过的基本信息,但这章并非如此。它教授您时间序列和索引的基础知识,以及如何设置适当的 Python 环境,该环境将用于本书代码的开发。您在阅读其他章节时可能需要参考它,这是一件好事!所以,让我们开始吧!

在本章中,我们将涵盖以下主要主题:

  • 理解时间序列

  • 什么是索引以及为什么我们需要索引?

  • 我们将要需要的 Python 知识

  • 从磁盘读取时间序列

  • 可视化时间序列

  • 使用矩阵轮廓

  • 探索 MPdist 距离

技术要求

为了跟随本章,这是整本书的基础,您需要在您的计算机上安装最新的 Python 3 版本,并且能够自行安装任何其他所需的软件。我们不会教您如何安装 Python 3 包,但我们会告诉您应该安装哪些包以及我们用来安装这些包的命令。同样,我们不会解释在您的机器上安装新软件的过程,但我们会告诉您我们用来在我们的机器上安装给定软件的命令。

本书在 GitHub 上的存储库地址为 github.com/PacktPublishing/Time-Series-Indexing。每个章节的代码都在其自己的目录中。因此,第一章 的代码可以在 ch01 文件夹中找到。您可以使用 git(1) 在您的计算机上下载整个存储库,或者您可以通过 GitHub 用户界面访问这些文件。

您可以使用以下方式使用 git(1) 下载本书的完整代码,包括 ch01 文件夹中的代码:

git clone git@github.com:PacktPublishing/Time-Series-Indexing.git

由于存储库名称较长,而本地目录以存储库名称命名,您可以通过以下方式执行前面的命令来缩短文件夹名称:

git clone git@github.com:PacktPublishing/Time-Series-Indexing.git tsi

这将把存储库的内容放入一个名为 tsi 的目录中。两种方式都是有效的——做最适合您的事情。

本书代码现在已存在于您的本地机器上。然而,为了运行大部分代码,您需要安装一些 Python 包——我们将在本章后面讨论所需的 Python 包。

免责声明

本书中的代码是在 Arch Linux 和 macOS Ventura 机器上编写和测试的。尽管本书以 Unix 为中心,但在 Microsoft Windows 机器上也存在类似的命令可以执行,这些命令应该不难找到和执行。重要的是所展示的代码、理解代码及其背后的逻辑,以及能够自己执行和修改它。如果这些信息对您有所帮助,我主要使用 Microsoft Visual Studio Code 在 macOS 和 Linux 上编写代码。

理解时间序列

时间序列是一组数据。请记住,时间序列不一定包含时间或日期数据——时间和日期数据通常以时间戳的形式出现。因此,时间序列可能包含时间戳,但通常不包含。实际上,本书中的大多数时间序列都不包含时间戳。在实践中,我们真正需要的是有序数据——这就是使一系列值成为时间序列的原因。

严格来说,大小为n的时间序列(T)是一个有序的数据点列表:T = { t 0, t 1, t 2, … t n−1}。数据点可以是带时间戳的,并存储单个值、一组值或一个值列表。时间序列的索引可能从 1 开始而不是 0——在这种情况下,T = { t 1, t 2, t 3, … t n}。真正重要的是,在这两种情况下,时间序列的长度都是n。因此,每个元素都有一个与其关联的索引值,这取代了时间戳的需求。本书中的时间序列将使用索引值来区分其元素。以下有序列表可以被视为一个时间序列——{1, -2, -3, 4, 5, 1, 2, 0.23, 4.3}。它包含九个元素。第一个元素是1,最后一个元素是4.3。如果第一个元素的索引是0,则最后一个元素的索引将是8;而如果第一个元素的索引是1,则最后一个元素的索引将是9。时间序列可以包含相同的值多次。

时间序列的另一种定义

时间序列是一系列按时间顺序进行的观察结果。许多类型的观察结果并不是真正的时间序列,但可以转换成时间序列。

图 1.1.1 显示了包含 1,000 个元素的时间序列的图形表示——即使像这里展示的这样一个小型时间序列,也难以搜索特定的子序列或值。正如我们稍后将要讨论的,这就是为什么索引很重要的原因。

图 1.1 – 可视化时间序列

图 1.1 – 可视化时间序列

可视化时间序列部分,我们将学习如何在 Python 中可视化时间序列。

下一个子节将告诉我们我们可以在哪里找到时间序列数据。

时间序列无处不在

你可能会问我们可以在哪里找到时间序列。答案是简单的:时间序列无处不在!从医疗数据到位置数据,从软件和硬件指标到金融信息和股价!成功使用它们可以帮助我们找到我们可能有的问题的答案,例如卖哪只股票或哪个硬盘将要失败。

让我们看看我们需要了解的一些定义,以更好地理解这些概念。

必要的定义

在本小节中,我们将学习一些与时间序列相关的核心定义:

  • 时间序列或子序列的 长度 是在时间序列或子序列中找到的元素数量。

  • 时间序列 T 的大小为 w子序列 sT 的一个子列表,其长度为 w 的连续元素。

  • 大小为 w滑动窗口 将时间序列分解为大小为 w 的子序列。滑动窗口将时间序列分割成多个子序列,每个子序列的长度等于滑动窗口的值。给定长度为 n 的时间序列和一个大小为 w 的滑动窗口,大小为 w 的子序列总数等于 n-w+1

让我们给你举一个例子。想象一下有一个以下时间序列,T: {0, 1, 2, 3, 4, 5, 6}。给定大小为 5 的滑动窗口,T 可以被分割成以下子序列:

  • {0, 1, 2, 3, 4}

  • {1, 2, 3, 4, 5}

  • {2, 3, 4, 5, 6}

因此,我们总共有三个子序列,每个子序列的长度为 5。由于这是一个繁琐的过程,我们将在本章学习如何让计算机为我们完成这项工作。

下一个小节将简要讨论时间序列数据挖掘的主题。

时间序列数据挖掘

数据挖掘 是收集、清理、处理、分析和理解数据的研究。数据挖掘是一个庞大的主题。实际上,数据挖掘是计算机科学的一个领域,它有自己的子主题和领域。数据挖掘最重要的领域如下:

  • 分类:这是在给定一组预定义的类别标签的情况下确定一个元素的类别标签的过程

  • 聚类:这是根据给定的标准(通常是距离函数)将数据分组到集合中的过程,使得组内的成员彼此相似

  • 异常检测:这是寻找一个与其他观察值差异足够大,足以引起怀疑它是由不同过程创建的过程

时间序列数据挖掘,正如其名称所暗示的,*是时间序列的数据挖掘。与常规数据挖掘相比,时间序列数据挖掘的主要区别在于,在时间序列中,数据是按时间排序的。因此,你不能自己安排时间序列数据。尽管时间提供了上下文,但重要的是实际值。

除了时间之外,时间序列数据还可以用经纬度值(空间数据)来表征。我们在这本书中不会处理空间数据。

拥有时间序列数据是好的,但如果我们不能比较这些数据,它们可能就毫无用处。接下来的小节将展示一些比较时间序列的流行技术和算法。

比较时间序列

要比较任何事物,我们需要一个度量标准。我们可以比较数值,因为数值本身就是度量标准。但如何比较时间序列呢?这是一个活跃的研究课题,目前还没有明确的答案。

在你继续阅读剩余的章节之前,花点时间思考一下你是否可以用不同数量的元素来比较时间序列。这是否可能?在继续阅读并找到答案之前,写下你的想法

嗯,结果证明你可以用不同数量的元素来比较时间序列。然而,并非所有度量函数都支持该功能。

写作和阅读

阅读任何有价值的书籍或研究论文都是好的,这让你能够学习新事物并保持头脑活跃。然而,为了测试你的知识和整理你的思路,你需要把它们写下来!我一直在这样做。毕竟,这本书就是这样诞生的!

欧几里得距离

欧几里得距离是一种找出两个时间序列有多接近或有多远的方法。简单来说,欧几里得距离衡量的是两个多维点之间的最短路径。一个包含多个元素的时间序列或子序列是一个多维点。

欧几里得距离优先考虑时间——它比较在相同时间出现的数据点,并忽略其他所有内容。因此,如果两个时间序列只在不同的时间匹配,它们被认为是不同的。最后,欧几里得距离与多维度数据一起工作——在这本书中,我们只使用一维数据。不要将多维点多维数据混淆。多维数据包含多维点。这本书的时间序列只包含一维数据(单个值)。然而,我们可以将时间序列或子序列视为一个 多维点

计算两个多维点欧几里得距离的公式可以描述如下。给定一个点 p = ( p 1, p 2, … , p n) 和一个点 q = ( q 1, q 2, … , q n),欧几里得距离是所有 (p i − q i) 2 值之和的平方根:

现在,让我们通过计算两个子序列对的欧几里得距离来举一些例子。第一对是 p = {1, 2, 3}q = {0, 2, 2}。因此,首先,我们找到所有 (p i − q i) 2 值:

  • (10)2*=1

  • (22)2*=0

  • (32)2*=1

然后,我们将结果相加:1 + 0 + 1 = 2。

最后,我们找到结果的平方根,它大约等于 1.414213。

现在,想象一下有两个以下的时间序列或子序列——p = {1, 2, -1, -3}q = {-3, 1, 2, -1}。尽管时间序列具有相同的元素,但这些元素是不同顺序的。它们的欧几里得距离可以像以前一样计算。首先,我们找出所有 (p i − q i)² 的值:

  • [1 − (− 3)]² = 4² = 16

  • (2 − 1)² = 1² = 1

  • ( − 1 − 2)² = ( − 3)² = 9

  • [( − 3) − (− 1)]² = ( − 2)² = 4

因此,欧几里得距离等于 (16+1+9+4) 的平方根 = 30,这大约等于 5.4472。

欧几里得距离的一个主要缺点是它要求两个时间序列长度相同。尽管存在克服这一局限性的技术,但这仍然是一个问题。其中一种技术涉及使用外推法使较短的时间序列长度等于较长的时间序列长度。

在接下来的工作中,我们不会手动计算欧几里得距离,因为 NumPy 提供了一种更好的方法——这在 ed.py 中得到了说明:

#!/usr/bin/env python3 
import numpy as np
import sys
def euclidean(a, b):
    return np.linalg.norm(a-b)
def main():
    ta = np.array([1, 2, 3])
    tb = np.array([0, 2, 2])
    if len(ta) != len(tb):
        print("Time series should have the same length!")
        print(len(ta), len(tb))
        sys.exit()
    ed = euclidean(ta, tb)
    print("Euclidean distance:", ed)
if __name__ == '__main__':
    main()

euclidean() 函数接受两个 NumPy 数组作为输入,并使用 np.linalg.norm() 返回它们的欧几里得距离作为输出。这是因为欧几里得距离是 l2 范数,而 numpy.linalg.norm()ord 参数的默认值是 2,这也是为什么没有特别定义它的原因。你不需要记住这一点;只需在需要时使用 euclidean() 函数即可。

两个时间序列硬编码在脚本中。运行 ed.py 生成以下输出:

$ ./ed.py
Euclidean distance: 1.4142135623730951

切比雪夫距离

切比雪夫距离的逻辑与欧几里得距离完全不同。这并不意味着它优于或劣于欧几里得距离,只是不同。如果你不知道该使用什么,就使用欧几里得距离。

因此,两个多维点之间的切比雪夫距离等于所有 |p i − q i| 值中的最大距离。|| 符号是一个量的 绝对值。简单来说,一个量的 绝对值 等于不带正负号的值。

现在,让我们通过计算两个子序列对的切比雪夫距离来展示一些示例。第一对是 {1, 2, 3}{0, 2, 2}。现在,让我们找出这两对之间的距离:

  • |1 – 0| = 1

  • |2 – 2| = 0

  • |3 – 2| = 1

因此,1、0 和 1 的最大值等于 1,这就是切比雪夫距离。

第二对是 {1, 2, -1, -3}{-3, 1, 2, -1}。和之前一样,我们找出相同位置(相同索引)的点对之间的距离:

  • |1 – (–3)| = 4

  • |2 – 1| = 1

  • |(–1) – 2| = 3

  • |(–3) – (–1)| = 2

因此,4、1、3 和 2 的最大值等于 4,这就是上述对之间的切比雪夫距离。

在本章的后面部分,我们将学习一个更复杂的距离函数,称为 MPdist

现在我们已经知道了如何比较时间序列和子序列,是时候讨论索引和索引化了。请记住,如果我们不能比较其数据,包括时间序列数据,我们就无法创建索引。

索引是什么?为什么我们需要索引?

你能想象在一个未排序的名字列表中搜索姓氏吗?你能想象在一个不按书籍主题(杜威分类法)排序书籍,然后按书名和作者姓氏排序的图书馆中寻找一本书吗?我不能!这两个例子展示了简单但有效的索引方案。数据越复杂,索引应该越复杂,以便进行快速搜索,也许还能更新数据。

图 1.2 显示了一个非常小的 iSAX 索引的可视化——实际上,由于时间序列可能非常大,iSAX 索引往往更大且更复杂。

图 1.2 – 一个小的 iSAX 索引

图 1.2 – 一个小的 iSAX 索引

在这个阶段,不要试图理解 iSAX 索引或节点的标题。一切都会在 第二章第三章 中变得清晰。现在,请记住节点的标题是 SAX 词,并且在一个 iSAX 索引上存在两种类型的节点 – 内部节点终端节点(叶节点)。关于 iSAX 索引及其与 SAX 词的关系将在 第三章 中变得清晰。

在下一节中,我们将开始使用 Python 并设置我们的环境。

我们将要需要的 Python 知识

本书中所展示的所有代码都是用 Python 编写的。因此,在本节中,我们将向您展示所需的 Python 知识,以便您更好地跟随本书。然而,不要期望在这里学习 Python 的基础知识——存在更多适合此目的的书籍。

关于其他编程语言呢?

一旦你学习和理解了所展示的理论,本书中的 Python 代码可以轻松地翻译成任何其他现代编程语言,例如 Swift、Java、C、C++、Ruby、Kotlin、Go、Rust 或 JavaScript。

如果你没有特定原因地不断更新所使用的 Python 包,你可能会遇到兼容性问题。作为一个经验法则,我建议在本书中,你应该使用相同的包版本,只要它们彼此兼容。存在两种主要的方法来实现这一点。你可以在找到兼容的版本后停止升级你的 Python 安装,或者你可以使用 Python 包管理器,如 Anaconda 或 pyenv。在本书中,我们将使用 Anaconda。

无论你使用什么,只要你知道如何操作你的工具,并且有一个稳定可靠的 Python 环境来工作,那就没问题。

我希望我们都同意,任何代码最重要的属性是正确性。然而,在我们有了工作的代码之后,我们可能需要对其进行优化,但如果我们不知道它是否运行得慢,我们就无法优化代码。所以,下一节将向您展示如何计算 Python 代码执行所需的时间。

计时 Python 代码

有时候我们需要知道我们的代码执行得多慢或多快,因为某些操作可能需要数小时甚至数天。本节介绍了一种计算代码块运行所需时间的简单技术。

timing.py 脚本展示了一种计时 Python 代码的技术——当你想知道一个过程需要多少时间完成时,这可能会非常有用。timing.py 的源代码如下:

#!/usr/bin/env python3
import time
start_time = time.time()
for i in range(5):
    time.sleep(1)
print("--- %.5f seconds ---" % (time.time() - start_time))

我们使用 time.time() 来启动计时,并使用相同的语句来声明计时的结束。这两个语句之间的差异是期望的结果。你还可以将这个差异保存在一个单独的变量中。

程序执行 time.sleep(1) 五次,这意味着总时间应该非常接近 5 秒。运行 timing.py 生成以下类型的输出:

$ ./timing.py
--- 5.01916 seconds ---

关于 Python 脚本

在这本书中,我们将主要向您展示完整的 Python 脚本,而不会省略任何语句。尽管这增加了额外的行,但它通过查看它们的 import 语句来帮助你理解 Python 脚本的功能,在阅读实际的 Python 代码之前。

下一个子节是关于 Anaconda 软件,它用于创建 Python 环境。

Anaconda 简介

Anaconda 是一个用于包、依赖和环境管理的软件产品。尽管 Anaconda 是一个商业产品,但存在一个针对个人从业者、学生和研究人员的个人版。Anaconda 所做的是创建一个受控环境,在那里你可以定义 Python 的版本以及你想要使用的包的版本。此外,你可以创建多个环境并在它们之间切换。

如果你不想使用 Anaconda,那也行——然而,如果你正在使用 Python 3 并且不想被 Python 3 包版本、不兼容性和依赖关系的细节所困扰,那么你应该尝试一下 Anaconda。需要包和环境管理软件的原因是,一些 Python 包对使用的 Python 版本非常挑剔。简单来说,Anaconda 确保你的 Python 3 环境不会改变,并赋予你将 Python 3 环境转移到多台机器的能力。Anaconda 的命令行工具被称为 conda

安装 Anaconda

Anaconda 是一个庞大的软件,因为它包含了许多包和实用工具。存在多种安装 Anaconda 的方法,这主要取决于你的开发环境。

在 macOS Ventura 机器上,我们可以使用 Homebrew 如下安装 Anaconda:

$ brew install anaconda

在 Arch Linux 机器上,Anaconda 可以按照以下方式安装:

$ pacman -S anaconda

我们将不会进一步讨论 Anaconda 的安装细节。安装过程简单直接,包含大量信息。最重要的任务是将 Anaconda 实用工具添加到你的PATH环境变量中,以便在 UNIX shell 的任何地方都可以访问它们。这也取决于你使用的 UNIX shell——我在 Linux 和 macOS 机器上都使用带有 Oh My Zsh 扩展的zsh,但你的环境可能有所不同。

如果你选择使用 Anaconda 来处理这本书,请确保你可以访问conda二进制文件,并且可以在你的机器上随意启用和禁用 Anaconda——你可能并不总是需要使用 Anaconda。

在我的 macOS Ventura 机器上,我可以这样禁用 Anaconda:

$ conda deactivate

我也可以这样启用 Anaconda:

$ source /opt/homebrew/anaconda3/bin/activate base

你应该将base替换为你想要的 Anaconda 环境。

之前的命令依赖于 Anaconda 安装的路径。因此,在我的 Arch Linux 机器上,我应该执行以下命令:

$ source /opt/anaconda/bin/activate base

你应该修改之前的命令以适应你的 Anaconda 安装。

当有新的 Anaconda 版本可用时,你可以通过执行以下命令来更新到最新版本:

$ conda update -n base -c defaults conda

创建新的 Anaconda 环境

创建新的 Anaconda 环境时,最重要的决定是选择 Python 3 版本。为了创建一个名为TSI的新 Anaconda 环境,并使用 Python 3.8.5,你应该运行以下命令:

$ conda create  ––name TSI python=3.8.5

为了激活此环境,运行conda activate TSIpython3 --version命令显示了给定 Anaconda 环境中的 Python 版本。

你可以使用conda info --envs命令列出所有现有的 Anaconda 环境(*字符表示活动环境):

$ conda info --envs
# conda environments:
#
TSI                      /home/mtsouk/.conda/envs/TSI
base                    /opt/anaconda

切换到不同的环境

本小节介绍了用于在不同环境之间切换的conda命令。切换到不同的环境就像使用conda activate environment_name激活不同的环境一样简单。

安装 Python 包

虽然你仍然可以使用pip3来安装 Python 包,但在 Anaconda 环境中安装 Python 包的最佳方式是使用conda install命令。请注意,conda install命令不能安装所有包——在这种情况下,请使用pip3

列出所有已安装的包

conda list命令会给出给定 Anaconda 环境中所有已安装 Python 包的完整列表。由于列表相当长,我们只展示其中的一部分:

$ conda list
# packages in environment at /home/mtsouk/.conda/envs/TSI:
#
# Name                    Version                   Build
python                    3.8.5                h7579374_1
readline                  8.2                  h5eee18b_0
numpy                     1.23.5                   pypi_0
pandas                    1.5.2                    pypi_0

删除现有的环境

你可以使用conda env remove --name ENVIRONMENT命令删除一个不活动的现有 Anaconda 环境。以下是在删除名为mtsouk的环境时的输出示例:

$ conda env remove --name mtsouk
Remove all packages in environment /home/mtsouk/.conda/envs/mtsouk:

关于 Python 环境、包版本和包不兼容性的讨论在此达到高潮。从现在开始,让我们假设我们有一个稳定的 Python 环境,我们可以使用现有的 Python 包,开发新的 Python 包,并且可以无任何问题地运行 Python 脚本。下一小节将列出我们需要安装的 Python 包。

所需的 Python 包

下面是一个所需 Python 包列表,以及每个包用途的说明:

  • NumPy:这是 Python 进行数组计算的标准化包。

  • Pandas:此包提供数据分析、时间序列和统计学的数据结构,包括从磁盘读取数据文件的函数。

  • SciPy:此包为 Python 科学计算提供基本函数。

  • Matplotlib:这是最受欢迎的 Python 科学绘图包。

  • Stumpy:这是一个强大的时间序列分析和时间序列数据挖掘包。您无需立即安装它,因为它对于 iSAX 索引的开发不是必需的。

这些是在一个全新的 Python 环境中您需要安装的基本包。Python 将自动安装任何包依赖项。

设置我们的环境

在本小节中,我们将设置我们的 Anaconda 环境。如前所述,这不是遵循本书所必需的,但它将帮助您避免在升级 Python 和 Python 包时可能出现的 Python 包不兼容问题。我们将在TSI Anaconda 环境中执行以下命令,然后我们就完成了:

(TSI) $ conda install numpy
(TSI) $ conda install pandas
(TSI) $ conda install scipy
(TSI) $ conda install matplotlib
(TSI) $ conda install stumpy

打印包版本

在本小节中,我们将展示一个 Python 脚本,该脚本仅加载所需的包并在屏幕上打印它们的版本。

load_packages.py的代码如下:

#!/usr/bin/env python3
import pandas as pd
import argparse
import stumpy
import numpy as np
import scipy
import matplotlib
def main():
     print("scipy version:", scipy.__version__)
     print("numpy version:", np.__version__) 
print("stumpy version:", stumpy.__version__) 
print("matplotlib version:", matplotlib.__version__) 
print("argparse version:", argparse.__version__) 
print("pandas version:", pd.__version__)
if __name__ == '__main__':
     main()

在我的 UNIX 机器上运行load_packages.py会打印以下信息:

$ chmod 755 ./load_packages.py
$ ./load_packages.py
scipy version: 1.9.2
numpy version: 1.23.4
stumpy version: 1.11.1
matplotlib version: 3.6.2
argparse version: 1.1
pandas version: 1.5.0

第一个命令是使 Python 脚本可执行所必需的,并且对于本书中所有以#!/usr/bin/env python3语句开始的 Python 脚本都是必需的。如果它们不以该语句开始,您可以使用python3 <script_name>来执行它们,而无需更改它们的权限。您可以通过运行man chmod来了解更多关于chmod(1)命令的信息。从现在开始,我们将假设您知道这些信息,并且不会展示更多的chmod命令和说明。您的输出可能略有不同,但这是正常的,因为包会更新。

创建样本数据

程序创建的样本数据的官方名称是合成数据。本节将展示一个基于给定参数创建合成数据的 Python 脚本。程序的逻辑基于随机生成的数字——正如你们大多数人可能知道的,随机生成的数字并不那么随机。这使得它们适合测试程序的性能,但不适合实际使用。然而,为了本书的目的,使用随机数生成的合成数据是可以接受的!

synthetic_data.py Python 脚本的代码如下:

#!/usr/bin/env python3
import random
import sys
precision = 5
if len(sys.argv) != 4:
    print("N MIN MAX")
    sys.exit()
# Number of values
N = int(sys.argv[1])
# Minimum value
MIN = int(sys.argv[2])
# Maximum value
MAX = int(sys.argv[3])
x = random.uniform(MIN, MAX)
# Random float number
for i in range(N):
    print(round(random.uniform(MIN, MAX), precision))

脚本接受三个参数,即创建最小值和最大值所需的浮点数值数量。运行脚本会生成以下类型的输出:

$ ./synthetic_data.py 5 1 3
1.18243
2.81486
1.74816
1.42797
2.21639

由于浮点值可以具有任何所需的精度,precision变量保存了小数点后要打印的数字位数。

创建自己的时间序列并不是获取数据的唯一方式。公开可用的时序数据也存在。让我们接下来看看这个。

公开可用的时序数据

存在提供时间序列数据样本的网站,允许每个人处理真实世界的时间序列数据。公开可用的时序数据的另一个重要方面是,人们可以使用相同的数据集与其他人比较他们的技术性能。这在学术界是一个大问题,因为人们必须证明他们的技术和算法在多个方面比其他人更快或更有效率。

一组非常流行的公开可用的时序数据文件可以在www.cs.ucr.edu/~eamonn/time_series_data_2018/(UCR 时间序列分类存档)找到。

如何处理时间序列

在 Python 中进行时间序列处理通常遵循以下步骤:

  1. 导入到 Python:在这个步骤中,我们将时间序列导入到 Python 中。有多种方法可以实现这一点,包括从本地文件、数据库服务器或互联网位置读取。在这本书中,我们将所有使用的时间序列包含在 GitHub 仓库中,作为纯文本文件,这些文件被压缩以节省磁盘空间。

  2. 将其转换为时间序列:在这个步骤中,我们将之前读取的数据转换为有效的时间序列格式。这主要取决于存储时间序列数据的 Python 包。

  3. 处理缺失值:在这个步骤中,我们寻找缺失值以及处理它们的方法。在这本书中,我们不会处理缺失值。所有展示的时间序列都是完整的。

  4. 处理时间序列:这一最后步骤涉及处理时间序列以执行所需的任务或任务。

从磁盘读取时间序列

在将时间序列存储在文件中之后,我们需要编写必要的 Python 代码来读取它并将其放入某种类型的 Python 变量中。本节将向您展示如何做到这一点。read_ts.py 脚本包含以下代码:

#!/usr/bin/env python3
import pandas as pd
import numpy as np
import sys
def main():
        filename = sys.argv[1]
        ts1Temp = pd.read_csv(filename, header = None)
        # Convert to NParray
        ta = ts1Temp.to_numpy()
        ta = ta.reshape(len(ta))
        print("Length:", len(ta))
if __name__ == '__main__':
        main()

在读取时间序列后,read_ts.py 打印时间序列中的元素数量:

$ ./read_ts.py ts2
Length: 50

pd.read_csv() 函数读取一个使用 CSV 格式的纯文本文件——在我们的案例中,每个值都在其自己的行上,因此应该没有问题,可以分隔同一行上的值。pd.read_csv() 函数能够检测输入文件中的问题。pd.read_csv() 的返回值是一个 DataFrameTextParser。在我们的案例中,它是一个 DataFrame

pd.read_csv() 语句的末尾放置 .astype(np.float64) 将确保所有值都被读取为浮点值,即使整个时间序列只包含整数值。此外,header = None 确保输入不包含包含文本或与实际数据不同的标题行的行。

ts1Temp.to_numpy() 调用将一个 DataFrame 转换为 NumPy 数组。因此,ts1Temp.to_numpy() 的返回值是一个 NumPy 数组。这是必需的,因为我们将会使用 NumPy 数组。

ta.reshape(len(ta)) 调用在不改变数据的情况下给现有的 NumPy 数组赋予一个新的形状。这是为了使用正确的形状处理时间序列数据。

由于时间序列文件可能相当大,因此压缩它们并以压缩格式使用是一个好主意。幸运的是,Pandas 可以通过一个参数读取压缩文件。这可以在 read_ts_gz.py 脚本中看到。执行任务的语句是 pd.read_csv(filename, compression='gzip', header = None).astype(np.float64)。在这里,您也可以看到 .astype(np.float64) 的实际应用。

如何存储时间序列

本书使用纯文本文件来存储时间序列。在这些文件中,每个值都在单独的一行上。存储时间序列的方法还有很多,包括 CSV 格式和 JSON 格式。

所有数据都是数值的吗?

并非所有数据都是数值的,但在时间序列中,几乎所有的数据都是数值的。所提供的脚本读取一个纯文本文件并确保所有数据都是数值的——请注意,isNumeric.py 脚本目前不支持压缩文件,因为它使用 open() 调用来读取输入文件,并期望每行只有一个值。

isNumeric.py 的代码如下:

#!/usr/bin/env python3
import sys
def main():
    if len(sys.argv) != 2:
        print("TS")
        sys.exit()
    TS = sys.argv[1]
    file = open(TS, 'r')
    Lines = file.readlines()
    count = 0
    for line in Lines:
        # Strips the newline character
        t = line.strip()
        try:
            _ = float(t)
        except:
            count = count + 1
    print("Number of errors:", count)
if __name__ == '__main__':
    main()

tryexcept 块是我们尝试使用 float() 将当前字符串值转换为浮点值的地方。如果这失败了,我们知道我们处理的不是有效的数值。

运行 isNumeric.py 会产生以下类型的输出:

$ cat ts.txt
5.2
-12.4
-    # Error
17.9
a a     # Error
2 3 4    # Error
4.2
$ ./isNumeric.py ts.txt
Number of errors: 3

由于我们有三个错误行,结果才是正确的。

所有行都有相同数量的数据吗?

在本子节中,我们提供了一个脚本,该脚本计算每行的单词数,检查每个单词是否是有效的浮点值,并检查每行是否有相同数量的值。如果不是,它将说明预期的和找到的字段数。此外,它将读取的第一行视为正确的,因此所有后续行应该有相同数量的数据字段。值由空白字符分隔。

floats_per_line.py 的代码如下:

#!/usr/bin/env python3
import sys
def main():
    if len(sys.argv) != 2:
        print("TS")
        sys.exit()
    TS = sys.argv[1]
    file = open(TS, 'r')
    Lines = file.readlines()
    first = True
    wordsPerLine = 0
    for line in Lines:
        t = line.strip()
        words = t.split()
        for word in words:
            try:
                _ = float(word)
            except:
                print("Error:", word)
        if first:
            wordsPerLine = len(words)
            first = False
        elif wordsPerLine != len(words):
            print("Expected", wordsPerLine, "found", len(words))
            continue
if __name__ == '__main__':
    main()

如果没有参数执行 String.split(),它将使用所有空白字符作为分隔符来分割字符串,这正是我们在这里用来分隔每行输入字段的方式。如果你的数据格式不同,你可能需要修改 String.split() 语句以匹配你的需求。

运行 floats_per_line.py 产生以下类型的输出:

$ ./floats_per_line.py ts.txt
Error: -
Error: a
Error: b
Expected 1 found 2
Expected 1 found 3

下一个子节将展示如何根据滑动窗口大小处理时间序列。

创建子序列

尽管我们从一个纯文本文件中读取时间序列作为一个整体,但我们将其处理为一系列子序列。在本子节中,你将学习如何根据给定的滑动窗口大小将时间序列处理为子序列列表。

Python 脚本的名称是 subsequences.py。我们将分两部分介绍它。其中之一是用于存储子序列的 Python 结构:

#!/usr/bin/env python3
import argparse
import stumpy
import numpy as np
import pandas as pd
import sys
class TS:
    def __init__(self, ts, index):
        self.ts = ts
        self.index = index

TS 类有两个成员,一个用于存储实际数据(ts 变量)和一个用于存储子序列的索引(索引变量)。接下来的章节将丰富 TS 类以适应我们不断增长的需求。

脚本的其余部分如下:

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-w", dest = "window", type=int)
    parser.add_argument("TS")
    args = parser.parse_args()
    windowSize = args.window
    file = args.TS
    ts = pd.read_csv(file, names=['values'], compression='gzip', header = None)
    ts_numpy = ts.to_numpy()
    length = len(ts_numpy)
    # Split time series into subsequences
    for i in range(length - windowSize + 1):
        # Get the subsequence
        ts = ts_numpy[i:i+windowSize]
        # Create new TS node based on ts
        ts_node = TS(ts, i)
if __name__ == '__main__':
    main()

argparse 包帮助我们整理命令行参数。在这种情况下,我们期望两个参数:首先,滑动窗口大小(-w),其次,包含时间序列的文件名。for 循环用于将时间序列分割成子序列并生成多个 TS 类成员。

之前的代码并不难阅读、理解或修改。预期在本书中大多数 Python 脚本中都会看到这种类型的代码!

在当前形式下,subsequences.py 不会生成任何输出。如果提供的文件名或其数据有问题,你将只会收到错误信息。

可视化时间序列

大多数情况下,对数据有一个高级概述是了解数据的一个很好的方法。获取时间序列概述的最佳方式是通过可视化它。

可视化时间序列有多种方式,包括 R 或 Matlab 等工具,或者使用大量现有的 JavaScript 包。在本节中,我们将使用一个名为 Matplotlib 的 Python 包来可视化数据。此外,我们还将输出保存到 PNG 文件中。这个可行的替代方案是使用 Jupyter notebook – Jupyter 随 Anaconda 一起提供 – 并在你的首选网页浏览器上显示图形输出。

visualize.py脚本读取包含值的纯文本文件(时间序列)并创建一个图表。visualize.py的 Python 代码如下:

#!/usr/bin/env python3
import sys
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
import math
def main():
    if len(sys.argv) != 2:
        print("TS")
        sys.exit()
    F = sys.argv[1]
    # Read Sequence as Pandas
    ts = pd.read_csv(F, compression='gzip', header = None)
    # Convert to NParray
    ta = ts.to_numpy()
    ta = ta.reshape(len(ta))
    plt.plot(ta, label=F, linestyle='-', markevery=100, marker='o')
    plt.xlabel('Time Series', fontsize=14)
    plt.ylabel('Values', fontsize=14)
    plt.grid()
    plt.savefig("CH01_03.png", dpi=300, format='png', bbox_inches='tight')
if __name__ == '__main__':
    main()

你必须熟悉大多数展示的代码,因为你已经在本章前面看到一些了。plt.plot()语句用于绘制数据,而plt.savefig()函数则将输出保存到文件中,而不是在屏幕上显示。

执行./visualize.py ts1.gz命令的输出可以在图 1.3中看到:

图 1.3 – 可视化时间序列

图 1.3 – 可视化时间序列

现在我们已经了解了如何处理时间序列和子序列,是时候介绍一个高级技术,称为矩阵轮廓,它展示了在处理时间序列时可能需要计算的任务,以及这些任务可能多么耗时。

使用矩阵轮廓

在本节以及下一节中,我们将使用stumpy Python 包。这个包与 iSAX 无关,但提供了许多与时间序列相关的先进功能。借助stumpy,我们可以计算矩阵轮廓

矩阵轮廓是两件事:

  • 一个距离向量,显示了时间序列中每个子序列与其最近邻的距离

  • 一个索引向量,显示了时间序列中每个子序列最近邻的索引

矩阵轮廓可用于许多时间序列挖掘任务。展示它的主要原因是为了理解处理时间序列可能会很慢,因此我们需要结构和技巧来提高与时间序列相关的任务性能。

为了更好地了解矩阵轮廓的使用以及stumpy计算矩阵轮廓所需的时间,以下是matrix_profile.py的 Python 代码:

#!/usr/bin/env python3
import pandas as pd
import argparse
import time
import stumpy
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-w", "--window", dest = "window", default = "16", help="Sliding Window", type=int)
    parser.add_argument("TS")
    args = parser.parse_args()
    windowSize = args.window
    inputTS = args.TS
    print("TS:", inputTS, "Sliding Window size:", windowSize)
    start_time = time.time()
    ts = pd.read_csv(inputTS, names=['values'], compression='gzip')
    # Convert to NParray
    ts_numpy = ts.to_numpy()
    ta = ts_numpy.reshape(len(ts_numpy))
    realMP = stumpy.stump(ta, windowSize)
    print("--- %.5f seconds ---" % (time.time() - start_time))
if __name__ == '__main__':
    main()

stumpy.stump()函数计算给定时间序列的矩阵轮廓。

我们将执行matrix_profile.py两次。第一次使用包含 100,000 个元素的时间序列,第二次使用包含 300,000 个元素的时间序列。与本书中几乎所有的读取时间序列的 Python 脚本一样,matrix_profile.py期望读取压缩的纯文本文件。

taskset(1)命令

taskset(1)命令用于将所需数量的核心分配给指定的进程,目前仅在 Linux 机器上可用。使用它的原因是在执行matrix_profile.pympdistance.py时限制可用的核心数量,因为默认情况下,由于使用了 Numba Python 包,它们会使用所有可用的核心。一般来说,在测试算法性能或比较不同算法时,使用单个核心更好。在 macOS 上没有类似的实用工具。

使用较短的时间序列运行matrix_profile.py会产生以下输出:

$ taskset --cpu-list 3 ./matrix_profile.py 100k.txt.gz
TS: 100k.txt.gz Sliding Window size: 16
--- 120.44 seconds ---

因此,stumpy.stump()处理包含 100,000 个元素的时间序列大约需要120.44 秒

运行 matrix_profile.py 并使用更大的时间序列会产生以下输出:

$ taskset --cpu-list 0 ./matrix_profile.py 300k.gz -w 1024
TS: 300k.gz Sliding Window size: 1024
--- 922.30060 seconds ---

在这里,stumpy.stump() 在单个 CPU 核心上处理包含 300,000 个元素的时间序列大约需要922 秒。现在,想象一下处理包含超过 1,000,000 个元素的时间序列会是什么样子!

你将在第七章中了解所有关于矩阵轮廓的内容,并理解为什么它如此缓慢。

下一节将讨论一个名为MPdist的距离函数,该函数在内部使用矩阵轮廓进行计算。

探索 MPdist 距离

MPdist 提供了一种计算两个时间序列之间距离的方法。严格来说,MPdist距离是一种基于矩阵轮廓的距离度量,其计算速度比欧几里得距离慢得多,但它不需要时间序列具有相同的大小。

如你所料,与欧几里得距离以及其他现有的距离度量相比,它必须提供许多优势。根据创建它的人的说法,MPdist 的主要优势如下:

  • 与大多数现有的距离函数相比,它在比较数据的方式上更加灵活。

  • 它考虑了可能不在同一时间发生的数据相似性,其中时间指的是相同的索引。

  • 由于其计算方式,MPdist 在特定分析场景中被认为更稳健。更具体地说,MPdist 对尖峰和缺失值更稳健。

由于 MPdist 基于矩阵轮廓,计算 MPdist 距离可能非常缓慢,尤其是在处理大型时间序列时。

首先,让我们看看mpdistance.py的 Python 代码:

#!/usr/bin/env python3
import stumpy
import stumpy.mpdist
import numpy as np
import time
import sys
import pandas as pd
if len(sys.argv) != 4:
    print("TS1 + TS2 + Window size")
    sys.exit()
# Time series files
TS1 = sys.argv[1]
TS2 = sys.argv[2]
windowSize = int(sys.argv[3])
print("TS1:", TS1, "TS2:", TS2, "Window Size:", windowSize)
# Read Sequence as Pandas
ts1Temp = pd.read_csv(TS1, compression='gzip', header = None).astype(np.float64)
# Convert to NParray
ta = ts1Temp.to_numpy()
ta = ta.reshape(len(ta))
# Read Sequence as Pandas
ts2Temp = pd.read_csv(TS2, compression='gzip', header = None).astype(np.float64)
# Convert to NParray
tb = ts2Temp.to_numpy()
tb = tb.reshape(len(tb))
print(len(ta), len(tb))
start_time = time.time()
mpdist = stumpy.mpdist(ta, tb, m=windowSize)
print("--- %.5f seconds ---" % (time.time() - start_time))
print("MPdist: %.4f " % mpdist)

该程序使用sys模块的命令行参数来读取所需数据,而不是使用argparse包。

所有这些操作都是通过调用stumpy.mpdist()完成的,它需要三个参数——两个时间序列和滑动窗口大小。

由于mpdistance.py计算两个时间序列之间的距离,它期望读取两个文件。使用包含 10 万个元素的两个合成数据集运行mpdistance.py会生成以下输出:

$ taskset --cpu-list 0 ./mpdistance.py 100k_1.txt.gz 100k_2.txt.gz 512
TS1: 100k_1.txt.gz TS2: 100k_2.txt.gz Window Size: 512
100000 100000
--- 349.81955 seconds ---
MPdist: 28.3882

因此,在配备 Intel i7 CPU 的 Linux 机器上,当使用单个 CPU 核心时,mpdistance.py执行需要349.81955秒。MPdist 距离的值为28.3882

如果我们使用两个包含五十万个元素的数据集(500,000 个元素),mpdistance.py的输出和执行所需的时间应该与以下类似:

$ taskset --cpu-list 3 ./mpdistance.py h_500k_f.gz t_500k_f.gz 2048
TS1: h_500k_f.gz TS2: t_500k_f.gz Window Size: 2048
506218 506218
--- 4102.92 seconds ---
MPdist: 38.2851

因此,在配备 Intel i7 CPU 的 Linux 机器上,当使用单个 CPU 核心时,mpdistance.py执行需要4102.92秒。MPdist 距离的值为38.2851

你将在第七章中了解 MPdist 的所有细节。现在,你应该记住的是,MPdist 是一个存在一些性能问题的距离函数。

实验并保持谦逊

如果我能给你一条要记住的建议,那就是实验和尝试。尽可能多地实验你所阅读的内容,质疑它,以新的方式思考,尝试新事物,并持续学习。同时,保持谦逊,不要忘记很多人为我们今天讨论时间序列和索引奠定了基础。

摘要

在本章中,我们学习了时间序列、索引和距离函数的基础知识。尽管本章包含的理论知识无论使用何种编程语言都是有效和相关的,但在 Python 以及其他编程语言中存在实现所展示任务的替代方法和包。不变的是方法的正确性——无论使用何种编程语言,你都必须从磁盘读取文本文件才能使用其数据,以及执行任务所需的逻辑步骤,例如时间序列的可视化。这意味着如果你知道在 Python 中从磁盘加载文本文件的替代方法,并且它允许你执行下一个任务,那么请自由使用它。如果你是 Python 开发初学者,我建议你遵循书中的建议,直到你对 Python 更加熟练。毕竟,所使用的 Python 包是 Python 社区中最受欢迎的。

在继续阅读本书之前,请确保你理解本章所呈现的知识,因为它是本书其余部分的基础,尤其是如果你是时间序列和索引的新手。

下一章将介绍 SAX 表示法,它是 iSAX 索引的一个组成部分。

资源和有用链接

练习

尝试以下练习:

  • 创建一个新的 Anaconda 环境。

  • 列出 Anaconda 环境中安装的包。

  • 删除现有的 Anaconda 环境。

  • 创建一个新的包含 1,000 个值(从 -10 到 +10)的合成数据集。

  • 创建一个新的包含 100,000 个值(从 0 到 +10)的合成数据集。

  • 编写一个 Python 脚本,逐行读取一个纯文本文件。

  • 编写一个 Python 脚本,逐字读取一个纯文本文件并打印出来。为什么逐字打印比逐行打印文件更困难?

  • 编写一个 Python 脚本,多次读取相同的纯文本文件,并计时该操作。文件读取的次数以及文件路径应作为命令行参数提供。

  • 修改 synthetic_data.py 以生成整数值而不是浮点值。

  • 使用 synthetic_data.py 创建一个包含 500,000 个元素的时间序列,并在生成的时间序列上执行 matrix_profile.py。不要忘记压缩纯文本文件。

  • 修改 mpdistance.py 以使用 argparse 读取其参数。

  • 尝试使用 visualize.py 来绘制你自己的时间序列图。当你绘制大时间序列时会发生什么?找到你想要的东西有多容易?

  • 图 1**.2 中的 iSAX 索引是一个二叉树吗?它是一个平衡树吗?为什么?

  • 修改 ed.py 以从压缩的纯文本文件中读取时间序列。

第二章:实现 SAX

本章是关于 iSAX 索引的 Symbolic Aggregate Approximation (SAX) 组件,分为两部分——第一部分是理论知识,第二部分是计算 SAX 和实际应用的代码示例。在章节末尾,你将看到如何计算一些有用的统计量,这些统计量可以让你对时间序列有一个更高的概述,并绘制数据的直方图。

在本章中,我们将涵盖以下主要主题:

  • 所需的理论

  • SAX 简介

  • 开发 Python 包

  • 使用 SAX 包

  • 计算时间序列的 SAX 表示

  • tsfresh Python 包

  • 创建时间序列的直方图

  • 计算时间序列的百分位数

技术要求

该书的 GitHub 仓库是 github.com/PacktPublishing/Time-Series-Indexing。每章的代码都在其自己的目录中。因此,第二章 的代码可以在 ch02 文件夹中找到。如果你已经使用 git(1) 获取了整个 GitHub 仓库的本地副本,就无需再次获取。只需在处理本章内容时将当前工作目录设置为 ch02

所需的理论

在本节中,你将学习支持 SAX 表示所需的理论。然而,请记住,这本书更注重实践而非理论。如果你想深入理解理论,你应该阅读本章中提到的以及即将发表的研究论文,以及每章末尾的“有用链接”部分。因此,理论主要是为了服务于我们的主要目的,即实现技术和算法。

SAX 的操作和细节在由 Jessica Lin、Eamonn Keogh、Li Wei 和 Stefano Lonardi 撰写的题为 Experiencing SAX: a novel symbolic representation of time series 的研究论文中得到了全面描述。这篇论文(doi.org/10.1007/s10618-007-0064-z)于 2007 年正式发表。你不必从头到尾阅读它,但下载并阅读其摘要和引言部分是个好主意。

我们将首先解释术语 PAASAXPAA 代表 Piecewise Aggregate Approximation。PAA 表示提供了一种降低时间序列维度的方法。这意味着它将一个长时间序列转换为一个更小的版本,这使得处理起来更容易。

PAA 也在 Experiencing SAX: a novel symbolic representation of time series 论文中进行了解释(doi.org/10.1007/s10618-007-0064-z)。从那里,我们可以很容易地理解 PAA 和 SAX 是密切相关的,因为 SAX 的理念基于 PAA。SAX 表示 是时间序列的 符号表示。简单来说,它提供了一种以摘要形式表示时间序列的方法,以便节省空间并提高速度。

PAA 和 SAX 的区别

PAA 和 SAX 表示之间的主要区别在于,PAA 只是基于滑动窗口大小计算时间序列的均值,而 SAX 表示利用这些均值并将 PAA 进一步转换为时间序列(或子序列)的离散表示。换句话说,SAX 表示将 PAA 表示转换为更易于处理的形式。正如您很快就会发现的,这种转换是在 断点的帮助下进行的,这些断点将均值值的数值空间划分为子空间。每个子空间都有一个基于给定断点值的离散表示。

PAA 和 SAX 都是降维技术。SAX 将在稍后进行更详细的解释,而关于 PAA 的讨论就到这里结束。

下一个子节告诉我们为什么我们需要 SAX。

我们为什么需要 SAX?

时间序列难以搜索。时间序列(或子序列)越长,搜索它或将其与另一个进行比较的计算量就越大。同样,使用索引时间序列的索引也是如此——iSAX 就是这样一种索引。

为了让您的事情更简单,我们将采取一个包含 x 个元素的子序列并将其转换为包含 w 个元素的表现形式,其中 w 远小于 x。严格来说,这被称为 降维,它使我们能够使用更少的数据处理长时间子序列。然而,一旦我们决定需要处理一个给定的子序列,我们就需要使用其全部维度来处理它——也就是说,所有它的 x 个元素。

下一个子节讨论了归一化,这使我们能够在不同的尺度上比较值。

归一化

您可能问的第一个两个问题是归一化是什么以及为什么需要它。

归一化是将使用不同尺度的值调整到共同尺度的过程。一个简单的例子是比较华氏和摄氏温度——除非我们将所有值都带到相同的尺度,否则我们无法这样做。这是归一化的最简单形式。

尽管存在各种类型的归一化,但这里需要的是 标准分数归一化,这是最简单的归一化形式,因为这是用于时间序列和子序列的。请勿将数据库归一化和范式与值归一化混淆,因为它们是完全不同的概念。

我们将归一化引入过程的原因如下:

  • 第一个也是最重要的原因是,我们可以比较使用不同值范围的数据集。一个简单的例子是比较摄氏度和华氏温度。

  • 由于数据异常减少但并未消除,这是前一点的一个副作用。

  • 通常,归一化数据更容易理解和处理,因为我们处理的是预定义范围内的值。

  • 使用归一化值索引进行搜索可能比使用较大值时更快。

  • 由于值较小,搜索、排序和创建索引更快。

  • 归一化在概念上更清晰,更容易维护和根据需要更改。

另一个支持归一化需求的简单例子是当比较正值和负值时。在比较这种不同类型的观察结果时,几乎不可能得出有用的结论。归一化解决了这些问题。

虽然我们不需要这样做,但请记住,我们不能从子序列的归一化版本回到原始子序列,因此归一化过程是不可逆的。

以下函数展示了如何使用 NumPy Python 包的帮助来归一化时间序列:

def normalize(x):
     eps = 1e-6
     mu = np.mean(x)
     std = np.std(x)
     if std < eps:
           return np.zeros(shape=x.shape)
     else:
           return (x-mu)/std

前一个函数揭示了归一化的公式。给定一个数据集,其每个元素的归一化形式等于观察值,减去数据集的平均值除以数据集的标准差——这两个统计术语在本章的The tsfresh Python 包部分中解释。

这在前一个函数的返回值中可以看到,(x-mu)/std。NumPy 足够聪明,可以计算每个观察值而不需要使用for循环。如果标准差接近0,这是由eps变量的值模拟的,那么normalize()的返回值将等于一个全为零的 NumPy 数组。

使用之前开发的函数(此处未显示)的normalize.py脚本,以时间序列作为输入,并返回其归一化版本。其代码如下:

#!/usr/bin/env python3
import sys
import pandas as pd
import numpy as np
def main():
     if len(sys.argv) != 2:
           print("TS")
           sys.exit()
     F = sys.argv[1]
     ts = pd.read_csv(F, compression='gzip', header = None)
     ta = ts.to_numpy()
     ta = ta.reshape(len(ta))
     taNorm = normalize(ta)
     print("[", end = ' ')
     for i in taNorm.tolist():
           print("%.4f" % i, end = ' ')
     print("]")
if __name__ == '__main__':
     main()

程序中的最后一个for循环用于以较小的精度打印taNorm NumPy 数组的内容,以便占用更少的空间。为此,我们需要使用tolist()方法将taNorm NumPy 数组转换为常规 Python 列表。

我们将向normalize.py提供一个短时间序列;然而,该脚本也可以处理更长的序列。normalize.py的输出如下:

$ ./normalize.py ts1.gz
[ -1.2272 0.9487 -0.1615 -1.0444 -1.3362 1.4861 -1.0620 0.7451 -0.4858 -0.9965 0.0418 1.7273 -1.1343 0.6263 0.3455 0.9238 1.2197 0.3875 -0.0483 -1.7054 1.3272 1.5999 1.4479 -0.4033 0.1525 1.0673 0.7019 -1.0114 0.4473 -0.2815 1.1239 0.7516 -1.3102 -0.6428 -0.3186 -0.3670 -1.6163 -1.2383 0.5692 1.2341 -0.0372 1.3250 -0.9227 0.2945 -0.5290 -0.3187 1.4103 -1.3385 -1.1540 -1.2135 ]

考虑到归一化,我们现在继续到下一个子节,我们将可视化一个时间序列,并展示原始版本和归一化版本之间的视觉差异。

可视化归一化时间序列

在本小节中,我们将通过可视化展示归一化版本和原始时序版本之间的差异。请注意,我们通常不会对整个时序进行归一化。归一化是在基于滑动窗口大小的子序列级别进行的。换句话说,为了本书的目的,我们将归一化子序列,而不是整个时序。此外,对于 SAX 表示法的计算,我们根据段值处理归一化子序列,这指定了 SAX 表示法将包含的部分。因此,对于段值为 2,我们将归一化子序列分成两个部分。对于段值为 4,我们将归一化子序列分成四个集合。

尽管如此,查看时序的归一化和原始版本是非常有教育意义的。visualize_normalized.py的 Python 代码(不包括normalize()的实现)如下:

#!/usr/bin/env python3
import sys
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
def main():
     if len(sys.argv) != 2:
           print("TS")
           sys.exit()
     F = sys.argv[1]
     ts = pd.read_csv(F, compression='gzip', header = None)
     ta = ts.to_numpy()
     ta = ta.reshape(len(ta))
     # Find its normalized version
     taNorm = normalize(ta)
     plt.plot(ta, label="Regular", linestyle='-', markevery=10, marker='o')
     plt.plot(taNorm, label="Normalized", linestyle='-.', markevery=10, marker='o')
     plt.xlabel('Time Series', fontsize=14)
     plt.ylabel('Values', fontsize=14)
     plt.legend()
     plt.grid()
     plt.savefig("CH02_01.png", dpi=300, format='png', bbox_inches='tight')
if __name__ == '__main__':
     main()

plt.plot()函数被调用了两次,每次都绘制一条线。您可以自由地实验 Python 代码,以改变输出的外观。

图 2.1显示了visualize_normalized.py ts1.gz的输出,它使用了一个包含 50 个元素的时序。

图 2.1 – 时序及其归一化版本的绘图

图 2.1 – 时序及其归一化版本的绘图

我认为图 2.1本身就很有说服力!归一化版本的值位于0 值附近,而原始时序的值可以在任何地方!此外,我们在不完全失去原始形状和边缘的情况下使原始时序更加平滑。

下一节将介绍 SAX 表示法的细节,这是每个 iSAX 索引的关键组成部分。

SAX 简介

如前所述,SAX代表符号聚合近似。SAX 表示法在 2007 年的论文《体验 SAX:时间序列的一种新颖的符号表示》中被正式宣布(doi.org/10.1007/s10618-007-0064-z)。

请记住,我们不想找到整个时序的 SAX 表示法。我们只想找到时序子序列的 SAX 表示法。时序和子序列之间的主要区别是时序通常比子序列大得多。

每个 SAX 表示法有两个参数,分别命名为基数段数。我们将首先解释基数参数。

基数参数

基数 参数指定了每个段可以有多少个可能的值。作为副作用,基数参数 定义了 y 轴的分割方式 – 这用于获取每个段的值。根据基数,存在多种指定段值的方法。这包括字母字符、十进制数和二进制数。在这本书中,我们将使用二进制数,因为它们更容易理解和解释,使用带有 预先计算的断点 的文件,这些断点适用于高达 256 的基数。

因此,基数 4,即 22,给出了四个可能的值,因为我们使用了 2 位。然而,我们可以轻松地将 00 替换为字母 a01 替换为字母 b10 替换为字母 c11 替换为字母 d,以此类推,以便使用字母而不是二进制数。请记住,这可能需要在展示的代码中进行最小的代码更改,并且当您对 SAX 和提供的 Python 代码感到舒适时,尝试这个练习会很好。

断点文件的格式如下,在我们的例子中支持高达 256 的基数,被称为 SAXalphabet

$ head -7 SAXalphabet
0
-0.43073,0.43073
-0.67449,0,0.67449
-0.84162,-0.25335,0.25335,0.84162
-0.96742,-0.43073,0,0.43073,0.96742
-1.0676,-0.56595,-0.18001,0.18001,0.56595,1.0676
-1.1503,-0.67449,-0.31864,0,0.31864,0.67449,1.1503

这里展示的值在 SAX 术语中被称为断点。第一行中的值将 y 轴分割成两个区域,由 x 轴分隔。因此,在这种情况下,我们需要 1 位来定义我们是在上空间(正 y 值)还是下空间(负 y 值)。

由于我们将使用二进制数来表示每个 SAX 段,因此没有必要浪费它们。因此,我们将使用的值是 2 的幂,从 2 1 (基数 2) 到 2 8 (基数 256)。

现在我们来展示 *图 2.2,它展示了 -0.67449, 0, 0.67449 如何将 y 轴分割,这在 2 2 基数中是使用的。底部部分从负无穷大到 -0.67449,第二部分从 -0.674490,第三部分从 00.67449,最后一部分从 0.67449 到正无穷大。

图 2.2 – 基数为 4 的 y 轴(三个断点)

图 2.2 – 基数为 4 的 y 轴(三个断点)

现在我们来展示 *图 2.3,它展示了 -1.1503, -0.67449, -0.31864, 0, 0.31864, 0.67449, 1.1503 如何分割 y 轴。这是针对 2 3 基数的。

图 2.3 – 基数为 8 的 y 轴(七个断点)

图 2.3 – 基数为 8 的 y 轴(七个断点)

由于这可能是一项繁琐的工作,我们创建了一个工具来完成所有的绘图。它的名字是 cardinality.py,它读取 SAXalphabet 文件,在绘图之前找到所需基数的断点。

cardinality.py 的 Python 代码如下:

#!/usr/bin/env python3
import sys
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
import os
breakpointsFile = "./sax/SAXalphabet"
def main():
     if len(sys.argv) != 3:
           print("cardinality output")
           sys.exit()
     n = int(sys.argv[1]) - 1
     output = sys.argv[2]
     path = os.path.dirname(__file__)
     file_variable = open(path + "/" + breakpointsFile)
     alphabet = file_variable.readlines()
     myLine = alphabet[n - 1].rstrip()
     elements = myLine.split(',')
     lines = [eval(i) for i in elements]
     minValue = min(lines) - 1
     maxValue = max(lines) + 1
     fig, ax = plt.subplots()
     for i in lines:
           plt.axhline(y=i, color='r', linestyle='-.', linewidth=2)
     xLabel = "Cardinality " + str(n)
     ax.set_ylim(minValue, maxValue)
     ax.set_xlabel(xLabel, fontsize=14)
     ax.set_ylabel('Breakpoints', fontsize=14)
     ax.grid()
     fig.savefig(output, dpi=300, format='png', bbox_inches='tight')
if __name__ == '__main__':
     main()

脚本需要两个命令行参数——基数和输出文件,用于保存图像。请注意,基数值为 8 需要 7 个断点,基数值为 32 需要 31 个断点,依此类推。因此,cardinality.py的 Python 代码减少了它将在SAXalphabet文件中搜索的行数,以支持该功能。因此,当给定基数值为 8 时,脚本将寻找SAXalphabet中具有 7 个断点的行。此外,由于脚本将断点值作为字符串读取,我们需要使用lines = [eval(i) for i in elements]语句将这些字符串转换为浮点值。其余的代码与 Matplotlib Python 包有关,以及如何使用plt.axhline()绘制线条。

下一个子节是关于段落数据的参数。

段参数

(段落数量的)参数指定了 SAX 表示将要拥有的部分(单词)的数量。因此,段值为 2 意味着 SAX 表示将有两个单词,每个单词使用指定的基数。因此,每个部分的值由基数确定。

这个参数的一个副作用是,在归一化子序列后,我们将它除以段落数量,并分别处理这些不同的部分。这就是 SAX 表示的工作方式。

基数和段值控制时间序列子序列以及整个时间序列的数据压缩比和准确性。

下一个子节展示了如何手动计算子序列的 SAX 表示——这是完全理解过程和能够识别代码中的错误或错误的最有效方式。

如何手动找到子序列的 SAX 表示

找到子序列的 SAX 表示看起来很简单,但需要大量的计算,这使得这个过程非常适合计算机。以下是找到时间序列或子序列的 SAX 表示的步骤:

  1. 首先,我们需要有段落数量和基数。

  2. 然后,我们归一化子序列或时间序列。

  3. 之后,我们将归一化的子序列除以段落数量。

  4. 对于这些部分中的每一个,我们找到它的平均值。

  5. 最后,基于每个平均值,我们根据基数计算其表示。基数定义了将要使用的断点值。

我们将使用两个简单的例子来说明时间序列 SAX 表示的手动计算。在这两种情况下,时间序列是相同的。不同之处在于 SAX 参数和滑动窗口大小。

让我们假设我们有一个以下时间序列和一个滑动窗口大小为 4:

{-1, 2, 3, 4, 5, -1, -3, 4, 10, 11, . . .}

基于滑动窗口大小,我们从时间序列中提取前两个子序列:

  • S1 = {-1, 2, 3, 4}

  • S2 = {2, 3, 4, 5}

我们应该采取的第一步是使用我们之前开发的 normalize.py 脚本 – 我们只需将每个子序列保存到自己的纯文本文件中,并使用 gzip 工具对其进行压缩,然后再将其作为输入提供给 normalize.py。如果你使用的是微软 Windows 机器,你应该寻找一个允许你创建此类 ZIP 文件的实用程序。另一种选择是使用纯文本文件,这可能在 pd.read_csv() 函数调用中需要一些小的代码更改。

当处理 S1 (s1.txt.gz) 和 S2 (s2.txt.gz) 时,normalize.py 脚本的输出如下:

$ ./normalize.py s1.txt.gz
[ -1.6036 0.0000 0.5345 1.0690 ]
$ ./normalize.py s2.txt.gz
[ -1.3416 -0.4472 0.4472 1.3416 ]

因此,S1S2 的归一化版本如下:

  • N1 = {-1.6036, 0.0000, 0.5345, 1.0690}

  • N2 = {-1.3416, -0.4472, 0.4472, 1.3416}

在这个第一个例子中,我们使用段值为 2,基数值为 4(22)。段值为 2 意味着我们必须将每个 归一化子序列 分成两部分。这两部分包含以下数据,基于 S1S2 的归一化版本:

  • 对于 S1,两部分是 {-1.6036, 0.0000}{``0.5345, 1.0690}

  • 对于 S2,两部分是 {-1.3416, -0.4472}{``0.4472, 1.3416}

每个部分的平均值如下:

  • 对于 S1,它们分别是 -0.80180.80175

  • 对于 S2,它们是 -0.89440.8944

对于基数 4,我们将查看 图 2**.2 和相应的断点,分别是 -0.6744900.67449。因此,每个段的 SAX 值如下:

  • 对于 S1,它们是 00,因为 -0.8018 位于图表底部,而 11

  • 对于 S2,它们是 0011,因为 0.8944 位于图表的顶部

因此,S1 的 SAX 表示是 [00, 11],而 S2 的表示也是 [00, 11]。这两个子序列具有相同的 SAX 表示是有道理的,因为它们只在一个元素上有所不同,这意味着它们的归一化版本相似。

注意,在两种情况下,较低的基数都从图表的底部开始。对于 图 2**.2,这意味着 00 位于图表底部,01 接着是,然后是 10,而 11 位于图表顶部。

在第二个例子中,我们将使用滑动窗口大小为 8,段值 4,基数值 8(23)。

关于滑动窗口大小

请记住,当滑动窗口大小保持不变时,子序列的归一化表示保持不变。然而,如果基数或段发生变化,生成的 SAX 表示可能完全不同。

根据滑动窗口大小,我们从时间序列中提取前两个子序列 – S1 = {-1, 2, 3, 4, 5, -1, -3, 4}S2 = {2, 3, 4, 5, -1, -3, 4, 10}

normalize.py 脚本的输出将是以下内容:

$ ./normalize.py T1.txt.gz
[ -0.9595 0.1371 0.5026 0.8681 1.2337 -0.9595 -1.6906 0.8681 ]
$ ./normalize.py T2.txt.gz
[ -0.2722 0.0000 0.2722 0.5443 -1.0887 -1.6330 0.2722 1.9052 ]

因此,S1S2的归一化版本分别是N1 = {-0.9595, 0.1371, 0.5026, 0.8681, 1.2337, -0.9595, -1.6906, 0.8681}N2 = {-0.2722, 0.0000, 0.2722, 0.5443, -1.0887, -1.6330, 0.2722, 1.9052}

段数值为 4 意味着我们必须将每个归一化子序列分成四个部分。对于S1,这些部分是{-0.9595, 0.1371}{0.5026, 0.8681}{1.2337, -0.9595},和{-1.6906, 0.8681}

对于S2,这些部分是{-0.2722, 0.0000}{0.2722, 0.5443}{-1.0887, -1.6330},和{0.2722, 1.9052}

对于S1,平均值是-0.41120.685350.1371,和-0.41125。对于S2,平均值是-0.13610.40825-1.36085,和1.0887

关于基数值为 8 的断点

这里提醒一下,对于基数值为 8 的情况,断点为(000)-1.1503,(001)-0.67449,(010)-0.31864,(011)0,(100)0.31864,(101)0.67449,以及(110)1.1503(111)。在括号中,我们展示了每个断点的 SAX 值。对于第一个断点,其左侧是 000 值,右侧是 001 值。对于最后一个断点,其左侧是 110 值,右侧是 111 值。记住,我们使用七个断点来表示基数值为 8。

因此,S1的 SAX 表示为['010', '110', '100', '010'],而S2的表示为['011', '101', '000', '110']。在 SAX 词周围使用单引号表示,尽管我们将其计算为二进制数,但内部我们将其作为字符串存储,因为这样更容易搜索和比较。

下一个子节考察了一个不能被段落数完美分割的子序列的案例。

我们如何将 10 个数据点分成 3 个段?

到目前为止,我们已经看到了子序列长度可以完美被段落数分割的例子。然而,如果不可能这样做,会发生什么呢?

在这种情况下,存在一些数据点同时贡献于两个相邻的段。然而,我们不是将整个点放入一个段中,而是将其一部分放入一个段,另一部分放入另一个段!

这在 《体验 SAX:时间序列的新符号表示法》 论文的第 18 页有进一步的解释。正如论文中所述,如果我们不能将滑动窗口长度除以段落数量,我们可以使用一个段中的一个点的部分和另一个段中的一个点的部分。我们只为位于两个段之间的点这样做,而不是任何随机点。这可以通过一个例子来解释。想象我们有一个时间序列,例如 T = {t1, t2, t3, t4, t5, t6, t7, t8, t9, t10}。对于 S1 段,我们取 t1, t2, t3 的值和 t4 值的三分之一。对于 S2 段,我们取 t5, t6 的值和 t4 和 t7 值的三分之二。对于 S3 段,我们取 t8, t9, t10 的值和之前未使用的 t7 值的三分之一。这也在 图 2.4* 中解释了。4:

图 2.4 – 将 10 个数据点分成 3 个段

图 2.4 – 将 10 个数据点分成 3 个段

简单来说,这是 SAX 创建者决定的一个约定,适用于所有我们无法完美地将元素数量除以段落数量的情况。

在这本书中,我们不会处理那种情况。滑动窗口大小,即生成的子序列的长度,以及段落数量都是完美除法的一部分,余数为 0。这种简化并没有改变 SAX 的工作方式,但它使我们的生活变得稍微容易一些。

下一个小节的主题是如何在不进行每个计算的情况下,从较高的基数转换为较低的基数。

降低 SAX 表示的基数

从本小节中获得的知识将在我们讨论 iSAX 索引时适用。然而,由于你将要学习的内容与 SAX 直接相关,我们决定先在这里讨论。

想象一下,我们有一个给定基数的 SAX 表示法,我们想要降低基数。这是否可能?我们能否在不从头开始计算一切的情况下做到这一点?答案是简单的——这可以通过忽略尾随位来实现。给定一个二进制值 10,100,第一个尾随位是 0,然后是下一个尾随位是 0,然后是 1,以此类推。因此,我们从末尾的位开始,逐个移除它们。

就像你们大多数人,包括我在第一次读到它时,可能会觉得这不清楚一样,让我给你们一些实际例子。让我们从这个章节的 《如何手动找到子序列的 SAX 表示法》 小节中的以下两个 SAX 表示法 [00, 11][010, 110, 100, 010] 开始。要将 [00, 11] 转换为基数 2,我们只需删除每个 SAX 词尾的数字。因此,新的 [00, 11] 版本将是 [0, 1]。同样,[010, 110, 100, 010] 将会变为 [01, 11, 10, 01],基数是 4,而对于基数 2,将是 [0, 1, 1, 0]

因此,从更高的基数——一个有更多数字的基数——我们可以通过从一段或多段(尾数位)的右侧 减去适当的数字 来降低基数。我们能朝相反的方向前进吗?不能不损失精度,但即便如此,这仍然比没有好。然而,通常情况下,我们不会朝相反的方向前进。到目前为止,我们已经了解了关于 SAX 表示的理论。接下来的部分将简要解释 Python 包的基础知识,并展示我们自己的包 sax 的开发过程。

开发 Python 包

在本节中,我们描述了开发一个 Python 包的过程,该包用于计算子序列的 SAX 表示。除了这是一个好的编程练习之外,当我们在后续章节中创建 iSAX 索引时,该包将在接下来的章节中得到丰富。

我们将首先解释 Python 包的基础知识。

Python 包的基础知识

我不是 Python 专家,所提供的信息远非完整。然而,它涵盖了有关 Python 包所需的知识。

在除了最新版本的 Python 之外的所有版本中,我们通常需要在每个 Python 包的目录中创建一个名为 __init__.py 的文件。它的目的是执行初始化操作和导入,以及定义变量。尽管在最新的 Python 版本中这种情况不再适用,但我们的包中仍然会包含一个 __init__.py 文件。好事是,如果你没有东西要放入其中,它可以是空的。本章末尾有一个链接指向官方 Python 文档,其中详细解释了包、常规包和命名空间包的使用,以及 __init__.py 的使用。

下一个子节将讨论我们将要开发的 Python 包的详细信息。

SAX Python 包

sax Python 包的代码包含在一个名为 sax 的目录中。sax 目录的内容通过 tree(1) 命令展示,你可能需要自行安装该命令:

$ tree sax
sax
├── SAXalphabet
├── __init__.py
├── __pycache__
│   __init__.cpython-310.pyc
│   sax.cpython-310.pyc
│   tools.cpython-310.pyc
│   variables.cpython-310.pyc
├── sax.py
├── tools.py
└── variables.py
2 directories, 9 files

Python 会在你开始使用 Python 包之后自动生成名为 __pycache__ 的目录,该目录包含预编译的 Python 字节码。你可以完全忽略该目录。

让我们先看看 sax.py 的内容,它将被分成多个代码块展示。

首先,我们有 import 部分,以及 normalize() 函数的实现,该函数用于规范化一个 NumPy 数组:

import numpy as np
from scipy.stats import norm
from sax import tools
import sys
sys.path.insert(0,'..')
def normalize(x):
     eps = 1e-6
     mu = np.mean(x)
     std = np.std(x)
     if std < eps:
           return np.zeros(shape=x.shape)
     else:
           return (x-mu)/std

之后,我们将介绍 createPAA() 函数的实现,该函数根据基数和段返回时间序列的 SAX 表示:

def createPAA(ts, cardinality, segments):
     SAXword = ""
     ts_norm = normalize(ts)
     segment_size = len(ts_norm) // segments
     mValue = 0
     for I in range(segments):
           ts_segment = ts_norm[segment_size * i :(i+1) * segment_size]
           mValue = meanValue(ts_segment)
           index = getIndex(mValue, cardinality)
           SAXword += str(index) +""""

Python 使用双斜杠 // 运算符执行向下取整除法。// 运算符的作用是在将结果向下取整到最接近的整数之前,将第一个数除以第二个数——这用于 segment_size 变量。

代码的其余部分是关于在处理给定的时间序列(或子序列)时指定正确的索引号。因此,for循环用于根据段值处理整个时间序列(或子序列)。

接下来,我们有一个计算 NumPy 数组平均值的函数的实现:

def meanValue(ts_segment):
     sum = 0
     for i in range(len(ts_segment)):
           sum += ts_segment[i]
     mean_value = sum / len(ts_segment)
     return mean_value

最后,我们有一个函数,它根据 SAX 词的平均值和基数返回 SAX 值。记住,我们在createPAA()函数中单独计算每个 SAX 词的平均值:

def getIndex(mValue, cardinality):
     index = 0
     # With cardinality we get cardinality + 1
     bPoints = tools.breakpoints(cardinality-1)
     while mValue < float(bPoints[index]):
           if index == len(bPoints)–- 1:
                 # This means that index should be advanced
                 # before breaking out of the while loop
                 index += 1
                 break
           else:
                 index += 1
     digits = tools.power_of_two(cardinality)
     # Inverse the result
     inverse_s = ""
     for i in binary_index:
           if i == '0':
                 inverse_s += '1'
           else:
                 inverse_s += '0'
     return inverse_s

之前的代码通过使用平均值来计算 SAX 词的 SAX 值。它迭代地访问断点,从最低值到最高值,直到平均值超过当前断点。这样,我们在断点列表中找到 SAX 词(平均值)的索引。

现在,让我们讨论一个棘手的问题,这与反转 SAX 词的最后几个语句有关。这主要与我们是开始从断点创建的不同区域的顶部还是底部计数有关。所有方法都是等效的——我们只是决定那样做。这是因为 SAX 的前一个实现使用了那个顺序,我们想要确保为了测试目的我们创建了相同的结果。如果你想改变这个功能,你只需要移除最后的for循环。

如您在本节开头所见,sax包由三个 Python 文件组成,而不仅仅是刚才我们展示的那个。因此,我们将展示剩下的两个文件。

首先,我们将展示variables.py的内容:

# This file includes all variables for the sax package
maximumCardinality = 32
# Where to find the breakpoints file
# In this case, in the current directory
breakpointsFile =""SAXalphabe""
# Sliding window size
slidingWindowSize = 16
# Segments
segments = 0
# Breakpoints in breakpointsFile
elements ="""
# Floating point precision
precision = 5

你可能会想知道为什么有这样的文件。答案是,我们需要一个地方来保存我们的全局参数和选项,而有一个单独的文件来处理这一点是一个完美的解决方案。当代码变得更长更复杂时,这会更有意义。

其次,我们展示tools.py中的代码:

import os
import numpy as np
import sys
from sax import variables
breakpointsFile = variables.breakpointsFile
maxCard = variables.maximumCardinality

在这里,我们引用了variable.py文件中的两个变量,分别是variables.breakpointsFilevariables.maximumCardinality

def power_of_two(n):
     power = 1
     while n/2 != 1:
           # Not a power of 2
           if n % 2 == 1:
                 return -1
           n = n / 2
           power += 1
     return power

这是一个辅助函数,我们在想要确保一个值是 2 的幂时使用它:

def load_sax_alphabet():
     path = os.path.dirname(__file__)
     file_variable = open(path +"""" + breakpointsFile)
     variables.elements = file_variable.readlines()
def breakpoints(cardinality):
     if variables.elements ==""":
           load_sax_alphabet()
     myLine = variables.elements[cardinality–- 1].rstrip()
     elements = myLine.split'''')
     elements.reverse()
     return elements

load_sax_alphabet()函数加载包含断点定义的文件内容,并将它们分配给variables.elements变量。breakpoints()函数在给定基数时返回断点值。

如您所见,整个包的代码相对较短,这是一个好事。

在本节中,我们开发了一个 Python 包来计算 SAX 表示。在下一节中,我们将开始使用sax包。

使用 SAX 包

现在我们有了 SAX 包,是时候通过开发各种实用程序来使用它了,从计算时间序列子序列的 SAX 表示的实用程序开始。

计算时间序列子序列的 SAX 表示

在本小节中,我们将开发一个实用工具,该工具计算时间序列所有子序列的 SAX 表示,并展示它们的归一化形式。该实用工具的名称是 ts2PAA.py,包含以下代码:

#!/usr/bin/env python3
import sys
import numpy as np
import pandas as pd
from sax import sax
def main():
     if len(sys.argv) != 5:
           print("TS1 sliding_window cardinality segments")
           sys.exit()
     file = sys.argv[1]
     sliding = int(sys.argv[2])
     cardinality = int(sys.argv[3])
     segments = int(sys.argv[4])
     if sliding % segments != 0:
           print("sliding MODULO segments != 0...")
           sys.exit()
     if sliding <= 0:
           print("Sliding value is not allowed:", sliding)
           sys.exit()
     if cardinality <= 0:
           print("Cardinality Value is not allowed:", cardinality)
           sys.exit()
     # Read Sequence as Pandas
     ts = pd.read_csv(file, names=['values'], compression='gzip')
     # Convert to NParray
     ts_numpy = ts.to_numpy()
     length = len(ts_numpy)
     PAA_representations = []
     # Split sequence into subsequences
     for i in range(length - sliding + 1):
           t1_temp = ts_numpy[i:i+sliding]
           # Generate SAX for each subsequence
           tempSAXword = sax.createPAA(t1_temp, cardinality, segments)
           SAXword = tempSAXword.split("_")[:-1]
           print(SAXword, end = ' ')
           PAA_representations.append(SAXword)
           print("[", end = ' ')
           for i in t1_temp.tolist():
                 for k in i:
                       print("%.2f" % k, end = ' ')
           print("]", end = ' ')
           print("[", end = ' ')
           for i in sax.normalize(t1_temp).tolist():
                 for k in i:
                       print("%.2f" % k, end = ' ')
           print("]")
if __name__ == '__main__':
     main()

ts2PAA.py 脚本接受一个时间序列,将其分割成子序列,并使用 sax.normalize() 计算每个子序列的归一化版本。

ts2PAA.py 的输出如下(为了简洁,省略了一些输出):

$ ./ts2PAA.py ts1.gz 8 4 2
['01', '10'] [ 5.22 23.44 14.14 6.75 4.31 27.94 6.61 21.73 ] [ -0.97 1.10 0.04 -0.80 -1.07 1.61 -0.81 0.90 ]
['01', '10'] [ 23.44 14.14 6.75 4.31 27.94 6.61 21.73 11.43 ] [ 1.07 -0.05 -0.94 -1.24 1.62 -0.96 0.87 -0.38 ]
['10', '01'] [ 14.14 6.75 4.31 27.94 6.61 21.73 11.43 7.15 ] [ 0.21 -0.73 -1.05 1.97 -0.75 1.18 -0.14 -0.68 ]
['01', '10'] [ 6.75 4.31 27.94 6.61 21.73 11.43 7.15 15.85 ] [ -0.76 -1.07 1.93 -0.77 1.14 -0.16 -0.70 0.40 ]
['01', '10'] [ 4.31 27.94 6.61 21.73 11.43 7.15 15.85 29.96 ] [ -1.22 1.32 -0.97 0.66 -0.45 -0.91 0.02 1.54 ]
['10', '01'] [ 27.94 6.61 21.73 11.43 7.15 15.85 29.96 6.00 ] [ 1.34 -1.02 0.65 -0.49 -0.96 0.00 1.56 -1.08 ]
. . .

之前的输出显示了时间序列所有子序列的 SAX 表示、原始子序列和子序列的归一化版本。每个子序列都在单独的一行上。

使用 Python 包

接下来的大多数章节都需要我们在这里开发的 SAX 包。出于简单起见,我们将 SAX 包的实现复制到所有使用该包的目录中。在希望每个软件或包只有一个副本的生产系统中,这可能不是最佳实践,但在学习和实验时是最佳实践。

到目前为止,我们已经学习了如何使用 sax 包的基本功能。

下一节介绍了一个实用工具,该工具用于计算时间序列子序列的 SAX 表示并打印结果。

计算时间序列的 SAX 表示

本章本节介绍了用于计算时间序列 SAX 表示的实用工具。该实用工具背后的逻辑所使用的 Python 数据结构是一个字典,其中键是转换为字符串的 SAX 表示,值是整数。

counting.py 的代码如下:

#!/usr/bin/env python3
import sys
import pandas as pd
from sax import sax
def main():
     if len(sys.argv) != 5:
           print("TS1 sliding_window cardinality segments")
           print("Suggestion: The window be a power of 2.")
           print("The cardinality SHOULD be a power of 2.")
           sys.exit()
     file = sys.argv[1]
     sliding = int(sys.argv[2])
     cardinality = int(sys.argv[3])
     segments = int(sys.argv[4])
     if sliding % segments != 0:
           print("sliding MODULO segments != 0...")
           sys.exit()
     if sliding <= 0:
           print("Sliding value is not allowed:", sliding)
           sys.exit()
     if cardinality <= 0:
           print("Cardinality Value is not allowed:", cardinality)
           sys.exit()
     ts = pd.read_csv(file, names=['values'], compression='gzip')
     ts_numpy = ts.to_numpy()
     length = len(ts_numpy)
     KEYS = {}
     for i in range(length - sliding + 1):
           t1_temp = ts_numpy[i:i+sliding]
           # Generate SAX for each subsequence
           tempSAXword = sax.createPAA(t1_temp, cardinality, segments)
           tempSAXword = tempSAXword[:-1]
           if KEYS.get(tempSAXword) == None:
                 KEYS[tempSAXword] = 1
           else:
                 KEYS[tempSAXword] = KEYS[tempSAXword] + 1
     for k in KEYS.keys():
           print(k, ":", KEYS[k])
if __name__ == '__main__':
     main()

for 循环将时间序列分割成子序列,并使用 sax.createPAA() 计算每个子序列的 SAX 表示,然后更新 KEYS 字典中的相关计数器。tempSAXword = tempSAXword[:-1] 语句从 SAX 表示中移除一个不需要的下划线字符。最后,我们打印 KEYS 字典的内容。

counting.py 的输出应类似于以下内容:

$ ./counting.py ts1.gz 4 4 2
10_01 : 18
11_00 : 8
01_10 : 14
00_11 : 7

这个输出告诉我们什么?

对于包含 50 个元素的时间序列 (ts1.gz) 和滑动窗口大小为 4 的情况,存在 18 个具有 10_01 SAX 表示的子序列,8 个具有 11_00 SAX 表示的子序列,14 个具有 01_10 SAX 表示的子序列,以及 7 个具有 00_11 SAX 表示的子序列。为了便于比较,并且能够将 SAX 表示用作字典的键,我们将 [01 10] 转换为 01_10 字符串,将 [11 00] 转换为 11_00,依此类推。

时间序列有多少个子序列?

请记住,给定一个包含 n 个元素的时间序列和滑动窗口大小为 w,子序列的总数是 n – w + 1

counting.py 可以用于许多实际任务,并将更新在 第三章 中。

下一个章节讨论了一个实用的 Python 包,可以帮助我们从统计角度更深入地了解处理时间序列。

tsfresh Python 包

这是一个与本书主题不直接相关的附加章节,但仍然很有帮助。它介绍了一个实用的 Python 包,名为 tsfresh,可以从统计角度给你提供一个关于时间序列的概览。我们不会展示 tsfresh 的所有功能,只是介绍那些你可以轻松使用来获取时间序列数据信息的部分——在这个阶段,你可能需要在你的机器上安装 tsfresh。请记住,tsfresh 包有很多依赖包。

因此,我们将计算数据集的以下属性——在这种情况下,是一个时间序列:

  • 平均值:数据集的平均值是所有值的总和除以值的数量。

  • 标准差:数据集的标准差衡量其变化量。有一个计算标准差的公式,但我们通常使用 Python 包中的函数来计算它。

  • 偏度:数据集的偏度是衡量其不对称性的一个指标。偏度的值可以是正的、负的、零或未定义。

  • 峰度:数据集的峰度是衡量数据集尾部特性的一个指标。用更数学化的术语来说,峰度衡量的是分布尾部相对于正态分布的厚重程度。

所有这些量在你绘制数据后会有更多的意义,这留给你作为练习;否则,它们只是数字。所以,现在我们了解了一些基本的统计术语,让我们展示一个 Python 脚本,它可以计算时间序列的所有这些量。

using_tsfresh.py 的 Python 代码如下:

#!/usr/bin/env python3
import sys
import pandas as pd
import tsfresh
def main():
     if len(sys.argv) != 2:
           print("TS")
           sys.exit()
     TS1 = sys.argv[1]
     ts1Temp = pd.read_csv(TS1, compression='gzip')
     ta = ts1Temp.to_numpy()
     ta = ta.reshape(len(ta))
     # Mean value
     meanValue = tsfresh.feature_extraction.feature_calculators.mean(ta)
     print("Mean value:\t\t", meanValue)
     # Standard deviation
     stdDev = tsfresh.feature_extraction.feature_calculators.standard_deviation(ta)
     print("Standard deviation:\t", stdDev)
     # Skewness
     skewness = tsfresh.feature_extraction.feature_calculators.skewness(ta)
     print("Skewness:\t\t", skewness)
     # Kurtosis
     kurtosis = tsfresh.feature_extraction.feature_calculators.kurtosis(ta)
     print("Kurtosis:\t\t", kurtosis)
if __name__ == '__main__':
     main()

使用 using_tsfresh.py 处理 ts1.gz 的输出应该类似于以下内容:

$ ./using_tsfresh.py ts1.gz
Mean value:  15.706410001204729
Standard deviation:  8.325017802111901
Skewness:     0.008971113265160474
Kurtosis:    -1.2750042973761417

tsfresh 包可以做更多的事情;我们只是展示了 tsfresh 功能的冰山一角。

下一个章节是关于创建时间序列的直方图。

创建时间序列的直方图

这是一个另一个附加章节,我们将展示如何创建时间序列的直方图,以更好地了解其值。

直方图,看起来很像条形图,定义了桶(bin)并计算落入每个桶中的值的数量。严格来说,直方图通过创建值的分布图来帮助你理解你的数据。你可以通过查看直方图看到最大值和最小值,以及发现数据模式。

histogram.py 的 Python 代码如下:

#!/usr/bin/env python3
import sys
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
import math
import os
if len(sys.argv) != 2:
     print("TS1")
     sys.exit()
TS1 = sys.argv[1]
ts1Temp = pd.read_csv(TS1, compression='gzip')
ta = ts1Temp.to_numpy()
ta = ta.reshape(len(ta))
min = np.min(ta)
max = np.max(ta)
plt.style.use('Solarize_Light2')
bins = np.linspace(min, max, 2 * abs(math.floor(max) + 1))
plt.hist([ta], bins, label=[os.path.basename(TS1)])
plt.legend(loc='upper right')
plt.show()

np.linspace() 函数的第三个参数帮助我们定义直方图的 bins 数量。第一个参数是最小值,第二个参数是展示样本的最大值。此脚本不会将其输出保存到文件中,而是打开一个 GUI 窗口来显示输出。plt.hist() 函数创建直方图,而 plt.legend() 函数将图例放置在输出中。

histogram.py 的一个示例输出可以在 图 2.5 中看到:

图 2.5 – 一个示例直方图

图 2.5 – 一个示例直方图

histogram.py 的另一个示例输出可以在 图 2.6 中看到:

图 2.6 – 一个示例直方图

图 2.6 – 一个示例直方图

那么,图 2.5图 2.6 中的直方图有什么区别?存在许多差异,包括图 2.5 中的直方图没有空 bins,并且包含负值和正值。另一方面,图 2.6 中的直方图只包含远离 0 的负值。

现在我们已经了解了直方图,让我们学习另一个有趣的统计量 – 百分位数

计算时间序列的百分位数

在本章最后一个附加部分,我们将学习如何计算时间序列或列表的百分位数(如果你觉得这里提供的信息难以理解,请随意跳过)。此类信息的主要用途是更好地理解你的时间序列数据。

百分位数是一个分数,其中给定百分比的分数在频率分布中。因此,20 百分位数是低于该分数的分数,占分布中数据集值的 20%。

四分位数是以下三个百分位数之一 – 25%,50%,或 75%。因此,我们有第一四分位数,第二四分位数和第三四分位数,分别。

百分位数和四分位数都是在按升序排序的数据集中计算的。即使你没有对那个数据集进行排序,相关的 NumPy 函数,即 quantile(),也会在幕后完成排序。

percentiles.py 的 Python 代码如下:

#!/usr/bin/env python3
import sys
import pandas as pd
import numpy as np
def main():
     if len(sys.argv) != 2:
           print("TS")
           sys.exit()
     F = sys.argv[1]
     ts = pd.read_csv(F, compression='gzip')
     ta = ts.to_numpy()
     ta = ta.reshape(len(ta))
     per01 = round(np.quantile(ta, .01), 5)
     per25 = round(np.quantile(ta, .25), 5)
     per75 = round(np.quantile(ta, .75), 5)
     print("Percentile 1%:", per01, "Percentile 25%:", per25, "Percentile 75%:", per75)
if __name__ == '__main__':
     main()

所有工作都是由 NumPy 包中的 quantile() 函数完成的。除了其他事情之外,quantile() 在进行任何计算之前都会适当地排列其元素。我们不知道内部发生了什么,但很可能是 quantile() 会将其输入按升序排序。

quantile() 的第一个参数是 NumPy 数组,第二个参数是我们感兴趣的百分比(百分位数)。25%的百分比等于第一个四分位数,50%的百分比等于第二个四分位数,75%的百分比等于第三个四分位数。1%的百分比等于 1%的百分位数,依此类推。

percentiles.py 的输出如下:

$ ./percentiles.py ts1.gz
Percentile 1%: 1.57925 Percentile 25%: 7.15484 Percentile 75%: 23.2298

概述

本章包括了 SAX 的理论背景和实际实现,以及从统计角度理解时间序列。由于 iSAX 索引的构建基于 SAX 表示,我们无法在不计算 SAX 表示的情况下构建 iSAX 索引。

在你开始阅读第三章之前,请确保你知道如何根据滑动窗口大小、段数和基数计算时间序列或子序列的 SAX 表示。

下一章包含与 iSAX 索引相关的理论,展示了如何手动构建 iSAX 索引(你会发现这非常有趣),并包括了一些实用的工具开发。

有用链接

练习

尝试在 Python 中解决以下练习:

  • 手动将 y 轴划分为 16 = 2⁴ 的基数。你是否将其划分为 16 个区域或 17 个区域?你使用了多少个断点?

  • 手动将 y 轴划分为 64 = 2⁶ 的基数。你是否将其划分为 64 个区域?

  • 使用 cardinality.py 工具绘制 16 = 2⁴ 基数断点。

  • 使用 cardinality.py 工具绘制 128 = 2⁷ 基数断点。

  • 使用 4 个段和基数 4(2²)找到子序列 {0, 2, -1, 2, 3, 4, -2, 4} 的 SAX 表示,别忘了先进行归一化。

  • 使用 2 个段和基数 2(2¹)找到子序列 {0, 2, -1, 2, 3, 4, -2, 4} 的 SAX 表示,别忘了先进行归一化。

  • 使用 4 个段和基数 2 找到子序列 {0, 2, -1, 2, 3, 1, -2, -4} 的 SAX 表示。

  • 给定时间序列 {0, -1, 1.5, -1.5, 0, 1, 0} 和滑动窗口大小为 4,使用 2 个段和基数 2 找到所有子序列的 SAX 表示。

  • 创建一个合成的时间序列并使用 using_tsfresh.py 处理它。

  • 创建一个包含 1,000 个元素的合成时间序列并使用 histogram.py 处理它。

  • 创建包含 5,000 个元素的合成时间序列,并使用histogram.py进行处理。

  • 创建包含 10,000 个元素的合成时间序列,并使用counting.py进行处理。

  • 创建包含 100 个元素的合成时间序列,并使用percentiles.py进行处理。

  • 创建包含 100 个元素的合成数据集,并使用counting.py进行检验。

  • 修改histogram.py以将其图形输出保存为 PNG 文件。

  • 使用histogram.py绘制时间序列,然后使用using_tsfresh.py进行处理。

第三章:iSAX – 必要的理论

现在我们已经了解了 SAX,包括归一化和计算子序列的 SAX 表示,现在是时候学习 iSAX 索引背后的理论了,在撰写本文时,它被认为是最好的时间序列索引之一。存在使 iSAX 更快、更紧凑的 iSAX 改进版本,但其核心思想保持不变。

如您从其名称中猜测的那样,iSAX 在某些方面依赖于 SAX。简单来说,每个 iSAX 索引的关键是 SAX 表示。因此,在 iSAX 索引中进行搜索依赖于 SAX 表示。

到目前为止,我相信提供更多关于 iSAX 的信息将有助于您在阅读本章时。iSAX 索引是一个树状结构,其中根节点,且仅根节点,可以有多个子节点,而根节点的所有子节点都是二叉树。此外,为了创建 iSAX 索引,我们需要一个时间序列和一个阈值值,这是叶子节点(在 iSAX 术语中的终端节点)可以存储的最大子序列数,以及一个段值和一个基数值。后两个参数与 SAX 表示相关。所有这些都在本章中进行了更详细的解释,但尽早了解整体情况是很有帮助的。

此外,在本章中,我们将逐步手动构建一个小型的 iSAX 索引,并使用大量的可视化来更好地理解这个过程。请确保在阅读完本章后亲自进行这个过程。

在本章中,我们将涵盖以下主要主题:

  • 背景信息

  • 理解 iSAX 的工作原理

  • iSAX 是如何构建的

  • 手动构建 iSAX 索引

  • 更新counting.py实用工具

技术要求

本书的 GitHub 仓库可以在github.com/PacktPublishing/Time-Series-Indexing找到。每个章节的代码都在其自己的目录中。因此,第三章的代码可以在ch03文件夹中找到。

背景信息

在本节的第一部分,我们将学习与 iSAX 相关的基本定义和概念。但首先,我们将提及描述 iSAX 操作的学术论文。iSAX 及其操作在由 Jin Shieh 和 Eamonn Keogh 所著的iSAX: 大规模时间序列数据集的磁盘感知挖掘和索引中进行了描述。您不必从头到尾阅读这篇论文,但正如我们之前提到的 SAX 研究论文一样,阅读其摘要和引言部分将有益于您。

此外,对 iSAX 已经进行了各种改进,主要是使其更快,这些改进在以下研究论文中有所体现:

  • iSAX 2.0: 索引和挖掘十亿时间序列,由 Alessandro Camerra、Themis Palpanas、Jin Shieh 和 Eamonn Keogh 所著

  • 《超过十亿个时间序列:使用 iSAX2+索引和挖掘非常大的时间序列集合》,作者:Alessandro Camerra、Jin Shieh、Themis Palpanas、Thanawin Rakthanmanon 和 Eamonn Keogh

  • 《DPiSAX:大规模分布式分区 iSAX》,作者:Djamel-Edine Yagoubi、Reza Akbarinia、Florent Masseglia 和 Themis Palpanas

  • 《数据系列索引的演变:iSAX 数据系列索引家族:iSAX、iSAX2.0、iSAX2+、ADS、ADS+、ADS-Full、ParIS、ParIS+、MESSI、DPiSAX、ULISSE、Coconut-Trie/Tree、Coconut-LSM》,作者:Themis Palpanas

我们不会在本书中处理上述研究论文,因为我们正在使用 iSAX 索引的初始版本。你不需要为了本书的目的阅读所有这些论文,但在你对 iSAX 感到舒适时查看它们将是一项很好的练习。

由于 iSAX 是一种树,下一节将介绍有关树和二叉树的基本信息。

树和二叉树

让我们先解释什么是有向图。有向图是一种边具有方向的图。有向无环图(DAG)是一种没有环的有向图。

在计算机科学中,是一种满足以下三个原则的 DAG 数据结构:

  • 它有一个根节点,这是进入树的入口点

  • 除了根节点外,每个顶点只有一个且仅有一个入口点

  • 存在一条路径连接根节点到每个顶点

如前所述,树的是树的第一节点。每个节点可以连接到一个或多个节点,具体取决于树类型。如果每个节点只连接到一个节点,那么这棵树就变成了链表!叶节点是没有子节点的节点。叶节点也被称为外部节点,而至少有一个子节点的节点称为内部节点。

二叉树是一种树,其中每个节点下最多有两个额外的节点。"最多"意味着它可以连接到 1 个、2 个或没有其他节点。树的深度,也称为高度,定义为从根节点到叶节点的最长路径,而节点的深度是从节点到树根节点的边数。

请记住,如果你使用相同的一组元素以不同的顺序创建两个二叉树,你将得到两个完全不同的树。最简单的方法是从不同的根节点开始。因此,我们事先不知道树的最终形状。

树有内部节点和叶节点。在 iSAX 术语中,这些分别被称为内部节点终端节点内部节点有一个父节点和至少一个子节点,而终端节点有一个父节点但没有自己的子节点。

二叉树在最长根节点到叶子节点的路径长度与最短路径长度之差为 0 或 1 时被认为是平衡树不平衡树是指不满足平衡条件的树。尽管 iSAX 索引可以通过其长度来表征,但确定 iSAX 索引是否平衡并没有什么意义,因为索引的工作方式并非如此,这主要与数据有关。然而,拥有平衡树是一件好事。

因此,尽管 iSAX 索引是树,但我们不能将所有二叉树规则应用于 iSAX 索引。然而,了解所有这些细节有助于更好地理解为什么某个 iSAX 索引可能比其他索引更快或更慢。

平衡二叉树

如果二叉树是平衡的,其搜索、插入和删除操作大约需要log(n)步,其中n是树中元素的总数。此外,平衡二叉树的高度大约是log2(n),这意味着具有 10,000 个元素的平衡树的高度为 14,这非常小。具有 100,000 个元素的平衡树的高度将是 17,具有 1,000,000 个元素的平衡树的高度将是 20!换句话说,将大量元素放入平衡二叉树并不会极大地改变树的搜索速度。换句话说,你最多用 20 步就可以到达一个具有 1,000,000 个节点的平衡树中的任何节点!

图 3.1展示了一个小型的 iSAX 索引,这使我们能够更深入地了解 iSAX 索引,而不会迷失在细节中。我们将在下一节中学习更多关于 iSAX 索引的细节。

图 3.1 – 一个小的 iSAX 索引

图 3.1 – 一个小的 iSAX 索引

那么,我们在这里有什么,它告诉我们什么呢?

由于 iSAX 索引的每个节点都有四个 SAX 词,我们知道段值是 4。然而,我们无法确定基数,因为基数从最小值开始,并在需要时增加,即当有分裂时。然而,即使在分裂时,也只有一个 SAX 词被提升到更高的基数值。这个特定的 iSAX 索引的根有三个子节点——我们假设根节点的其余子节点为空。终端节点是[0 0 1 1][0 1 0 1][11 1 0 0][10 11 0 0][10 10 00 0][10 10 01 0],内部节点是[1 1 0 0][10 1 0 0][10 10 0 0]。我们无法对阈值值做出任何假设。

第六章将介绍可视化 iSAX 索引的技术,这在处理大型 iSAX 索引并希望对索引有一个概览时非常有用。

下一节将提供更多关于 iSAX 的详细信息。

理解 iSAX 的工作原理

在本节中,我们将讨论 iSAX 的工作方式,这包括构建阶段以及其使用和参数。除了必要的理论之外,我们还将展示一个实用的命令行工具,该工具可以帮助你理解给定其参数的情况下,iSAX 索引可以有多少个子序列。

iSAX 与 SAX 的关系

iSAX 与 SAX 表示之间的关系很简单。iSAX 索引中所有节点(除了没有键的根节点)的键都是 SAX 表示。因此,构建和搜索 iSAX 索引的大部分工作都是基于 SAX 的。

我们不会在 iSAX 索引中删除或更新元素,不是因为不可能,而是因为这不是 iSAX 索引的工作方式。

现在是时候讨论 iSAX 索引的参数了,因为 iSAX 的构建依赖于它们。

集合参数

[1 000 0 11] SAX 表示和另一个具有[10001 00 0 1] SAX 表示的表示。第二个区别是我们从 2 的基数值开始。这意味着根节点的所有子节点在它们的 SAX 词中都具有 2 的基数值。

段参数

iSAX 索引的参数与每个 SAX 表示中的工作方式完全相同,并定义了每个 SAX 表示中的SAX 词数量。

阈值参数

阈值参数是新的,定义了终端节点可以存储的最大子序列数。我们不能超过这个值。阈值参数定义了节点分割何时发生。

下一个子节将介绍一个命令行工具,该工具基于段的数量计算归一化子序列的平均值,因为平均值定义了 SAX 词。

计算归一化平均值

本节介绍了一个实用程序,该实用程序基于段值输出时间序列所有归一化子序列的平均值。如果不根据段值分割,计算所有归一化子序列的平均值就没有意义,因为归一化后,子序列的平均值非常接近 0,这是归一化的一个要点。

在本例中,我们将使用meanValues.py脚本。其代码如下:

#!/usr/bin/env python3
# Date: Monday 19 December 2022
#
# This utility outputs the mean values of all
# (NORMALIZED) subsequences of a time series
import sys
import numpy as np
import pandas as pd
sys.path.insert(0,'..')
def normalize(x):
    eps = 1e-6
    mu = np.mean(x)
    std = np.std(x)
    if std < eps:
        return np.zeros(shape=x.shape)
    else:
        return (x-mu)/std
def main():
    if len(sys.argv) != 4:
        print("Usage: TS1 sliding_window segments")
        sys.exit()
    file = sys.argv[1]
    # We prefer values which are powers of 2
    sliding = int(sys.argv[2])
    segments = int(sys.argv[3])
    if sliding <= 0:
        print("Sliding value is not allowed:", sliding)
        sys.exit()
    ts = pd.read_csv(file, names=['values'],
        compression='gzip')
    ts_numpy = ts.to_numpy()
    length = len(ts_numpy)
    splits = sliding // segments
    # Split time series into subsequences
    for i in range(length - sliding + 1):
        t1_temp = ts_numpy[i:i+sliding]
        normalized = normalize(t1_temp)
        for s in range(segments):
            temp = normalized[splits*s:splits*(s+1)]
            mValue = np.mean(temp)
            print(round(mValue,5))

根据段的数量,该实用程序将每个子序列分割成部分,并计算每个部分的平均值。代码的最后部分如下:

if __name__ == '__main__':
    main()

meanValues.py的输出如下(为了简洁起见,省略了大部分输出):

$ ./meanValues.py ts1.gz 16 4
-0.33294
-0.00404
0.10926
0.22772
-0.51625
0.05592
-0.20672
. . .

你可以将输出保存下来,并用如histogram.py这样的工具进行处理,该工具在上一章中已介绍。

图 3.2 展示了使用meanValues.py处理两个不同时间序列(每个时间序列大约有 500,000 个元素)的histogram.py输出,使用了1024的滑动窗口大小和 4 个段。四个段意味着对于每个子序列,我们必须计算四个平均值,因为我们把每个子序列分成四个部分。因此,一个 500,000 个元素的时间序列会产生 2,000,000 个平均值,这些平均值将进入直方图。

图 3.2 – 来自两个时间序列的 histogram.py 输出

图 3.2 – 来自两个时间序列的 histogram.py 输出

图 3.2 告诉我们什么?上面的图是名为 ECG 的时间序列,下面的直方图是名为 EEG 的时间序列。ECG 时间序列的平均值(因此是基数值)的大多数(大约 200 万个值)都落在-0.50.5的值范围内。这意味着,基于断点值,许多 SAX 词将非常相似,因此将落入少量的 iSAX 分支中,使得ECG 数据集的 iSAX 索引非常不平衡。另一方面,对于 EEG 数据集,平均值在直方图中分布得更好,这意味着它们将产生的 SAX 词将分布得更好,生成的 iSAX 索引将更加平衡。

如果你想了解更多关于直方图中值分布的信息,你可以增加 bin 的数量,以获得更详细的输出。

下一个小节将介绍一个命令行实用程序,该实用程序计算给定参数的 iSAX 索引可以存储的最大子序列数。请记住,这是一个理想情况,其中 iSAX 索引完全平衡,所有终端节点都有最大数量的子序列,这通常不是情况。

一个 iSAX 索引可以有多大?

在本小节中,我们将计算在理想条件下 iSAX 索引可以存储的最大子序列数。正如你所知,iSAX 索引的根节点可以有超过两个子节点,而所有其他内部节点只有一个或两个子节点,这些子节点可以是其他内部节点或终端节点。一个内部节点可以有任意组合的内部节点和终端节点作为子节点。

现在,让我们一起来做一个练习:假设我们想要找出一个基数42个段的 iSAX 索引可以有多少个节点。由于我们关注的是节点,阈值参数在我们的讨论中并不起关键作用,所以现在忽略它。我们将在讨论结束时回到阈值参数。

首先,我们将计算根节点的子节点数。这仅取决于段的数量,因为根节点的所有子节点都具有基数2。因此,如果我们有 2 个段,根节点的最大子节点数是 4,即 2 的 2 次方。这些有以下的 SAX 表示:[0, 0][0, 1][1, 0][1, 1]。如果你熟悉二进制系统,这些都可以用 2 位表示。

如果我们有 3 个段,根节点的最大子节点数是 8,即 2 的 3 次方。这些有以下的 SAX 表示:[0, 0, 0][0, 0, 1][0, 1, 0][0, 1, 1][1, 0, 0][1, 0, 1][1, 1, 0][1, 1, 1]。如果你熟悉二进制系统,这些都可以用 3 位表示。对于根节点的每个子节点,每个 SAX 词的大小可以与基数值一样大。

现在,回到我们最初的问题,即计算一个基数值为 4、2 个段的 iSAX 索引可以拥有的最大节点数。正如证明的那样,当有 2 个段时,根节点的子节点数是 4。每个段可以有的值数量与基数相同,在这个例子中是 4。所以,有 2 个段,我们可以有 4 乘以 4 的可能组合,即 16。这是这个 iSAX 索引可以存储的最大终端节点数。因此,当我们有基数值为 4 和 2 个段时,最大终端节点数是 16。

现在我们知道了终端节点的总数,是时候停止忽略阈值参数,看看它能为我们的提供什么信息了。阈值参数可以帮助我们在理想情况下计算这个 iSAX 索引可以容纳的最大子序列数。我们之所以谈论理想情况,是因为大多数情况下,iSAX 索引是不平衡的,而且没有办法确保一个 iSAX 索引将会是平衡的。这是因为这取决于子序列和滑动窗口的大小。

因此,当我们有 64 个终端节点和阈值值为 100 时,在理想情况下,一个 iSAX 索引可以存储的最大子序列数是6,400

在考虑了所有这些信息之后,我们将开发一个小型的 Python 脚本来为我们进行计算。maximumISAX.py的代码如下:

#!/usr/bin/env python3
import sys
def main():
    if len(sys.argv) != 4:
        print("cardinality segments threshold")
        print("Suggestion: The window be a power of 2.")
        print("The cardinality SHOULD be a power of 2.")
        sys.exit()
    cardinality = int(sys.argv[1])
    segments = int(sys.argv[2])
    threshold = int(sys.argv[3])
    terminalNodes = pow(cardinality, segments)
    print("Nodes:", terminalNodes)
    subsequences = terminalNodes * threshold
    print("Maximum number of subsequences:", subsequences)
if __name__ == '__main__':
    main()

maximumISAX.py的输出如下:

$ ./maximumISAX.py 4 4 100
Nodes: 256
Maximum number of subsequences: 25600

因此,前面的输出告诉我们,一个有 4 个段、基数值为 4 和阈值值为 100 的 iSAX 索引可以容纳多达25600个子序列。

如果我们用相同数量的段和不同的基数运行maximumISAX.py,我们将得到以下输出:

$ ./maximumISAX.py 16 4 100
Nodes: 65536
Maximum number of subsequences: 6553600

因此,前面的输出告诉我们,一个有 4 个段、基数值为 16 和阈值值为 100 的 iSAX 索引,尽管根节点仍有 16 个子节点,但可以容纳多达6553600个子序列。

下一小节将讨论当没有空间将给定的子序列添加到 iSAX 索引中时会发生什么。

当没有空间为添加更多子序列到 iSAX 索引时会发生什么?

iSAX 索引可能会溢出。在实践中,这意味着 iSAX 索引可能没有足够的空间来添加更多的子序列。这种情况发生在 iSAX 索引的一个或多个分支已经使用了所有段的全部基数,并且这些终端节点达到了阈值值。图 3.3展示了这一点:

图 3.3 – iSAX 溢出

图 3.3 – iSAX 溢出

假设具有 SAX 表示[10 10 00 00][10 10 00 01]的节点已满,并且正在使用它们的全部基数。这意味着除非我们增加 iSAX 索引的一个或多个相关参数,否则我们不能再提升具有[10 10 00 00][10 10 00 01]SAX 表示的节点。所以,如果我们有一个具有[10 10 00 00][10 10 00 01]SAX 表示的子序列,我们不知道如何处理它,因此 iSAX 索引发生了溢出。

在本节中,我们学习了关于 iSAX 的基本信息。下一节将更详细地讨论 iSAX 的构建。

iSAX 是如何构建的

本节将要描述 iSAX 索引是如何构建的。所有提供的信息都是基于描述 iSAX 的研究论文。逻辑步骤如下:

  • 我们从一个节点开始,这个节点是 iSAX 索引的根。根节点不包含实际数据(子序列),但它包含指向所有具有指定段值和2的基数值的节点的指针,这是一个只能有两个值的单比特,即01

  • 之后,我们构建根节点的子节点,在这个初始点,它们都是终端节点,没有任何子序列。

  • 现在,我们开始根据它们的 SAX 表示向根节点的子节点添加子序列。

  • 当终端节点的阈值值达到时,我们根据指定的提升策略进行分割,并将子序列分配到两个新创建的终端节点。

  • 这个过程会一直进行,直到所有子序列都插入到 iSAX 索引中,或者没有地方可以添加子序列。

下一节将解释如何搜索 iSAX,以便更好地了解构建过程与搜索操作之间的关系。

iSAX 是如何搜索的

iSAX 索引的搜索过程可以描述如下:

  • 首先,我们有一个子序列,我们想在 iSAX 索引中查找这个子序列。

  • 然后,我们必须使用与 iSAX 索引相同的参数计算该子序列的 SAX 表示。

  • 之后,我们使用子序列的 SAX 表示来找出它属于根节点的哪个子节点。

  • 然后,我们继续在二叉树中搜索,直到找到一个具有相同 SAX 表示的节点。记住,我们可能需要根据 iSAX 节点减少子序列中的某些 SAX 词。如果不存在这样的节点,那么 iSAX 索引不包含该子序列。

  • 最后,我们检查该节点的子序列 – 假设存在这样的终端节点 – 并寻找那个特定的子序列。

一般而言,这个过程与我们将该子序列添加到 iSAX 索引中直到找到它所属的节点是相同的。之后,我们搜索该节点的子序列。

下一个子节将讨论提升策略,这与在需要发生分割时哪个 SAX 词将被提升有关。

提升策略

提升策略这个术语指的是在即将发生分割时,将要更新为更高基数段的选取。分割发生在我们即将向一个已经包含等于阈值值的子序列的终端节点添加新的子序列时。只有那个终端节点将要 被分割

阈值值

负责终端节点分割的唯一参数是阈值参数。如果阈值参数是无限的,那么所有 iSAX 索引的终端节点都将具有 2 的基数值,即根子节点。

在分割节点时,有两种提升策略:

  • [0 1 0 0] SAX 表示。对于需要进行的第一次提升,我们提升第一个 SAX 词,得到[00 1 0 0][01 1 0 0]。在接下来的提升中,我们将提升第二个 SAX 词,即使这次提升发生在不同的 SAX 表示上。所以,如果我们需要提升[1 1 0 1],这将给我们[1 10 0 1][1 11 0 1]

  • [00 1 0 0]将给我们[000 1 0 0][001 1 0 0],前提是我们有一个至少为 8 的基数值。当最左边的 SAX 词达到其满基数时,我们继续下一个右边的 SAX 词。

没有正确或错误的提升策略 – 使用你最喜欢的方法。

下一个子节将介绍 iSAX 的一个基本操作,即节点分割。

分割节点

本节将更详细地讨论节点分割过程。分割仅在一种情况下发生:当一个终端节点即将拥有比阈值值允许的更多子序列时。在这种情况下,那个终端节点变成一个内部节点。然后,根据提升策略创建两个终端节点,它们是新创建的内部节点的子节点。想象一下,iSAX 索引在图 3**.4中展示,目前存储了 10 个子序列:S 0 到 S 9。

我们现在想要将 S 10 子序列添加到该索引中,但[10 10 00 0]已满:

图 3.4 – 分割节点前

图 3.4 – 分割节点前

因此,为了添加一个 SAX 表示为[10 10 00 01]的子序列,我们需要分裂[10 10 00 0]终端节点,现在它变成了具有相同 SAX 表示的内节点,以及[10 10 00 00][10 10 00 01]的 SAX 表示,分别。因此,在这种情况下,我们提升了最后一个 SAX 词。

之后,我们将迭代之前存储在[10 10 00 0]中的所有子序列,根据它们提升后的 SAX 表示,将它们放入[10 10 00 00][10 10 00 01]。我们不会深入探讨分裂后子序列的分布情况,因为这已经在接下来的手动构建 iSAX 索引部分中详细解释了。

如果分裂不能解决问题怎么办?

在前面的例子中,S10 将进入[10 10 00 01]终端节点,前提是有足够的空间。在罕见的情况下,新的子序列以及所有之前存储的子序列都进入同一个终端节点,分裂过程将继续进行,直到问题自行解决或出现 iSAX 溢出。

图 3.5显示了节点分裂后的 iSAX 索引的新版本:

图 3.5 – 节点分裂后

图 3.5 – 节点分裂后

在本节中,我们学习了 iSAX 构建过程的各个阶段。下一节将通过一个真实示例和图表展示如何手动构建 iSAX 索引。

手动构建 iSAX 索引

在本节中,我们将手动创建一个小型的 iSAX 索引。为了更好地理解这个过程,我们将展示所有步骤并描述所有必要的计算。

如果您还记得本章前面的内容,创建 iSAX 索引的步骤可以描述如下:

  1. 根据给定的滑动窗口大小将时间序列分割成子序列。

  2. 对于每个子序列,根据给定的参数计算其 SAX 表示。

  3. 开始将时间序列的子序列插入到 iSAX 索引中。最初,除了根节点外,所有 iSAX 节点都是终端节点。

  4. 一旦终端节点满了——达到了阈值——通过增加其某个段的基数来分裂该节点,并创建两个新的终端节点。

  5. 原始终端节点变成了一个内节点,现在它是新创建的终端节点的父节点。

  6. 根据它们的 SAX 表示,将原始终端节点的子序列分割成两个新的终端节点。

子序列去哪里?

重要的是要意识到,具有[00 10 10 11] SAX 表示的子序列必须位于根的[0 1 1 1]子节点下。之后,确切的位置(终端节点)取决于在过程中将要发生的提升,可以是[00 10 10 1][00 1 1 11][0 1 10 11]等等。然而,如果我们使用其完整的 SAX 表示,它只能进入[00 10 10 11]终端节点。

我们将使用一个小时序来进行 iSAX 构建过程,以便不会花费太长时间完成。然而,原则是相同的。我们还需要使用上一章中的ts2PAA.py实用工具来获取每个子序列的 SAX 表示——我们不希望手动计算一切。

由于我们处理的是一个小时序,我们将使用2作为段值,8作为基数值,这意味着我们将使用 3 位来表示基数,以及15作为阈值值。滑动窗口大小为8

因此,ts2PAA.py处理ts1.gz时的输出如下(我们目前忽略输出的归一化部分,为了简洁省略):

$ ../ch02/ts2PAA.py ts1.gz 8 8 2
[011, 100] [5.22 23.44 14.14 6.75 4.31 27.94 6.61 21.73]
[011, 100] [23.44 14.14 6.75 4.31 27.94 6.61 21.73 11.43]
[100, 011] [14.14 6.75 4.31 27.94 6.61 21.73 11.43 7.15]
[011, 100] [6.75 4.31 27.94 6.61 21.73 11.43 7.15 15.85]
[011, 100] [4.31 27.94 6.61 21.73 11.43 7.15 15.85 29.96]
[100, 011] [27.94 6.61 21.73 11.43 7.15 15.85 29.96 6.00]
[010, 101] [6.61 21.73 11.43 7.15 15.85 29.96 6.00 20.74]
[011, 100] [21.73 11.43 7.15 15.85 29.96 6.00 20.74 18.39]
[011, 100] [11.43 7.15 15.85 29.96 6.00 20.74 18.39 23.23]
[010, 101] [7.15 15.85 29.96 6.00 20.74 18.39 23.23 25.71]
[011, 100] [15.85 29.96 6.00 20.74 18.39 23.23 25.71 18.74]
[011, 100] [29.96 6.00 20.74 18.39 23.23 25.71 18.74 15.09]
[100, 011] [6.00 20.74 18.39 23.23 25.71 18.74 15.09 1.22]
[101, 010] [20.74 18.39 23.23 25.71 18.74 15.09 1.22 26.61]
[100, 011] [18.39 23.23 25.71 18.74 15.09 1.22 26.61 28.89]
[011, 100] [23.23 25.71 18.74 15.09 1.22 26.61 28.89 27.62]
[010, 101] [25.71 18.74 15.09 1.22 26.61 28.89 27.62 12.12]
[010, 101] [18.74 15.09 1.22 26.61 28.89 27.62 12.12 16.77]
[011, 100] [15.09 1.22 26.61 28.89 27.62 12.12 16.77 24.43]
[100, 011] [1.22 26.61 28.89 27.62 12.12 16.77 24.43 21.37]
[101, 010] [26.61 28.89 27.62 12.12 16.77 24.43 21.37 7.03]
[100, 011] [28.89 27.62 12.12 16.77 24.43 21.37 7.03 19.24]
[101, 010] [27.62 12.12 16.77 24.43 21.37 7.03 19.24 13.14]
[100, 011] [12.12 16.77 24.43 21.37 7.03 19.24 13.14 24.91]
[011, 100] [16.77 24.43 21.37 7.03 19.24 13.14 24.91 21.79]
[100, 011] [24.43 21.37 7.03 19.24 13.14 24.91 21.79 4.53]
[011, 100] [21.37 7.03 19.24 13.14 24.91 21.79 4.53 10.12]
[100, 011] [7.03 19.24 13.14 24.91 21.79 4.53 10.12 12.83]
[110, 001] [19.24 13.14 24.91 21.79 4.53 10.12 12.83 12.42]
[101, 010] [13.14 24.91 21.79 4.53 10.12 12.83 12.42 1.97]
[101, 010] [24.91 21.79 4.53 10.12 12.83 12.42 1.97 5.13]
[100, 011] [21.79 4.53 10.12 12.83 12.42 1.97 5.13 20.26]
[011, 100] [4.53 10.12 12.83 12.42 1.97 5.13 20.26 25.83]
[010, 101] [10.12 12.83 12.42 1.97 5.13 20.26 25.83 15.19]
[001, 110] [12.83 12.42 1.97 5.13 20.26 25.83 15.19 26.59]
[010, 101] [12.42 1.97 5.13 20.26 25.83 15.19 26.59 7.77]
[011, 100] [1.97 5.13 20.26 25.83 15.19 26.59 7.77 17.96]
[100, 011] [5.13 20.26 25.83 15.19 26.59 7.77 17.96 11.07]
[110, 001] [20.26 25.83 15.19 26.59 7.77 17.96 11.07 12.83]
[100, 011] [25.83 15.19 26.59 7.77 17.96 11.07 12.83 27.30]
[100, 011] [15.19 26.59 7.77 17.96 11.07 12.83 27.30 4.29]
[100, 011] [26.59 7.77 17.96 11.07 12.83 27.30 4.29 5.84]
[100, 011] [7.77 17.96 11.07 12.83 27.30 4.29 5.84 5.34]

由于ts1.gz包含一个包含 50 个元素的时序,我们将从中获取 43 个子序列。根据之前的输出,我们将为每个子序列分配一个名称——该名称基于子序列在原始时序中的起始索引——并将该名称与 SAX 表示关联。因此,第一个子序列将被命名为 S 0,第二个 S 1,以此类推。最后一个将被称为 S 42。

我们首先创建图 3**.6中所示的结构。在这个结构中,我们有根节点及其子节点,目前这些子节点都是空的。这些子节点是通过创建所有使用指定数量的段和基数值为 2 的 SAX 表示来构建的,这意味着每个 SAX 词只有一个数字,可以是01。在此阶段,所有这些子节点都是终端节点。这只是 iSAX 索引的初始版本,这意味着最终可能会有没有子序列的子节点(空)。然而,这种表示有助于我们编程。此外,请记住,根节点的每个子节点都是其自己的 二叉树 的根节点

在这一点上,所有子序列都根据 iSAX 参数与 最大基数 相关联。然而,最初,这个 最大基数 根据在 第二章 中“减少 SAX 表示的基数”小节中提出的规则减少到 2 的基数值。只有在分裂之后,我们才需要使用除 2 以外的基数 – 而且这仅发生在分裂的子序列中。当然,在大 iSAX 索引中,没有很多终端节点在其所有段上使用 基数值为 2 – 到现在为止,应该很清楚这样的终端节点是直接连接到根节点的。参见图:

图 3.6 – iSAX 的初始版本,包括根及其子节点

图 3.6 – iSAX 的初始版本,包括根及其子节点

因此,首先,我们将子序列 S 0 放入索引中。在这样做之前,我们需要减少其基数,从 [011, 100] 变为 [0, 1]。此时,我们根据简化后的基数在根节点的子节点中找到匹配的基数,并将其放置在那里。现在,我们将子序列 S 1 放入索引中,其 SAX 表示为 [011, 100],变为 [0, 1]。现在,我们将子序列 S 2 放入索引中,其 SAX 表示为 [100, 011],变为 [1, 0]。然后,我们将子序列 S 3 放入索引中,其 SAX 表示为 [011, 100],变为 [0, 1]

之后,我们有一个看起来像 图 3.7 中所示的 iSAX 索引:

图 3.7 – 向 iSAX 索引中添加四个子序列

图 3.7 – 向 iSAX 索引中添加四个子序列

现在,我们将子序列 S 4 放入索引中,其 SAX 表示为 [011, 100],变为 [0, 1]

然后,我们将子序列 S 5 放入索引中,其 SAX 表示为 [100, 011],变为 [1, 0]

之后,我们插入子序列 S 6 到索引中,其 SAX 表示为 [010, 101],经过简化后变为 [0, 1]

现在,我们将子序列 S 7 放入索引中,其 SAX 表示为 [011, 100],经过简化后变为 [0, 1]

现在,我们将子序列 S 8 放入索引中,其 SAX 表示为 [011, 100],变为 [0, 1]

然后,我们将子序列 S 9 放入索引中,其 SAX 表示为 [010, 101],变为 [0, 1]

接下来,我们将子序列 S 10 放入索引中,其 SAX 表示为 [011, 100],变为 [0, 1]

目前,具有 [0, 1] SAX 表示的节点有 9 个子序列(图 3.8)。

图 3.8 – 包含 11 个子序列的 iSAX 索引

图 3.8 – 包含 11 个子序列的 iSAX 索引

现在,我们将子序列 S 11 放入索引中,其 SAX 表示为 [011, 100],简化后变为 [0, 1]。然后,我们将子序列 S 12 放入索引中,其 SAX 表示为 [100, 011],简化后变为 [1, 0]

现在,我们将子序列 S 13 插入索引中,其 SAX 表示为 [101, 010],简化后变为 [1, 0]。之后,我们将子序列 S 14 放入索引中,其 SAX 表示为 [100, 011],简化后变为 [1, 0]

现在,我们将子序列 S 15 放入索引中,其 SAX 表示为 [011, 100],简化后变为 [0, 1]。然后,我们将子序列 S 16 放入索引中,其 SAX 表示为 [010, 101],简化后变为 [0, 1]

现在,我们将子序列 S 17 放入索引中,其 SAX 表示为 [010, 101],简化后变为 [0, 1]。之后,我们将子序列 S 18 放入索引中,其 SAX 表示为 [011, 100],简化后变为 [0, 1]

现在,我们将子序列 S 19 放入 iSAX 索引中,其 SAX 表示为 [100, 011],经过简化后变为 [1, 0]。然后,我们将子序列 S 20 放入 iSAX 索引中,其 SAX 表示为 [101, 010],简化后变为 [1, 0]

现在,我们将子序列 S 21 放入 iSAX 索引中,其 SAX 表示为 [100, 011],简化后变为 [1, 0]

然后,我们将子序列 S 22 放入 iSAX 索引中,其 SAX 表示为 [101, 010],简化后变为 [1, 0]

现在,我们将子序列 S 23 放入 iSAX 索引中,其 SAX 表示为 [100, 011],简化后变为 [1, 0]

之后,我们将子序列 S 24 放入 iSAX 索引中,其 SAX 表示为 [011, 100],简化后变为 [0, 1]。此时,SAX 表示为 [0, 1] 的 iSAX 节点已满。

接下来,我们将子序列 S 25 放入 iSAX 索引中,其 SAX 表示为 [100, 011],简化后变为 [1, 0]

图 3.9 展示了当前版本的 iSAX 索引(为了简洁,省略了根节点):

图 3.9 – 向 iSAX 索引添加子序列

图 3.9 – 向 iSAX 索引添加子序列

现在,我们尝试将子序列 S 26 插入 iSAX 索引中,其 SAX 表示为 [011, 100],简化后变为 [0, 1]。此时,我们必须对 [0, 1] 终端节点进行分割。因此,[0, 1] 成为一个内部节点,并创建了两个新的终端节点,它们成为 [0, 1] 的子节点:[00, 1][01, 1]

现在,我们必须根据新的基数计算 [0, 1] 所有现有子序列的 SAX 表示,以便将它们放入两个新创建的终端节点之一。

之前 [0, 1] 终端节点的子序列的新 SAX 表示如下:S 0 --> [01, 1],S 1 --> [01, 1],S 3 --> [01, 1],S 4 --> [01, 1],S 6 --> [01, 1],S 7 --> [01, 1],S 8 --> [01, 1],S 9 --> [01, 1],S 10 --> [01, 1],S 11 --> [01, 1],S 15 --> [01, 1],S 16 --> [01, 1],S 17 --> [01, 1],S 18 --> [01, 1],S 24 --> [01, 1],以及 S 26 --> [01, 1]。如果你仔细观察,你会注意到所有先前子序列的第一个 SAX 词都是相同的:01。这意味着分裂将不会起作用,我们需要执行额外的分裂。没有特别的原因,我们将继续提升第一个 SAX 词。因此,[01, 1] 将成为一个内部节点,并创建两个新的终端节点:[010, 1][011, 1]。没有必要提升其他终端节点([00, 1]),因为那里没有问题。

因此,以下是先前子序列的新 SAX 表示:S 0 --> [011, 1],S 1 --> [011, 1],S 3 --> [011, 1],S 4 --> [011, 1],S 6 --> [010, 1],S 7 --> [011, 1],S 8 --> [011, 1],S 9 --> [010, 1],S 10 --> [011, 1],S 11 --> [011, 1],S 15 --> [011, 1],S 16 --> [010, 1],S 17 --> [010, 1],S 18 --> [011, 1],S 24 --> [011, 1],以及 S 26 --> [011, 1]

这种分裂解决了问题,因此子序列被放入适当的终端节点。图 3.10 展示了 iSAX 索引的最新版本(为了简洁,省略了根节点)。

图 3.10 – iSAX 索引的节点分裂

图 3.10 – iSAX 索引的节点分裂

如前所述,必须执行分裂的原因是我们不希望终端节点中存储的子序列数量超过阈值值。

现在,我们将子序列 S 27 放入索引中,其 SAX 表示为 [100, 011],变为 [1, 0]。接下来,我们将子序列 S 28 放入索引中,其 SAX 表示为 [110, 001],变为 [1, 0]

现在,我们将子序列 S 29 放入索引中,其 SAX 表示为 [101, 010],变为 [1, 0]。接下来,我们将子序列 S 30 放入索引中,其 SAX 表示为 [101, 010],变为 [1, 0]

现在,我们将子序列 S 31 放入索引中,其 SAX 表示为 [100, 011],变为 [1, 0]。此时,我们必须分裂 [1, 0] 终端节点,它成为一个具有两个新终端节点作为子节点的内部节点:[10, 0][11, 0]。因此,存储在 [1, 0] 中的子序列的新 SAX 表示如下:S 2 --> [10, 0],S 5 --> [10, 0],S 12 --> [10, 0],S 13 --> [10, 0],S 14 --> [10, 0],S 19 --> [10, 0],S 20 --> [10, 0],S 21 --> [10, 0],S 22 --> [10, 0],S 23 --> [10, 0],S 25 --> [10, 0],S 27 --> [10, 0],S 28 --> [11, 0],S 29 --> [10, 0],S 30 --> [10, 0],以及 S 31 --> [10, 0]

图 3.11展示了 iSAX 索引的最新版本(为了简洁,省略了根节点)。请注意,[10, 0]终端节点已满,无法存储更多子序列。

图 3.11 – iSAX 索引中的更多节点分裂

图 3.11 – iSAX 索引中的更多节点分裂

现在,我们将子序列 S 32 放入索引中,其 SAX 表示为[011, 100],变为[011, 1](根据我们的需求减少基数)。接下来,我们将子序列 S 33 插入索引中,其 SAX 表示为[010, 101],当减少后变为[010, 1]

之后,我们将子序列 S 34 放入索引中,其 SAX 表示为[001, 110],变为[00, 1]。接下来,我们将子序列 S 35 插入索引中,其 SAX 表示为[010, 101],变为[010, 1]

之后,我们将子序列 S 36 放入索引中,其 SAX 表示为[011, 100],变为[011, 1]。接下来,我们将子序列 S 37 插入索引中,其 SAX 表示为[100, 011],当减少后变为[10, 0]。最后一个子序列导致[10, 0]节点分裂,变为一个具有两个子节点的内部节点:[100, 0][101, 0]

因此,存储在[10, 0]中的子序列的新 SAX 表示如下:S 2 --> [100, 0],S 5 --> [100, 0],S 12 --> [100, 0],S 13 --> [101, 0],S 14 --> [100, 0],S 19 --> [100, 0],S 20 --> [101, 0],S 21 --> [100, 0],S 22 --> [101, 0],S 23 --> [100, 0],S 25 --> [100, 0],S 27 --> [100, 0],S 29 --> [101, 0],S 30 --> [101, 0],S 31 --> [100, 0],以及 S 37 --> [101, 0]

图 3.12展示了更新后的 iSAX 索引(为了简洁,省略了根节点)。

图 3.12 – 包含 38 个子序列的 iSAX 索引更新版本

图 3.12 – 包含 38 个子序列的 iSAX 索引更新版本

接下来,我们将 S 38 放入索引中,其 SAX 表示为[110, 001],变为[11, 0]。然后,我们将子序列 S 39 插入索引中,其 SAX 表示为[100, 011],变为[100, 0]

然后,我们将子序列 S 40 放入索引中,其 SAX 表示为[100, 011],变为[100, 0]。之后,我们插入子序列 S 41,变为[100, 0]

图 3.13展示了 iSAX 索引的最终版本。如果你计算终端节点中的子序列数量,你会发现它们是 43 个,这是基于滑动窗口大小和时间序列长度的正确数量。

图 3.13 – iSAX 索引的最终版本

图 3.13 – iSAX 索引的最终版本

到现在为止,你应该同意手动创建 iSAX 索引是一个繁琐的过程,没有人应该被迫这样做。这使得它成为使用计算机执行的完美候选者。

现在我们已经知道了如何手动填充 iSAX 索引,是时候更新第二章中的counting.py实用程序了。

更新 counting.py 实用程序

记得第二章中的counting.py实用程序吗?在本节中,我们将更新它并使用它来完成一些重要的任务。我们不会完全改变现有的功能或丢弃所有现有的代码。我们将基于现有的counting.py实用程序代码进行构建,这是一种开发新软件的极好且富有成效的方式。

更新版的实用程序可用于以下任务:

  • 检查时序是否可以适应 iSAX 索引。这个计算基于counting.py现有的功能,并结合测试所有字典条目的值是否小于阈值值。

  • 检查时序是否可以通过增加段数或提高阈值来适应 iSAX 索引。这个计算基于counting.py现有的功能,并增加了额外的计算和测试。

  • 检查 iSAX 索引是否相对平衡。这可能会对索引的性能产生重大影响。然而,正如您已经知道的,索引通常是不平衡的。这个功能背后的想法是基于计算根的每个子节点下的子序列数量。简单来说,我们计算具有 2 个基数值的子序列在节点上的数量,因为这些是根的子节点,并且我们在屏幕上打印结果。尽管这个测试不是决定性的,但它给我们一个很好的想法,了解子序列在 iSAX 索引中的分布情况。请注意,如果您使用大于 4 的段值,您将从这个实用程序中获得大量的输出。

新版的counting.py实用程序,称为countingv2.py,已经被重构以包含一个函数,包含以下代码,分为四个部分。第一部分如下:

#!/usr/bin/env python3
import sys
import pandas as pd
from sax import sax
def calculate(ts_numpy, sliding, segments, cardinality):
    KEYS = {}
    length = len(ts_numpy)
    for i in range(length - sliding + 1):
        t1_temp = ts_numpy[i:i+sliding]
        tempSAXword = sax.createPAA(t1_temp, cardinality, segments)
        tempSAXword = tempSAXword[:-1]
        if KEYS.get(tempSAXword) == None:
            KEYS[tempSAXword] = 1
        else:
            KEYS[tempSAXword] = KEYS[tempSAXword] + 1
    return KEYS

calculate()函数,它计算每个 SAX 表示的子序列数量,被多次调用,这可能会使脚本变慢。因此,在看到countingv2.py的实际应用之前,让我提醒您,当处理包含数百万元素的时序数据时,实用程序可能会变得缓慢。

第二部分如下:

def main():
    if len(sys.argv) != 6:
        print("TS1 sliding_window cardinality segments threshold")
        sys.exit()
    file = sys.argv[1]
    sliding = int(sys.argv[2])
    cardinality = int(sys.argv[3])
    segments = int(sys.argv[4])
    threshold = int(sys.argv[5])
    if sliding % segments != 0:
        print("sliding MODULO segments != 0...")
        sys.exit()
    if sliding <= 0:
        print("Sliding value is not allowed:", sliding)
        sys.exit()
    if cardinality <= 0:
        print("Cardinality Value is not allowed:",
            cardinality)
        sys.exit()
    ts = pd.read_csv(file, names=['values'],
        compression='gzip')
    ts_numpy = ts.to_numpy()
    # See if it fits
    overflow = False
    KEYS = calculate(ts_numpy, sliding, segments,
        cardinality)
    maxVal = max(KEYS.values())
    if maxVal > threshold:
        overflow = True

溢出的条件如下:如果一个节点,通过其 SAX 表示来识别,其子序列数量超过阈值值,则我们有一个溢出。我们不是检查所有节点,而是获取节点中找到的子序列的最大值(max(KEYS.values())),并将其与给定的阈值值进行比较。

第三部分包含以下 Python 代码:

    # See if we can make it fit or reduce the parameters
    if overflow:
        i = 2
        while overflow:
            # We cannot have more segments than the window
            if segments * i > sliding:
                break
            print("Increasing segments to", i * segments)
            overflow = False
            KEYS = calculate(ts_numpy, sliding,
                segments * i, cardinality)
            maxVal = max(KEYS.values())
            if maxVal > threshold:
                overflow = True
                print("Overflow")
                i = 2 * i
            if overflow == False:
                print("New segments:", i * segments)
    else:
        print("Threshold can be", max(KEYS.values()))
        print("Reducing cardinality to", cardinality//2)
        overflow = False
        KEYS = calculate(ts_numpy, sliding, segments,
            cardinality//2)
        maxVal = max(KEYS.values())
        if maxVal > threshold:
            print("Cannot reduce cardinality")
        elif overflow == False:
            print("New cardinality:", cardinality//2)

之前的代码工作如下。

如果发生溢出,代码将参数segments的值加倍,同时考虑到segments参数不能大于滑动窗口的大小。只要发生溢出且段的大小不超过滑动窗口的大小,这种情况就会持续发生。如果一开始就没有溢出,代码会尝试将cardinality参数的值减半,看看会发生什么。

最后的部分是以下内容:

   # Now let us see whether the iSAX index is going to be
    # balanced or not using a cardinality value of 2
    KEYS = calculate(ts_numpy, sliding, segments, 2)
    minVal = min(KEYS.values())
    maxVal = max(KEYS.values())
    print("Min:", minVal, "Max:", maxVal)
    for k in KEYS.keys():
        print(k, ":", KEYS[k])

代码的最后这部分使用基数值为 2来确定每个子序列将被放置在根节点的哪个子节点下。在这种情况下,我们不在乎溢出。

现在我将向您展示countingv2.py的一些实际用途。想象一下,有一个包含 450,000 个元素的时间序列,你想知道它是否可以放入一个有 4 个段、基数32和阈值值1500的 iSAX 索引中,当使用1024的滑动窗口时。如果你想进行这个测试,请使用来自第一章synthetic_data.py脚本创建一个包含合成数据的时间序列。countingv2.py的输出将如下所示(你的输出将因我们不是使用相同的时间序列而有所不同):

$ ./countingv2.py 450k.txt.gz 1024 32 4 1500
Threshold can be 317
Reducing cardinality to 16
Cannot reduce cardinality
Min: 11942 Max: 76534
0_1_1_1 : 26080
0_0_1_1 : 57549
0_0_1_0 : 11942
0_1_1_0 : 53496
0_1_0_0 : 13154
1_1_0_0 : 76534
1_0_0_0 : 19933
1_0_1_0 : 25430
1_0_1_1 : 20768
1_0_0_1 : 56345
0_1_0_1 : 22142
1_1_1_0 : 31547
1_1_0_1 : 20707
0_0_0_1 : 13850

之前的输出告诉我们什么?第一行告诉我们,我们可以使用原始 SAX 参数的阈值值317,整个时间序列将适合 iSAX 索引——这意味着时间序列可以放入 iSAX 索引中。然而,当将基数减少到16时,iSAX 索引无法使用 4 个段和阈值值1500来容纳整个时间序列。最后几行告诉我们,76534个子序列属于[1 1 0 0] SAX 表示,11942个子序列属于[0 0 1 0] SAX 表示。根节点其他子节点下的子序列数量在7653411942之间。一般来说,这并不是一个坏的子序列分布,尽管根节点有两个潜在的子节点没有出现在输出中:[0 0 0 0][1 1 1 1]

现在,让我们使用相同的滑动窗口长度进行相同的测试,但这次使用一个有 2 个段的 iSAX 索引,基数128,阈值值1500。这次,输出如下:

$ ./countingv2.py 450k.txt.gz 1024 128 2 1500
Increasing segments to 4
New segments: 4
Min: 207226 Max: 242251
0_1 : 207226
1_0 : 242251

之前的输出告诉我们什么?它告诉我们给定的 iSAX 参数不足以在 iSAX 索引中容纳提供的时间序列。然而,将段的数量增加到 4 将解决问题。最后两行显示了一部分问题:所有子序列都去了[0 1][1 0],这意味着根节点的[1 1][0 0]子节点根本没有被使用,这可能会是一个不好的情况。除了数据本身,这也取决于滑动窗口的大小。

归一化和 SAX 表示

在多次运行 countingv2.py 之后,我注意到,很难得到所有零或所有一的 SAX 表示,尤其是在处理基数 2 的情况下。这种现象的主要原因在于 归一化。由于归一化,子序列的归一化值不能完全落在 0 的左侧或右侧,除非我们处理的是全零的子序列。在这种情况下,归一化版本与原始版本相同,平均值正好是 0。根据我们处理零的方式,这意味着我们必须决定它是否包含在 0 断点的左侧或右侧区域,我们很容易得到全零或全一的 SAX 表示。然而,在同一个 iSAX 索引中同时拥有这两种情况是非常罕见的,甚至根本不可能。在解释所提供工具的结果时,请记住这一点。

最后,让我们使用具有 4 个段、基数 64 和阈值 250 的 iSAX 索引进行相同的检查:

$ ./countingv2.py 450k.txt.gz 1024 64 4 250
Threshold can be 105
Reducing cardinality to 32
Cannot reduce cardinality
Min: 11942 Max: 76534
0_1_1_1 : 26080
0_0_1_1 : 57549
0_0_1_0 : 11942
0_1_1_0 : 53496
0_1_0_0 : 13154
1_1_0_0 : 76534
1_0_0_0 : 19933
1_0_1_0 : 25430
1_0_1_1 : 20768
1_0_0_1 : 56345
0_1_0_1 : 22142
1_1_1_0 : 31547
1_1_0_1 : 20707
0_0_0_1 : 13850

之前的输出告诉我们,所选参数创建的 iSAX 索引可以容纳时间序列的所有子序列,但我们无法将基数减少到 32。另外,由于我们使用相同数量的段,关于子序列分布的结果与 countingv2.py 的第一次执行结果完全相同。

当你拥有这样的命令行工具时,最好尽可能多地实验它们,以便更好地了解 iSAX 以及参数如何影响其形状。

因此,在这一章的最后部分,我们改进了 counting.py 工具,使其更加有用。

摘要

在本章中,我们讨论了 iSAX 索引背后的理论以及 SAX 表示如何与 iSAX 索引相关。我们还学习了如何根据时间序列和所需的参数手动创建 iSAX 索引。此外,我们开发了一些实用的命令行工具,以支持 iSAX 索引。我们现在理解了 iSAX 所需的理论,并准备好应用它。

下一章将以此章为基础,开发一个用于计算 iSAX 索引的 Python 包。

有用链接

练习

尝试完成以下练习:

  • 手动为 {1, 2, -2, 2, 0, 1, 3, 4} 时间序列创建一个滑动窗口为 4、SAX 表示有 2 个段、基数 4 和阈值 2 的 iSAX 索引。

  • 手动为时间序列 {1, 0, 0, 2, 0, 1, -3, 0} 创建一个 iSAX 索引,滑动窗口大小为 4,SAX 表示包含 2 个段,基数值为 4,阈值值为 4。

  • 手动为时间序列 {1, -1, -1, 2, 0, 1, -3, 0, 4, 6, 8, 10} 创建一个 iSAX 索引,滑动窗口大小为 6,SAX 表示包含 2 个段,基数值为 8,阈值值为 4。

  • 手动为时间序列 {1, -1, -1, 2, 0, 1, -3, 0, 4, 6, 8, 10} 创建一个 iSAX 索引,滑动窗口大小为 4,SAX 表示包含 2 个段,基数值为 4,阈值值为 4。

  • 手动为时间序列 {0, 0, 0, 0, 1, -1, -1, 2, 0, 1, -3, 0, 4, 6, 8, 10, 0, 0} 创建一个 iSAX 索引,滑动窗口大小为 4,SAX 表示包含 2 个段,基数值为 4,阈值值为 2。

  • 创建一个包含 2,000,000 个元素的样本时间序列,并检查它理论上是否可以适应一个包含 6 个段、基数值为 32 和阈值值为 500 的 iSAX 索引。

第四章:iSAX – 实现

在继续本章并开始编写代码之前,请确保您已经很好地理解了上一章中涵盖的信息,因为本章全部关于在 Python 中实现 iSAX。作为一个一般原则,如果您不能手动执行一项任务,那么您将无法在计算机的帮助下执行该任务——同样的原则适用于构建和使用 iSAX 索引。

在阅读本章时,请记住,我们正在创建一个适合内存的 iSAX 索引,并且不使用任何外部文件来存储每个终端节点的子序列。原始的 iSAX 论文建议使用外部文件来存储每个终端节点的子序列,主要是因为当时与今天相比,RAM 的限制更大,而今天我们可以轻松地拥有具有许多 CPU 核心和超过 64 GB RAM 的计算机。因此,使用 RAM 使得整个过程比使用磁盘文件要快得多。然而,如果您系统上的 RAM 不多,并且正在处理大型时间序列,您可能会使用交换空间,这会减慢处理速度。

在本章中,我们将涵盖以下主要主题:

  • 快速浏览 isax Python 包

  • 存储子序列的类

  • iSAX 节点的类

  • 整个 iSAX 索引的类

  • 解释缺失的部分

  • 探索剩余的文件

  • 使用 iSAX Python 包

技术要求

代码的 GitHub 仓库可以在 github.com/PacktPublishing/Time-Series-Indexing 找到。每个章节的代码都在其自己的目录中。因此,本章的代码可以在 ch04 文件夹及其子文件夹中找到。

第一部分简要介绍了本章为特定目的开发的 Python 包,这个包奇怪地被命名为 isax,然后再详细介绍。

那么,错误怎么办?

我们已经尽力提供无错误的代码。然而,任何程序都可能出现错误,尤其是当程序长度超过 100 行时!这就是为什么理解 iSAX 索引的操作和构建原理以及 SAX 表示至关重要,以便能够理解代码中可能存在的小或大问题,或者能够将现有实现移植到不同的编程语言。我使用同事提供的 Java 实现作为起点编写了 iSAX 的 Python 版本。

快速浏览 iSAX Python 包

在本节中,我们将首次了解 iSAX Python 包,以更好地了解其支持的功能。虽然我们将从我们在 第二章 中开发的 sax 包的代码开始,但我们打算将那个包重命名为 isax 并创建额外的源代码,该代码命名为 isax.py

isax 目录中 Python 文件的结构如下:

$ tree isax/
isax/
├── SAXalphabet
├── __init__.py
├── isax.py
├── sax.py
├── tools.py
└── variables.py
1 directory, 6 files

因此,总共有六个文件。您已经从 sax 包中知道了其中五个。唯一的新一个是 isax.py 源代码文件,这是本章的核心文件。此外,我们还将向 variables.py 文件添加更多全局变量,并将一些函数添加到 tools.py

isax.py 文件中找到的方法列表(不包括 __init__() 函数)如下:

$ grep -w def isax/isax.py | grep -v __init__
    def insert(self, ts, ISAX):
    def nTimeSeries(self):
    def insert(self, ts_node):

我们之所以谈论方法而不是函数,是因为每个函数都附加到一个 Python 类上,这使其自动成为该类的方法。

此外,如果我们还包括 __init__() 函数,那么我们可能会对 Python 文件中找到的类数量有一个很好的预测。在这种情况下,您可能想运行 grep -w def -n1 isax/isax.py

$ grep -w def -n1 isax/isax.py
5-class TS:
6:    def __init__(self, ts, segments):
7-        self.index = 0
--
11-class Node:
12:    def __init__(self, sax_word):
13-        self.left = None
--
19-    # Follow algorithm from iSAX paper
20:    def insert(self, ts, ISAX):
21-        # Accessing a subsequence
--
127-
128:    def nTimeSeries(self):
129-        if self.terminalNode == False:
--
141-class iSAX:
142:    def __init__(self):
143-        # This is now a hash table
--
148-
149:    def insert(self, ts_node):
150-        # Array with number of segments

因此,我们有三个类,分别命名为 TSNodeiSAX

下一节将讨论 isax.py 的方法及其所属的类。

存储子序列的类

在本小节中,我们将解释用于 TS 的 Python 类。类的定义如下:

class TS:
    def __init__(self, ts, segments):
        self.index = 0
        self.ts = ts
        self.maxCard = sax.createPAA(ts,
            variables.maximumCardinality, segments)

定义该类的对象时,我们需要提供 ts 参数,它是一个存储为 NumPy 数组的子序列,以及使用 segments 参数指定的段数。之后,maxCard 字段将自动初始化为具有最大基数该子序列的 SAX 表示。index 参数是可选的,并保持子序列在原始时间序列中的位置。iSAX 不使用 index 参数,但有一个这样的字段是好的。

此类没有附加任何方法,这与下一个将要介绍的 Node 类不同。

表示 iSAX 节点的类

在本小节中,我们将解释用于保持内部和终端节点的 Python 结构。这是该包的一个重要部分及其功能:

class Node:
    def __init__(self, sax_word):
        self.left = None
        self.right = None
        self.terminalNode = False
        self.word = sax_word
        self.children = [TS] * variables.threshold

如果我们处理的是内部节点,则 terminalNode 字段设置为 False。然而,如果 terminalNode 字段的布尔值设置为 True,则我们正在处理一个终端节点。

word 字段包含节点的 SAX 表示。最后,leftright 字段是内部节点的两个子节点的链接,而 children 字段是一个包含终端节点子序列的列表。

Node 类有两个方法:

  • insert(): 此方法用于向节点添加子序列

  • nTimeSeries(): 此方法用于计算终端节点中存储的子序列数量

接下来,让我们谈谈表示整个 iSAX 索引的类。

整个 iSAX 索引的类

isax 包的最后一个类用于表示整个 iSAX 索引:

class iSAX:
    def __init__(self):
        # This is now a hash table
        self.children = {}
        # HashTable for storing Nodes
        self.ht = {}
        self.length = 0

children 字段包含根节点的子节点——实际上,iSAX 类的实例是 iSAX 索引的根节点。

ht 字段,它是一个字典,包含了 iSAX 索引中的所有节点。每个键是节点的 SAX 表示,它是唯一的,每个值是一个 Node 实例。最后,length 字段包含了存储在 iSAX 索引中的子序列数量,这是一个可选字段。

iSAX 类只有一个方法,称为 insert(),用于将子序列插入到 iSAX 索引中。

我们为什么要使用这三个类?

iSAX 索引的实现包含三个不同的实体:子序列、节点以及 iSAX 索引本身,它由索引的根节点表示。iSAX 包含节点,而节点包含其他节点或子序列。这些实体中的每一个都有自己的类。

到目前为止,我们已经了解了我们包中使用的 Python 类的详细信息。下一节将介绍实现缺失的部分。

解释缺失的部分

在本节中,我们将展示类方法的实现。我们首先从 iSAX 类的 insert() 函数开始,这个函数不应与 Node 类的 insert() 函数混淆。在 Python 以及许多其他编程语言中,类是独立的实体,这意味着只要在类命名空间内是唯一的,它们就可以有相同名称的方法。

我们将分八部分展示 Node.insert() 的代码。该方法接受两个参数——除了 self,表示当前的 Node 对象之外——这两个参数是我们试图插入的子序列以及 Node 实例所属的 iSAX 索引。

为什么需要一个 iSAX 实例作为参数?我们需要它以便能够通过访问 iSAX.ht 来向 iSAX 索引添加新节点。

insert() 的第一部分如下:

    # Follow algorithm from iSAX paper
    def insert(self, ts, ISAX):
        # Accessing a subsequence
        variables.nSubsequences += 1
        if self.terminalNode:
            if self.nTimeSeries() == variables.threshold:
                variables.nSplits += 1
                # Going to duplicate self Node
                temp = Node(self.word)
                temp.children = self.children
                temp.terminalNode = True
                # The current Terminal node becomes
                # an inner node
                self.terminalNode = False
                self.children = None

insert() 的第一件事是检查我们是否正在处理一个终端节点。这是因为如果我们正在处理终端节点,我们将尝试将给定的子序列存储在终端节点中,而不会有任何延迟。第二个检查是终端节点是否已满。如果已满,那么我们将进行分裂。首先,我们使用 temp = Node(self.word) 语句复制当前节点,并将当前终端节点通过将 terminalNode 的值更改为 False 变成内部节点。在此阶段,我们必须创建两个新的空节点,它们将成为当前节点的两个子节点——前者将在接下来的代码摘录中实现。

insert() 函数的第二部分如下:

                # Create TWO new Terminal nodes
                new1 = Node(temp.word)
                new1.terminalNode = True
                new2 = Node(temp.word)
                new2.terminalNode = True
                n1Segs = new1.word.split('_')
                n2Segs = new2.word.split('_')

在前面的代码中,我们创建了两个新的终端节点,这两个节点将成为即将分裂的节点的子节点。这两个新节点目前具有与即将分裂的节点相同的 SAX 表示,并成为内部节点。它们 SAX 表示的改变,即分裂的标志,将在接下来的代码中实现。

第三部分包含以下代码:

                # This is where the promotion
                # strategy is selected
                if variables.defaultPromotion:
                    tools.round_robin_promotion(n1Segs)
                else:
                    tools.shorter_first_promotion(n1Segs)
                # New SAX_WORD 1
                n1Segs[variables.promote] =
                    n1Segs[variables.promote] + "0"
                # CONVERT it to string
                new1.word = "_".join(n1Segs)
                # New SAX_WORD 2
                n2Segs[variables.promote] =
                    n2Segs[variables.promote] + "1"
                # CONVERT it to string
                new2.word = "_".join(n2Segs)
                # The inner node has the same
                # SAX word as before but this is
                # not true for the two
                # NEW Terminal nodes, which should
                # be added to the Hash Table
                ISAX.ht[new1.word] = new1
                ISAX.ht[new2.word] = new2
                # Associate the 2 new Nodes with the
                # Node that is being splitted
                self.left = new1
                self.right = new2

在代码摘录的开头,我们处理提升策略,该策略在tools.py文件中实现,这在The tools.py file部分有解释,并且与定义将要提升的 SAX 词(段)有关。

之后,代码通过两个字符串操作创建了分割的两个 SAX 表示——这是我们将 SAX 词(段)存储为字符串以及我们使用列表来保存整个 SAX 表示的主要原因。之后,我们将 SAX 表示转换为存储在new1.wordnew2.word中的字符串,然后使用ISAX.ht[new1.word] = new1ISAX.ht[new2.word] = new2将这些相应的节点放入 iSAX 索引中。在iSAX.ht Python 字典中找到这两个节点的键是它们的 SAX 表示。代码的最后两条语句通过定义内部节点的leftright字段将两个新的终端节点与内部节点关联起来,从而表示其两个子节点。

Node.insert()方法的第四部分代码如下:

                # Check all TS in original node
                # and put them
                # in one of the two children
                #
                # This is where the actual
                # SPLITTING takes place
                #
                for i in range(variables.threshold):
                    # Accessing a subsequence
                    variables.nSubsequences += 1
                    # Decrease TS.maxCard to
                    # current Cardinality
                    tempCard =
                        tools.promote(temp.children[i],
                        n1Segs)
                    if tempCard == new1.word:
                        new1.insert(temp.children[i], ISAX)
                    elif tempCard == new2.word:
                        new2.insert(temp.children[i], ISAX)
                    else:
                        if variables.overflow == 0:
                            print("OVERFLOW:", tempCard)
                        variables.overflow =
                            variables.overflow + 1
                # Now insert the INITIAL TS node!
                # self is now an INNER node
                self.insert(ts, ISAX)
                if variables.defaultPromotion:
                    # Next time, promote the next segment
                    Variables.promote = (variables.promote
                        + 1) % variables.segments

我们可以肯定的是,在将之前存储在成为内部节点的终端节点中的子序列分割后,我们不会出现溢出。然而,我们仍然需要调用self.insert(ts, ISAX)来插入之前造成溢出的子序列,并查看会发生什么。

最后的if检查我们是否使用默认的提升策略,即轮询策略,如果是这样,它将提升段切换到下一个。

但我们如何知道是否存在溢出情况?如果将子序列提升到比其当前基数(tempCard)更高的基数,而这个子序列不能分配给两个新创建的终端节点(new1.wordnew2.word)中的任何一个,那么我们知道它没有被提升。因此,我们有一个溢出条件。这体现在if tempCard == new1.word:块的else:分支中。

Node.insert()的第五部分如下:

            else:
                # TS is added if we have a Terminal node
                self.children[self.nTimeSeries()] = ts

当我们处理一个非满的终端节点时,会执行之前的else代码。因此,我们将给定的子序列存储在children列表中——这是向 iSAX 索引添加新子序列的理想方式。

insert()函数的第六部分代码如下:

        else:
            # Otherwise, we are dealing with an INNER node
            # and we should add it to the
            # INNER node by trying
            # to find an existing terminal node
            # or create a new one
            # See whether it is going to be
            # included in the left
            # or the right child
            left = self.left
            right = self.right

如果我们正在处理一个内部节点,我们必须根据子序列的 SAX 表示来决定它将进入左子节点还是右子节点,以便最终找到将存储该子序列的终端节点。这就是过程的开始。

第七部分包含以下代码:

            leftSegs = left.word.split('_')
            # Promote
            tempCard = tools.promote(ts, leftSegs)

在前面的代码中,我们改变(减少)子序列的最大基数以适应左节点的基数——我们本可以使用右节点,因为两个节点使用相同的基数。持有新基数的tempCard变量将被用来决定子序列在树中要遵循的路径,直到找到适当的终端节点。

Node.insert()的最后一部分如下:

            if tempCard == left.word:
                left.insert(ts, ISAX)
            elif tempCard == right.word:
                right.insert(ts, ISAX)
            else:
                if variables.overflow == 0:
                    print("OVERFLOW:", tempCard, left.word,
                        right.word)
                variables.overflow = variables.overflow + 1
        return

如果tempCard与左节点或右节点的 SAX 表示不匹配,那么我们知道它没有被提升,这意味着我们有一个溢出条件。

这是Node.insert()实现背后的逻辑——代码中存在许多你可以阅读的注释,并且你可以添加自己的print()语句以更好地理解流程。

为什么我们要在子序列中存储最大基数?

存储这个子序列最大基数的原因是我们可以轻松地降低这个最大基数,而无需进行诸如从头开始计算新的 SAX 表示等困难的计算。这种小的优化使得分割操作变得更快。

Node类的另一个方法称为nTimeSeries(),其实现如下:

    def nTimeSeries(self):
        if self.terminalNode == False:
            print("Not a terminal node!")
            return
        n = 0
        for i in range(0, variables.threshold):
            if type(self.children[n]) == TS:
                n = n + 1
        return n

所展示的函数返回存储在终端节点中的子序列数量。首先,nTimeSeries()确保我们在遍历children列表的内容之前正在处理一个终端节点。如果存储值的类型是TS,那么我们有一个子序列。

之后,我们将讨论并解释iSAX类的insert()方法,该方法分为三个部分。当我们要向 iSAX 索引添加子序列时,会调用iSAX.insert()方法。

iSAX.insert()的第一个部分如下:

    def insert(self, ts_node):
        # Array with number of segments
        # For cardinality 1
        segs = [1] * variables.segments
        # Get cardinality 1 from ts_node
        # in order to find its main subtree
        lower_cardinality = tools.lowerCardinality(segs,
            ts_node)
        lower_cardinality_str = ""
        for i in lower_cardinality:
            lower_cardinality_str = lower_cardinality_str +
                "_" + i
        # Remove _ at the beginning
        lower_cardinality_str = lower_cardinality_str[
            1:len(lower_cardinality_str)]

代码的这一部分找到根节点中将要放置给定子序列的子节点。lower_cardinality_str值用作查找根节点相关子节点的键——tools.lowerCardinality()函数将在稍后解释。

iSAX.insert()的第二部分包含以下代码:

        # Check whether the SAX word with CARDINALITY 1
        # exists in the Hash Table.
        # If not, create it and update Hash Table
        if self.ht.get(lower_cardinality_str) == None:
            n = Node(lower_cardinality_str)
            n.terminalNode = True
            # Add it to the hash table
            self.children[lower_cardinality_str] = n
            self.ht[lower_cardinality_str] = n
            n.insert(ts_node, self)

如果具有lower_cardinality_str SAX 表示的根节点的子节点找不到,我们创建相应的根子节点并将其添加到self.children哈希表(字典)中,并调用insert()将给定的子序列放在那里。

iSAX.insert()的最后一部分如下:

        else:
            n = self.ht.get(lower_cardinality_str)
            n.insert(ts_node, self)
        return

如果具有lower_cardinality_str SAX 表示的根节点的子节点存在,那么我们尝试插入该子序列,从而调用insert()

在这一点上,我们从iSAX类级别转到Node类级别。

isax.py并不是唯一包含新代码的文件。下一节将展示对剩余包文件的添加和更改,以完成实现。

探索剩余的文件

除了 isax.py 文件外,isax Python 包由更多的源代码文件组成,主要是因为它基于 sax 包。我们将从 tools.py 文件开始。

tools.py 文件

与我们最初在 第二章 中看到的 tools.py 源代码文件相比,有一些新增内容,这主要与提升策略有关。如前所述,我们支持两种提升策略:轮询和从左到右。

轮询策略在这里实现:

def round_robin_promotion(nSegs):
    # Check if there is a promotion overflow
    n = power_of_two(variables.maximumCardinality)
    t = 0
    while len(nSegs[variables.promote]) == n:
        # Go to the next SAX word and promote it
        Variables.promote = (variables.promote + 1) %
            variables.segments
        t += 1
        if t == variables.segments:
            if variables.overflow == 0:
                print("Non recoverable Promotion overflow!")
            return

在轮询情况下,我们试图找到比指定最大基数(一个不满的段)的数字更少的右侧段。如果前一次提升发生在最后一个段,那么我们就回到第一个段并从头开始。为了计算最大基数(SAX 单词的长度)的二进制位数,我们使用 power_of_two() 函数,该函数对于基数 8 返回 3,对于基数 16 返回 4,依此类推。如果我们遍历给定 SAX 表示的所有段(nSegs)并且所有段都具有最大长度,我们知道存在溢出条件。

也称为 最短优先 的从左到右策略在这里实现:

def shorter_first_promotion(nSegs):
    length = len(nSegs)
    pos = 0
    min = len(nSegs[pos])
    for i in range(1,length):
        if min > len(nSegs[i]):
            min = len(nSegs[i])
            pos = i
    variables.promote = pos

从左到右的提升策略遍历给定 SAX 表示变量(nSegs)的所有段,从左到右,并找到最左边的最小长度段。因此,如果第二和第三段具有相同的最小长度,该策略将选择第二个,因为它是最左边的可用段。之后,它将 variables.promote 设置为所选段值。

接下来,我们将讨论 tools.py 中驻留的两个附加函数,它们被称为 promote()lowerCardinality()

promote() 函数的实现如下:

def promote(node, segments):
    new_sax_word = ""
    max_array = node.maxCard.split("_")[
        0:variables.segments]
    # segments is an array
    #
    for i in range(variables.segments):
        t = len(segments[i])
        new_sax_word = new_sax_word + "_" +
            max_array[i][0:t]
    # Remove _ from the beginning of the new_sax_word
    new_sax_word = new_sax_word[1:len(new_sax_word)]
    return new_sax_word

promote() 函数将现有 SAX 表示(node)的段的数字长度复制到给定的子序列(s)中,以便它们在所有 SAX 单词中都具有相同的基数。这 允许我们比较 这两个 SAX 表示。

lowerCardinality() 的实现如下:

def lowerCardinality(segs, ts_node):
    # Get Maximum Cardinality
    max = ts_node.maxCard
    lowerCardinality = [""] * variables.segments
    # Because max is a string, we need to split.
    # The max string has an
    # underscore character at the end.
    max_array = max.split("_")[0:variables.segments]
    for i in range(variables.segments):
        t = segs[i]
        lowerCardinality[i] = max_array[i][0:t]
    return lowerCardinality

lowerCardinality() 函数降低了一个节点在其所有 SAX 单词(段)中的基数。这主要是由 iSAX.insert() 函数需要的,以便将子序列放入根的适当子节点中。在我们将子序列放入根的适当子节点之后,我们一次提升子序列 SAX 表示的单个段,以找出它在 iSAX 索引中的位置。记住,所有 iSAX 节点的键都是 SAX 表示,通常它们的段有不同的基数。

如何测试单个函数

个人而言,我更喜欢创建小的命令行实用工具来测试复杂的函数,理解其操作,并可能发现错误!

让我们创建两个小的命令行实用工具来更详细地展示promote()lowerCardinality()的使用。

首先,我们在usePromote.py实用工具中演示了promote()函数,该实用工具包含以下代码:

#!/usr/bin/env python3
from isax import variables
from isax import isax
import numpy as np
variablesPromote = 0
maximumCardinality = 8
segments = 4
def promote(node, s):
    global segments
    new_sax_word = ""
    max_array = node.maxCard.split("_")[0:segments]
    for i in range(segments):
        t = len(s[i])
        new_sax_word = new_sax_word + "_" +
            max_array[i][0:t]
    new_sax_word = new_sax_word[1:len(new_sax_word)]
    return new_sax_word

重要的是要记住,promote()函数通过将子序列的最大 SAX 表示(s)降低以匹配存储在node参数中的给定 SAX 表示,来模拟现有 SAX 表示的段长度。

usePromote.py的其余部分如下:

def main():
    global variablesPromote
    global maximumCardinality
    global segments
    variables.maximumCardinality = maximumCardinality
    ts = np.array([1, 2, 3, 4])
    t = isax.TS(ts, segments)
    SAX_WORD = "0_0_1_1_"
    Segs = SAX_WORD.split('_')
    print("Max cardinality:", t.maxCard)
    SAX_WORD = "00_0_1_1_"
    Segs = SAX_WORD.split('_')
    print("P1:", promote(t, Segs))
    SAX_WORD = "000_0_1_1_"
    Segs = SAX_WORD.split('_')
    print("P2:", promote(t, Segs))
    SAX_WORD = "000_01_1_1_"
    Segs = SAX_WORD.split('_')
    print("P3:", promote(t, Segs))
    SAX_WORD = "000_011_1_100_"
    Segs = SAX_WORD.split('_')
    print("P4:", promote(t, Segs))
if __name__ == '__main__':
    main()

usePromote.py文件中,所有内容都是硬编码的,因为我们只想更多地了解promote()函数的使用,而不想了解其他内容。然而,由于promote()函数在isax包中有许多依赖项,我们必须将其整个实现放入我们的脚本中,并对 Python 代码进行必要的修改。

给定一个子序列ts和一个TS类实例t,我们可以使用最大基数来计算ts的 SAX 表示,然后将其降低以匹配其他 SAX 词的基数。

运行usePromote.py生成以下输出:

$ ./usePromote.py
Max cardinality: 000_010_101_111_
P1: 00_0_1_1
P2: 000_0_1_1
P3: 000_01_1_1
P4: 000_010_1_111

输出显示,给定子序列的最大基数(000_010_101_111)已被降低以匹配四个其他 SAX 词的基数。

之后,我们在useLCard.py实用工具中演示了lowerCardinality()函数,该实用工具包含以下代码:

#!/usr/bin/env python3
from isax import variables
from isax import tools
from isax import isax
import numpy as np
def main():
    global maximumCardinality
    global segments
    # Used by isax.TS()
    variables.maximumCardinality = 8
    variables.segments = 4
    ts = np.array([1, 2, 3, 4])
    t = isax.TS(ts, variables.segments)
    Segs = [1] * variables.segments
    print(tools.lowerCardinality(Segs ,t))
    Segs = [2] * variables.segments
    print(tools.lowerCardinality(Segs ,t))
    Segs = [3] * variables.segments
    print(tools.lowerCardinality(Segs ,t))
if __name__ == '__main__':
    main()

这次,我们没有在我们的代码中放置lowerCardinality()的实现,因为它有较少的依赖项,可以直接从tools.py文件中使用。我们传递给lowerCardinality()的参数是我们想要在每一个 SAX 词中得到的数字位数。所以,1表示一位数字,这意味着基数是 2¹,而3表示三位数字,计算出的基数是 2³。

再次强调,在useLCard.py中,所有内容都是硬编码的,因为我们只想更多地了解lowerCardinality()函数的使用,而不想了解其他内容。运行useLCard.py生成以下输出:

$ ./useLCard.py
['0', '0', '1', '1']
['00', '01', '10', '11']
['000', '010', '101', '111']

因此,给定一个 SAX 表示为000_010_101_111的子序列,我们计算其基数分别为248的 SAX 表示。

接下来,我们将展示对variables.py文件的修改,该文件包含全局变量,这些变量可以被包中的所有文件或使用isax包的实用工具访问。

variables.py文件

本小节展示了更新后的variables.py文件的内容,其中包含在代码的任何地方都可以访问的变量。

需要多少功能才算足够?

请记住,有时我们可能需要包含有助于调试或未来可能需要的功能,因此,我们可能需要包含不会立即或总是使用的变量或实现函数。只需记住,在想要支持一切和取悦每个人(这是不可能的)以及想要支持绝对最小功能(这通常缺乏灵活性)之间保持良好的平衡。

variables.py 文件的内容如下:

# This file includes all variables for the isax package
#
maximumCardinality = 32
breakpointsFile = "SAXalphabet"
# Breakpoints in breakpointsFile
elements = ""
slidingWindowSize = 16
segments = 0
# Maximum number of time series in a terminal node
threshold = 100
# Keeps number of splits
nSplits = 0
# Keep number of accesses of subsequences
nSubsequences = 0
# Currently supporting TWO promotion strategies
defaultPromotion = True
# Number of overflows
overflow = 0
# Floating point precision
precision = 5
# Segment to promote
promote = 0

variables.promote变量定义了如果需要,将要提升的 SAX 词。简单来说,我们根据variables.promote的值创建一个分割的两个节点的 SAX 表示——我们提升由variables.promote值定义的段。每次我们有一个分割时,variables.promote都会根据提升(分割)策略更新,并准备好下一次分割。

如果你希望查看同一文件两个版本之间的更改,可以使用diff(1)实用程序。在我们的情况下,ch03目录中找到的variables.py文件与当前版本之间的差异如下:

2c2
< # This file includes all variables for the sax package
---
> # This file includes all variables for the isax package
13a14,16
> # Breakpoints in breakpointsFile
> elements = ""
>
20,21c23,24
< # Breakpoints in breakpointsFile
< elements = ""
---
> # Maximum number of time series in a terminal node
> threshold = 100
22a26,37
> # Keeps number of splits
> nSplits = 0
>
> # Keeps number of accesses of subsequences
> nSubsequences = 0
>
> # Currently supporting TWO promotion strategies
> defaultPromotion = True
>
> # Number of overflows
> overflow = 0
>
24a40,42
>
> # Segment to promote
> promote = 0

>开头的行显示了ch04/isax/variables.py文件的内容,而以<开头的行显示了ch03/sax/variables.py文件中的语句。

下一个小节将讨论sax.py,它并没有发生太多变化。

sax.py 文件

sax.py 文件并没有任何实际上的改动。然而,我们应该修改它的import语句,因为它不再是一个独立的包,而是另一个不同名称的包的一部分。因此,我们需要修改以下两个语句:

from sax import sax
from sax import variables

我们用以下这些语句来替换它们:

from isax import sax
from isax import variables

除了这些,不需要进行额外的更改。

现在我们已经知道了isax包的源代码,是时候看看这个代码的实际应用了。

使用 iSAX Python 包

在本节中,我们将使用isax Python 包来开发实用的命令行工具。但首先,我们将学习如何从用户那里读取 iSAX 参数。

读取 iSAX 参数

本小节说明了如何读取 iSAX 参数,包括时间序列的文件名,以及如何为其中的一些参数设置默认值。尽管我们在第二章中看到了相关的代码,但这次我们将更详细地解释这个过程。此外,代码还将展示我们如何使用这些输入参数来设置位于./isax/variables.py文件内的相关变量。提醒一下,存储在./isax/variables.py或类似文件中的变量——碰巧我们使用的是./isax/variables.py——只要我们成功导入了相关文件,就可以在我们的代码的任何地方访问。

我们需要创建一个 iSAX 索引

作为提醒,要创建一个 iSAX 索引,我们需要一个时间序列和一个阈值值,这是终端节点可以持有的最大子序列数,以及一个段值和一个基数值。最后,我们还需要一个滑动窗口大小。

作为一条经验法则,当处理全局变量时,最好使用长且描述性的名称。此外,为全局参数提供默认值也是一个好的实践。

这里展示了 parameters.py 的 Python 代码:

#!/usr/bin/env python3
import argparse
from isax import variables
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-s", "--segments",
        dest = "segments", default = "4",
        help="Number of Segments", type=int)
    parser.add_argument("-c", "--cardinality",
        dest = "cardinality", default = "32",
        help="Cardinality", type=int)
    parser.add_argument("-w", "--window", dest = "window",
        default = "16", help="Sliding Window Size",
        type=int)
    parser.add_argument("TS1")
    args = parser.parse_args()
    variables.segments = args.segments
    variables.maximumCardinality = args.cardinality
    variables.slidingWindowSize = args.window
    windowSize = variables.slidingWindowSize
    maxCardinality = variables.maximumCardinality
    f1 = args.TS1
    print("Time Series:", f1, "Window Size:", windowSize)
    print("Maximum Cardinality:", maxCardinality,
        "Segments:", variables.segments)
if __name__ == '__main__':
    main()

所有工作都是由 argparse 包和用于定义命令行参数和选项的 parser.add_argument() 语句完成的。dest 参数定义了参数的名称——这个名称将在以后用于读取参数的值。

parser.add_argument() 的另一个参数被称为 type,它允许我们定义参数的数据类型。这可以避免许多问题,并减少将字符串转换为实际值所需的代码,因此尽可能使用 type

之后,我们调用 parser.parse_args(),然后我们就可以读取任何想要的 rgparse 参数。

运行 parameters.py 会生成以下输出:

$ ./parameters.py -s 2 -c 32 -w 16 ts1.gz
Time Series: ts1.gz Window Size: 16
Maximum Cardinality: 32 Segments: 2

如果发生错误,parameters.py 会生成以下输出:

$ ./parameters.py -s 1 -c cardinality ts1.gz
usage: parameters.py [-h] [-s SEGMENTS] [-c CARDINALITY] [-w WINDOW] TS1
parameters.py: error: argument -c/--cardinality: invalid int value: 'cardinality'

在这种情况下,错误是 cardinality 参数是一个字符串,而我们是期望一个整数值。错误输出非常具有信息性。

如果缺少必要的参数,parameters.py 会生成以下输出:

$ ./parameters.py
usage: parameters.py [-h] [-s SEGMENTS] [-c CARDINALITY] [-w WINDOW] TS1
parameters.py: error: the following arguments are required: TS1

下一个部分展示了我们如何处理时间序列的子序列以创建一个 iSAX 索引。

如何处理子序列以创建 iSAX 索引

这是一个非常重要的子部分,因为在这里,我们解释了用于存储 iSAX 索引每个子序列数据的 Python 结构。

代码不会说谎!

如果你对每个子序列的字段和数据存储有疑问,请查看 Python 代码以了解更多信息。文档可能会说谎,但代码永远不会。

subsequences.py 脚本展示了我们如何创建子序列,如何将它们存储在 Python 数据结构中,以及我们如何处理它们:

#!/usr/bin/env python3
import argparse
import numpy as np
import pandas as pd
from isax import sax
from isax import variables
class TS:
    def __init__(self, ts, index):
        self.ts = ts
        self.sax = sax.createPAA(ts,
            variables.maximumCardinality,
            variables.segments)
        self.index = index
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-w", "--window", dest = "window",
        default = "16", help="Sliding Window Size",
        type=int)
    parser.add_argument("-s", "--segments",
        dest = "segments", default = "4",
        help="Number of Segments", type=int)
    parser.add_argument("-c", "--cardinality",
        dest = "cardinality", default = "32",
        help="Cardinality", type=int)
    parser.add_argument("TS")
    args = parser.parse_args()
    windowSize = args.window
    variables.segments = args.segments
    variables.maximumCardinality = args.cardinality
    file = args.TS

我们再次定义 TS 类,并在 subsequences.py 中使用这个版本,以便能够在不改变 isax 包代码的情况下对 TS 类进行更多修改。在此之前,我们已经读取了程序的参数,并且准备读取时间序列:

    ts = pd.read_csv(file, names=['values'],
        compression='gzip', header = None)
    ts_numpy = ts.to_numpy()
    length = len(ts_numpy)

目前,我们使用 ts_numpy 变量将时间序列存储为 NumPy 数组:

    # Split sequence into subsequences
    n = 0
    for i in range(length - windowSize + 1):
        # Get the actual subsequence
        ts = ts_numpy[i:i+windowSize]
        # Create new TS node based on ts
        ts_node = TS(sax.normalize(ts), i)
        n = n + n

for 循环根据滑动窗口大小将时间序列分割成子序列。每个子序列的归一化版本存储在具有三个成员的 TS() 结构中:子序列的归一化版本(ts)、子序列的 SAX 表示(sax)以及子序列在时间序列中的位置(index)。TS() 结构的最后一个成员允许我们在需要时找到子序列的原始版本。

现在,检查以下代码:

    print("Created", n, "TS() nodes")
if __name__ == '__main__':
    main()

完成后,脚本会打印出已处理的子序列数量。

只有在我们将子序列的 SAX 表示存储在其基于最大基数构建的 Python 结构中之后,我们才准备好将该子序列放入 iSAX 索引中。因此,下一个步骤(此处未展示)是将每个TS()节点放入 iSAX 索引中。

subsequences.py的输出会告诉你已经处理了多少个子序列:

$ ./subsequences.py ts1.gz
Created 35 TS() nodes

总结来说,这是我们处理子序列以便将它们添加到 iSAX 索引中的方法。在下一小节中,我们将创建我们的第一个 iSAX 索引!

创建我们的第一个 iSAX 索引

在本节中,我们将首次创建一个 iSAX 索引。但首先,我们将展示用于此目的的 Python 实用工具。createiSAX.py的 Python 代码分为四个部分。第一部分如下:

#!/usr/bin/env python3
from isax import variables
from isax import isax
from isax import tools
from isax import sax
import sys
import pandas as pd
import numpy as np
import time
import argparse
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-s", "--segments",
        dest = "segments", default = "16",
        help="Number of Segments", type=int)
    parser.add_argument("-c", "--cardinality",
        dest = "cardinality", default = "16",
        help="Cardinality", type=int)
    parser.add_argument("-w", "--windows", dest = "window",
        default = "16", help="Sliding Window Size",
        type=int)
    parser.add_argument("-t", "--threshold",
        dest = "threshold", default = "1000",
        help="Threshold for split", type=int)
    parser.add_argument("-p", "--promotion",
        action='store_true',
        help="Define Promotion Strategy")
    parser.add_argument("TSfile")
    args = parser.parse_args()

这第一部分是关于import语句和通过argparse读取所需参数。

createiSAX.py的第二部分如下:

    variables.segments = args.segments
    variables.maximumCardinality = args.cardinality
    variables.slidingWindowSize = args.window
    variables.threshold = args.threshold
    variables.defaultPromotion = args.promotion
    file = args.TSfile
    maxCardinality = variables.maximumCardinality
    segments = variables.segments
    windowSize = variables.slidingWindowSize
    if tools.power_of_two(maxCardinality) == -1:
        print("Not a power of 2:", maxCardinality)
        sys.exit()
    if variables.segments > variables.slidingWindowSize:
        print("Segments:", variables.segments,
            "Sliding window:", variables.slidingWindowSize)
        print("Sliding window size should be bigger than #
            of segments.")
        sys.exit()
    print("Max Cardinality:", maxCardinality, "Segments:",
        variables.segments,
        "Sliding Window:", variables.slidingWindowSize,
        "Threshold:", variables.threshold,
        "Default Promotion:", variables.defaultPromotion)

createiSAX.py的这一部分中,我们将参数分配给相关的局部和全局变量,并进行一些测试以确保参数是有意义的。使用局部变量的原因是有更小的变量名可以工作。print()语句将参数输出到屏幕上。

createiSAX.py的第三部分包含以下代码:

    ts = pd.read_csv(file, names=['values'],
        compression='gzip')
    ts_numpy = ts.to_numpy()
    length = len(ts_numpy)
    #
    # Initialize iSAX index
    #
    ISAX = isax.iSAX()

在这部分中,我们读取压缩的时间序列文件,创建一个 NumPy 变量来存储整个时间序列。之后,我们初始化一个变量来存储 iSAX 索引。由于类的名称是iSAX,相关变量被初始化为isax.iSAX()类的实例。

createiSAX.py的最后部分包含以下代码:

    # Split sequence into subsequences
    for i in range(length - windowSize + 1):
        # Get the subsequence
        ts = ts_numpy[i:i+windowSize]
        # Create new TS node based on ts
        ts_node = isax.TS(ts, segments)
        ISAX.insert(ts_node)
if __name__ == '__main__':
    main()

这最后部分根据滑动窗口大小分割时间序列,创建TS()对象,并使用ISAX变量通过iSAX类的insert()方法将它们插入到 iSAX 索引中——记住,是iSAX.insert()调用Node.insert()

运行createiSAX.py会产生以下输出:

$ ./createiSAX.py ts1.gz
Max Cardinality: 16 Segments: 16 Sliding Window: 16 Threshold: 1000 Default Promotion: False
$ ./createiSAX.py
usage: createiSAX.py [-h] [-s SEGMENTS] [-c CARDINALITY] [-w WINDOW] [-t THRESHOLD] [-p] TSfile
createiSAX.py: error: the following arguments are required: TSfile

好处在于createiSAX.py为所有 iSAX 参数提供了默认值。然而,提供包含时间序列的文件的路径是必需的。

在下一小节中,我们将开发一个命令行实用工具,用于计算 iSAX 索引中的子序列总数。

计算 iSAX 索引的子序列

这是一个非常实用的工具,它不仅展示了如何遍历整个 iSAX 索引,还允许你计算 iSAX 索引的所有子序列,并确保在过程中没有遗漏任何子序列,这可以用于测试目的。

执行计数的countSub.py代码如下——其余的实现与createiSAX.py相同:

    # Visit all entries in Dictionary
    # Count TS in Terminal Nodes
    sum = 0
    for k in ISAX.ht:
        t = ISAX.ht[k]
        if t.terminalNode:
            sum += t.nTimeSeries()
    print(length - windowSize + 1, sum)

代码访问 iSAX 类的 ISAX.ht 字段,因为这是 iSAX 索引中所有节点都保存的地方。如果我们正在处理一个终端节点,那么我们调用 nTimeSeries() 方法来找到存储在该终端节点中的子序列数量。我们对所有终端节点都这样做,然后我们就完成了。最后一条语句打印出理论上的子序列数量以及实际在 iSAX 索引中找到的子序列数量。只要这两个值相同,我们就没问题。

ch04 目录中运行 countSub.py 在一个短时间序列上,生成以下类型的输出:

$ ./countSub.py ts1.gz
Max Cardinality: 16 Segments: 16 Sliding Window: 16 Threshold: 1000 Default Promotion: False
35 35

下一个子节显示了构建 iSAX 索引所需的时间。

创建 iSAX 索引需要多长时间?

在本小节中,我们将计算计算机创建 iSAX 索引所需的时间。iSAX 构建阶段中任何延迟的主要原因是对节点的分割和子序列的重新排列。我们分割得越广泛,生成索引所需的时间就越长。

计算 iSAX 索引创建所需时间的 howMuchTime.py 代码如下——其余的实现与 createiSAX.py 相同:

    start_time = time.time()
    print("--- %.5f seconds ---" % (time.time() –
        start_time))

第一条语句位于我们使用 pd.read_csv() 开始读取时间序列文件之前,第二条语句位于我们完成分割并将时间序列插入 iSAX 索引之后。

howMuchTime.py 处理 ts1.gz 的输出类似于以下内容:

$ ./howMuchTime.py -w 2 -s 2 ts1.gz
Max Cardinality: 16 Segments: 2 Sliding Window: 2 Threshold: 1000 Default Promotion: False
--- 0.00833 seconds ---

由于 ts1.gz 是一个包含 50 个元素的短时间序列,输出并不那么有趣。因此,让我们尝试使用 howMuchTime.py 在更大的时间序列上。

以下输出显示了 howMuchTime.py 在 macOS 机器上创建包含 500,000 个元素的时间序列 iSAX 索引所需的时间——你可以在自己的机器上创建相同长度的时间序列,并尝试相同的命令或使用提供的文件,该文件名为 500k.gz

$ ./howMuchTime.py 500k.gz
Max Cardinality: 16 Segments: 16 Sliding Window: 16 Threshold: 1000 Default Promotion: False
--- 114.80277 seconds ---

使用以下命令创建了 500k.gz 文件:

$ ./ch01/synthetic_data.py 500000 -1 1 > 500k
$ gzip 500k

以下输出显示了 howMuchTime.py 在 macOS 机器上创建包含 2,000,000 个元素的时间序列 iSAX 索引所需的时间——你可以在自己的机器上创建相同长度或更长的时序,并尝试相同的命令或使用提供的文件,该文件名为 2M.gz

$ ./howMuchTime.py 2M.gz
Max Cardinality: 16 Segments: 16 Sliding Window: 16 Threshold: 1000 Default Promotion: False
--- 450.37358 seconds ---

使用以下命令创建了 2M.gz 文件:

$ ./ch01/synthetic_data.py 2000000 -10 10 > 2M
$ gzip 2M

我们可以得出的一个有趣的结论是,对于四倍大的时间序列,我们的程序构建它大约需要四倍的时间。然而,情况并不总是如此。

此外,创建 iSAX 索引所需的时间并不能完全说明问题,尤其是在繁忙的机器或内存较少的慢速机器上进行测试时。更重要的是节点分割的数量以及访问子序列的次数。访问子序列的最小次数等于时间序列的子序列数量。然而,当发生分割时,我们必须重新访问涉及到的子序列,以便根据新创建的 SAX 表示和终端节点进行分配。分割和重新访问子序列会增加 iSAX 的构建时间。

因此,我们将创建 howMuchTime.py 的修改版本来打印节点分割的数量以及子序列访问的总数。新工具的名称是 accessSplit.py。执行分割和子序列访问计数的语句已经在 isax/isax.py 中存在,我们只需要访问两个全局变量,即 variables.nSplitsvariables.nSubsequences,以获取结果。

使用默认参数在 500k.gz 上运行 accessSplit.py 会产生以下类型的输出:

$ ./accessSplit.py 500k.gz
Max Cardinality: 16 Segments: 16 Sliding Window: 16 Threshold: 1000 Default Promotion: False
Number of splits: 0
Number of subsequence accesses: 499985

这个输出告诉我们什么?它告诉我们没有发生分割!在实践中,这意味着该特定 iSAX 索引的根节点只有终端节点作为子节点。这是好是坏?一般来说,这意味着索引像哈希表一样工作,其中哈希函数是计算 SAX 表示的函数。大多数情况下,这不是我们希望索引具有的理想形式,因为我们一开始就可以使用哈希表!

如果我们使用不同的参数在相同的时间序列上运行 accessSplit.py,我们将得到关于 iSAX 索引构建的完全不同的输出:

$ ./accessSplit.py -w 1024 -s 8 -c 32 500k.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 1024 Threshold: 1000 Default Promotion: False
Number of splits: 4733
Number of subsequence accesses: 16370018

这个输出告诉我们什么?它告诉我们即使在相对较小的时间序列上,iSAX 参数在 iSAX 索引创建时间中起着巨大的作用。然而,子序列访问的次数大约是时间序列子序列总数的 33 倍,这相当大,因此效率不高。

让我们现在尝试在更大的时间序列 2M.gz 上运行 accessSplit.py,看看会发生什么:

$ ./accessSplit.py 2M.gz
Max Cardinality: 16 Segments: 16 Sliding Window: 16 Threshold: 1000 Default Promotion: False
Number of splits: 0
Number of subsequence accesses: 1999985

如前所述,我们正在使用 iSAX 索引作为哈希表,这不是我们期望的行为。让我们尝试使用不同的参数:

$ ./accessSplit.py -s 8 -c 32 2M.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 16 Threshold: 1000 Default Promotion: False
Number of splits: 3039
Number of subsequence accesses: 13694075

这次,访问子序列的次数大约是时间序列长度的七倍,这比我们处理 500k.gz 文件时更为现实。

我们将在 第五章 中再次使用 accessSplit.py。但到目前为止,我们将更多地了解 iSAX 索引的溢出问题。

处理 iSAX 溢出

在本小节中,我们将对溢出情况进行实验。请记住,在variables.py中存在一个专门的全局参数,它保存了由于溢出而被忽略的子序列数量。除此之外,这还有助于你更快地修复 iSAX 参数,因为你知道溢出有多严重。通常,修复溢出最简单的方法是增加阈值值,但这样做在搜索 iSAX 索引或比较两个 iSAX 索引时可能会产生严重影响。

createiSAX.py相比,overflow.py的 Python 代码只有一个变化,那就是以下语句,因为导致溢出的 SAX 表示默认情况下会被打印出来:

print("Number of overflows:", variables.overflow)

这主要是因为功能内置在isax包中,当第一次发生溢出时会自动打印一条消息,我们只需访问variables.overflow变量来找出总的溢出次数。

使用500k.gz时间序列处理时,overflow.py的输出包括以下信息:

$ ./overflow.py -w 1024 -s 8 500k.gz
Max Cardinality: 16 Segments: 8 Sliding Window: 1024 Threshold: 1000 Default Promotion: False
OVERFLOW: 1000_0111_0111_1000_1000_0111_0111_1000
Number of overflows: 303084

之前的输出告诉我们,导致溢出的第一个 SAX 表示是1000_0111_0111_1000_1000_0111_0111_1000,总共发生了303084次溢出——我们可能还有更多导致溢出的 SAX 表示,但我们决定只打印第一个。这意味着有303084个子序列没有被插入到 iSAX 索引中,与时间序列的长度相比,这是一个非常大的数字。

现在我们尝试使用其他提升策略执行相同的命令,看看会发生什么:

$ ./overflow.py -w 1024 -s 8 500k.gz -p
Max Cardinality: 16 Segments: 8 Sliding Window: 1024 Threshold: 1000 Default Promotion: True
Non recoverable Promotion overflow!
OVERFLOW: 1000_0111_0111_1000_1000_0111_0111_1000
Number of overflows: 303084

结果显示,我们得到了相同类型的溢出和完全相同的总溢出次数。这完全合理,因为溢出情况与提升策略无关,而是与 SAX 表示有关。不同的提升策略可能会稍微改变 iSAX 索引的形状,但它与 溢出情况 无关

由于303084是一个很大的数字,我们可能需要大幅增加 iSAX 索引的容量,但又不能创建一个不必要的大的 iSAX 索引。因此,考虑到这一点,我们可以尝试通过改变 iSAX 索引的参数来解决溢出问题。那么,让我们尝试通过增加阈值值来这样做:

$ ./overflow.py -w 1024 -s 8 -c 16 -t 1500 500k.gz
Max Cardinality: 16 Segments: 8 Sliding Window: 1024 Threshold: 1500 Default Promotion: False
OVERFLOW: 0111_1000_1000_1000_1000_0111_0111_0111
Number of overflows: 176454

因此,看起来我们减少了一半的溢出次数,这对开始来说是个好事。然而,尽管我们使用了与之前相同的基数,但这次导致第一次溢出的却是不同的 SAX 表示(0111_1000_1000_1000_1000_0111_0111_0111),这意味着增加的阈值值解决了之前由1000_0111_0111_1000_1000_0111_0111_1000 SAX 表示引起的溢出条件。

让我们通过增加cardinality值并同时降低阈值值来再试一次:

$ ./overflow.py -w 1024 -s 8 -c 32 -t 500 500k.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 1024 Threshold: 500 Default Promotion: False
Number of overflows: 0

因此,我们最终找到了一组适用于1024滑动窗口大小和500k.gz数据集的参数组合。

有没有找到哪些参数有效和无效的配方?没有,因为这主要取决于数据集的值和滑动窗口大小。你越使用并实验 iSAX 索引,你就越会了解哪些参数对于给定的数据集和滑动窗口大小效果最好。

因此,在本节的最后,我们了解了 iSAX 溢出,并介绍了一种解决这种情况的技术。

摘要

在本章中,我们看到了isax Python 包的实现细节,该包允许我们创建 iSAX 索引。请确保你理解代码,最重要的是知道如何使用代码。

此外,我们还实现了许多命令行工具,使我们能够创建 iSAX 索引,并了解在分割和子序列访问以及溢出条件方面幕后发生了什么。更好地理解 iSAX 索引的结构使我们能够选择更好的索引,并避免使用较差的索引。

下一章将通过展示如何搜索和连接 iSAX 索引来将 iSAX 索引应用于实践。

有用链接

练习

尝试完成以下练习:

  • 创建一个包含 100,000 个元素、值从-10 到 10的合成数据集,并构建一个具有 4 个段、基数 64 和阈值1000的 iSAX 索引。你的机器创建 iSAX 索引花费了多长时间?是否有溢出?

  • 创建一个包含 100,000 个元素、值从-1 到 1的合成数据集,并构建一个具有 4 个段、基数 64 和阈值1000的 iSAX 索引。你的机器创建该 iSAX 索引花费了多长时间?

  • 创建一个包含 500,000 个元素、值从0 到 10的合成数据集,并构建一个具有 4 个段、基数 64 和阈值1000的 iSAX 索引。你的机器创建 iSAX 索引花费了多长时间?

  • 创建一个包含 500,000 个元素、值从0 到 10的合成数据集,并构建一个具有 4 个段、基数 64 和阈值1000的 iSAX 索引。发生了多少次分割和子序列访问?如果你将阈值值增加到1500会发生什么?

  • 创建一个包含 150,000 个元素、值从-1 到 1的合成数据集,并构建一个具有 4 个段、基数 64 和阈值1000的 iSAX 索引。是否有溢出?构建 iSAX 索引时执行了多少次分割?

  • 2M.gz上使用不同的 iSAX 参数进行accessSplit.py实验。哪些参数似乎效果最好?不要忘记,高阈值值对搜索有很大影响;因此,通常不要使用非常大的阈值值以降低分割次数。

  • 500k.gz文件上使用各种 iSAX 参数对accessSplit.py进行实验。哪些参数看起来效果最好?

第五章:加入和比较 iSAX 索引

在上一章中,我们开发了一个名为 isax 的 Python 包,用于创建 iSAX 索引,以索引时间序列的子序列,给定一个滑动窗口。

在本章中,我们将实验滑动窗口大小如何影响在创建 iSAX 索引时分割数和子序列访问次数的数量。

然后,我们将使用 isax 包创建的 iSAX 索引,尝试将它们连接并比较。通过 比较,我们旨在了解 iSAX 索引的效率,而通过 连接,我们意味着能够根据 SAX 表示找到两个 iSAX 索引中的相似节点。

本章的最后部分将简要讨论 Python 测试,然后再为 isax 包开发简单的测试。测试是开发过程中的一个重要部分,不应被忽视。编写测试所花费的时间是值得的!

在本章中,我们将涵盖以下主要内容:

  • 滑动窗口大小如何影响 iSAX 构建速度

  • 检查 iSAX 索引的搜索速度

  • 连接 iSAX 索引

  • 实现 iSAX 索引的连接

  • 解释 Python 代码

  • 使用 Python 代码

  • 编写 Python 测试

技术要求

本书的相关 GitHub 仓库可以在 github.com/PacktPublishing/Time-Series-Indexing 找到。每个章节的代码都在自己的目录中。因此,本章的代码可以在 ch05 文件夹中找到。您可以使用 git(1) 在您的计算机上下载整个仓库,或者您可以通过 GitHub 用户界面访问所需的文件。

滑动窗口大小如何影响 iSAX 构建速度

在本节中,我们将继续使用上一章中开发的 accessSplit.py 工具,以找出滑动窗口大小是否会影响 iSAX 索引的构建速度,前提是剩余的 iSAX 参数保持不变。

简而言之,我们将使用不同的方法来了解更多关于 iSAX 索引的质量以及滑动窗口大小是否会影响构建速度。我们将使用以下滑动窗口大小进行实验:162561024409616384。我们将使用来自 第四章500k.gz 时间序列,8 个段,最大基数值为 32,阈值值为 500

对于窗口大小为 16 的情况,结果如下:

$ ./accessSplit.py -s 8 -c 32 -t 500 -w 16 500k.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 16 Threshold: 500 Default Promotion: False
Number of splits: 1376
Number of subsequence accesses: 2776741

对于滑动窗口大小为 256 的情况,结果如下:

$ ./accessSplit.py -s 8 -c 32 -t 500 -w 256 500k.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 256 Threshold: 500 Default Promotion: False
Number of splits: 4234
Number of subsequence accesses: 10691624

与滑动窗口大小为 16 相比,使用滑动窗口大小为 256 创建的 iSAX 索引具有超过三倍的分割数和四倍的子序列访问次数。

接下来,对于窗口大小为 1024 的情况,结果如下:

$ ./accessSplit.py -s 8 -c 32 -t 500 -w 1024 500k.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 1024 Threshold: 500 Default Promotion: False
Number of splits: 5983
Number of subsequence accesses: 15403024

与之前一样,我们比16256滑动窗口大小有更多的分割和子序列访问。简单来说,构建这个 iSAX 索引需要更多的 CPU 时间。

接下来,对于窗口大小为4096的结果如下:

$ ./accessSplit.py -s 8 -c 32 -t 500 -w 4096 500k.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 4096 Threshold: 500 Default Promotion: False
OVERFLOW: 10000_10000_01111_01111_01111_10000_10000_01111
Number of splits: 6480
Number of subsequence accesses: 18537820

在这种情况下,不仅构建 iSAX 索引的速度较慢,而且一个500.gz时间序列也无法适应这些参数的 iSAX 索引,我们将需要使用不同的 iSAX 参数才能使 iSAX 索引工作。

溢出对 iSAX 索引的构建有影响吗?

当我们在 iSAX 索引上有一个或多个溢出时,这意味着所有 SAX 词的全基数已经被使用——回想一下,SAX 词的数量由段的数量定义。因此,我们在终端节点上有多个基于当前阈值值的分割,这意味着我们比通常有更多的子序列访问。因此,溢出对 iSAX 索引的构建时间有很大影响。此外,就像这还不够糟糕一样,我们必须找到新的 iSAX 参数,以防止溢出发生,同时保持 iSAX 操作高效。记住,分割的数量也是我们接近溢出的一个简单指示。

最后,对于最大的窗口大小(16384),结果如下:

$ ./accessSplit.py -s 8 -c 32 -t 500 -w 16384 500k.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 16384 Threshold: 500 Default Promotion: False
OVERFLOW: 01111_10000_10000_01111_10000_01111_10000_01111
Number of splits: 6996
Number of subsequence accesses: 19201125

再次,我们有一个溢出情况,这次是在16384滑动窗口大小上,针对不同的 SAX 表示。我们将保留这两个溢出,并创建一些结果图。溢出的分辨率留给你作为练习。

图 5**.1显示了每个滑动窗口大小的分割数量,我们可以看到滑动窗口的大小越大,特定时间序列的分割数量就越多。

图 5.1– 每个滑动窗口大小的分割数量图

图 5.1– 每个滑动窗口大小的分割数量图

图 5**.2显示了每个滑动窗口大小的子序列访问次数。在这种情况下,我们不是绘制子序列访问的绝对数量,而是将总子序列访问次数除以总子序列数来显示一个分数。这是一个公平的计算,因为较大的时间序列有更多的子序列。

图 5.2 – 每个滑动窗口大小的子序列访问百分比图

图 5.2 – 每个滑动窗口大小的子序列访问百分比图

图 5**.2中,我们可以看到滑动窗口的大小越大,子序列访问的次数也越多。对于最小的滑动窗口(16),与最大的滑动窗口(16384)相比,对时间序列子序列的访问次数大约少八倍。

iSAX 索引的构建速度是一个重要因素。然而,它并不是 iSAX 索引质量的唯一标准。下一节将探讨 iSAX 索引的搜索速度。

检查 iSAX 索引的搜索速度

本节介绍了一个实用工具,它接受两个时间序列,分别命名为TS1TS2,理想情况下它们的长度相似,创建两个 iSAX 索引,分别命名为D1D2,并执行以下搜索:

  • D1中搜索TS2的所有子序列。在这种情况下,我们不确定TS2的子序列是否在D1中。在大多数情况下,我们无法在TS1中找到TS2的子序列。这是基于 iSAX 节点 SAX 表示的连接在寻找子序列相似性时可能更合适的主要原因。

  • D2中搜索TS1的所有子序列。在这种情况下,我们不确定TS1的子序列是否在D2中。和之前一样,在大多数情况下,我们无法在TS2中找到TS1的子序列,因此,在从TS2创建的 iSAX 索引(D2)中也无法找到。

  • D1中搜索TS1的所有子序列,这意味着TS1的所有子序列都在D1中。通过这个测试,我们只想了解 iSAX 索引在执行搜索操作时的速度。这个搜索操作主要取决于阈值大小,因为更大的阈值意味着在到达适当的终端节点时需要查找更多的子序列。

  • D2中搜索TS2的所有子序列,这意味着TS2的所有子序列都在D2中,并且将被找到。

所有这些搜索都在一个名为speed.py的 Python 脚本中实现。

speed.py的核心功能在函数中实现。第一个函数包含以下代码:

def createISAX(file, w, s):
    # Read Sequence as Pandas
    ts = pd.read_csv(file, names=['values'],
        compression='gzip').astype(np.float64)
    # Convert to NParray
    ts_numpy = ts.to_numpy()
    length = len(ts_numpy)
    ISAX = isax.iSAX()
    ISAX.length = length
    # Split sequence into subsequences
    for i in range(length - w + 1):
        # Get the subsequence
        ts = ts_numpy[i:i+w]
        # Create new TS node based on ts
        ts_node = isax.TS(ts, s)
        ISAX.insert(ts_node)
    return ISAX, ts_numpy

createISAX()函数创建一个 iSAX 索引,并返回一个指向isax.ISAX()类的链接以及一个包含时间序列所有元素的 NumPy 数组。

第二个函数实现如下:

def query(ISAX, q):
    global totalQueries
    totalQueries = totalQueries + 1
    Accesses = 0
    # Create TS Node
    qTS = isax.TS(q, variables.segments)
    segs = [1] * variables.segments
    #If the relevant child of root is not there, we have a miss
    lower_cardinality = tools.lowerCardinality(segs, qTS)
    lower_cardinality_str = ""
    for i in lower_cardinality:
        lower_cardinality_str = lower_cardinality_str + "_"
            + i

query()的第一部分,我们使用tools.lowerCardinality()segs构建 iSAX 索引根节点潜在子节点的 SAX 表示,然后构建lower_cardinality_str字符串:

    # Remove _ at the beginning
    Lower_cardinality_str = lower_cardinality_str[1:len(
        lower_cardinality_str)]
    if ISAX.ht.get(lower_cardinality_str) == None:
        return False, 0
    # Otherwise, we have a hit
    n = ISAX.ht.get(lower_cardinality_str)
    while n.terminalNode == False:
        left = n.left
        right = n.right
        leftSegs = left.word.split('_')
        # Promote
        tempCard = tools.promote(qTS, leftSegs)
        if tempCard == left.word:
            n = left
        elif tempCard == right.word:
            n = right
    # Iterate over the subsequences of the terminal node
    for i in range(0, variables.threshold):
        Accesses = Accesses + 1
        child = n.children[i]
        if type(child) == isax.TS:
            # print("Shapes:", child.ts.shape, qTS.ts.shape)
            if np.allclose(child.ts, qTS.ts):
                return True, Accesses
        else:
            return False, Accesses
    return False, Accesses

query()的第二部分,我们检查lower_cardinality_str键是否可以在 iSAX 索引中找到。

如果可以找到,我们就跟随那个子树,它从 iSAX 索引的根节点的一个子节点开始,直到我们找到适当的终端节点。如果找不到,那么我们有一个错误,并且过程终止。

query()函数如果找到子序列则返回True,否则返回False。它的第二个返回值是在尝试找到该查询子序列时发生的子序列访问次数。

speed.py的其余代码放在main()函数中,并将分三部分介绍——第一部分如下:

    # Build iSAX for TS1
    i1, ts1 = createISAX(f1, windowSize, segments)
    totalSplits = totalSplits + variables.nSplits
    totalAccesses = totalAccesses + variables.nSubsequences
    # Build iSAX for TS2
    variables.nSubsequences = 0
    variables.nSplits = 0
    i2, ts2 = createISAX(f2, windowSize, segments)
    totalSplits = totalSplits + variables.nSplits
    totalAccesses = totalAccesses + variables.nSubsequences

在这个第一部分,我们构建两个 iSAX 索引,并存储拆分和子序列访问次数。

speed.py的第二个部分包含以下代码:

    # Query iSAX for TS1
    for idx in range(0, len(ts1)-windowSize+1):
        currentQuery = ts1[idx:idx+windowSize]
        found, ac = query(i1, currentQuery)
        if found == False:
            print("This cannot be happening!")
            return
        totalAccesses = totalAccesses + ac
    # Query iSAX for TS1
    for idx in range(0, len(ts2)-windowSize+1):
        currentQuery = ts2[idx:idx+windowSize]
        found, ac = query(i1, currentQuery)
        totalAccesses = totalAccesses + ac

在程序的这个部分,我们查询第一个 iSAX 索引。在第一个for循环块中,我们在 iSAX 中搜索第一个时间序列的所有子序列。由于这个 iSAX 索引了第一个时间序列,所有子序列都将在这个 iSAX 索引中找到。在这样做的时候,我们存储了对子序列的访问次数,这是由query()函数返回的。在第二个for循环块中,我们做同样的事情,但这次是为第二个时间序列。因此,在第一个时间序列的 iSAX 索引中找到第二个时间序列(TS2)的子序列的可能性很小。

speed.py的最后一部分如下所示:

    # Query iSAX for TS2
    for idx in range(0, len(ts2)-windowSize+1):
        currentQuery = ts2[idx:idx+windowSize]
        found, ac = query(i2, currentQuery)
        if found == False:
            print("This cannot be happening!")
            return
        totalAccesses = totalAccesses + ac
    # Query iSAX for TS2
    for idx in range(0, len(ts1)-windowSize+1):
        currentQuery = ts1[idx:idx+windowSize]
        found, ac = query(i2, currentQuery)
        totalAccesses = totalAccesses + ac

main()函数的最后一部分与之前的代码类似。唯一的区别是这次我们查询的是第二个 iSAX 索引,而不是第一个。再次,我们存储了对子序列的访问次数。

在运行speed.py之前,我们需要创建另一个时间序列,它将被存储在506k.gz中。在这种情况下,第二个时间序列被创建如下:

$ ../ch01/synthetic_data.py 506218 -10 10 > 506k
$ gzip 506k

虽然两个时间序列不需要有相同的长度,但我们决定使它们的长度尽可能接近。

使用speed.py生成以下类型的输出:

$ ./speed.py -s 8 500k.gz 506k.gz
Max Cardinality: 16 Segments: 8 Sliding Window: 16 Threshold: 1000 Default Promotion: False
Total subsequence accesses: 1060326778
Total splits: 1106
Total queries: 2012376

请记住,之前的命令在 MacBook Pro 机器上花费了超过三个小时!速度将取决于你的 CPU。

如果我们使用了不同的 SAX 参数,输出将如下所示:

$ ./speed.py -s 4 -c 64 500k.gz 506k.gz
Max Cardinality: 64 Segments: 4 Sliding Window: 16 Threshold: 1000 Default Promotion: False
Total subsequence accesses: 1083675402
Total splits: 2034
Total queries: 2012376

虽然第一次运行speed.py需要 1,106 次分割,第二次运行需要 2,034 次分割,但就总子序列访问次数而言,两个结果都非常接近。

如预期,两种情况下总查询次数相同,因为我们处理的是相同的时间序列,因此子序列的数量也相同。

现在我们已经知道了如何在 iSAX 索引上执行查找和搜索,是时候学习另一个重要的操作了,那就是 iSAX 索引的连接。

连接 iSAX 索引

到目前为止,我们已经有了想要用于执行基本时间序列数据挖掘任务的 iSAX 索引。其中之一是在两个或多个时间序列之间找到相似子序列。在我们的案例中,我们正在处理两个时间序列,但通过一些小的改动,这个方法可以扩展到更多的时间序列。

如何连接 iSAX 索引

给定两个或多个 iSAX 索引,我们决定如何以及为什么将它们连接起来。我们甚至可以使用具有2的基数值的 SAX 表示来连接它们。然而,使用节点 SAX 表示作为连接键是最合理的选择。在我们的案例中,我们将使用 iSAX 索引和节点的 SAX 表示来寻找相似子序列。这是因为我们有这样的直觉:具有相同 SAX 表示的节点中的子序列彼此之间是接近的。术语接近是相对于一个距离度量来定义的。为了本章的目的,我们将使用欧几里得距离来比较相同大小的子序列。

现在,让我们用更精确的方式重新表述。基于 SAX 表示的 iSAX 索引的 连接 是一种在搜索使用相同参数构建的第二 iSAX 索引的节点时,为第一个 iSAX 索引的每个节点找到最相似节点(基于 SAX 表示)的方法。这样,我们节省了时间,因为我们只需要比较相似终端节点的子序列。基于 SAX 表示的相似性完美吗?不,它并不完美。但我们使用时间序列索引来使事情更快。

这种连接背后的想法是在阅读了由 Georgios Chatzigeorgakidis、Kostas Patroumpas、Dimitrios Skoutas、Spiros Athanasiou 和 Spiros Skiadopoulos 撰写的 Scalable Hybrid Similarity Join over Geolocated Time Series 论文之后产生的,该论文发表在 Scalable Hybrid Similarity Join over Geolocated Time Series 上。

下一节将展示如何根据其节点的 SAX 表示实现 iSAX 索引的连接。

实现 iSAX 索引的连接

对于 iSAX 索引连接的实现,我们将假设我们有两个准备好的 iSAX 索引,分别保存在两个不同的 Python 变量中,并继续进行。我们需要一个 Python 函数,该函数接受两个 iSAX 索引并返回一个欧几里得距离列表,这是两个时间序列中所有子序列的最近邻。请记住,如果一个 iSAX 索引的节点与另一个 iSAX 索引不匹配,那么该节点及其子序列将不会得到处理。因此,欧几里得距离列表可能比预期的要短一些。这就是我们为什么不能使用不必要的大的 iSAX 参数的主要原因。简单地说,当 4 个段可以完成任务时,不要使用 16 个段。

此外,请记住,子序列的真实最近邻可能不在具有相同 SAX 表示的终端节点中——这是我们为了额外的速度和避免二次处理成本(比较第一时间序列的所有子序列与第二时间序列的所有子序列,反之亦然)所付出的代价。

因此,我们需要根据当前的 iSAX 索引实现和表示实现 isax 包的先前功能。

因此,我们将该功能放在 isax 包中,使用一个名为 iSAXjoin.py 的单独文件。

除了那个文件之外,我们在 isax/tools.py 中添加了一个用于计算两个子序列之间欧几里得距离的函数:

def euclidean(a, b):
    return np.linalg.norm(a-b)

如果你还记得从 第一章,在 ch01/ed.py 脚本中,euclidean() 使用 NumPy 的魔法来计算两个子序列之间的欧几里得距离。不要忘记,在这本书中,我们总是比较 归一化子序列

最后,我们在 isax/variables.py 中添加了以下变量:

# List of Euclidean distances
ED = []

ED 全局变量是一个 Python 列表,用于存储两个 iSAX 索引之间连接的结果,这是一个欧几里得距离的列表。

现在我们将展示并解释isax/iSAXjoin.py的代码。

解释 Python 代码

iSAXjoin.py中的代码将分为五个部分。

第一部分如下:

from isax import variables
from isax import tools
def Join(iSAX1, iSAX2):
    # Begin with the children of the root node.
    # That it, the nodes with SAX words
    # with a Cardinality of 1.
    for t1 in iSAX1.children:
        k1 = iSAX1.children[t1]
        if k1 == None:
            continue
        for t2 in iSAX2.children:
            k2 = iSAX2.children[t2]
            if k2 == None:
                continue
            # J_AB
            _Join(k1, k2)
            # J_BA
            _Join(k2, k1)
    return

Join()函数是两个 iSAX 索引连接的入口点。然而,该函数只有一个目的,即创建两个 iSAX 根节点子节点的所有组合,以便传递控制到_Join()。由于_Join()中参数的顺序很重要,因此_Join()被调用了两次。第一次,第一个 iSAX 索引的根子节点是第一个参数,第二次,第二个 iSAX 索引的根子节点是第一个参数。

iSAXjoin.py的第二部分如下:

def _Join(t1, t2):
    if t1.word != t2.word:
        return
    # Inner + Inner
    if t1.terminalNode==False and t2.terminalNode==False:
        _Join(t1.left, t2.left)
        _Join(t1.right, t2.left)
        _Join(t1.left, t2.right)
        _Join(t1.right, t2.right)

当我们处理来自两个 iSAX 索引的内部节点时,我们只需组合它们的所有子节点——记住每个内部节点有两个子节点——其余部分由递归处理。

第三部分包含以下代码:

    # Terminal + Inner
    elif t1.terminalNode==True and t2.terminalNode==False:
        _Join(t1, t2.left)
        _Join(t1, t2.right)

如果我们正在处理一个内部节点和一个终端节点,我们将扩展内部节点,其余部分由递归处理。

iSAXjoin.py的第四部分如下:

    # Inner + Terminal
    elif t1.terminalNode == False and t2.terminalNode == True:
        _Join(t1.left, t2)
        _Join(t1.right, t2)

如前所述,当处理一个内部节点和一个终端节点时,我们扩展内部节点,其余部分由递归处理。

最后一部分包含以下 Python 代码:

    # Terminal + Terminal
    # As both are terminal nodes, calculate
    # Euclidean Distances between Time Series pairs
    elif t1.terminalNode==True and t2.terminalNode==True:
        for i in range(t1.nTimeSeries()):
            minDistance = None
            for j in range(t2.nTimeSeries()):
                distance =round(tools.euclidean
                (t1.children[i].ts, t2.children[j].ts),
                variables.precision)
                # Keeping the smallest Euclidean Distance for each node
                # of the t1 Terminal node
                if minDistance == None:
                    minDistance = distance
                elif minDistance > distance:
                    minDistance = distance
            # Insert distance to PQ
            if minDistance != None:
                variables.ED.append(minDistance)
    else:
        print("This cannot happen!")

最后这部分是递归调用_Join()停止的地方,因为我们正在处理两个终端节点。这意味着我们可以计算它们子序列的欧几里得距离。我们没有在调用tools.euclidean()之前对子序列进行归一化的事实意味着我们期望所有终端节点中的子序列都是以归一化的形式存储的。请注意,我们将结果存储在variables.ED列表中。

关于两个 iSAX 索引连接的实现就到这里。下一节将介绍如何使用(相似性)连接代码。

使用 Python 代码

在本节中,我们将使用我们开发的相似性连接代码来开始连接 iSAX 索引。join.py的源代码分为三个部分。第一部分如下:

#!/usr/bin/env python3
from isax import variables
from isax import isax
from isax import tools
from isax.sax import normalize
from isax.iSAXjoin import Join
import sys
import pandas as pd
import time
import argparse
def buildISAX(file, windowSize):
    variables.overflow = 0
    # Read Sequence as Pandas
    ts = pd.read_csv(file, names=['values'],
        compression='gzip', header = None)
    ts_numpy = ts.to_numpy()
    length = len(ts_numpy)
    ISAX = isax.iSAX()
    ISAX.length = length
    for i in range(length - windowSize + 1):
        ts = ts_numpy[i:i+windowSize]
        # Create new TS node based on ts
        # Store the normalized version of the subsequence
        ts_node = isax.TS(normalize(ts),
            variables.segments)
        ISAX.insert(ts_node)
    if variables.overflow != 0:
        print("Number of overflows:", variables.overflow)
    return ISAX

这里没有新的内容——我们只需要导入必要的外部包,包括isax.iSAXjoin,并开发一个函数,该函数根据时间序列文件和滑动窗口大小创建一个 iSAX 索引。该函数返回 iSAX 索引的根节点。然而,请注意,子序列以归一化的形式存储在所有TS()对象内部。

第二部分是main()函数的开始,并包含以下 Python 代码:

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-s", "--segments",
        dest = "segments", default = "16",
        help="Number of Segments", type=int)
    parser.add_argument("-c", "--cardinality",
        dest = "cardinality", default = "256",
        help="Cardinality", type=int)
    parser.add_argument("-w", "--window", dest = "window",
        default = "16", help="Sliding Window Size",
        type=int)
    parser.add_argument("-t", "--threshold",
        dest = "threshold", default = "50",
        help="Threshold for split", type=int)
    parser.add_argument("-p", "--promotion",
        action='store_true',
        help="Define Promotion Strategy")
    parser.add_argument("TS1")
    parser.add_argument("TS2")
    args = parser.parse_args()
    variables.segments = args.segments
    variables.maximumCardinality = args.cardinality
    variables.slidingWindowSize = args.window
    variables.threshold = args.threshold
    variables.defaultPromotion = args.promotion
    windowSize = variables.slidingWindowSize
    maxCardinality = variables.maximumCardinality
    f1 = args.TS1
    f2 = args.TS2
    if tools.power_of_two(maxCardinality) == -1:
        print("Not a power of 2:", maxCardinality)
        sys.exit()
    if variables.segments > variables.slidingWindowSize:
        print("Segments:", variables.segments,
            "Sliding window:", variables.slidingWindowSize)
        print("Sliding window size should be bigger than # of segments.")
        sys.exit()
    print("Max Cardinality:", maxCardinality, "Segments:",
        variables.segments,
        "Sliding Window:", variables.slidingWindowSize,
        "Threshold:", variables.threshold,
        "Default Promotion:", variables.defaultPromotion)

由于我们正在连接两个 iSAX 索引,我们需要两个单独的命令行参数(TS1TS2)来定义包含时间序列数据的压缩纯文本文件的路径。

join.py的最后一部分如下:

    # Build iSAX for TS1
    start_time = time.time()
    i1 = buildISAX(f1, windowSize)
    print("i1: %.2f seconds" % (time.time() - start_time))
    # Build iSAX for TS2
    start_time = time.time()
    i2 = buildISAX(f2, windowSize)
    print("i2: %.2f seconds" % (time.time() - start_time))
    # Join the two iSAX indexes
    Join(i1, i2)
    variables.ED.sort()
    print("variables.ED length:", len(variables.ED))
    maximumLength = i1.length+i2.length - 2*windowSize + 2
    print("Maximum length:", maximumLength)
if __name__ == '__main__':
    main()

在这里,我们通过两次调用buildISAX()创建两个 iSAX 索引,然后使用Join()将它们连接起来,该函数返回没有值。为了查看计算出的值列表,我们需要访问variables.ED。我们打印欧几里得距离列表的长度以及它的理论最大长度,即time_series_length – sliding_window_size + 1,以便更好地了解没有匹配的子序列数量。在输出中,我们还打印了创建每个 iSAX 索引所需的时间,作为过程额外信息的补充。

到目前为止,我们已经准备好使用join.py。这意味着我们应该提供必要的参数和输入。

使用join.py会产生以下类型的输出:

$ ./join.py -s 8 -c 32 -t 1000 500k.gz 506k.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 16 Threshold: 1000 Default Promotion: False
i1: 170.94 seconds
i2: 179.80 seconds
variables.ED length: 970603
Maximum length: 1006188

因此,创建第一个索引花费了170.94秒,创建第二个 iSAX 索引花费了179.80秒。欧几里得距离列表有970603个元素,而最大元素数量是1006188,这意味着我们错过了一些终端节点,因为它们的 SAX 表示在另一个 iSAX 索引中没有匹配。这并不罕见,我们大多数时候都应该预料到这种情况,因为时间序列及其 iSAX 索引是不同的。

我们有一长串欧几里得距离,那又如何呢?

你可能会问,“我们该如何处理那个欧几里得距离列表?”简单地说,创建这样一个距离列表的主要目的是什么?有很多用途,包括以下:

  • 通过找到列表中的最小欧几里得距离来找出两个时间序列有多接近。

  • 找出给定数值范围内的欧几里得距离列表。这是比较两个时间序列相似性的另一种方法。

  • 根据距离度量找出与其他子序列相比差异更大的子序列。在数据挖掘术语中,这些子序列被称为异常值

我认为你明白了我们为什么要执行连接计算——我们需要更好地理解参与连接的两个时间序列之间的联系。使用 SAX 表示的原因是为了剪枝节点和子序列,从而节省 CPU 时间。

由于连接操作可能很慢,下一小节将介绍一种方便的技术,用于将欧几里得距离列表保存到磁盘上,并在需要时从磁盘加载列表,以便无需从头开始执行整个过程。

保存输出

iSAX 索引的连接可能需要时间。有没有办法让这个过程不那么痛苦?是的,我们可以将相似性连接的内容,即一个列表,保存到文件中,这样我们就不必每次需要时都从头创建该列表。记住,为了使这可行,两个 iSAX 索引必须使用相同的参数为完全相同的时间序列创建。

saveLoadList.py脚本在main()函数中展示了这个想法——你可以看到join.pybuildISAX()的实现。main()函数的前部分如下。为了简洁起见,省略了一些代码:

def main():
. . .
    # Reading command line parameters
. . .
    # Build iSAX for TS1
    start_time = time.time()
    i1 = buildISAX(f1, windowSize)
    print("i1: %.2f seconds" % (time.time() - start_time))
    # Build iSAX for TS2
    start_time = time.time()
    i2 = buildISAX(f2, windowSize)
    print("i2: %.2f seconds" % (time.time() - start_time))
    # Now, join the two iSAX indexes
    Join(i1, i2)
    variables.ED.sort()
    print("variables.ED length:", len(variables.ED))
    # Now save it to disk
    #
    # Define filename
    filename = "List_" + basename(f1) + "_" + basename(f2) + "_" + str(maxCardinality) + "_" + str(variables.segments) + "_" + str(windowSize) + ".txt"
    print("Output file:", filename)
    f = open(filename, "w")
    # Write to disk
    for item in variables.ED:
        f.write('%s\n' %item)
    f.close()

在前面的代码中,我们将相似性连接数据放入 variables.ED 中,通过从 isax.iSAXjoin 调用 Join() 并打印其长度。之后,我们计算了输出文件的文件名,该文件名存储在 filename 变量中,该变量基于程序的参数。这是一种将 variables.ED 创建到该文件中的便捷方法。

main() 的第二部分包含以下代码:

    # Now try to open it
    f = open(filename, "r")
    PQ = []
    for item in f.readlines():
        PQ.append(float(item.rstrip()))
    f.close()
    print("PQ length:", len(PQ))

在前面的代码中,我们尝试读取用于存储 variables.ED 内容的文件名,并将纯文本文件的内容放入 PQ 变量中。最后,我们打印 PQ 的长度,以便将其与 variables.ED 的长度进行比较,并确保一切按预期工作。

运行 saveLoadList.py 脚本生成以下输出:

$ ./saveLoadList.py -s 8 -c 32 -t 1000 500k.gz 506k.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 16 Threshold: 1000 Default Promotion: False
i1: 168.73 seconds
i2: 172.39 seconds
variables.ED length: 970603
Output file: List_500k.gz_506k.gz_32_8_16.txt
PQ length: 970603

从前面的输出中,我们可以理解列表包含 970603 个元素。此外,我们保存列表内容的文件名为 List_500k.gz_506k.gz_32_8_16.txt。文件名中缺少的唯一信息是阈值值。

下一个小节将介绍一个实用程序,该实用程序查找在另一个 iSAX 索引中没有匹配的 iSAX 索引的节点,反之亦然。

寻找没有匹配的 iSAX 节点

在本小节中,我们指定了在另一个 iSAX 索引中没有匹配的 iSAX 索引的节点,反之亦然。实际上,我们将打印出每个 iSAX 索引终端节点的 SAX 表示,这些终端节点在另一个 iSAX 索引中没有匹配的终端节点。

noMatch.py 脚本通过以下代码实现了该想法——我们假设我们已经为两个时间序列创建了两个 iSAX 索引,因此我们不需要重复编写创建 iSAX 索引的代码:

    # Visit all entries in Dictionary
    sum = 0
    for k in i1.ht:
        t = i1.ht[k]
        if t.terminalNode:
            saxWord = t.word
            # Look for a match in the other iSAX
            if saxWord in i2.ht.keys():
                i2Node = i2.ht[saxWord]
                # Need that to be a terminal node
                if i2Node.terminalNode == False:
                    sum = sum + 1
                    print(saxWord, end=' ')
    print()

前面的代码遍历第一个 iSAX 索引的所有节点,寻找终端节点。一旦找到终端节点,我们就获取其 SAX 表示,并在另一个 iSAX 索引中寻找具有相同 SAX 表示的终端节点。如果找不到这样的节点,我们就打印出第一个 iSAX 索引终端节点的 SAX 表示,该节点没有匹配项。

我们现在应该对第二个时间序列和第二个 iSAX 索引使用相同的流程。这里展示的代码与前面的代码类似:

    # Look at the other iSAX
    for k in i2.ht:
        t = i2.ht[k]
        if t.terminalNode:
            saxWord = t.word
            # Look for a match in the other iSAX
            if saxWord in i1.ht.keys():
                i1Node = i1.ht[saxWord]
                # Sstill need that to be a terminal node
                if i1Node.terminalNode == False:
                    sum = sum + 1
                    print(saxWord, end=' ')
    print()
    print("Number of iSAX nodes without a match:", sum)

因此,在检查第二个 iSAX 索引后,我们打印出没有匹配的终端节点的总数。

运行 noMatch.py 生成以下类型的输出:

$ ./noMatch.py -s 8 -c 32 -t 1500 -w 128 500k.gz 506k.gz
Max Cardinality: 32 Segments: 8 Sliding Window: 128 Threshold: 1500 Default Promotion: False
011_10_10_10_01_10_10_01 011_01_01_10_10_10_10_10 011_01_10_01_10_10_10_10 011_10_01_10_10_10_10_01 100_01_01_10_01_01_01_10 011_10_10_01_01_10_10_10 100_01_01_01_10_10_01_01 011_01_10_10_10_01_10_10 100_100_011_100_01_10_01_01 100_011_100_100_01_01_01_10 100_011_011_011_10_01_10_10 011_011_100_011_10_10_01_10 100_100_011_100_10_01_01_01 100_011_011_011_10_10_10_01 100_011_100_100_10_01_01_01 011_100_011_100_01_01_10_10 100_011_011_011_10_10_01_10 011_011_011_100_01_10_10_10 100_100_011_011_01_01_10_10 011_011_011_100_10_01_10_10 011_100_011_100_10_10_01_01 100_100_100_10_10_01_01_01 011_011_011_10_10_01_01_10 100_100_100_10_01_10_01_01 100_100_100_01_01_01_10_10
100_10_01_10_01_01_01_01 100_01_01_10_01_10_01_01 100_01_01_01_10_01_10_01 100_01_01_01_10_01_01_10 100_01_10_01_01_01_10_01 011_10_10_10_10_01_01_10 100_011_100_100_01_01_10_01 011_011_100_011_10_01_10_10 011_100_100_100_01_10_01_01 011_100_100_100_10_01_01_01 011_100_011_100_10_01_01_10 011_011_011_100_10_10_01_10 011_011_100_100_10_10_01_01 011_011_011_01_01_10_10_10 011_011_011_01_10_10_10_01 100_100_100_011_01_01_10_01 011_011_011_10_01_10_01_10 011_011_011_100_10_10_10_01 100_100_100_01_01_10_01_10 011_011_011_10_10_01_10_01 100_100_100_10_01_01_10_01
Total number of SAX nodes without a match: 46

由于我们为两个 iSAX 索引使用相同的提升策略,输出显示两个 iSAX 索引具有不同的结构,因此第二个 iSAX 索引上没有匹配的 SAX 表示列表中的差异。此外,我们还可以看到打印的 SAX 表示中的最大基数仅为 8,并且大多数 SAX 单词的基数是 4,这意味着没有太多的分割。

最后,请注意,一般来说,SAX 表示中的段数越少,没有匹配的节点数就越少。此外,阈值值越大,没有匹配的节点数就越少,因为大的阈值值会最小化分割。一般来说,可能的 SAX 表示越少,没有匹配的节点数就越少

这可以在以下输出中看到,其中我们将段的数量减少到4,并将基数增加到64

$ ./noMatch.py -s 4 -c 64 -t 1500 -w 128 500k.gz 506k.gz
Max Cardinality: 64 Segments: 4 Sliding Window: 128 Threshold: 1500 Default Promotion: False
101_01_01_10 010_10_01_10 1001_0110_0110_100 01100_1001_1000_0111 01100_0111_1000_1001 01100_1001_0111_1000 10011_1000_0110_0111 01100_1000_0111_1001 011110_01111_10000_10000 0101_1000_1000_100 1000_0101_1000_100 0111_0111_011_101 01111_01101_1001_1000 10000_01101_10000_10001 10001_01110_10001_01110 01110_01101_1001_1000 01110_10001_01110_10001 01111_10010_01110_01111 01110_10000_01110_1001 01101_10001_10000_10000 10001_10010_0111_0110 01101_10000_10001_10000 01110_01110_10010_1000
1001_0110_1001_011 10011_0110_1000_0111 011110_10000_01111_10000 10010_01111_01111_01110 10001_01110_01110_10001 10001_10010_0110_0111 10001_10000_01101_10000 10010_01111_01110_01111 01110_01111_01111_10010 01111_01110_01111_10010 10001_10001_01101_0111
Total number of SAX nodes without a match: 34

在这种情况下,我们没有那么多没有匹配的终端节点。

下一节简要介绍了通过编写三个基本的 Python 测试来测试isax包的 Python 代码的主题。

编写 Python 测试

在本章的最后部分,我们将学习 Python 测试,并使用pytest包编写三个测试来测试我们的代码。

由于pytest包默认未安装,你应该首先使用你喜欢的安装方法来安装它。pytest包的一部分是pytest命令行工具,用于运行测试。

单元测试

在本节中,我们正在编写单元测试,这些通常是用来确保我们的代码按预期工作的函数。单元测试的结果要么是PASS,要么是FAIL。单元测试越广泛,就越有用。

安装成功后,如果你在一个不包含任何有效测试的目录上执行pytest命令,你会得到有关你的系统和 Python 安装的信息。在 macOS 机器上,输出如下:

$ pytest
========================= test session starts ==========
platform darwin -- Python 3.10.9, pytest-7.2.1, pluggy-1.0.0
rootdir: /Users/mtsouk/code/ch05
collected 0 items
================ no tests ran in 0.00s ====================

就测试函数而言,有一个简单的规则你必须记住。当使用pytest包时,任何以test_为前缀的 Python 函数,其文件名也以test_为前缀,都可以作为测试函数。

可以编写许多测试。通常,我们希望尽可能多地测试,从代码中最关键的部分开始,逐步过渡到不那么关键的部分。为了本章的目的,我们决定通过编写三个测试来测试实现的核心理念。

下一小节将更详细地讨论本章将要实现的三个测试。

我们要测试什么?

首先要定义的是我们要测试什么以及为什么要测试。对于本章,我们将编写以下三个测试:

  • 我们将计算 iSAX 索引中的子序列数量,并确保 iSAX 索引包含所有子序列。

  • 我们将测试 iSAX 构建过程中的节点分割数量——这次,正确的分割数量将被存储在一个全局变量中。

  • 最后,我们将对同一时间序列进行自我连接。这意味着我们应该得到一个欧几里得距离列表,其中所有值都等于 0。请记住,由于我们谈论的是浮点数,欧几里得距离可能非常接近 0,但不是正好等于 0。

测试列表远未完整,但它是一个很好的方式来展示测试的使用和实用性。

时间序列的文件名、iSAX 参数以及分割和子序列的数量将被作为全局变量在包含测试代码的源代码文件中给出,以简化原因。如果您想动态地将参数传递给pytest测试,请访问章节末尾的“有用链接”部分中的“Basic patterns and examples of pytest”链接以获取更多信息。

比较子序列数量

在这个测试中,我们比较 iSAX 索引中的子序列数量与基于滑动窗口大小和时间序列长度的理论子序列数量。

相关代码如下:

def test_count_subsequences():
    variables.nSplits = 0
    variables.segments = segments
    variables.maximumCardinality = cardinality
    variables.slidingWindowSize = slidingWindow
    variables.threshold = threshold
    i, ts = createISAX(TS, slidingWindow, segments)
    sum = 0
    for k in i.ht:
        t = i.ht[k]
        if t.terminalNode:
            sum += t.nTimeSeries()
    assert sum == len(ts) - slidingWindow + 1

首先,我们根据在test_isax.py的前置部分中找到的全局值,在./isax/variables.py中适当地设置全局变量。

createISAX()辅助函数用于创建测试用的 iSAX 索引。您之前在speed.py实用程序中见过这个函数。

重要的是,与测试密切相关的是assert关键字的用法。assert检查随后的语句的真实性。如果语句为True,则assert语句通过。否则,它抛出一个异常,结果测试函数失败。assert关键字被用于我们所有的测试函数中。

接下来,我们将讨论检查节点分割数量的测试。

检查节点分割数量

为了进行这个测试,我们假设我们有一个任何编程语言中的不同程序,该程序被认为是正确的,并且给我们实际的节点分割数量。这个节点分割数量存储在一个全局变量(splits)中,并由测试函数读取。

相关的 Python 代码如下:

def test_count_splits():
    variables.nSplits = 0
    variables.segments = segments
    variables.maximumCardinality = cardinality
    variables.slidingWindowSize = slidingWindow
    variables.threshold = threshold
    variables.defaultPromotion = False
    i, ts = createISAX(TS, slidingWindow, segments)
    assert variables.nSplits == splits

在此代码中,我们首先根据在test_isax.py中找到的全局值,在./isax/variables.py中适当地设置全局变量。不要忘记在您的测试函数中重置variables.nSplits并选择正确的提升策略(variables.defaultPromotion)。

最后一个测试函数计算时间序列与自身的连接,这意味着连接后的所有欧几里得距离都应该等于0

所有欧几里得距离都是 0

在这个测试中,我们将创建时间序列的 iSAX 索引并将其与自身连接。由于我们正在比较时间序列与自身,欧几里得距离列表应该只包含零。因此,通过这个单元测试,我们检查我们代码的逻辑正确性。

相关的 Python 测试函数实现如下:

def test_join_same():
    variables.nSplits = 0
    variables.segments = segments
    variables.maximumCardinality = cardinality
    variables.slidingWindowSize = slidingWindow
    variables.threshold = threshold
    i, _ = createISAX(TS, slidingWindow, segments)
    Join(i, i)
    assert np.allclose(variables.ED, np.zeros(len(variables.ED))) == True

首先,我们根据在 test_isax.py 中找到的全局值,在 ./isax/variables.py 中适当地设置全局变量。然后,我们调用 createISAX() 来构建 iSAX 索引,然后调用 Join() 函数来填充欧几里得距离列表。

NumPy 的 zeros() 函数创建一个所有元素为零的 NumPy 数组。它的参数定义了将要返回的 NumPy 数组的长度。NumPy 的 allclose() 函数在其两个 NumPy 数组参数在容差范围内相等时返回 True。这主要是因为当使用浮点值时,由于四舍五入可能会存在小的差异。

在下一个子节中,我们将运行测试并查看结果。

运行测试

在本节中,我们将运行测试并查看其结果。所有之前的代码都可以在 ./ch05/test_isax.py 文件中找到。

所有测试均成功,结果如下:

$ pytest
================= test session starts =====================
platform darwin -- Python 3.10.9, pytest-7.2.1, pluggy-1.0.0
rootdir: /Users/mtsouk/TSi/code/ch05
collected 3 items
test_isax.py ...                                    [100%]
============= 3 passed in 2784.53s (0:46:24) ==============

如果有一个或多个测试失败,输出将如下所示(在这种情况下,只有一个测试失败):

$ pytest
====================== test session starts ================
platform darwin -- Python 3.10.9, pytest-7.2.1, pluggy-1.0.0
rootdir: /Users/mtsouk/TSi/code/ch05
collected 3 items
test_isax.py .F.                                  [100%]
=====================FAILURES ============================
____________________ test_count_splits ___________________
    variables.nSplits = 0
    variables.segments = segments
    variables.maximumCardinality = cardinality
    variables.slidingWindowSize = slidingWindow
    variables.threshold = threshold
    _, _ = createISAX(TS, slidingWindow, segments)
>   assert variables.nSplits == splits
E   assert 5669 == 5983
E    +  where 5669 = variables.nSplits
test_isax.py:58: AssertionError
================ short test summary info ==================
FAILED test_isax.py::test_count_splits - assert 5669 == 5983
=========== 1 failed, 2 passed in 2819.21s (0:46:59) ======

好处在于输出显示了为什么一个或多个测试失败的原因,并包含了相关的代码。在这种情况下,是 assert variables.nSplits == splits 语句失败了。

这是本章的最后一节,但也是最重要的一节,因为测试可以在开发过程中为你节省大量时间。我们测试的主要目的是测试代码的逻辑和正确性,这非常重要。

摘要

在本章中,我们看到了测试 iSAX 索引速度和基于节点 SAX 表示合并两个 iSAX 索引的代码。然后,我们简要讨论了测试 Python 代码的主题,并为 isax 包实现了三个测试。

我们还讨论了基于节点类型的 iSAX 索引的合并,以及我们进行的测试确保了代码的核心逻辑是正确的。

在下一章中,我们将学习如何可视化 iSAX 索引,以更好地理解其结构和性能。

在你开始阅读并学习第六章之前,尝试使用本章中开发的命令行工具,并尝试创建你自己的。

有用链接

练习

尝试完成以下练习:

  • 使用accessSplit.py来了解滑动窗口大小如何影响从第四章中构建2M.gz时序数据的速度。为以下滑动窗口大小进行实验:16256102440961638432786

  • 你能否使用accessSplit.py和我们在本章开头遇到的500.gz时序数据解决溢出情况?

  • 尝试减少在检查 iSAX 索引的搜索速度部分中展示的speed.py示例中的阈值值,并看看会发生什么。

  • 创建两个包含 250,000 个元素的时序数据,并使用speed.py来理解当段落数量在 20 到 40 范围内时它们的行为。不要忘记使用适当的大小滑动窗口。

  • 使用speed.py进行实验,但这次,改变阈值值而不是段落数量。在 iSAX 索引的搜索速度中,阈值值是否比段落数量更重要?

  • 修改speed.py以显示子序列查询中的缺失次数。

  • 修改join.py以打印执行连接操作所需的时间。

  • 修改saveLoadList.py以在保存包含欧几里得距离的列表内容时将阈值值包含在文件名中。

  • 在自己的机器上运行pytest命令,并查看你得到的输出。

第六章:可视化 iSAX 索引

在上一章中,我们学习了比较和合并 iSAX 索引。然而,如果没有将其作为图像来查看,仍然很难想象 iSAX 索引的结构和高度。

尽管有些人喜欢文本,有些人喜欢日志文件,还有些人喜欢数字,但几乎所有人都喜欢美观且信息丰富的可视化。此外,所有人都理解拥有数据的高级视图的重要性。这包括 iSAX 索引和树结构,主要是因为没有其他实际的方法来完成同样的任务,尤其是在处理大型时间序列时。

第一章中,我们学习了如何可视化时间序列。本章全部内容都是关于如何可视化 iSAX 索引,以便更好地理解它们的大小、形状和结构。

可视化大型结构如 iSAX 索引并不是一个简单的过程,而是一个试错的过程。由于没有单一的视觉方式可以完成这项工作,我们将尝试不同的图表类型,看看它们能告诉我们关于 iSAX 索引的什么信息。因此,你可以在本章中看到很多可视化,我期望你在阅读本书的过程中将创建更多的可视化。

在本章中,我们将涵盖以下主要主题:

  • 将 iSAX 索引以 JSON 格式存储

  • 可视化 iSAX 索引

  • 尝试一些激进的方法

  • 更多 iSAX 索引可视化

  • 使用瀑布图

  • 将 iSAX 索引可视化为可折叠树

技术要求

本书 GitHub 仓库位于github.com/PacktPublishing/Time-Series-Indexing。每章的代码都存放在各自的目录中。因此,第六章的代码可以在ch06文件夹中找到。然而,在本章中,ch06文件夹下存在许多包含我们将要创建的不同可视化的代码的目录——这是一种很好的代码组织方式。

将 iSAX 索引以 JSON 格式存储

对于本章的可视化,我们将使用低级的D3.js JavaScript 库。

D3.js 是创建可视化的唯一方式吗?

强大的D3.js JavaScript 库并非万能,因此,它不是创建可视化的唯一方式。存在许多擅长绘图的数据包,如 Python 包,以及编程语言如 R 或 Julia。然而,JavaScript 可以用于在网页环境中展示你的图表,而其他选项通常不是这种情况。

为了让 JavaScript D3.js 代码工作,我们需要以 JSON 格式表示一个 iSAX 索引,以便它能够被 JavaScript 代码理解——我们主要需要以计算机和编程语言能够理解的方式表示 iSAX 节点之间的结构和连接。因此,我们应该采取的第一步是将 Python 代码中的 iSAX 索引表示及其结构转换为由 JSON 记录构成的不同结构。

尽管这种 JSON 格式并非通用,可能在某些情况下会失败,但在这个章节中我们将使用它,因为所有展示的 D3.js 代码都能很好地与它兼容——所有展示的例子都经过测试并且完全可用。

首先,我们需要访问 d3js.org/ 并点击页面顶部的 Examples,这将带我们到 observablehq.com/@d3/gallery。后者页面将带我们到一个页面,上面有专业、功能性强且美观的图表,看起来适合我们在本章中想要绘制的类型和数量的数据。

从可用的可视化长列表中,我们需要选择那些我们喜欢并且与我们的数据和其结构相匹配的——我们的第一次尝试可能并不完美。不要忘记 iSAX 索引可以有大量的节点。因此,我们应该理性思考,选择那些与大量数据看起来很好的东西。

从那个列表中,我们选择 Tree, Tidy。在可视化背后,有嵌入到 HTML 中的 JavaScript 代码,它读取 JSON 数据,解析它,并创建可视化。

现在我们已经找到了我们偏好的可视化(observablehq.com/@d3/tree),我们可以开始查看 JavaScript 代码,以更好地了解从 JavaScript 代码期望的数据格式。然而,更重要的是 JSON 记录的格式。

JavaScript 代码在哪里?

JavaScript 是一种功能强大但低级的编程语言。好事是展示的视觉化不需要任何 JavaScript 知识就能工作。你只需要将你的数据放在正确的格式和位置,这就足够了!

我们将要支持的 JSON 记录应该具有以下格式——这个格式是通过查看 JavaScript 代码使用的 JSON 文件找到的:

 "name": "flare",
 "children": 
  {
   "name": "analytics",
   "children": [
    {
.
.
.

我们想要支持的结构的常见想法是,我们有一个根节点——树的根节点——它有多个子节点,这些子节点有自己的子节点,以此类推。每个节点的名称由一个 name 字段指定给树的根节点——在这种情况下,该名称将是 flare

在一些展示的可视化中需要额外的字段。在本章的后面部分,我们将了解到终端节点有一个额外的字段用于存储它们所包含的子序列的数量 – 这不是每个可视化都需要使用的。然而,思想是相同的。

下一个图将展示一个带有自定义数据的整洁树可视化示例。在接下来的章节中,我们将展示更复杂的可视化。这是一个简单的树结构,有一个根节点和 13 个子节点:

![图 6.1 – 一棵树的可视化

图 6.1 – 一棵树的可视化

考虑到所有这些,我们现在可以开始编写我们的 Python 代码。将 iSAX 索引表示为 JSON 文件的 Python 脚本名为exportJSON.pyexportJSON.py背后的逻辑是,在创建 iSAX 索引后,我们遍历它以生成所需格式的 JSON 输出。

但首先,这是 Python 脚本中将要使用的 JSON 记录的定义:

JSON_message = {
  "name": None,
  "size": None,
  "children": []
}

这是基本的 JSON 记录格式 – 根据我们的定制需求,我们可以根据需要向此记录添加更多字段,而不会破坏任何 JavaScript 代码,因为 JavaScript 只会读取它需要的字段。尽管格式在代码中定义,但 Python 代码并不使用它,主要是因为 Python 不需要为存储在字典中的 JSON 数据预定义结构。然而,将其定义为参考点是很好的。

exportJSON.py中读取现有 iSAX 索引并打印 JSON 输出的 Python 代码可以在main()函数的末尾找到:

    # The JSON data to return
    data = {}
    data['name'] = "0"
    data['children'] = []
    # Create JSON output
    for subTree in ISAX.children:
        if ISAX.ht[subTree] == None:
            continue
        subTreeData = createJSON(ISAX.ht[subTree])
        data['children'].append(subTreeData)
    print(json.dumps(data))

存储 JSON 记录的 Python 字典名为data。默认情况下,根节点的名称为0,这是一个字符串 – 这与flare类似。你可以将其更改为任何你喜欢的名称。

之前的代码仅访问和处理 iSAX 索引根节点的子节点。其余部分由createJSON()函数处理。createJSON()函数是实际通过为正在检查的当前子树添加数据来生成 JSON 输出的函数。data变量包含所有 JSON 数据。

最后一条语句使用json.dumps()在屏幕上打印所有 JSON 记录。

createJSON()函数的实现如下:

def createJSON(subtree):
    if subtree == None:
        return None
    t = {}
    t['name'] = subtree.word
    t['children'] = []
    # First, check if this is a Terminal node
    if subtree.terminalNode == True:
        t['size'] = subtree.nTimeSeries()
        return t
    # This is still a Terminal node
    # Just in case!
    elif subtree.left == None and subtree.right == None:
        print("This should not happen!")
        return t
    else:
        ch1 = createJSON(subtree.left)
        ch2 = createJSON(subtree.right)
        t['children'].append(ch1)
        t['children'].append(ch2)
    return t

createJSON()函数通过递归调用以访问每个子树的每个节点。这主要是因为我们需要处理所有内部节点和所有终端节点。

关于 JSON 输出

这个特定的 Python 脚本基于特定的 JSON 记录格式生成 JSON 输出。一旦你理解了这个概念,修改脚本、添加更多字段到 JSON 记录中,或者创建完全不同的东西都会变得容易。所有这些都取决于可视化脚本期望与之一起工作的格式。

对于终端节点,我们保留它们所包含的子序列数量——这发生在 size 字段中,每个终端节点的处理就到这里结束。然而,对于内部节点,我们递归调用 createJSON() 来处理每个内部节点的左右子节点或子树。

考虑到所有这些,让我们看看 exportJSON.py 的实际应用。首先,我们将使用一个小 iSAX 索引,使用包含 100 个元素的时间序列 ts.gz——由于其体积小,ts.gz 将用于实验目的。ts.gz 是通过运行 ../ch01/synthetic_data.py 100 -10 10 创建的,输出保存到名为 ts 的文件中,然后使用 gzip(1) 压缩 ts

运行 exportJSON.py 并使用 ts.gz 会产生以下类型的输出:

$ ./exportJSON.py -s 3 -c 8 ts.gz
{"name": "0", "children": [{"name": "0_0_1", "children": [], "size": 18}, {"name": "0_1_1", "children": [], "size": 12}, {"name": "1_0_1", "children": [], "size": 12}, {"name": "1_0_0", "children": [], "size": 17}, {"name": "0_1_0", "children": [], "size": 12}, {"name": "1_1_0", "children": [], "size": 13}, {"name": "1_1_1", "children": [], "size": 1}]}

使用 jq(1) 工具处理这些数据,该工具会美化 JSON 记录,生成下一个更好的输出——在这种情况下,根的所有子节点都是终端节点:

$ ./exportJSON.py -s 3 -c 8 ts.gz | jq
{
  "name": "0",
  "children": [
    {
      "name": "0_0_1",
      "children": [],
      "size": 18
    },
    {
      "name": "0_1_1",
      "children": [],
      "size": 12
    },
    {
      "name": "1_0_1",
      "children": [],
      "size": 12
    },
    {
      "name": "1_0_0",
      "children": [],
      "size": 17
    },
    {
      "name": "0_1_0",
      "children": [],
      "size": 12
    },
    {
      "name": "1_1_0",
      "children": [],
      "size": 13
    },
    {
      "name": "1_1_1",
      "children": [],
      "size": 1
    }
  ]
}

请注意,输出取决于 iSAX 索引的参数。不同的 iSAX 参数会产生不同的输出和不同的树结构。

现在,让我们尝试使用名为 100k.gz 的更大时间序列来运行 exportJSON.py,它包含 100,000 个元素,创建方式如下:

$ ../ch01/synthetic_data.py 100000 -100 100 > 100k
$ gzip 100k

我们使用 100k.gz 运行了 exportJSON.py

$ ./exportJSON.py -s 4 -c 16 -t 2500 100k.gz > 100k.json

输出文件被保存为 100k.json。使用 2500 的阈值是为了得到一个更紧凑的树。然而,最终,重要的是你的需求和实际的 iSAX 索引参数。

到目前为止,我们已经使用 exportJSON.py 处理了 ts.gz100k.gz,最终我们得到了两个名为 ts.json100k.json 的 JSON 文件。尽管我们可能需要 ts.json 进行测试,但所有即将到来的可视化都将使用 100k.json

下一个小节将介绍如何在本地机器上下载 JavaScript 项目并从那里执行它。

本地下载 JavaScript 代码

D3.js 及其强大的功能。因此,在本小节中,我们将学习如何做到这一点。

在每个来自 observablehq.com/@d3/gallery 的可视化中,当我们点击网页右上角出现的三个点时,会出现一个菜单。从该菜单中,点击 导出 链接,它将显示一个子菜单。从该子菜单中,我们应该点击 下载代码 选项。这将下载当前项目的内容到我们的本地机器上,作为一个压缩文件,我们应该解压缩并使用它。

为了本节的目的,我们将下载位于observablehq.com/@d3/tree的 JavaScript 项目,这将下载一个名为tree.tgz的文件。在我们解压该文件后,我们将得到一个名为tree的目录。虽然不需要完全理解目录的内容,但这会有所帮助。然而,你需要知道包含数据的 JSON 文件的路径。

当检查tree目录的内容时,tree(1)实用程序的输出,以树形格式列出目录内容,如下所示:

$ tree
.
├── 5432439324f2c616@268.js
├── 7a9e12f9fb3d8e06@498.js
├── LICENSE.txt
├── README.md
├── files
│   └── 85b8f86120ba5c8012f55b82fb5af4fcc9ff5e3cf250d110e111b3ab 98c32a3fa8f5c19f956e096fbf550c47d6895783a4edf72a9c474bef5782f 879573750ba.json
├── index.xhtml
├── index.js
├── inspector.css
├── package.json
└── runtime.js
2 directories, 10 files

包含记录的 JSON 文件位于files目录中——这是我们需要用我们自己的数据文件覆盖的文件。为了加载项目,我们需要访问index.xhtml文件,该文件将加载所有必要的依赖项。

我们只需要将我们自己的 JSON 数据放入files文件夹中——这是需要进行的唯一更改。

下一个子节是关于在你的机器上运行下载的 JavaScript 项目,这需要运行你自己的本地 HTTP 服务器。

本地运行代码

在本地运行代码的过程包括以下步骤:

  1. 进入包含代码的目录。

  2. 修改包含数据的 JSON 文件——每个示例都有一个名为files的目录中的 JSON 数据文件。

  3. 运行一个本地 HTTP 服务器。

包含数据的 JSON 文件有一个长而奇怪的文件名,它嵌入到 JavaScript 代码中。我对 JavaScript 并不十分精通,所以我将使用默认的文件名,它位于files目录中。出于安全原因,不允许网络服务器访问其根目录之外的文件。因此,我们需要将我们在每个项目的files目录中用exportJSON.py创建的 JSON 文件复制到,并覆盖现有的 JSON 文件,即使我们在所有示例中都使用相同的文件。

下一个子节将展示如何运行你自己的本地 HTTP 服务器并查看 JavaScript 代码的实际运行情况。

运行本地 HTTP 服务器

运行本地 HTTP 服务器最简单的方法是在感兴趣的目录中执行python3 -m http.server。如果一切顺利,HTTP 服务器将监听端口号8000,并且可以通过 http://localhost:8000/访问。这比看起来要简单得多。

这个过程将在本章中一直使用。所有必需的文档和文件都在本书的 GitHub 仓库中,所以你不需要下载更多。如果你想进行实验,只需更改 JSON 数据文件的內容即可。

下一个子节将展示如何测试这个过程。

测试过程

我已经将上一个项目的目录从tree重命名为TreeTidy——使用描述性的目录名称是一种良好的实践。

因此,首先,我们需要进入本书 GitHub 仓库中的 ch06 目录,然后进入 TreeTidy 目录。之后,我们需要运行 python3 -m http.server。现在,我们已经在本地机器上运行了一个监听 8000 TCP 端口的 HTTP 服务器。因此,我们需要将我们的网络浏览器指向 http://localhost:8000/ 并查看生成的可视化。

Python 网络服务器生成的输出将如下所示:

$ python3 -m http.server
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
::ffff:127.0.0.1 - - [29/Mar/2023 20:42:08] "GET / HTTP/1.1" 200 -
::ffff:127.0.0.1 - - [29/Mar/2023 20:42:08] "GET /inspector.css HTTP/1.1" 200 -
::ffff:127.0.0.1 - - [29/Mar/2023 20:42:08] "GET /runtime.js HTTP/1.1" 200 -
::ffff:127.0.0.1 - - [29/Mar/2023 20:42:08] "GET /index.js HTTP/1.1" 200 -
::ffff:127.0.0.1 - - [29/Mar/2023 20:42:08] "GET /5432439324f2c616@268.js HTTP/1.1" 200 -
::ffff:127.0.0.1 - - [29/Mar/2023 20:42:08] "GET /7a9e12f9fb3d8e06@498.js HTTP/1.1" 200 -
::ffff:127.0.0.1 - - [29/Mar/2023 20:42:08] code 404, message File not found
::ffff:127.0.0.1 - - [29/Mar/2023 20:42:08] "GET /favicon.ico HTTP/1.1" 404 -

如果在生成的输出中看到任何错误信息,你应该尝试解决它们。

然而,除非你处于错误的目录或 TCP 端口 8000 上运行了另一个 TCP 服务,否则应该没有问题。

到目前为止,我们已经学习了如何以 JSON 格式表示 iSAX 索引以及如何从 observablehq.com/ 下载 JavaScript 项目。

在接下来的章节中,我们将开始我们的 iSAX 可视化之旅。

可视化 iSAX 索引

在本节中,我们将开始可视化 iSAX 索引。

就像在计算的大部分领域一样,你的可视化会随着时间的推移而改进。最初的视觉通常不如后来的美观和/或信息丰富。因此,在我们最终得到一个漂亮的 iSAX 可视化之前,我们将进行实验并尝试各种方法。

由于可视化包括个人品味,你选择的可视化可能与本章中使用的不同。然而,我们需要开始行动并在过程中不断改进!

让我们从下一小节的可视化开始。

一个个人故事

在撰写本书时,我正在进行与 iSAX 相关的研究。在我的一个实验中,我运行了一个创建两个 iSAX 索引并将它们以比第五章第五章中展示的更复杂的方式连接的实用程序。该实用程序处理了包含 500,000 个元素的 2 个时间序列,并且运行了超过 18 天!此外,该实用程序处理包含 1,500,000 个元素的 2 个时间序列需要大约 2 个小时,这意味着该实用程序运行良好。我决定使用一个单独的 Python 实用程序来可视化每个 iSAX 索引。长话短说,在包含 500,000 个元素的时间序列的情况下,我错误地使用了 32 个段和 4 的基数值,而不是 4 个段和 32 的基数值!这意味着每个 iSAX 索引的根节点有 2 的 32 个子节点!因此,连接它们需要进行如此多的计算,这解释了为什么实用程序在 18 天后仍在运行。如果我在早期就可视化每个 iSAX 索引,我会更早地发现这个问题。

将 iSAX 可视化为树状结构

在这次尝试中,我们将使用各种可视化方法将 iSAX 索引可视化为一棵树。由于 iSAX 具有树状结构,使用这种类型的可视化是非常有意义的。

对于这个子节,我们将使用之前看到的TreeTidy目录中的可视化。首要任务是更新TreeTidy目录中files目录下存储的 JSON 文件。如果我们处于TreeTidy目录中,我们可以运行cp ../100k.json files/85b8f8…9573750ba.json。为了简洁起见,省略了完整文件名 - 只需确保在shell自动补全的帮助下使用正确的文件名。

图 6.2中,你可以看到使用D3.js代码生成的 iSAX 索引的可视化,该代码也生成了图 6.1的样本输出。

图 6.2 – 将 iSAX 索引作为树可视化

图 6.2 – 将 iSAX 索引作为树可视化

图 6.2.2*告诉我们什么?它告诉我们我们正在处理一个相对较小的、相当平衡的 iSAX 索引(终端节点的深度差异不大),这是一个好事。默认情况下,终端节点以灰色圆圈进行可视化,而内部节点是黑色的。

那么,我们接下来能做什么?接下来,我们可以尝试使用不同的 iSAX 参数可视化100k.gz。在这种情况下,我们将使用以下参数:

$ ./exportJSON.py -s 4 -c 16 -t 5000 100k.gz

如前所述,生成的输出将被存储在files目录中现有的 JSON 文件中。更新的输出可以在图 6.3中看到:

图 6.3 – 将阈值值增加到 5,000

图 6.3 – 将阈值值增加到 5,000

如预期,iSAX 索引比之前小,主要是因为我们减少了节点分裂。然而,它看起来不如图 6.2中展示的 iSAX 平衡。2*。

还有其他需要做的事情吗?我们可以进行更多实验,并将段值从4更改为3,同时保持阈值值在5000

因此,这次,我们将使用以下命令生成 JSON 输出:

$ ./exportJSON.py -s 3 -c 16 -t 5000 100k.gz

之后,我们需要将输出存储在files目录中位于的 JSON 文件中。新的可视化可以在图 6.4中看到:

图 6.4 – 以三个段可视化 iSAX

图 6.4 – 以三个段可视化 iSAX

如预期,根节点有较少的子节点。然而,图 6.4中展示的 iSAX 索引的总体形状与图 6.3中展示的相似。就我个人而言,我认为图 6.2展示的 iSAX 索引比其他两个版本更好、更平衡。平衡树,因此平衡的 iSAX 索引,通常搜索更快,这是一个期望的特性。

在本节中,我们看到了如何将 iSAX 索引作为树结构进行可视化,这是很有意义的,因为 iSAX 索引本身就是树。

在下一节中,我们将尝试为 iSAX 索引结构进行不同类型的可视化。毕竟,可视化和实验是好朋友。

尝试一些激进的方法

在本节中,我们将尝试一种不同的可视化方法来可视化 iSAX 索引,以防它揭示任何额外的信息。因此,我们将使用 ch06 目录中的 TreeRadialTidy 目录,并用 files 目录中找到的 100k.json 替换 JSON 文件——正确的文件已经在那里。然而,如果您想使用自己的数据,您应该更新该文件。

接下来,我们应该运行 Python HTTP 服务器并将我们的网页浏览器指向 http://localhost:8000/。生成的输出在 图 6.5 中展示:

图 6.5 – 使用径向树结构

图 6.5 – 使用径向树结构

我们可以从 图 6.5 中获得哪些信息?这比常规树状结构好吗?我不知道它是否更好,但它确实以全新的方式展示了相同的信息!

径向树的一个优点是,当处理具有较大深度的 iSAX 索引时,它表现更好,因为它们可以更好地适应屏幕。我个人认为,平面树状结构比径向树更适合 iSAX 索引。

下一节将继续通过尝试更多可视化来继续 iSAX 索引的可视化过程。

更多 iSAX 索引可视化

我们还没有完成!存在通过向输出添加更多信息以及压缩其各个部分的能力来改进先前可视化的方法——总是在图表或图表上放置过多信息的危险,但在这里我们不会犯这个错误。

首先,我们将前往 ZoomableTreemap 目录,尝试一个名为 可缩放树状图 的可缩放结构,当处理大型 iSAX 索引时,这种结构更为合适。

可缩放树状图使用一个额外的属性称为 value。在这种情况下,我有两个选择:要么更改 Python 脚本的输出,要么更改 JavaScript 代码。我决定后者。因此,我将 JavaScript 代码中的 value 属性更改为 size,这是 Python 脚本生成的。然而,在我们的情况下,这导致了 JavaScript 代码中与展示值总和相关的错误,这意味着这不是正确的决定。

因此,我们将更改 JSON 文件并将 size 字段名称替换为 value

如前所述,我们应该用 100k.json 覆盖 files 目录中的 JSON 文件并运行 Python HTTP 服务器。生成的输出可以在 图 6.6 中看到:

图 6.6 – 可缩放树状图可视化

图 6.6 – 可缩放树状图可视化

结果表明,可缩放树状图可能难以阅读和理解——甚至难以意识到我们正在讨论一个树状结构。因此,它可能不是 iSAX 可视化的好选择。然而,可缩放 功能在几乎所有情况下都非常方便。

如果我们使用了有错误的版本,那么输出中的数值将被替换为 NaN 值——这很可能与 JavaScript 代码有关。

让我们现在继续讨论一些不同的事情。前往 ZoomableSunburst 目录,并将 files 目录中的文件替换为 100k.json 文件。再次,我们需要进行代码更改。具体来说,我们需要将 86ddbc29bd33f9d6@357.js 中使用的 value 字段替换为我们 JSON 记录中的 size 字段。GitHub 上的代码存储了所有必要的更改。生成的输出可以在 图 6.7 中看到:

图 6.7 – 可缩放的太阳花可视化

图 6.7 – 可缩放的太阳花可视化

这种可视化的主要优势是它不会从一开始就显示整个 iSAX,但当我们通过点击太阳花的不同部分来放大可视化时,它可以做到这一点。因此,它隐藏了一些信息,这些信息可以根据需要显示。

如果我们放大太阳花的任何部分,我们将更接近地查看 iSAX 索引的该特定部分。

图 6.8 展示了太阳花的一部分:

图 6.8 – 缩放太阳花中的 0_0_1_1 子树

图 6.8 – 缩放太阳花中的 0_0_1_1 子树

再次,缩放功能很实用,我们希望在可视化中拥有它。下一节将讨论一种有趣的图表类型,称为冰柱图,看起来它适合用于可视化 iSAX 索引。

使用冰柱图

在本节中,我们将讨论一种不同类型的图表,称为 冰柱图。冰柱图是一种用于展示层次聚类的方法,能够使用从根节点到叶子的矩形扇区来可视化层次数据。在我们的案例中,我们将使用 可缩放冰柱图

首先,请前往 ZoomableIcicle 目录,并将 files 中的 JSON 文件替换为 100k.json。这次,我们不是更改 JavaScript 代码,而是将 JSON 文件的字段名从 size 更改为 value。一般来说,更改输入数据比更改代码更好

图 6.9 展示了生成的冰柱可视化的一部分。左侧的矩形代表根节点,其中包含 99,985 个子序列——这是在 iSAX 索引中存储的子序列总数。

图 6.9 – 使用冰柱可视化 iSAX

图 6.9 – 使用冰柱可视化 iSAX

除了节点的 SAX 表示之外,每个矩形还显示了其下存储的子序列数量。因此,1_0_0_1 子树有 10,936 个子序列——这是另一个实用的功能。

进一步来说,如果我们放大 1_0_0_0 子树,我们将得到如图 图 6.10 所示的输出:

图 6.10 – 仔细查看 1_0_0_0 子树

图 6.10 – 仔细查看 1_0_0_0 子树

同样,如果我们放大1_1_1_0子树,我们将得到图 6.11中展示的可视化:

图 6.11 – 仔细查看 1_1_1_0 子树

图 6.11 – 仔细查看 1_1_1_0 子树

让我们更详细地讨论一下图 6.11。它告诉我们什么?它告诉我们根节点的1_1_1_0子节点存储了4,342个子序列。其中4,246个子序列位于10_1_1_0子树下,其余的子序列位于11_1_1_0子树下。

如果我们放大10_1_1_0节点,我们将得到图 6.12,它显示10_10_10_0子树有4,031个节点。

图 6.12 – 仔细查看 10_1_1_0 子树

图 6.12 – 仔细查看 10_1_1_0 子树

由于我们使用了一个较小的阈值值,我们知道10_10_10_0节点是一个可以进一步展开的内节点。

这样,我们可以探索 iSAX 索引并找到我们想要的信息。

冰柱图看起来适合可视化 iSAX 索引。然而,如果我们进行更多实验,可能会发现更好的可视化类型。

下一节将展示一个 iSAX 索引的可折叠树可视化。

将 iSAX 可视化为可折叠树

尽管可缩放的冰柱图看起来非常有前景,但有些人可能想要一种看起来像树但仍然具有可缩放冰柱图的一些多功能性的可视化。对于这些人,我们将尝试使用可折叠树

首先,我们进入CollapsibleTree目录,然后运行 Python 网络服务器。然后,我们访问http://localhost:8000/图 6.13显示了可折叠树可视化的输出:

图 6.13 – 将 iSAX 可视化为可折叠树

图 6.13 – 将 iSAX 可视化为可折叠树

可折叠树的主要优势在于我们可以随意展开或折叠节点,这意味着我们可以轻松地集中精力在最感兴趣的节点上,而不是迷失在 iSAX 索引的细节中。

然而,可折叠树不显示索引每个子树下的子序列数量。

在本节的最后部分,我们看到了可折叠树的运作方式,并了解了它的多功能性以及局限性。

摘要

可视化是理解数据的一种极好方式。同样,可视化也是理解 iSAX 索引结构,尤其是大型索引结构的一种极好方式。在本章中,我们看到了使用 D3.js JavaScript 库可视化 iSAX 索引的各种方法,并更深入地了解了子序列的分布和 iSAX 索引的高度。

然而,尝试使用 D3.js JavaScript 库、R 或其他适当的 Python 包进行自己的可视化将是非常棒的,这些包也可以创建令人印象深刻的可视化。

最后,不要低估良好可视化的力量,因为它可以以易于发现的方式揭示大量信息。只需记住,可视化是一门难以掌握的艺术。

下一章将介绍如何使用 iSAX 索引来进行矩阵 Profile 和 MPdist 距离的近似计算

有用的链接

  • JavaScripten.wikipedia.org/wiki/JavaScript

  • Mozilla 开发者网络:developer.mozilla.org/en/JavaScript

  • D3.js JavaScript 库的官方页面:d3js.org/

  • 通过阅读由 J. B. Kruskal 和 J. M. Landwehr 撰写的 Icicle Plots: Better Displays for Hierarchical Clustering 论文,您可以了解更多关于冰柱图的信息。

  • R 项目:www.r-project.org/

  • Seaborn Python 包:seaborn.pydata.org/

  • Julia 编程语言:julialang.org/

  • plotly Python 库:plotly.com/python/

  • 一本关于数据可视化艺术的非常好的书是 Edward R. Tufte 所著的 The Visual Display of Quantitative Information

  • D3 画廊observablehq.com/@d3/gallery

  • 树,整洁observablehq.com/@d3/tree

  • 可缩放 Treemapobservablehq.com/@d3/zoomable-treemap

  • 可缩放 Sunburstobservablehq.com/@d3/zoomable-sunburst

  • 可缩放 Icicleobservablehq.com/@d3/zoomable-icicle

  • 树,径向 整洁observablehq.com/@d3/radial-tree

  • 可折叠 observablehq.com/@d3/collapsible-tree

练习

尝试以下练习:

  • 创建一个包含 50,000 个元素的时序,并使用 6、8 和 10 个段绘制其 iSAX 索引。在所有情况下,使用阈值值 500

  • 创建一个包含 150,000 个元素的时序,并使用 4、6 和 8 个段绘制其 iSAX 索引。

  • 创建一个包含 250,000 个元素的时序,并绘制其 iSAX 索引,分为 4、6 和 10 个段。在所有情况下,使用阈值值 5000

  • 创建一个名为 exportJSON.py 的版本,用名为 value 的字段替换 size 字段。

  • 如果你熟悉 JavaScript,请更改可缩放的冰柱图的色彩。

  • 如果您熟悉 JavaScript,请将可缩放的冰柱图从上到下调整,而不是从左到右

  • 如果您熟悉 JavaScript,请将可折叠的树形图从上到下调整,而不是从左到右。这比之前更好吗?

  • 尝试使用可缩放的圆形排列可视化效果,您可以在observablehq.com/@d3/zoomable-circle-packing找到它。您对它有什么看法?

第七章:使用 iSAX 近似 MPdist

到目前为止,本书中我们看到了 iSAX 在搜索子序列和基于 SAX 表示的 iSAX 索引连接中的应用,但没有看到其他应用。

在本章中,我们将使用 iSAX 索引来近似计算时间序列之间的矩阵轮廓向量和MPdist距离——我们仍然会使用 iSAX 进行搜索和连接,但最终结果将更加复杂。本章的主导思想是,从 SAX 表示的角度来看,iSAX 索引组的终端节点具有相似子序列——这是我们试图在我们的近似计算中利用的。

在本章中,我们将涵盖以下主要主题:

  • 理解矩阵轮廓

  • 使用 iSAX 计算矩阵轮廓

  • 理解 MPdist

  • 使用 iSAX 计算 MPdist

  • 在 Python 中实现 MPdist 计算

  • 使用 Python 代码

技术要求

本书在 GitHub 上的仓库地址为github.com/PacktPublishing/Time-Series-Indexing。每章的代码都存放在各自的目录中。因此,第七章的代码可以在 GitHub 仓库的ch07文件夹中找到。

理解矩阵轮廓

时间序列无处不在,我们可能需要在大型时间序列上执行许多任务,包括相似性搜索、异常检测、分类和聚类。直接处理大型时间序列非常耗时,并且会减慢处理速度。上述大多数任务都是基于使用给定滑动窗口大小计算子序列最近邻的计算。这就是矩阵轮廓发挥作用的地方,因为它可以帮助你在计算了这些任务之后执行它们。

我们已经在第一章中看到了矩阵轮廓,但在这个部分,我们将更详细地讨论它,以便更好地理解它为什么计算起来如此缓慢。

存在许多研究论文介绍了矩阵轮廓及其扩展,包括以下内容:

  • 由 Chin-Chia Michael Yeh、Yan Zhu、Liudmila Ulanova、Nurjahan Begum、Yifei Ding、Hoang Anh Dau、Diego Furtado Silva、Abdullah Mueen 和 Eamonn J. Keogh 撰写的Matrix Profile I: All Pairs Similarity Joins for Time Series: A Unifying View That Includes Motifs, Discords and Shapeletsieeexplore.ieee.org/document/7837992

  • 矩阵配置 II:利用新型算法和 GPU 突破一亿大关,用于时间序列模式和连接,由 Yan Zhu、Zachary Zimmerman、Nader Shakibay Senobari、Chin-Chia Michael Yeh、Gareth Funning、Abdullah Mueen、Philip Brisk 和 Eamonn Keogh 撰写(ieeexplore.ieee.org/abstract/document/7837898)

  • 矩阵配置疯狂:数据序列中的可变长度模式和冲突发现,由 Michele Linardi、Yan Zhu、Themis Palpanas 和 Eamonn J. Keogh 撰写(doi.org/10.1007/s10618-020-00685-w)

关于归一化

就像 SAX 表示一样,本章中将要计算的所有的欧几里得距离都使用归一化子序列。

下一个子节将展示矩阵配置计算返回的内容。

矩阵配置计算什么?

在本节中,我们将解释矩阵配置计算什么。想象一下有一个时间序列和一个小于时间序列长度的滑动窗口大小。矩阵配置计算两个向量

第一个向量包含每个子序列的欧几里得距离最近邻。索引0处的值是开始于索引0的子序列的最近邻的欧几里得距离,依此类推。

在第二个向量中,向量中每个位置的值是最近邻子序列的索引,对应于前一个向量中存储的欧几里得距离。所以,如果索引0的值是123,这意味着原始时间序列中开始于索引0的子序列的最近邻是原始时间序列中开始于索引123的子序列。第一个向量将包含那个欧几里得距离值。

非常重要的是理解,当使用自连接来计算时间序列的矩阵配置时——也就是说,通过查找相同时间序列子序列的最近邻——我们需要排除接近我们正在检查的子序列。这是必需的,因为共享相同顺序中许多元素的子序列默认情况下往往具有较小的欧几里得距离。然而,当处理来自另一个时间序列的子序列时,我们不需要从计算中排除任何子序列。

矩阵轮廓向量的计算的一个朴素实现是获取第一个子序列,将其与所有其他子序列(排除接近的子序列)进行比较,找到其最近邻,并将欧几里得距离和最近邻的索引放在两个向量的索引0处。然后,对其他所有子序列执行相同的操作。虽然这对于较小的时序数据有效,但它的算法复杂度为 O(n²),这意味着对于一个有 10,000 个子序列的时间序列,我们需要执行 10,000 次 10,000 次计算(100,000,000 次)。我们将实现该算法以了解它在现实生活中的速度有多慢。

原始矩阵轮廓论文的作者创造了一种巧妙的技术,该技术涉及快速傅里叶变换,可以以可行的复杂度计算矩阵轮廓向量——该算法的名称是Mueen 相似性搜索算法MASS)。如果您想了解更多关于 MASS 算法的细节以及矩阵轮廓背后的思想,您应该阅读矩阵轮廓 I:时间序列的所有成对相似性连接:一个包含基序、不一致性和形状的统一视图这篇论文(ieeexplore.ieee.org/document/7837992)。

下一节将展示计算矩阵轮廓向量的朴素算法的实现。该算法的朴素性在于其复杂度,而不是其准确性。

手动计算精确的矩阵轮廓

在本小节中,我们将手动计算精确的矩阵轮廓以展示这个过程可能有多慢,尤其是在处理大型时间序列时。我们使用“精确”这个词来区分我们将在本章的“使用 iSAX 计算矩阵轮廓”部分中实现的近似矩阵轮廓计算。

mp.pymain()函数中的最后几个 Python 语句如下:

    dist, index = mp(ta, windowSize)
    print(dist)
    print(index)

第一条语句运行mp()函数,该函数返回两个值,这两个值都是列表(向量),它们是两个矩阵轮廓向量。

mp()函数的实现是我们计算两个向量的地方,分为两部分。第一部分包含以下代码:

def mp(ts, window):
    l = len(ts) - window + 1
    dist = [None] * l
    index = [None] * l
    for i1 in range(l):
        t1 = ts[i1:i1+window]
        min = None
        minIndex = 0
        exclusionMin = i1 - window // 4
        if exclusionMin < 0:
            exclusionMin = 0
        exclusionMax = i1 + window // 4
        if exclusionMax > l-1:
            exclusionMax = l-1

在前面的代码中,我们遍历给定时间序列的所有子序列。对于每个这样的子序列,我们定义排除区域的索引,如矩阵轮廓 I:时间序列的所有成对相似性连接:一个包含基序、不一致性和形状的统一视图论文中所述。

对于16大小的滑动窗口,排除区域是子序列左侧和右侧的4个元素(16 // 4)。

mp()的第二部分如下:

        for i2 in range(l):
            # Exclusion zone
            if i2 >= exclusionMin and i2 <= exclusionMax:
                continue
            t2 = ts[i2:i2+window]
            temp = round(euclidean(t1, t2), 3)
            if min == None:
                min = temp
                minIndex = i2
            elif min > temp:
                min = temp
                minIndex = i2
        dist[i1] = min
        index[i1] = minIndex
    return dist, index

在本节的部分,我们比较代码第一部分中的每个子序列与时间序列的所有子序列,同时考虑到排除区域。

mp()的缺点是它包含两个for循环,这使得其计算复杂度为O(n2)

当使用来自第六章ts.gz时序(该时序位于书籍 GitHub 仓库的ch06目录中)时,mp.py的输出与以下内容类似,对于滑动窗口为16 – 我们将使用这个输出通过将其与原始矩阵轮廓算法及其输出进行比较来测试我们实现的正确性:

$ ./mp.py ../ch06/ts.gz -w 16
TS: ../ch06/ts.gz Sliding Window size: 16
[3.294, 3.111, 3.321, 3.535, 3.285, 3.373, 3.332, 3.693, 4.066, 4.065, 3.898, 3.484, 3.372, 3.1, 3.047, 3.299, 3.056, 3.361, 3.766, 3.759, 3.871, 3.884, 3.619, 3.035, 2.358, 3.012, 3.052, 3.136, 3.161, 3.219, 3.309, 3.526, 3.386, 3.973, 4.207, 4.101, 4.249, 4.498, 4.492, 4.255, 4.241, 3.285, 3.517, 3.494, 3.257, 3.316, 3.526, 4.183, 4.011, 3.294, 3.111, 3.321, 3.535, 3.1, 3.047, 3.332, 3.035, 2.358, 3.012, 3.052, 3.136, 3.161, 3.219, 3.201, 3.187, 3.017, 2.676, 2.763, 2.959, 3.952, 3.865, 3.678, 3.687, 3.201, 3.187, 3.017, 2.676, 2.763, 2.959, 3.316, 3.526, 3.899, 3.651, 3.664, 3.885]
[49, 50, 51, 52, 53, 54, 55, 56, 57, 46, 65, 27, 28, 53, 54, 74, 75, 76, 77, 59, 60, 61, 55, 56, 57, 58, 59, 60, 61, 62, 14, 15, 16, 66, 71, 68, 69, 56, 20, 63, 26, 75, 66, 67, 78, 79, 80, 81, 82, 0, 1, 2, 3, 13, 14, 6, 23, 24, 25, 26, 27, 28, 29, 73, 74, 75, 76, 77, 78, 79, 80, 61, 62, 63, 64, 65, 66, 67, 68, 45, 46, 62, 63, 64, 65]
--- 0.36465 seconds ---

想当然地认为,具有最小和最大欧几里得距离的子序列可以被认为是异常值,因为它们与其他所有子序列不同 – 这就是矩阵轮廓用于异常检测的一个例子。

使用滑动窗口大小为32mp.py生成以下类型的输出:

$ ./mp.py ../ch06/ts.gz -w 32
TS: ../ch06/ts.gz Sliding Window size: 32
[4.976, 5.131, 5.38, 5.485, 5.636, 5.75, 5.87, 6.076, 6.502, 6.705, 6.552, 6.145, 6.279, 6.599, 6.766, 6.667, 6.577, 6.429, 6.358, 6.358, 5.978, 5.804, 5.588, 5.092, 4.976, 5.01, 5.35, 5.456, 6.036, 6.082, 6.258, 6.513, 6.556, 6.553, 6.672, 6.745, 6.767, 6.777, 7.018, 7.12, 6.564, 6.203, 6.291, 6.118, 6.048, 5.869, 6.142, 6.431, 6.646, 4.976, 5.131, 5.38, 5.485, 5.636, 5.75, 5.588, 5.092, 4.976, 5.01, 5.35, 5.456, 6.036, 6.082, 6.258, 6.513, 6.556, 6.598, 6.518, 6.473]
[49, 50, 51, 52, 53, 54, 55, 56, 24, 58, 24, 25, 26, 27, 63, 64, 65, 55, 56, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 67, 68, 62, 63, 0, 65, 67, 0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 1, 2, 3, 4, 5, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 17, 57, 59]
--- 0.22118 seconds ---

最后,使用滑动窗口大小为64,生成的输出如下:

$ ./mp.py ../ch06/ts.gz -w 64
TS: ../ch06/ts.gz Sliding Window size: 64
[10.529, 10.406, 10.475, 10.377, 10.702, 10.869, 10.793, 10.827, 10.743, 11.14, 10.865, 10.819, 10.876, 10.808, 10.802, 10.73, 10.713, 10.67, 11.288, 11.296, 11.113, 11.202, 11.196, 11.121, 11.033, 11.145, 11.228, 11.125, 11.108, 10.865, 10.819, 10.671, 10.702, 10.529, 10.406, 10.475, 10.377]
[33, 34, 35, 36, 32, 33, 34, 35, 36, 28, 29, 30, 31, 32, 33, 34, 35, 36, 0, 1, 2, 3, 4, 5, 6, 7, 8, 8, 0, 10, 11, 3, 4, 0, 1, 2, 3]
--- 0.03179 seconds ---

这里输出较小的原因是,滑动窗口大小越大,从时序中创建的子序列数量就越少。

现在,让我们实验一个包含 25,000 个元素的时序,其创建方式如下:

$ ../ch01/synthetic_data.py 25000 -5 5 > 25k
$ gzip 25k

与之前相同的滑动窗口大小,25k.gz的结果如下(仅显示时间 – 为了简洁,省略了其余输出):

$ ./mp.py -w 16 25k.gz
--- 43707.95353 seconds ---
$ ./mp.py -w 32 25k.gz
--- 44162.44419 seconds ---
$ ./mp.py -w 64 25k.gz
--- 45113.62417 seconds ---

在这个阶段,我们应该意识到计算矩阵轮廓向量可能非常慢,因为在上次运行中,mp.py花费了 45,113 秒来计算矩阵轮廓。

你能想到为什么即使滑动窗口大小的微小增加也会增加整体时间吗?答案是,滑动窗口大小越大,子序列长度越大,因此计算两个子序列之间欧几里得距离所需的时间就越长。以下是计算滑动窗口大小为2048的矩阵轮廓向量所需的时间:

$ ./mp.py -w 2048 25k.gz
--- 46271.63763 seconds ---

请记住,MASS 算法没有这样的问题,因为它以自己的巧妙方式计算欧几里得距离。因此,其性能仅取决于时序长度。

现在,让我们展示一个 Python 脚本,该脚本使用stumpyPython 包通过 MASS 算法计算精确的矩阵轮廓。我们使用realMP.py脚本来计算矩阵轮廓向量,其实现如下:

#!/usr/bin/env python
import pandas as pd
import argparse
import time
import stumpy
import numpy as np
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-w", "--window", dest = "window",
        default = "16", help="Sliding Window", type=int)
    parser.add_argument("TS")
    args = parser.parse_args()
    windowSize = args.window
    inputTS = args.TS
    print("TS:", inputTS, "Sliding Window size:",
        windowSize)
    start_time = time.time()
    ts = pd.read_csv(inputTS, names=['values'],
        compression='gzip')
    ts_numpy = ts.to_numpy()
    ta = ts_numpy.reshape(len(ts_numpy))
    realMP = stumpy.stump(ta, windowSize)
    realDistances = realMP[:,0]
    realIndexes = realMP[:,1]
    print("--- %.5f seconds ---" % (time.time() –
        start_time))
    print(realDistances)
    print(realIndexes)
if __name__ == '__main__':
    main()

stumpy.stump()的返回值是一个多维数组。第一列([:,0])是距离向量,第二列([:,1])是索引向量。在前面的代码中,我们打印了这两个向量,当处理大型时序时这并不方便 – 如果你想,请注释掉这两个print()语句。

为了验证mp.py的正确性,我们展示了realMP.py对于ts.gz时序和滑动窗口大小为64的输出:

$ ./realMP.py ../ch06/ts.gz -w 64
TS: ../ch06/ts.gz Sliding Window size: 64
--- 11.31371 seconds ---
[10.5292 10.40594 10.47460 10.3770 10.7024 10.8689 10.7928
 10.8274 10.74260 11.140 10.864 10.818 10.8757 10.8078
 10.8017 10.7296 10.7129 10.6704
 11.2882 11.2963 11.1125 11.2019 11.19556 11.1206
 11.0330 11.14458 11.22779 11.12475
 11.10825 10.864619 10.8186 10.6714
 10.7024 10.52926 10.40594 10.4746 10.3770]
[33 34 35 36 32 33 34 35 36 28 29 30 31 32 33 34 35 36 0 1 2 3 4 5 6 7 8 8 0 10 11 3 4 0 1 2 3]

既然我们已经确信 mp.py 的正确性,让我们用 25k.gz 时间序列进行实验,看看计算精确矩阵轮廓向量需要多少时间。

realMP.pystumpy.stump() 函数在单个 CPU 核心上计算 25k.gz 时间序列的矩阵轮廓向量所需的时间如下:

$ taskset --cpu-list 0 ./realMP.py 25k.gz -w 1024
TS: 25k.gz Sliding Window size: 1024
--- 11.19547 seconds ---
[42.41325061659 42.4212959655 42.45021115618 ...
 42.64248908665 42.64380072599 42.6591584368]
[10218 10219 10220 ... 7240 7241 20243]

realMP.py 在 Intel i7 的 8 个 CPU 核心上计算 25k.gz 时间序列的矩阵轮廓向量所需的时间如下:

$ ./realMP.py 25k.gz -w 1024
TS: 25k.gz Sliding Window size: 1024
--- 9.68259 seconds ---
[42.41325061659 42.4212959655 42.45021115618 ...
 42.64248908665 42.64380072599 42.6591584368]
[10218 10219 10220 ... 7240 7241 20243]

此外,realMP.pystumpy.stump() 函数在单个 CPU 核心上计算 ch06/100k.gz 时间序列和滑动窗口大小为 1024 的矩阵轮廓向量所需的时间如下:

$ taskset --cpu-list 0 ./realMP.py ../ch06/100k.gz -w 1024
TS: ../ch06/100k.gz Sliding Window size: 1024
--- 44.45451 seconds ---
[42.0661718111 42.044733861 42.050637591 ...
 42.252694931 42.225343182 42.2147590858]
[51861 51862 51863 ... 13502 13503 13504]

最后,让我们在 500k.gz 时间序列上尝试 realMP.py,该时间序列来自 第四章单个 CPU 核心

$ taskset --cpu-list 0 ./realMP.py ../ch04/500k.gz -w 1024
TS: ../ch04/500k.gz Sliding Window size: 1024
--- 1229.49608 seconds ---
[41.691930926 41.689248432 41.642429848 ...
 41.712625718 41.6520521157 41.636642904]
[446724 446725 446726 ... 260568 260569 260570]

从前面的输出中可以得出结论,随着时间序列长度的增加,计算矩阵轮廓的速度会变慢,这是考虑对其进行近似计算的主要原因。我们在精度上失去的,我们在时间上获得的。我们不能拥有一切!

下一个部分将解释我们将使用的技术,以帮助使用 iSAX 近似计算矩阵轮廓向量。

使用 iSAX 计算矩阵轮廓

首先,让我们明确一点:我们将介绍一种近似方法。如果您想计算精确的矩阵轮廓,那么您应该使用使用原始算法的实现。

使用的技术背后的想法如下:子序列的最近邻更有可能出现在与正在检查的子序列相同的终端节点中存储的子序列中。因此,我们不需要检查时间序列的所有子序列,只需检查其中的一小部分。

下一个小节将讨论并解决可能在我们的计算中出现的一个问题,即如果我们无法在终端节点中找到一个子序列的适当匹配,我们将怎么办。

如果没有有效的匹配会发生什么?

在本小节中,我们将阐明过程中的问题案例。存在两种条件可能会导致不希望的情况:

  • 一个终端节点只包含一个子序列

  • 对于给定的子序列,终端节点中所有剩余的子序列都在排除区域

在这两种情况下,我们都不可能找到一个子序列的近似最近邻。我们能否解决这些问题?

对于这个问题,存在多个答案,包括什么都不做或选择不同的子序列并使用它来计算最近邻的欧几里得距离。我们选择了后者,但不是随机选择子序列,而是选择紧邻排除区左侧的子序列。如果没有空间在排除区左侧,我们将选择紧邻排除区右侧的子序列。由于这两个条件不能同时发生,所以我们没问题!

下一个子节将讨论如何计算与真实距离矩阵配置文件向量相比的近似距离矩阵配置文件向量的误差。

计算误差

如前所述,我们正在计算一个近似矩阵配置文件向量。在这种情况下,我们需要一种方法来计算我们与真实值之间的距离。存在多种方法来计算两个数量之间的误差值。由于矩阵配置文件是一系列值,我们需要找到一种方法来计算支持一系列值的误差值,而不仅仅是单个值。

最常见的方法是找到近似向量和精确向量之间的欧几里得距离。然而,这并不总是能揭示全部真相。一个好的替代方案是使用 均方根 误差RMSE)。

RMSE 的公式一开始可能有点复杂。它在 图 7.1 中展示:

图 7.1 – RMSE 公式

图 7.1 – RMSE 公式

实际上,这意味着我们找到实际值和近似值之间的差异,并将该差异平方。我们对所有这些对都这样做,然后将所有这些值相加——这就是大希腊字母 Sigma 的作用。之后,我们将这些值除以对的数量。最后,我们找到最后一个值的平方根,这样我们就完成了。如果你不擅长数学,请记住,你不需要记住这个公式——我们将在稍后用 Python 实现。

RMSE 所具有的期望特性是它考虑了我们比较的元素数量。简单来说,RMSE 取的是 平均值,而欧几里得距离取的是 总和。在我们的情况下,使用平均误差看起来更为合适。

例如,向量 (0, 0, 0, 2, 2)(2, 1, 0, 0, 0) 之间的欧几里得距离等于 3.6055。另一方面,这两个向量的均方根误差(RMSE)等于 1.61245

考虑到所有这些,我们准备展示我们的近似实现。

近似矩阵配置文件实现

在本节中,我们展示了用于近似计算矩阵配置文件向量的 Python 脚本。

apprMP.py 中的重要代码可以在 approximateMP() 函数中找到,该函数分为四个部分。函数的第一部分如下:

def approximateMP(ts_numpy):
    ISAX = isax.iSAX()
    length = len(ts_numpy)
    windowSize = variables.slidingWindowSize
    segments = variables.segments
    # Split sequence into subsequences
    for i in range(length - windowSize + 1):
        ts = ts_numpy[i:i+windowSize]
        ts_node = isax.TS(ts, segments)
        ts_node.index = i
        ISAX.insert(ts_node)
    vDist = [None] * (length - windowSize + 1)
    vIndex = [None] * (length - windowSize + 1)
    nSubsequences = length - windowSize + 1

之前的代码将时间序列分割成子序列并创建 iSAX 索引。它还初始化vDistvIndex变量,分别用于保存距离列表和索引列表。

approximateMP()的第二部分如下:

for k in ISAX.ht:
        t = ISAX.ht[k]
        if t.terminalNode == False:
            continue
        # I is the index of the subsequence
        # in the terminal node
        for i in range(t.nTimeSeries()):
            # This is the REAL index of the subsequence
            # in the time series
            idx = t.children[i].index
            # This is the subsequence that we are examining
            currentTS = t.children[i].ts
            exclusionMin = idx–- windowSize // 4
            if exclusionMin < 0:
                exclusionMin = 0
            exclusionMax = idx + windowSize // 4
            if exclusionMax > nSubsequences-1:
                exclusionMax = nSubsequences-1
            min = None
            minIndex = 0

在之前的代码中,我们取 iSAX 索引的每个节点并确定它是否是终端节点 – 我们只对终端节点感兴趣。如果我们处理的是终端节点,我们处理那里存储的每个子序列。首先,我们定义排除区的索引,确保排除区左侧的最小值是0 – 这是时间序列第一个元素的索引 – 并且排除区右侧的最大值不大于时间序列长度减 1。

其第三部分如下:

           for sub in range(t.nTimeSeries()):
                # This is the REAL index of the subsequence
                # we are examining in the time series
                currentIdx = t.children[sub].index
                if currentIdx >= exclusionMin and currentIdx <= exclusionMax:
                    continue
                temp = round(tools.euclidean(currentTS,
                    t.children[sub].ts), 3)
                if min == None:
                    min = temp
                    minIndex = currentIdx
                elif min > temp:
                    min = temp
                    minIndex = currentIdx

我们将所选终端节点的每个子序列与它包含的其他子序列进行比较,因为我们预计最近邻很可能位于同一个节点中。

然后,我们确保将要与初始子序列进行比较的子序列的索引不在排除区域。如果我们找到这样的子序列,我们计算欧几里得距离并保留相关的索引值。从所有这些位于终端节点且位于排除区域外的子序列中,我们保留最小的欧几里得距离和相关的索引。

我们对 iSAX 索引中所有终端节点的所有子序列都这样做。

approximateMP()函数的最后部分如下:

            # Pick left limit first, then the right limit
            if min == None:
                if exclusionMin-1 > 0:
                    randomSub = ts_numpy[exclusionMin-
                        1:exclusionMin+windowSize-1]
                    vDist[idx] = round(tools.euclidean(
                        currentTS, randomSub), 3)
                    vIndex[idx] = exclusionMin - 1
                else:
                    randomSub = ts_numpy[exclusionMax+
                        1:exclusionMax+windowSize+1]
                    vDist[idx] = round(tools.euclidean(
                        currentTS, randomSub), 3)
                    vIndex[idx] = exclusionMax + 1
            else:
                vDist[idx] = min
                vIndex[idx] = minIndex
    return vIndex, vDist

如果在此阶段我们没有有效的欧几里得距离值(None),我们将比较初始子序列与排除区左侧的子序列,如果存在的话 – 这意味着排除区左侧不是0。否则,我们将其与排除区右侧的子序列进行比较。我们将相关的索引和欧几里得距离分别放入vIndexvDist变量中。然而,如果我们已经从之前得到了索引和欧几里得距离,我们使用这些值。

下一个小节将比较使用不同 iSAX 参数时我们近似技术的准确性。

比较两个不同参数集的准确性

在本小节中,我们将使用两组不同的 iSAX 参数来计算单个时间序列的近似矩阵轮廓向量,并使用 RMSE 检查结果的准确性。

为了使事情更简单,我们创建了一个 Python 脚本,该脚本计算欧几里得距离的两个近似矩阵轮廓向量,以及精确的矩阵轮廓向量,并计算 RMSE – 该脚本的名称是rmse.py。我们不会展示rmse.py的整个 Python 代码,只展示从计算 RMSE 的函数开始的重要 Python 语句:

def RMSE(realV, approximateV):
    diffrnce = np.subtract(realV, approximateV)
    sqre_err = np.square(diffrnce)
    rslt_meansqre_err = sqre_err.mean()
    error = math.sqrt(rslt_meansqre_err)
    return error

之前的代码实现了根据 图 7.1 中提出的公式计算 RMSE 值。

剩余的相关 Python 代码位于 main() 函数中:

    # Real Matrix Profile
    TSreshape = ts_numpy.reshape(len(ts_numpy))
    realMP = stumpy.stump(TSreshape, windowSize)
    realDistances = realMP[:,0]
    # Approximate Matrix Profile
    _, vDist = approximateMP(ts_numpy)
    rmseError = RMSE(realDistances, vDist)
    print("Error =", rmseError)

首先,我们使用 stumpy.stump() 计算实际的矩阵轮廓向量,然后使用 approximateMP() 使用欧几里得距离计算近似的矩阵轮廓向量。之后,我们调用 RMSE() 函数并得到数值结果,然后将其打印到屏幕上。

因此,让我们运行 rmse.py 并看看我们得到什么:

$ ./rmse.py -w 32 -s 4 -t 500 -c 16 25k.gz
Max Cardinality: 16 Segments: 4 Sliding Window: 32 Threshold: 500 Default Promotion: False
Error = 10.70823863253679

现在,让我们再次使用 rmse.py,但这次,使用不同的 iSAX 参数,如下所示:

$ ./rmse.py -w 32 -s 4 -t 1500 -c 16 25k.gz
Max Cardinality: 16 Segments: 4 Sliding Window: 32 Threshold: 1500 Default Promotion: False
Error = 9.996114543048341

之前的结果告诉我们什么?首先,结果告诉我们我们的近似技术没有留下任何没有欧几里得距离的子序列。如果有这样的情况,那么 rmse.py 将会生成如下错误信息:

TypeError: unsupported operand type(s) for -: 'float' and 'NoneType'

由于在 vDist 的初始化中,所有元素都被设置为 None,因此之前的错误意味着至少有一个元素的值没有被重置。因此,它仍然等于 None,并且我们的代码无法从 None 中减去由 stumpy.stump() 计算出的浮点数值。

此外,结果告诉我们更大的阈值值会产生更准确的结果,这是完全合理的,因为每个终端节点中有更多的子序列。然而,这使得近似矩阵轮廓的计算变慢。一般来说,每个终端节点中的子序列数量越接近阈值值,准确性就越好——我们不希望终端节点中存储少量子序列。

既然我们已经了解了矩阵轮廓,让我们讨论 MPdist,它是如何计算的,以及矩阵轮廓在此计算中的作用。

理解 MPdist

既然我们已经了解了矩阵轮廓(Matrix Profile),我们就准备好学习 MPdist 以及矩阵轮廓在 MPdist 计算中的作用。定义 MPdist 距离的论文是 S. Gharghabi、S. Imani、A. Bagnall、A. Darvishzadeh 和 E. Keogh 撰写的 矩阵轮廓 XII:MPdist:一种新的时间序列距离度量,允许在更具挑战性的场景中进行数据挖掘 (ieeexplore.ieee.org/abstract/document/8594928)。

MPdist 的直觉是,如果两个时间序列在其整个持续时间中具有相似的模式,则可以认为它们是相似的。这些模式通过滑动窗口以子序列的形式提取。这如图 图 7.2 所示:

图 7.2 – 时间序列分组

图 7.2 – 时间序列分组

图 7.2 中,我们看到 MPdist (c) 更好地理解遵循相同模式的时间序列之间的相似性,而欧几里得距离 (b) 则基于时间比较时间序列,因此将呈现的时间序列分组不同。在我看来,基于 MPdist 的分组更准确。

MPdist(根据其创造者所说)的优点在于,MPdist 距离度量试图比大多数可用的距离度量更加灵活,包括欧几里得距离,并且它考虑了可能不会同时发生的相似性。此外,MPdist 可以比较不同大小的时序数据——欧几里得距离无法做到这一点——并且只需要一个参数(滑动窗口大小)来操作。

下一个子节将讨论 MPdist 的计算方式。

如何计算 MPdist

在本节中,我们将讨论实际 MPdist 的计算方式,以便更好地理解该过程的复杂性。

MPdist 的计算基于矩阵轮廓。首先,我们得到两个时序 A 和 B 以及一个滑动窗口大小。然后,对于第一个时序的每个子序列,我们在第二个时序中找到其最近邻居,并将相关的欧几里得距离放入值列表中。我们对第一个时序的所有子序列都这样做。这被称为AB 连接。然后,我们以相同的方式对第二个时序进行操作——这被称为BA 连接。因此,最终我们计算了ABBA 连接,并有一个从最小到最大的欧几里得距离列表。从这个列表中,我们得到等于列表长度*5%的索引值处的欧几里得距离——MPdist 的作者决定使用该索引处的欧几里得距离作为 MPdist 值。

对于 AB 连接和 BA 连接,MPdist 的作者使用 MASS 算法计算每个子序列的最近邻居,以避免 O(n²)的低效算法复杂度。

在下一个子节中,我们将创建一个 Python 脚本,手动计算两个时序之间的 MPdist 距离。

手动计算 MPdist

在本节中,我们将展示如何手动计算两个时序之间的 MPdist 值。实现背后的思想基于mp.py中的代码——然而,由于矩阵轮廓返回的是值向量而不是单个值,因此存在根本性的差异。

mpdist.py的逻辑代码在两个函数中实现,分别命名为mpdist()JOIN()mpdist()的实现如下:

def mpdist(ts1, ts2, window):
    L_AB = JOIN(ts1, ts2, window)
    L_BA = JOIN(ts2, ts1, window)
    JABBA = L_AB + L_BA
    JABBA.sort()
    index = int(0.05 * (len(JABBA) + 2 * window)) + 1
    return JABBA[index]

之前的代码使用JOIN()函数来计算AB JoinBA Join。然后,它将数值结果(都是欧几里得距离)连接起来,并对它们进行排序。基于连接的长度,它计算index,该值用于从JABBA数组中选择一个值。

JOIN()的实现如下:

def JOIN(ts1, ts2, window):
    LIST = []
    l1 = len(ts1) - window + 1
    l2 = len(ts2) - window + 1
    for i1 in range(l1):
        t1 = ts1[i1:i1+window]
        min = round(euclidean(t1, ts2[0:window]), 4)
        for i2 in range(1, l2):
            t2 = ts2[i2:i2+window]
            temp = round(euclidean(t1, t2), 4)
            if min > temp:
                min = temp
        LIST.append(min)
    return LIST

这就是连接的实现。对于ts1时序中的每个子序列,我们在ts2时序中找到最近的邻居——在这种情况下不需要排除区域。

mpdist.py的缺点是它包含两个for循环,这使得它的计算复杂度为 O(n²)——这并不奇怪,因为 MPdist 基于矩阵轮廓。因此,之前的技术仅适用于小时间序列。一般来说,暴力算法通常不适用于大量数据。

到目前为止,我们将创建两个包含 10,000 个元素的时序:

$ ../ch01/synthetic_data.py 10000 -5 5 > 10k1
$ ../ch01/synthetic_data.py 10000 -5 5 > 10k2
$ gzip 10k1; gzip 10k2

当使用10k1.gz10k2.gz以及滑动窗口大小为128时,mpdist.py的输出如下:

$ ./mpdist.py 10k1.gz 10k2.gz -w 128
--- 12026.64167 seconds ---
MPdist: 12.5796

mpdist.py计算 MPdist 大约需要 12,026 秒。

当使用10k1.gz10k2.gz以及滑动窗口大小为2048时,mpdist.py的输出如下:

$ ./mpdist.py 10k1.gz 10k2.gz -w 2048
--- 9154.55179 seconds ---
MPdist: 60.7277

你认为为什么2048滑动窗口的计算速度比128滑动窗口的计算速度快?这很可能与2048滑动窗口需要更少的迭代次数(1,920 次乘以 1,920,等于 3,686,400)有关,因为滑动窗口的大小更大,这也补偿了在2048滑动窗口情况下计算较大子序列欧几里得距离的成本。

让我们看看 MASS 算法计算 MPdist 需要多少时间。

stumpy.mpdist()函数在单个 CPU 核心上计算之前的 MPdist 距离所需的时间如下——我们使用的是来自第一章mpdistance.py脚本:

$ taskset --cpu-list 0 ../ch01/mpdistance.py 10k1.gz 10k2.gz 128
TS1: 10k1.gz TS2: 10k2.gz Window Size: 128
--- 10.28342 seconds ---
MPdist: 12.5790
$ taskset --cpu-list 0 ../ch01/mpdistance.py 10k1.gz 10k2.gz 2048
TS1: 10k1.gz TS2: 10k2.gz Window Size: 2048
--- 10.03479 seconds ---
MPdist: 60.7277

因此,stumpy.mpdist()函数大约需要 10 秒钟。

stumpy.mpdist()函数在四个 CPU 核心上计算之前的 MPdist 距离所需的时间如下:

$ taskset --cpu-list 0,1,2,3 ../ch01/mpdistance.py 10k1.gz 10k2.gz 128
TS1: 10k1.gz TS2: 10k2.gz Window Size: 128
--- 9.42861 seconds ---
MPdist: 12.5790
$ taskset --cpu-list 0,1,2,3 ../ch01/mpdistance.py 10k1.gz 10k2.gz 2048
TS1: 10k1.gz TS2: 10k2.gz Window Size: 2048
--- 9.33578 seconds ---
MPdist: 60.7277

为什么使用单个 CPU 核心时时间几乎相同?答案是,对于小时间序列,stumpy.mpdist()没有足够的时间来使用所有 CPU 核心。

最后,stumpy.mpdist()函数在八个 CPU 核心上计算两个 MPdist 距离所需的时间如下:

$ ../ch01/mpdistance.py 10k1.gz 10k2.gz 128
TS1: 10k1.gz TS2: 10k2.gz Window Size: 128
--- 9.54642 seconds ---
MPdist: 12.5790
$ ../ch01/mpdistance.py 10k1.gz 10k2.gz 2048
TS1: 10k1.gz TS2: 10k2.gz Window Size: 2048
--- 9.33648 seconds ---
MPdist: 60.7277

为什么使用四个 CPU 核心时时间相同?正如之前所提到的,对于非常小的时序,使用的 CPU 核心数量对计算时间没有影响,因为没有足够的时间来使用它们。

我们现在准备利用现有知识,在 iSAX 的帮助下近似计算 MPdist。

使用 iSAX 计算 MPdist

在本节中,我们将讨论我们关于使用 iSAX 索引来近似计算 MPdist 的观点和想法。

我们知道 iSAX 会将具有相同 SAX 表示的子序列放在一起。正如之前所提到的,我们的感觉是,在另一个时序的具有相同 SAX 表示的子序列中找到给定时序的子序列的最近邻的可能性更大。

下一节是关于将我们的想法付诸实践。

在 Python 中实现 MPdist 计算

在本节中,我们将讨论两种使用 iSAX 帮助近似计算 MPdist 的方法。

第一种方法比第二种方法简单得多,并且稍微基于矩阵轮廓的近似计算。我们从第一时间序列中取每个子序列,并将其与第二时间序列的 iSAX 索引中具有相同 SAX 表示的终端节点匹配,以获取近似最近邻——如果一个子序列没有基于其 SAX 表示的匹配,我们忽略该子序列。因此,在这种情况下,我们不连接 iSAX 索引,这使得过程变得非常慢——我们的实验将展示这种技术有多慢。

对于第二种方法,我们只使用两个 iSAX 索引的相似性连接,这是我们第一次在第五章中看到的。

下一个子节展示了第一种技术的实现。

使用近似矩阵轮廓法

虽然我们不返回任何矩阵轮廓向量,但这种技术看起来像是在计算矩阵轮廓,因为我们逐个检查子序列而不是成组检查,并返回它们与近似最近邻的欧几里得距离。在这个技术中,计算中没有排除区域,因为我们正在比较来自两个不同时间序列的子序列。

apprMPdist.py中的重要代码如下——我们假设我们已经生成了两个 iSAX 索引:

    # We search iSAX2 for the NN of the
    # subsequences from TS1
    for idx in range(0, len(ts1)-windowSize+1):
        currentQuery = ts1[idx:idx+windowSize]
        t = NN(i2, currentQuery)
        if t != None:
            ED.append(t)
    # We search iSAX1 for the NN of the
    # subsequences from TS2
    for idx in range(0, len(ts2)-windowSize+1):
        currentQuery = ts2[idx:idx+windowSize]
        t = NN(i1, currentQuery)
        if t != None:
            ED.append(t)
    ED.sort()
    idx = int(0.05 * ( len(ED) + 2 * windowSize)) + 1
    print("Approximate MPdist:", round(ED[idx], 3))

对于第一时间序列的每个子序列,使用NN()函数在第二时间序列的 iSAX 索引中搜索近似最近邻。然后,对第二时间序列的子序列和第一时间序列的 iSAX 索引执行相同的操作。

有趣的是NN()函数的实现,它在之前的代码中使用。我们将分三部分介绍NN()。第一部分如下:

def NN(ISAX, q):
    ED = None
    segments = variables.segments
    threshold = variables.threshold
    # Create TS Node
    qTS = isax.TS(q, segments)
    segs = [1] * segments
    # If the relevant child of root is not there
    # we have a miss
    lower_cardinality = tools.lowerCardinality(segs, qTS)
    lower_cardinality_str = ""
    for i in lower_cardinality:
        lower_cardinality_str=lower_cardinality_str+"_"+i
    lower_cardinality_str = lower_cardinality_str[1:len(
        lower_cardinality_str)]
    if ISAX.ht.get(lower_cardinality_str) == None:
        return None

在前面的代码中,我们试图找到与我们要检查的子序列具有相同 SAX 表示的 iSAX 节点——我们从 iSAX 根节点的子节点开始。如果找不到根节点的此类子节点,则我们有一个缺失,并忽略该特定子序列。由于最终的欧几里得距离列表很大(这取决于时间序列的长度),忽略一些子序列对最终结果没有真正的影响。

NN()函数的第二部分如下:

    # Otherwise, we have a hit
    n = ISAX.ht.get(lower_cardinality_str)
    while n.terminalNode == False:
        left = n.left
        right = n.right
        leftSegs = left.word.split('_')
        # Promote
        tempCard = tools.promote(qTS, leftSegs)
        if tempCard == left.word:
            n = left
        elif tempCard == right.word:
            n = right

在前面的代码中,我们试图通过遍历 iSAX 索引来定位具有所需 SAX 表示的 iSAX 节点。

NN()的最后部分如下:

    # Iterate over the subsequences of the terminal node
    for i in range(0, threshold):
        child = n.children[i]
        if type(child) == isax.TS:
            distance = tools.euclidean(normalize(child.ts),
                normalize(qTS.ts))
            if ED == None:
                ED = distance
            if ED > distance:
                ED = distance
        else:
            break
    return ED

定位到所需的终端节点后,我们将其子序列与给定的子序列进行比较,并返回找到的最小欧几里得距离。主程序将这些最小欧几里得距离放入一个列表中。

现在,让我们讨论第二种技术,它将两个 iSAX 索引连接起来。

使用两个 iSAX 索引的连接

第二种方法比第一种方法快得多。在这种情况下,我们根据第五章中的技术连接两个 iSAX 索引,并得到欧几里得距离列表。从这个列表中,我们选择一个值作为近似的 MPdist。

如果 iSAX 节点之间没有匹配会发生什么?

在一些罕见的情况下,这取决于时间序列数据和 iSAX 参数,一个 iSAX 中的某些节点可能最终在另一个 iSAX 中没有匹配,反之亦然。在我们的情况下,我们忽略这些节点,这意味着我们最终得到的欧几里得距离列表比预期的要小。

joinMPdist.py中的重要代码如下——我们假设我们已经生成了两个 iSAX 索引:

    # Join the two iSAX indexes
    Join(i1, i2)
    variables.ED.sort()
    print("variables.ED length:", len(variables.ED))
    # Index
    idx = int(0.05*(len(variables.ED) + 2*windowSize))+1
    print("Approximate MPdist:", variables.ED[idx])

之前的代码使用了来自isax.iSAXjoinJoin()函数,这是我们实现并见过的第五章。我们已经看到了两个 iSAX 索引的连接。然而,这是我们第一次真正使用该连接的结果。

我们现在将开始使用现有的实现并查看它们的性能。

使用 Python 代码

在本节中,我们将使用我们创建的 Python 脚本。

使用本章前面创建的每个包含 10,000 个元素的两个时间序列运行apprMPdist.py会产生以下类型的输出:

$ ./apprMPdist.py 10k1.gz 10k2.gz -s 3 -c 64 -t 500 -w 120
Max Cardinality: 64 Segments: 3 Sliding Window: 120 Threshold: 500 Default Promotion: False
MPdist: 351.27 seconds
Approximate MPdist: 12.603

使用更大的滑动窗口大小会产生以下输出:

$ ./apprMPdist.py 10k1.gz 10k2.gz -s 3 -c 64 -t 500 -w 300
Max Cardinality: 64 Segments: 3 Sliding Window: 300 Threshold: 500 Default Promotion: False
MPdist: 384.74 seconds
Approximate MPdist: 21.757

因此,更大的滑动窗口大小需要更多时间。如前所述,这是因为计算更大滑动窗口大小的欧几里得距离较慢。

执行joinMPdist.py会产生以下输出:

$ ./joinMPdist.py 10k1.gz 10k2.gz -s 3 -c 64 -t 500 -w 120
Max Cardinality: 64 Segments: 3 Sliding Window: 120 Threshold: 500 Default Promotion: False
MPdist: 37.70 seconds
variables.ED length: 17605
Approximate MPdist: 12.60282

如前所述,使用更大的滑动窗口会产生以下输出:

$ ./joinMPdist.py 10k1.gz 10k2.gz -s 3 -c 64 -t 500 -w 300
Max Cardinality: 64 Segments: 3 Sliding Window: 300 Threshold: 500 Default Promotion: False
MPdist: 31.24 seconds
variables.ED length: 13972
Approximate MPdist: 21.76263

看起来joinMPdist.pyapprMPdist.py快得多,这是完全合理的,因为它同时使用两个 iSAX 索引来构建欧几里得距离列表。简单来说,运行joinMPdist.py需要的计算更少。

下一个子节比较了两种方法在处理更长时间序列时的准确性和速度。

比较方法的准确性和速度

这两种方法都远非完美。然而,在本节中,我们将比较它们与stumpyPython 包中找到的 MPdist 实现相关的准确性和速度。

我们希望在大时间序列上测试我们的代码,因为我们的技术可能比stumpy的精确 MPdist 函数更快。在这种情况下,我们将使用两个大约有 50 万个元素的时间序列——我们已经在第五章中创建了这样的时间序列。

对于apprMPdist.py,以下是对滑动窗口大小为1206001200的结果:

$ ./apprMPdist.py ../ch05/500k.gz ../ch05/506k.gz -s 6 -c 64 -t 500 -w 120
Max Cardinality: 32 Segments: 6 Sliding Window: 120 Threshold: 500 Default Promotion: False
MPdist: 19329.64 seconds
Approximate MPdist: 12.405
$ ./apprMPdist.py ../ch05/500k.gz ../ch05/506k.gz -s 6 -c 64 -t 500 -w 600
Max Cardinality: 64 Segments: 6 Sliding Window: 600 Threshold: 500 Default Promotion: False
MPdist: 21219.60 seconds
Approximate MPdist: 31.871
$ ./apprMPdist.py ../ch05/500k.gz ../ch05/506k.gz -s 6 -c 64 -t 500 -w 1200
Max Cardinality: 64 Segments: 6 Sliding Window: 1200 Threshold: 500 Default Promotion: False
MPdist: 23120.07 seconds
Approximate MPdist: 46.279

对于joinMPdist.py脚本,滑动窗口大小为1206001200的输出如下:

$ ./joinMPdist.py ../ch05/500k.gz ../ch05/506k.gz -s 6 -c 64 -t 500 -w 120
Max Cardinality: 64 Segments: 6 Sliding Window: 120 Threshold: 500 Default Promotion: False
MPdist: 2595.92 seconds
variables.ED length: 910854
Approximate MPdist: 12.40684
$ ./joinMPdist.py ../ch05/500k.gz ../ch05/506k.gz -s 6 -c 64 -t 500 -w 600;
Max Cardinality: 64 Segments: 6 Sliding Window: 600 Threshold: 500 Default Promotion: False
MPdist: 2270.72 seconds
variables.ED length: 798022
Approximate MPdist: 31.88064
$ ./joinMPdist.py ../ch05/500k.gz ../ch05/506k.gz -s 6 -c 64 -t 500 -w 1200
Max Cardinality: 64 Segments: 6 Sliding Window: 1200 Threshold: 500 Default Promotion: False
MPdist: 2145.76 seconds
variables.ED length: 674777
Approximate MPdist: 46.29538

当处理较大的时间序列时,joinMPdist.py的结果非常有希望。尽管看起来滑动窗口越大,技术越快,但这并不完全正确,因为随着滑动窗口的增大,我们会有更多没有匹配的节点,因此值列表会变小,这意味着随着滑动窗口的增加,我们计算的欧几里得距离会更少。这并不总是如此,因为这取决于时间序列数据。

最后,当在单个 CPU 核心上运行stumpyPython 包时的结果如下:

$ taskset --cpu-list 0 ../ch01/mpdistance.py ../ch05/500k.gz ../ch05/506k.gz 120
TS1: ../ch05/500k.gz TS2: ../ch05/506k.gz Window Size: 120
500000 506218
--- 4052.73237 seconds ---
MPdist: 11.4175
$ taskset --cpu-list 0 ../ch01/mpdistance.py ../ch05/500k.gz ../ch05/506k.gz 600
TS1: ../ch05/500k.gz TS2: ../ch05/506k.gz Window Size: 600
500000 506218
--- 4042.52154 seconds ---
MPdist: 30.7796
$ taskset --cpu-list 0 ../ch01/mpdistance.py ../ch05/500k.gz ../ch05/506k.gz 1200
TS1: ../ch05/500k.gz TS2: ../ch05/506k.gz Window Size: 1200
500000 506218
--- 4045.72392 seconds ---
MPdist: 45.1887

图 7.3显示了近似方法的准确性,这些方法被称为搜索连接,与使用的三种滑动窗口大小相比,与名为实际的真实 MPdist 值。

图 7.3 – 比较近似方法与实际 MPdist 的准确性

图 7.3 – 比较近似方法与实际 MPdist 的准确性

图 7.3的输出告诉我们什么?首先,近似方法表现相当不错,因为近似值与实际的 MPdist 值非常接近。因此,至少对于我们的示例时间序列,近似技术非常精确。

类似地,图 7.4比较了近似方法在单个 CPU 核心上运行时,对于三种滑动窗口大小所用的近似方法的时间与stumpy计算时间——所提供的近似方法的时间不包括创建两个* iSAX 索引所需的时间。

图 7.4 – 比较近似方法与实际 MPdist 的时间

图 7.4 – 比较近似方法与实际 MPdist 的时间

图 7.4的输出告诉我们什么?第一种技术确实很慢,不应该使用——这就是实验的目的:找出什么有效,什么无效。另一方面,第二种近似技术的性能非常好。此外,图 7.4显示,无论滑动窗口大小如何,stumpy的计算时间都是相同的——这是 MASS 算法的一个良好且期望的特征。

摘要

尽管 iSAX 的主要目的是通过索引来帮助我们搜索子序列,但还有其他使用 iSAX 索引的方法。

在这一章中,我们介绍了一种近似计算矩阵轮廓向量的方法,以及两种近似计算两个时间序列之间的 MPdist 距离的方法。所有这些技术都使用了 iSAX 索引。

我们提出了两种近似计算 MPdist 的方法。在这两种方法中,将两个 iSAX 索引合并的方法比另一种方法效率高得多——因此,仅使用 iSAX 索引本身并不能保证效率;我们必须正确使用 iSAX 索引才能获得更好的结果。

剩下一个小章节来完成这本书,内容是如果你对时间序列和数据库真的感兴趣,你可以采取的下一步。

有用链接

练习

尝试完成以下练习:

  • 尝试使用包含 50,000 个元素的时间序列与mp.py一起使用,并查看完成滑动窗口大小为1620484096所需的时间。

  • 尝试使用包含 65,000 个元素的时间序列与mp.py一起使用,并查看完成所需的时间。

  • 尝试调整mp.py的排除区域限制,并查看你得到的结果。

  • 使用realMP.pystumpy.stump()计算包含 200,000 个元素的时间序列的矩阵配置文件向量——如果你没有这样的时间序列,请创建一个。

  • 使用realMP.pystumpy.stump()计算包含 500,000 个元素的时间序列的矩阵配置文件向量。现在,考虑一下,一个包含 500,000 个元素的时间序列相对较小!

  • 使用单核代码在2M.gz时间序列上尝试realMP.py,如第四章所示。正如你所见,realMP.py在处理更大的时间序列时会变得非常慢。现在,考虑一下,一个包含 200,000 个元素的时间序列并不大。

  • 我们可以通过存储子序列的归一化版本,并在计算欧几里得距离时使用这些归一化版本,而不是每次调用euclidean()函数时在euclidean()函数内部计算归一化版本,来使mp.py运行得更快一些。尝试实现这个功能。

  • 类似地,我们可以通过存储子序列的归一化版本并使用它们进行欧几里得距离计算来使mpdist.py运行得更快。

  • 创建一个类似于图 7**.4的图像,但用于更大的时间序列。从包含 1,000,000 个元素的时间序列开始,看看你得到的结果。

第八章:结论和下一步

您可以将 iSAX 用作传统索引或更复杂的东西,就像我们在第七章中所做的那样。通过这种 iSAX 的实际应用,我们已到达本书的最后一章!感谢您阅读这本书,这是一本多人合作的成果,而不仅仅是作者的个人努力。

时序和时序数据挖掘,在学术界和工业界都是热门话题,主要是因为如今数据通常以时序方式出现。似乎这还不够,这些时序数据中包含大量我们需要快速且准确地处理的数据。

本章将为您提供方向,如果您真正对时序或数据库感兴趣,您将了解接下来应该关注哪里以及什么。

免责声明:所有提到的书籍和研究论文都是个人喜好。您的个人品味或研究兴趣可能有所不同。

在本章中,我们将涵盖以下主要主题:

  • 总结到目前为止我们所学的所有内容

  • iSAX 的其他变体

  • 关于时序的有趣研究论文

  • 关于数据库的有趣研究论文

  • 有用书籍

总结到目前为止我们所学的所有内容

时序无处不在!但随着我们收集更多数据,时序数据往往会变得越来越大。因此,我们需要更快地处理和搜索大型时序数据,以便从数据中得出有用的结论。

iSAX 索引旨在帮助您快速搜索您的时序数据。我希望这本书已经为您提供了开始使用时序和子序列,以及 Python 中的 iSAX 索引所必需的工具和知识。然而,这些知识和所展示的技术很容易转移到其他编程语言,包括但不限于Swift、Java、C、C++、Ruby、Kotlin、GoRustJavaScript

我们相信,我们已经提供了适量的关于使用适当理论和实践进行时序索引的知识,以便您能够成功处理时序并开发 iSAX 索引。

下一个部分将介绍 iSAX 的改进版本。

iSAX 的其他变体

本书已向您介绍了 iSAX 的初始形式。存在更多 iSAX 的变体,使它的操作和构建更快。然而,核心功能保持不变。您可以通过阅读以下研究论文来深入了解:

  • iSAX 2.0:使用 iSAX 索引和挖掘十亿个时序,由 Alessandro Camerra、Themis Palpanas、Jin Shieh 和 Eamonn Keogh 撰写

  • 超越十亿个时序:使用 iSAX2+索引和挖掘非常大的时序数据集,由 Alessandro Camerra、Jin Shieh、Themis Palpanas、Thanawin Rakthanmanon 和 Eamonn Keogh 撰写

  • DPiSAX:大规模分布式分区 iSAX,由 Djamel Edine Yagoubi、Reza Akbarinia、Florent Masseglia 和 Themis Palpanas 撰写

  • 《数据系列索引的演变:iSAX 系列数据系列索引:iSAX、iSAX2.0、iSAX2+、ADS、ADS+、ADS-Full、ParIS、ParIS+、MESSI、DPiSAX、ULISSE、Coconut-Trie/Tree、Coconut-LSM》,由 Themis Palpanas 撰写

请记住,这些都是高级研究论文,你可能一开始会觉得难以理解。然而,如果你坚持不懈,你最终会理解它们的。

在数据库领域,存在许多索引,因为索引对于快速回答 SQL 查询是必不可少的。即使它们与时间序列没有直接关联,你也可能想看看它们,并调整它们以便与时间序列和子序列一起工作。

一个非常著名的索引称为R 树,它是一种基于B+树的层次数据结构。你可以通过阅读 Antonin Guttman 撰写的《R-trees: A Dynamic Index Structure for Spatial Searching》来了解更多关于 R 树索引的信息。

最后,在撰写这本书的过程中,出现了一个新的基于 SAX 表示的时间序列索引,它试图纠正 iSAX 的缺点。这个新索引的名称是Dumpy。Dumpy 背后的核心逻辑与 iSAX 相同,但在构建索引的过程中进行了一些调整,以便在索引内部有更好的子序列分布。

你可以通过阅读arxiv.org/abs/2304.08264上的论文来了解更多关于 Dumpy 的信息。

下一节将提到一些关于时间序列的有趣研究论文。

时间序列有趣的论文

这里有一份关于时间序列聚类和异常检测的研究论文列表,你可能觉得很有趣:

  • 《时间序列数据中的异常/离群值检测综述》,由 Ane Blazquez-Garcia、Angel Conde、Usue Mori 和 Jose A. Lozano 撰写

  • 《时间序列中的异常检测:全面评估》,由 Sebastian Schmidl、Phillip Wenig 和 Thorsten Papenbrock 撰写

  • 《时间序列异常检测技术综述:迈向未来展望》,由 Kamran Shaukat、Talha Mahboob Alam、Suhuai Luo、Shakir Shabbir、Ibrahim A. Hameed、Jiaming Li、Syed Konain Abbas 和 Umair Javed 撰写

  • 《时间序列聚类——十年回顾》,由 Saeed Aghabozorgi、Ali Seyed Shirkhorshidi 和 Teh Ying Wah 撰写

  • 《离散序列的异常检测:综述》,由 Varun Chandola、Arindam Banerjee 和 Vipin Kumar 撰写

下一节将提到一些关于数据库的有趣研究论文。

数据库有趣的论文

由于时间序列与数据库相关联,我将给你一个关于数据库的经典研究论文的小列表:

  • 《全局查询优化》,由 Timos K. Sellis 撰写

  • 《POSTGRES 的设计》,由 Michael Stonebraker 和 Lawrence A. Rowe 撰写

  • 《INGRES 的设计与实现》,由 Michael Stonebraker、Gerald Held、Eugene Wong 和 Peter Kreps 撰写

  • 《大型共享数据库的关系数据模型》,由 E. F. Codd 撰写

  • 《西雅图数据库研究报告》,可在dl.acm.org/doi/10.1145/3524284找到

下一节将提出一些关于数据库的有趣且宝贵的书籍。

有用书籍

在本书的最后一部分,我将列出与计算机科学和软件工程相关的有用书籍,从数据库领域开始。

数据库有用书籍

由于时间序列与数据库相关联,我将为您提供一个关于数据库的经典书籍小清单:

  • 《数据库系统阅读材料,第 4 版》,由 Joseph M. Hellerstein 和 Michael Stonebraker 编辑

  • 《数据库内部机制》,由 Alex Petrov 撰写

  • 《数据挖掘导论,第 2 版》,由 Pang-Ning Tan、Michael Steinbach、Anuj Karpatne 和 Vipin Kumar 合著

  • 《数据库系统:完整指南,第 2 版》,由 Hector Garcia-Molina、杰弗里·D·乌尔曼和 Jennifer Widom 合著

  • 《数据库管理系统,第 3 版》,由 Raghu Ramakrishnan 和 Johannes Gehrke 合著

数据库和时间序列并非与操作系统和计算机编程孤立。因此,在处理数据库时,拥有强大的计算机科学背景将大有裨益。下一小节将介绍一些有助于此的书籍。

建立强大的计算机科学背景

本小节将介绍一些有助于您建立强大计算机科学背景的书籍。以下列出了以下书籍:

  • 《算法导论,第 4 版》,由 Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest 和 Clifford Stein 合著

  • 《编程珠玑,第 2 版》,由Jon Bentley撰写

  • 《更多编程珠玑:程序员的自白》,由 Jon Bentley 撰写

  • 《代码大全:软件构造实用手册》,由史蒂夫·麦克康奈尔撰写

  • 《编写解释器》,由 Robert Nystrom 撰写

  • 《算法设计手册,第 3 版》,由 Steven S. Skiena 撰写

  • 《统计学习元素:数据挖掘、推理和预测,第 2 版》,由 Trevor Hastie、Robert Tibshirani 和 Jerome Friedman 合著

  • 《编译原理、技术和工具,第 2 版》,由阿尔弗雷德·阿霍、Jeffrey Ullman、拉维·塞西和 Monica Lam 合著

  • 《用 Go 编写编译器》,由 Thorsten Ball 撰写

您不必从头到尾阅读每一页。然而,这份书单将为您在计算机科学领域打下坚实的基础。

下一小节将提出一些能帮助您成为更好的 UNIX 和 Linux 开发者和高级用户的书籍。

UNIX 和 Linux 相关书籍

本小节将介绍一些与 UNIX 和 Linux 操作系统相关的书籍。以下列出了以下书籍:

  • 《UNIX 编程环境》,由Brian W. Kernighan和 Rob Pike 合著

  • 《编程实践》,由 Brian W. Kernighan 和 Rob Pike 合著

  • 《UNIX 实用工具》,由 Shelley Powers、Jerry Peek、Tim O’Reilly 和 Mike Loukides 合著

  • 《UNIX 编程环境高级编程(第三版)》,作者W. Richard Stevens和 Stephen Rago

  • 《UNIX 网络编程》,作者 W. Richard Stevens

  • *《C 程序设计语言(第二版)》,作者 Brian W. Kernighan 和丹尼斯 M. Ritchie

下一个小节将介绍与 Python 编程语言相关的实用书籍。

Python 编程语言书籍

本小节介绍了与 Python 编程语言相关的书籍。以下列出了以下书籍:

  • 《流畅 Python》,作者 Luciano Ramalho

  • 《Effective Pandas》,作者 Matt Harrison

  • 《精通 Python:利用 Python 的全部功能编写强大而高效的代码(第二版)》,作者 Rick van Hattem

  • 《专家 Python 编程:通过学习最佳编码实践和高级编程概念掌握 Python(第四版)》,作者 Michal Jaworski 和 Tarek Ziade

  • 《使用 Python 进行时间序列分析食谱》,作者 Tarek A. Atwan

  • 《Python 数据分析(第三版)》,作者 Avinash Navlani、Armando Fandango 和 Ivan Idris

  • 《使用 PyTorch 和 Scikit-Learn 进行机器学习:用 Python 开发机器学习和深度学习模型》,作者 Sebastian Raschka、Yuxi (Hayden) Liu 和 Vahid Mirjalili

有了这个,我们就完成了你可以从中受益的书籍列表。

摘要

在这本书的最后一章,我们列出了一份长长的有趣书籍和研究论文列表。时间序列、时间序列数据挖掘以及数据库,总的来说,都是一些有趣的领域,如果你以尊重的态度对待它们,并持续实验和学习新事物,这些领域将使你终身忙碌。我可以向你保证,在这些领域里,无论是学术界还是工业界,你都不会感到无聊。

如果你从这本书中只保留一个要点,那就是要始终保持好奇心和实验精神

然而,第一步是找到你最感兴趣的事情,并跟随那个方向。如果你打算在某个事物上花费大量时间,你肯定应该找到它既有趣又具有挑战性!

非常感谢您选择这本书。如果您有任何关于改进潜在未来版本的书籍的建议,请随时提出。您的评论和建议可能会产生差异!

有用链接

练习

尝试以下操作:

  • 作为练习,了解pandas.read_csv()函数支持的压缩文件类型。

  • 如果你时间足够,尝试用 Go、Swift 或 Rust 等其他编程语言实现 iSAX 索引。

  • 如果你真的很喜欢 Python,你可以尝试优化isax包的代码。

  • 这是一个非常困难的练习:你可以尝试通过允许使用numba Python 包来提高 iSAX 索引的搜索性能。我个人无法用 Python 编写这样的程序。

  • 这又是一个非常困难的练习:尝试创建一个在你的 GPU上运行的搜索算法的并行版本!如果你做到了,请告诉我!

posted @ 2025-10-27 08:55  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报