
在现代数据科学和机器学习中,我们很容易达到这样一种程度:我们典型的Python工具--像numpy 、pandas 、或scikit-learn --在处理时间或内存使用方面并不能真正地与我们的数据相适应。这是向分布式计算工具(通常是像Apache Spark这样的工具)转移的一个自然点,但这可能意味着为一个全新的系统重新调整我们的工作流程,在我们熟悉的Python生态系统和一个独特的JVM世界之间游走,并使我们的开发工作流程大大复杂。Dask库将分布式计算的力量与数据科学的Python开发的灵活性结合起来,并与常见的Python数据工具无缝集成。在这篇文章中,我们将为分析和机器学习建立一个简单的数据管道,在Dask中处理文本数据。
什么是分布式计算?
考虑以下情况:你有一个数据集,也许是一个文本文件的集合,它太大了,在你的机器上无法装入内存。这没关系--我们可以使用Python的文件流和其他生成器工具来迭代我们的数据集,而不需要把所有的数据装入内存但是......那么我们就会遇到速度上的限制,因为即使我们有了所有的内存智能,工作仍然是在单线程中运行。由于Python(更正确地说,是CPython,大多数人都在使用)中的全局解释器锁的安全特性,在Python中编写并行代码可能有点棘手。不过有几个很好的解决方案,要么使用GIL之外的低级工具(比如numpy 在已编译的非Python代码中进行多线程重载工作),要么在Python代码中使用多线程/进程,比如joblib 或multiprocessing 。但是试图用并行化来加速你的代码是很难做到的,而且可能导致(即使是正确完成)代码的可读性降低,需要你完全重新架构你的进程...而且你仍然受限于机器上的资源!
对于像这样真正的大规模问题,分布式计算可以是一个强大的解决方案。在一个分布式系统中,不是仅仅试图在一台机器上的多个进程或线程中工作,而是将工作外包给多个独立的工作机,每个工作机都在自己的内存和/或磁盘空间以及自己的处理器上处理数据集的各个块。这些工作节点没有共享内存和磁盘空间(就像适当的多线程代码那样),而只是通过相对简单的信息传递相互沟通或与中央调度器沟通。作为对设置集中式调度器的设计复杂性的交换,以及保持工作节点之间相当程度的分离(因为在工作节点之间大量移动数据是一个潜在的非常昂贵的操作),分布式计算系统可以让你在非常大的数据集上扩展代码,在任何数量的工作节点上并行运行。
分布式计算的Dask
通常,转向分布式计算需要使用Apache Spark这样的工具。虽然功能强大,但Spark工作起来很棘手,它很难在本地建立原型,需要处理在JVM中运行的代码(这与DS/ML生态系统中的典型Python代码相比,可能是一个重大的心理模式转换),并且在处理某些任务(如一流的机器学习或大型多维矢量操作)方面能力有限。Dask旨在颠覆这一点,作为一个本地的Python工具,它从头到尾都是为了与典型的Python数据工具集成(在某些情况下,基本上可以替代)。
在引擎盖下,Dask是一个分布式任务调度器,而不是一个数据工具本身--也就是说,Dask调度器所关心的是协调Delayed 对象(本质上是包裹任意Python代码的异步承诺)及其依赖关系到一个执行图。与Spark相当受限的高层基元(实际上是MapReduce范式的扩展)相比,这意味着Dask可以协调高度复杂的任务,使RAPIDS中的多GPU调度等显著用途成为可能。不过对于我们的目的来说,我们并不真的需要担心这些低级别的内部因素--Dask为我们提供了几个集合,用于将低级别的任务包装成高级别的工作流程。
dask.bag:无序集合,实际上是Python迭代器的分布式替代,从文本/二进制文件或任意Delayed序列中读取。dask.array:分布式数组,具有类似于numpy的接口,非常适用于扩展大型矩阵操作dask.dataframe:分布式pandas,用于有效地处理表格和组织数据。dask_ml分布式包装:围绕scikit-learn,类似机器学习工具。
由于Dask是Python原生的,开始使用它就像在你选择的环境中安装pip 或conda (或使用他们的一个docker镜像)一样简单,即使在一台笔记本电脑上也能运行良好(尽管同样的代码可以通过最小的修改,扩展到千人集群的用例!)。
让我们开始吧!
开始
在这个例子中,我们将使用archive.org开源的Stack Exchange数据的转储。我们将提取其中一个Posts 数据集,它本身是一个相当简单的XML文件,但它可能(对于较大的社区,如Stack Overflow)太大,无法装入内存或在单机上快速处理。我们将能够在Dask中进行所有的处理,只需一个简单的黑客设置--因为我们知道该文件是用XML页眉和页脚结构的,我们可以从命令行中剥离这些。
$ sed -i '' '1,2d;$d' Posts.xml
之后,文本文件的每一行都是一个独立的XMLrow 对象,所以我们可以很容易地在多个worker之间进行分片。
我们可以设置我们的初始数据读取。
>>> client = dask.distributed.Client() # uses local distributed scheduler
>>> posts = dask.bag.read_text("Posts.xml", blocksize=1e8)
一旦我们声明了Client ,同一会话中的任何操作都将默认使用它进行计算。通过调用它而不需要任何参数,Dask会自动启动一个本地集群来运行工作。这是我最喜欢Dask的地方之一--与为本地开发设置Spark所涉及的提升相比,我发现Dask工具的易用性是其最大的优势之一。
然后,我们只需要将文本文件(或多个文件)读入一个Dask包(这里的 "包 "是数学意义上的,即一个有重复的无序集合)。在实践中,我们真的只需要把包看作是类似于分布式迭代器的东西--我们可以把函数映射到每个元素上,过滤它们,并以MapReduce的方式把它们折叠起来。对于很多数据集来说,这是一个很好的起点,因为我们最初无法对数据的结构做出很多保证。
>>> posts
dask.bag<bag-from-delayed, npartitions=1>
如果我们检查posts 对象,我们会看到一个有点不透明的对dask.Delayed 对象的引用,而不是我们数据的实际表示。这是因为Dask调度器在我们声明操作时懒洋洋地建立了一个执行图,所以现在的对象只是Dask正在计划的步骤的表示(在这种情况下,从一个延迟的文件读取中创建一个包,因为我们正在处理一个小的演示文件)。在我们明确告诉它之前,图实际上不会计算任何东西,这使得调度器可以优化其操作。如果我们想检查我们的数据,我们可以把样本拉出来。
>>> posts.take(1)
(' <row Id="5" PostTypeId="1" ... />\r\n',) # truncating the rest of the XML here
Dask会懒洋洋地计算出足够的数据来产生我们所要求的表示,所以我们从文件中得到一个单一的XML行对象。
自然地,我们会开始想对我们的bag 的元素应用函数,以使我们的数据有序化,而map() 方法是这方面的面包和黄油。让我们使用标准库的xml.etree.ElementTree ,把我们的数据从XML中取出来放到一个Python字典中。
>>> posts = posts.map(lambda row: ElementTree.fromstring(row).attrib)
>>> posts.take(1)
({'Id': '5',
'PostTypeId': '1',
'CreationDate': '2014-05-13T23:58:30.457',
'Score': '9',
'ViewCount': '671',
'Body': '<p>I\'ve always been interested in machine learning, but I can\'t figure out one thing about starting out with a simple "Hello World" example - how can I avoid hard-coding behavior?</p>\n\n<p>For example, if I wanted to "teach" a bot how to avoid randomly placed obstacles, I couldn\'t just use relative motion, because the obstacles move around, but I don\'t want to hard code, say, distance, because that ruins the whole point of machine learning.</p>\n\n<p>Obviously, randomly generating code would be impractical, so how could I do this?</p>\n',
'OwnerUserId': '5',
'LastActivityDate': '2014-05-14T00:36:31.077',
'Title': 'How can I do simple machine learning without hard-coding behavior?',
'Tags': '<machine-learning>',
'AnswerCount': '1',
'CommentCount': '1',
'FavoriteCount': '1',
'ClosedDate': '2014-05-14T14:40:25.950'},)
回顾一下,Dask在这里只是懒洋洋地建立一个计算图。每次我们重新绑定posts 变量时,我们只是把这个引用移到图的头部。我们实际上可以通过一个内置的可视化工具(在引擎盖下使用graphviz )看到这个图是什么样子。
>>> posts.visualize(rankdir="LR")

和map ,我们也可以从这里过滤包的元素--让我们只保留顶级的帖子而不是回复,用PostTypeId 表示。
>>> posts = posts.filter(lambda row: row["PostTypeId"] == "1")
Dask包也支持通过其fold 和foldby 方法进行聚合,这支持类似MapReduce的范式。然而,我倾向于发现自己在数据框架中进行这样的操作。幸运的是,一旦我们的数据有了一定的结构,就很容易创建数据框架。现在我们知道我们所关心的字段是存在的,让我们继续创建一个。
>>> metadata = {
... "Id": int,
... "CreationDate": "datetime64[ns]",
... "Body": str,
... "Tags": str
... }
>>> posts = posts.to_dataframe(meta=metadata)
Dask可以尝试推断数据类型,但这可能会导致潜在的昂贵的计算或错误(特别是围绕空处理)。我发现一般来说,对类型进行明确的说明是很有用的,特别是因为这给了我们一个立即过滤选定列和应用初始数据类型转换的机会。在这里我们只需要为Dask提供Python类型来生成一个模式。(我们也可以使用更深奥的类型--例如,datetime64 是一个numpy 数据类型。)
数据框架操作
一旦我们对数据进行了合理的结构化处理,并从我们的dask.bag ,将其转换为表格式的dask.dataframe 对象,我们就会发现自己处于数据科学工具的熟悉领域。
>>> posts.head()
Id CreationDate Body Tags
0 5 2014-05-13 23:58:30.457 <p>I've always been interested in machine lear... <machine-learning>
1 7 2014-05-14 00:11:06.457 <p>As a researcher and instructor, I'm looking... <education><open-source>
2 14 2014-05-14 01:25:59.677 <p>I am sure data science as will be discussed... <data-mining><definitions>
3 15 2014-05-14 01:41:23.110 <p>In which situations would one system be pre... <databases>
4 16 2014-05-14 01:57:56.880 <p>I use <a href="http://www.csie.ntu.edu.tw/~... <machine-learning><bigdata><libsvm>
这看起来就像一个正常的pandas 数据框架--事实上,Dask数据框架的每个分区本身就是一个pandas 数据框架!我们典型的数据框架操作通常被支持,比如重命名或通过索引访问行/列。
>>> snakecase_regex = re.compile(r"(?<!^)(?=[A-Z])")
>>> posts.columns = [re.sub(snakecase_regex, "_", c).lower() for c in posts.columns]
我们也可以使用来自pandas 的典型列操作。数字操作如期进行(因为底层的dask.array 反映了numpy 操作),或者我们可以利用pandas.str 和pandas.dt 子模块来处理字符串和日期数据。(作为一个旁观者,没有充分利用这些鲜为人知的工具是我在新的数据科学家身上看到的一个常见的错误--他们经常会在一列中使用apply 字符串函数,而不是使用pandas 内建程序,后者通常会快很多)。例如,我们可以将我们的标签的XML字符串分割成一个数组,并对主体做一些简单的文本清理。
>>> tag_regex = re.compile(r"(?<=<)\S*?(?=>)")
>>> posts.tags = posts.tags.str.findall(tag_regex)
>>> posts.body = posts.body.map(lambda body: BeautifulSoup(body).get_text(), meta=("body", str))
>>> posts.head()
id creation_date body tags
0 5 2014-05-13 23:58:30.457 I've always been interested in machine learnin... [machine-learning]
1 7 2014-05-14 00:11:06.457 As a researcher and instructor, I'm looking fo... [education, open-source]
2 14 2014-05-14 01:25:59.677 I am sure data science as will be discussed in... [data-mining, definitions]
3 15 2014-05-14 01:41:23.110 In which situations would one system be prefer... [databases]
4 16 2014-05-14 01:57:56.880 I use Libsvm to train data and predict classif... [machine-learning, bigdata, libsvm]
数据过滤(正如你所期望的)也像一个典型的pandas 操作。例如,只保留标记为python 的帖子。
>>> python_posts = posts[posts.tags.map(lambda tags: "python" in tags)]
请注意,当对可能是有序的列进行过滤时(如日期戳),我们有可能最终删除一些分区中的大部分或全部,而留下其他分区不被触及。在这样的操作之后,可能值得重新划分dask.dataframe ,以更好地在其工作者之间分配数据。Dask通常会智能地做这件事(尽可能地按索引分区),所以我们真的只需要知道过滤后我们需要多少分区(或者说,我们预计会删除多少数据)。
分析与机器学习
现在我们已经得到了干净的数据,我们可以从中提取一些有用的知识。例如,让我们看一下与python 标签共同出现的最常见的标签。
>>> python_posts = python_posts.explode("tags") # replicates rows, one per value in `tags`
>>> python_posts = python_posts[python_posts.tags != 'python']
>>> tag_counts = python_posts.tags.value_counts()
检查这个,我们看到我们仍然在分布式的土地上:tag_counts 被绑定到一个Dask系列(类似于pandas 对象),所有的底层计算都被卷进了它。然而,我们现在处于一个点上,聚集的结果实际上适合在内存中,所以我们可以通过简单地触发计算来实现它。
>>> tag_counts = tag_counts.compute() # returns a pandas series
>>> tag_counts.head(5)
machine-learning 1190
scikit-learn 640
pandas 542
keras 510
tensorflow 352
Name: tags, dtype: int64
这将根据需要触发其依赖链上的任务,以根据需要返回一个内存或磁盘上的对象。现实上,一旦一个数据集可以在一台机器上处理,一般来说,这样做会比把它放在分布式环境中要快得多。幸运的是,Dask对象在计算时可以无缝转移到类似的本地Python对象(例如,这里的pandas.Series )。
除了基线Dask的分析能力外,我们还可以在相关的dask-ml 包中访问大量的机器学习功能。让我们试着从我们的数据集中区分出关于Python与R的帖子。
>>> posts.tags = posts.tags.map(lambda tags: list(set(tags).intersection({"python", "r"})))
>>> posts = posts[posts.tags.map(lambda tags: len(tags) == 1)] # exactly one tag, not both
>>> posts.tags = posts.tags.map(lambda tags: tags[0]).astype("category")
因此,我们把数据集减少到只标记为python 或r (但不是两者)的帖子,而放弃其他的标记。dask-ml 包提供了一些scikit-learn 管道工具的分布式等价物。
>>> from dask_ml.model_selection import train_test_split
>>> from dask_ml.preprocessing import LabelEncoder
>>> from dask_ml.feature_extraction.text import HashingVectorizer
>>> train, test = train_test_split(posts, test_size=0.25, random_state=42)
>>> label_encoder = LabelEncoder().fit(train["tags"])
>>> vectorizer = HashingVectorizer(stop_words="english").fit(train["body"])
这些工具将在Dask集群中分配它们的行动(尽管注意到train_test_split 将在分区之间洗数据,这可能是一个昂贵的步骤),预处理步骤返回适合机器学习算法的Dask数组。这里的LabelEncoder 可以从我们上面为我们的标签设置的category 数据类型中受益,让集群避免了可能昂贵的重复扫描整个数据集来学习可用的标签。同样地,HashingVectorizer 被设计为在分布式数据集上有效工作。与像CountVectorizer 或TfidfVectorizer 在scikit-learn ,需要整理分区之间的信息不同,HashingVectorizer 是无状态的,可以在整个数据集上并行运行(因为它只依赖于每个输入标记的哈希值),有效地生成我们文本的稀疏矩阵表示。
一旦我们准备好了数据,Dask还为我们提供了一些扩展ML算法的选项。例如,我们可以用它在较小的数据集上并行化网格搜索和超参数优化,在XGBoost等算法中处理可并行化的任务,或者在分区的数据集上管理批量学习。让我们试试最后一个选项,在我们的数据上训练一个简单的分类器--因为Dask集成了典型的Python数据栈,我们可以开箱即用scikit-learn ,只需要算法支持partial_fit 批量训练范式就可以发挥得很好。
>>> from dask_ml.wrappers import Incremental
>>> from sklearn.linear_model import SGDClassifier
>>> model = Incremental(SGDClassifier(penalty="l1"), scoring="accuracy", assume_equal_chunks=True)
在这里,我们将我们的简单线性分类器(具有强大的L1准则,因此它应该学会关注我们稀疏表示中最有信息量的标记)包装在Incremental ,这将让我们在每个分区上训练我们的数据。为了训练,我们只需运行
>>> X = vectorizer.transform(train["body"])
>>> y = label_encoder.transform(train["tags"])
>>> model.fit(X, y, classes=[0, 1])
Incremental(estimator=SGDClassifier(penalty='l1'), scoring='accuracy')
这看起来很像scikit-learn ,但有一些额外的奇怪之处--例如,模型需要知道所有可用的类,以防在一个给定的批次中没有代表,而且Incremental 包装器需要我们保证来自X 和y 的块将是同等大小的(我们可以安全地假设我们的转化器步骤的输出)。分数也是如此。
>>> X_test = vectorizer.transform(test["body"])
>>> y_test = label_encoder.transform(test["tags"])
>>> print(f"{model.score(X_test, y_test):.3f}")
0.896
考虑到我们根本没有调整这个问题,还不算太糟糕
转移到一个远程集群
到目前为止,我们所做的一切在本地机器上运行良好,使用的是Dask集群的LocalCluster 变体。不过,Dask真正的优势之一是可以轻松地将这些代码转换到远程集群上运行。
在我们的管道开始时,我们声明了一个没有任何参数的dask.distributed.Client() 。这就自动启动了一个本地版本的集群进行计算。(如果我们想限制其资源,我们也可以明确地创建一个dask.distributed.LocalCluster )。要使用一个远程集群,我们只需创建一个类似于
>>> client = dask.distributed.Client("tcp://<address:port of dask cluster>")
用远程集群的调度器的地址来创建客户端,那么我们剩下的工作基本上就可以 "正常工作 "了Dask甚至给我们提供了一个方便的集群监控仪表盘,这样我们就可以看到任务进度、资源使用情况等(实际上我们也可以在我们的本地集群上看到这些,localhost:8787 )。
由于我们不能再让Dask工作者引用我们本地机器上的文件,我们确实需要一种方法来获取我们的数据,并从集群中获取。幸运的是,Dask的开发者也支持一些方便的工具来与S3和GCS等云数据存储进行交互。例如,对于S3中的数据,我们可以使用s3fs 包,它为我们提供了一个类似文件系统的对象来处理S3中的数据。在Dask中,我们可以直接将S3路径传递给我们的文件I/O,就像它是本地的一样,如
>>> posts = dask.bag.read_text("s3://<S3 bucket path to data>")
而s3fs ,就可以在后台处理事情了。我们也可以在我们的本地机器上访问这些文件,如
>>> fs = s3fs.S3FileSystem()
>>> with fs.open("s3://<S3 bucket path>", "rb") as rf:
... data = rf.read() # or whatever file operation we want!
我们需要考虑的最后一件事是远程计算(一般来说,不仅仅是Dask)的环境控制。在我们的本地集群中,工作者是在同一个Python环境中执行的,所以任何在我们自己的环境中可用的包和函数都对工作者可用。在远程集群中,工作者自然是在运行他们自己的隔离的Python环境,所以我们需要考虑我们有哪些代码可以提供给工作者。
我们向Dask集群发出的函数调用是用cloudpickle ,但更复杂的本地代码或对其他包的引用会很困难。一般来说,我尽量使用Dask的内建程序(尤其是在数据框架中),其他情况下则参考软件包--因为Dask运行的是标准的Python环境,所以在worker上安装软件包是很简单的,而管理Dask集群的Kubernetes资源使得将这些安装推送到整个集群也很容易。
那Spark呢?
作为一个分布式计算和数据处理系统,Dask吸引了人们对Spark的自然比较。就我个人而言,在使用过Spark和Dask之后,我发现来自数据科学背景的人开始使用Dask要简单得多。事实上,它非常有意地反映了常见的数据科学工具,如numpy 和pandas ,这使得Python用户的入门门槛大大降低,无论是学习该工具还是扩展现有项目。此外,由于Dask是一个原生的Python工具,设置和调试都要简单得多。Dask 及其相关工具可以简单地通过pip 或conda 安装在正常的 Python 环境中,而调试就像阅读正常的 Python 堆栈跟踪或 REPL 输出一样直接。与为开发设置本地Spark和解密Python代码中交错的JVM输出的非艰巨任务相比(因为现实中,数据科学家或机器学习工程师将通过pyspark 与集群互动),这大大简化了开发过程。虽然pyspark ,可以集成定制的Python代码,但将其部署到集群上是不容易的(相对于简单的pip/conda ,在集群上为Dask安装额外的包,即使在Kubernetes这样的基础设施上部署集群也是很直接的)。
Spark是专门为传统的数据处理任务而设计的,与Dask相比,我发现它的ML工具在我的工作中相当匮乏。说到这里,有一些考虑,Dask并不是最好的选择--例如,Dask目前没有一个很好的方法来处理流式数据,而Spark可以与火花流范式整合,或者更容易与Apache Beam等新工具对话。同样,虽然Dask的Python原生实现对DS/ML从业者来说是一个福音,但它确实意味着缺乏Spark的跨语言支持。总之,对于需要与Hadoop生态系统中的其他Scala/Java工具(尤其是流媒体工具)集成的数据工程团队来说,Spark可能是一个更好的选择--但对于以Python为中心、有大量数据分析和机器学习需求的团队来说,Dask可以成为一个真正强大的工具。
总结
对于数据科学家或机器学习工程师来说,从传统Python数据栈中的单机代码(numpy,pandas,scikit-learn 等)迁移到分布式计算环境可能是令人生畏的。Dask工具集使这种迁移变得更简单,它与许多常见的Python数据栈有分布式的相似性。这可以为你的工作流程提供集群规模的动力--或者只是在一台机器上轻松管理并行性和磁盘缓存。