Python 入门指南(六)(4)

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: Python 入门指南(六)

Python 入门指南(六)(3)https://developer.aliyun.com/article/1507438

生成器表达式

有时我们想处理一个新的序列,而不将新的列表、集合或字典拉入系统内存。如果我们只是一个接一个地循环遍历项目,并且实际上并不关心是否创建了一个完整的容器(如列表或字典),那么创建该容器就是浪费内存。当一次处理一个项目时,我们只需要当前对象在内存中的可用性。但是当我们创建一个容器时,所有对象都必须在开始处理它们之前存储在该容器中。

例如,考虑一个处理日志文件的程序。一个非常简单的日志文件可能以这种格式包含信息:

Jan 26, 2015 11:25:25 DEBUG This is a debugging message. Jan 26, 2015 11:25:36 INFO This is an information method. Jan 26, 2015 11:25:46 WARNING This is a warning. It could be serious. Jan 26, 2015 11:25:52 WARNING Another warning sent. Jan 26, 2015 11:25:59 INFO Here's some information. Jan 26, 2015 11:26:13 DEBUG Debug messages are only useful if you want to figure something out. Jan 26, 2015 11:26:32 INFO Information is usually harmless, but helpful. Jan 26, 2015 11:26:40 WARNING Warnings should be heeded. Jan 26, 2015 11:26:54 WARNING Watch for warnings. 

流行的网络服务器、数据库或电子邮件服务器的日志文件可能包含大量的数据(我曾经不得不清理近 2TB 的日志文件)。如果我们想处理日志中的每一行,我们不能使用列表理解;它会创建一个包含文件中每一行的列表。这可能不适合在 RAM 中,并且可能会使计算机陷入困境,这取决于操作系统。

如果我们在日志文件上使用for循环,我们可以在将下一行读入内存之前一次处理一行。如果我们能使用理解语法来获得相同的效果,那不是很好吗?

这就是生成器表达式的用武之地。它们使用与理解相同的语法,但不创建最终的容器对象。要创建生成器表达式,将理解包装在()中,而不是[]{}

以下代码解析了以前介绍的格式的日志文件,并输出了一个只包含WARNING行的新日志文件:

import sys 
inname = sys.argv[1] 
outname = sys.argv[2] 
with open(inname) as infile: 
    with open(outname, "w") as outfile: 
 warnings = (l for l in infile if 'WARNING' in l) 
        for l in warnings: 
            outfile.write(l) 

该程序在命令行上获取两个文件名,使用生成器表达式来过滤警告(在这种情况下,它使用if语法并保持行不变),然后将警告输出到另一个文件。如果我们在示例文件上运行它,输出如下:

Jan 26, 2015 11:25:46 WARNING This is a warning. It could be serious.
Jan 26, 2015 11:25:52 WARNING Another warning sent.
Jan 26, 2015 11:26:40 WARNING Warnings should be heeded.
Jan 26, 2015 11:26:54 WARNING Watch for warnings. 

当然,对于这样一个简短的输入文件,我们可以安全地使用列表理解,但是如果文件有数百万行,生成器表达式将对内存和速度产生巨大影响。

for表达式括在括号中会创建一个生成器表达式,而不是元组。

生成器表达式通常在函数调用内最有用。例如,我们可以在生成器表达式上调用summinmax,而不是列表,因为这些函数一次处理一个对象。我们只对聚合结果感兴趣,而不关心任何中间容器。

总的来说,在四个选项中,尽可能使用生成器表达式。如果我们实际上不需要列表、集合或字典,而只需要过滤或转换序列中的项目,生成器表达式将是最有效的。如果我们需要知道列表的长度,或对结果进行排序、去除重复项或创建字典,我们将不得不使用理解语法。

生成器

生成器表达式实际上也是一种理解;它将更高级(这次确实更高级!)的生成器语法压缩成一行。更高级的生成器语法看起来甚至不那么面向对象,但我们将再次发现,这只是一种简单的语法快捷方式,用于创建一种对象。

让我们进一步考虑一下日志文件示例。如果我们想要从输出文件中删除WARNING列(因为它是多余的:这个文件只包含警告),我们有几种不同级别的可读性选项。我们可以使用生成器表达式来实现:

import sys
# generator expression
inname, outname = sys.argv[1:3]
with open(inname) as infile:
    with open(outname, "w") as outfile:
 warnings = (
 l.replace("\tWARNING", "") for l in infile if "WARNING" in l
 )
        for l in warnings:
            outfile.write(l)

尽管如此,这是完全可读的,但我不想使表达式比这更复杂。我们也可以使用普通的for循环来实现:

with open(inname) as infile:
    with open(outname, "w") as outfile:
        for l in infile:
            if "WARNING" in l:
                outfile.write(l.replace("\tWARNING", ""))

这显然是可维护的,但在如此少的行数中有如此多级缩进有点丑陋。更令人担忧的是,如果我们想要做一些其他事情而不是简单地打印出行,我们还必须复制循环和条件代码。

现在让我们考虑一个真正面向对象的解决方案,没有任何捷径:

class WarningFilter:
    def __init__(self, insequence):
        self.insequence = insequence
 def __iter__(self):
        return self
 def __next__(self):
        l = self.insequence.readline()
        while l and "WARNING" not in l:
            l = self.insequence.readline()
        if not l:
 raise StopIteration
        return l.replace("\tWARNING", "")
with open(inname) as infile:
    with open(outname, "w") as outfile:
        filter = WarningFilter(infile)
        for l in filter:
            outfile.write(l)

毫无疑问:这太丑陋和难以阅读了,你甚至可能无法理解发生了什么。我们创建了一个以文件对象为输入的对象,并提供了一个像任何迭代器一样的__next__方法。

这个__next__方法从文件中读取行,如果不是WARNING行,则将其丢弃。当我们遇到WARNING行时,我们修改并返回它。然后我们的for循环再次调用__next__来处理后续的WARNING行。当我们用完行时,我们引发StopIteration来告诉循环我们已经完成了迭代。与其他示例相比,这相当丑陋,但也很强大;现在我们手头有一个类,我们可以随心所欲地使用它。

有了这样的背景,我们终于可以看到真正的生成器在起作用了。下一个示例完全与前一个示例相同:它创建了一个具有__next__方法的对象,当输入用完时会引发StopIteration

def warnings_filter(insequence):
    for l in insequence:
        if "WARNING" in l:
 yield l.replace("\tWARNING", "")
with open(inname) as infile:
    with open(outname, "w") as outfile:
        filter = warnings_filter(infile)
        for l in filter:
            outfile.write(l)

好吧,那可能相当容易阅读…至少很简短。但这到底是怎么回事?这根本毫无意义。而且yield到底是什么?

实际上,yield是生成器的关键。当 Python 在函数中看到yield时,它会将该函数包装在一个对象中,类似于我们之前示例中的对象。将yield语句视为类似于return语句;它退出函数并返回一行。但与return不同的是,当函数再次被调用(通过next())时,它将从上次离开的地方开始——在yield语句之后的行——而不是从函数的开头开始。

在这个示例中,yield语句之后没有行,因此它会跳到for循环的下一个迭代。由于yield语句位于if语句内,它只会产生包含WARNING的行。

虽然看起来这只是一个循环遍历行的函数,但实际上它创建了一种特殊类型的对象,即生成器对象:

>>> print(warnings_filter([]))
<generator object warnings_filter at 0xb728c6bc>  

我将一个空列表传递给函数,充当迭代器。函数所做的就是创建并返回一个生成器对象。该对象上有__iter____next__方法,就像我们在前面的示例中创建的那样。(你可以调用内置的dir函数来确认。)每当调用__next__时,生成器运行函数,直到找到yield语句。然后它返回yield的值,下一次调用__next__时,它会从上次离开的地方继续。

这种生成器的使用并不那么高级,但如果你没有意识到函数正在创建一个对象,它可能看起来像魔术一样。这个示例非常简单,但通过在单个函数中多次调用yield,你可以获得非常强大的效果;在每次循环中,生成器将简单地从最近的yield处继续到下一个yield处。

从另一个可迭代对象中产生值

通常,当我们构建一个生成器函数时,我们会陷入一种情况,我们希望从另一个可迭代对象中产生数据,可能是我们在生成器内部构造的列表推导或生成器表达式,或者可能是一些传递到函数中的外部项目。以前可以通过循环遍历可迭代对象并逐个产生每个项目来实现。然而,在 Python 3.3 版本中,Python 开发人员引入了一种新的语法,使其更加优雅一些。

让我们稍微调整一下生成器的例子,使其不再接受一系列行,而是接受一个文件名。这通常会被视为不好的做法,因为它将对象与特定的范例联系在一起。如果可能的话,我们应该在输入上操作迭代器;这样,同一个函数可以在日志行来自文件、内存或网络的情况下使用。

这个代码版本说明了你的生成器可以在从另一个可迭代对象(在本例中是一个生成器表达式)产生信息之前做一些基本的设置:

def warnings_filter(infilename):
    with open(infilename) as infile:
 yield from (
 l.replace("\tWARNING", "") for l in infile if "WARNING" in l
 )
filter = warnings_filter(inname)
with open(outname, "w") as outfile:
    for l in filter:
        outfile.write(l)

这段代码将前面示例中的for循环合并为一个生成器表达式。请注意,这种转换并没有帮助任何事情;前面的示例中使用for循环更易读。

因此,让我们考虑一个比其替代方案更易读的例子。构建一个生成器,从多个其他生成器中产生数据可能是有用的。例如,itertools.chain函数按顺序从可迭代对象中产生数据,直到它们全部耗尽。这可以使用yield from语法非常容易地实现,因此让我们考虑一个经典的计算机科学问题:遍历一棵通用树。

通用树数据结构的一个常见实现是计算机的文件系统。让我们模拟 Unix 文件系统中的一些文件夹和文件,这样我们就可以有效地使用yield from来遍历它们:

class File:
    def __init__(self, name):
        self.name = name
class Folder(File):
    def __init__(self, name):
        super().__init__(name)
        self.children = []
root = Folder("")
etc = Folder("etc")
root.children.append(etc)
etc.children.append(File("passwd"))
etc.children.append(File("groups"))
httpd = Folder("httpd")
etc.children.append(httpd)
httpd.children.append(File("http.conf"))
var = Folder("var")
root.children.append(var)
log = Folder("log")
var.children.append(log)
log.children.append(File("messages"))
log.children.append(File("kernel"))

这个设置代码看起来很费力,但在一个真实的文件系统中,它会更加复杂。我们需要从硬盘读取数据并将其结构化成树。然而,一旦在内存中,输出文件系统中的每个文件的代码就非常优雅:

def walk(file):
    if isinstance(file, Folder):
        yield file.name + "/"
        for f in file.children:
 yield from walk(f)
    else:
        yield file.name

如果这段代码遇到一个目录,它会递归地要求walk()生成每个子目录下所有文件的列表,然后产生所有这些数据以及它自己的文件名。在它遇到一个普通文件的简单情况下,它只会产生那个文件名。

顺便说一句,解决前面的问题而不使用生成器是相当棘手的,以至于它是一个常见的面试问题。如果你像这样回答,准备好让你的面试官既印象深刻又有些恼火,因为你回答得如此轻松。他们可能会要求你解释到底发生了什么。当然,凭借你在本章学到的原则,你不会有任何问题。祝你好运!

yield from语法在编写链式生成器时是一个有用的快捷方式。它被添加到语言中是出于不同的原因,以支持协程。然而,它现在并没有被那么多地使用,因为它的用法已经被asyncawait语法所取代。我们将在下一节看到两者的例子。

协程

协程是非常强大的构造,经常被误解为生成器。许多作者不恰当地将协程描述为带有一些额外语法的生成器。这是一个容易犯的错误,因为在 Python 2.5 中引入协程时,它们被介绍为我们在生成器语法中添加了一个 send 方法。实际上,区别要更微妙一些,在看到一些例子之后会更有意义。

协程是相当难以理解的。在asyncio模块之外,它们在野外并不经常使用。你绝对可以跳过这一部分,快乐地在 Python 中开发多年,而不必遇到协程。有一些库广泛使用协程(主要用于并发或异步编程),但它们通常是这样编写的,以便你可以使用协程而不必真正理解它们是如何工作的!所以,如果你在这一部分迷失了方向,不要绝望。

如果我还没有吓到你,让我们开始吧!这是一个最简单的协程之一;它允许我们保持一个可以通过任意值增加的累加值:

def tally(): 
    score = 0 
    while True: 
 increment = yield score 
        score += increment 

这段代码看起来像是不可能工作的黑魔法,所以在逐行描述之前,让我们证明它可以工作。这个简单的对象可以被棒球队的记分应用程序使用。可以为每个队伍分别保留计分,并且他们的得分可以在每个半局结束时累加的得分增加。看看这个交互式会话:

>>> white_sox = tally()
>>> blue_jays = tally()
>>> next(white_sox)
0
>>> next(blue_jays)
0
>>> white_sox.send(3)
3
>>> blue_jays.send(2)
2
>>> white_sox.send(2)
5
>>> blue_jays.send(4)
6  

首先,我们构建了两个tally对象,一个用于每个队伍。是的,它们看起来像函数,但与上一节中的生成器对象一样,函数内部有yield语句告诉 Python 要付出很大的努力将简单的函数转换为对象。

然后我们对每个协程对象调用next()。这与调用任何生成器的next()做的事情是一样的,也就是说,它执行每一行代码,直到遇到yield语句,返回该点的值,然后暂停,直到下一个next()调用。

到目前为止,没有什么新鲜的。但是回顾一下我们协程中的yield语句:

increment = yield score

与生成器不同,这个yield函数看起来像是要返回一个值并将其赋给一个变量。事实上,这正是发生的事情。协程仍然在yield语句处暂停,等待被另一个next()调用再次激活。

除了我们不调用next()。正如你在交互式会话中看到的,我们调用一个名为send()的方法。send()方法和next()完全相同的事情,只是除了将生成器推进到下一个yield语句之外,它还允许你从生成器外部传入一个值。这个值被分配给yield语句的左侧。

对于许多人来说,真正令人困惑的是这发生的顺序:

  1. yield发生,生成器暂停
  2. send()发生在函数外部,生成器被唤醒
  3. 传入的值被分配给yield语句的左侧
  4. 生成器继续处理,直到遇到另一个yield语句

因此,在这个特定的例子中,我们构建了协程并通过单次调用next()将其推进到yield语句,然后每次调用send()都将一个值传递给协程。我们将这个值加到它的得分上。然后我们回到while循环的顶部,并继续处理,直到我们遇到yield语句。yield语句返回一个值,这个值成为我们最近一次调用send的返回值。不要错过这一点:像next()一样,send()方法不仅提交一个值给生成器,还返回即将到来的yield语句的值。这就是我们定义生成器和协程之间的区别的方式:生成器只产生值,而协程也可以消耗值。

next(i)i.__next__()i.send(value)的行为和语法相当不直观和令人沮丧。第一个是普通函数,第二个是特殊方法,最后一个是普通方法。但这三个都是做同样的事情:推进生成器直到它产生一个值并暂停。此外,next()函数和相关的方法可以通过调用i.send(None)来复制。在这里有两个不同的方法名是有价值的,因为它有助于我们的代码读者轻松地看到他们是在与协程还是生成器交互。我只是觉得在某些情况下它是一个函数调用,而在另一种情况下它是一个普通方法有点令人恼火。

回到日志解析

当然,前面的例子可以很容易地使用一对整数变量编码,并在它们上调用x += increment。让我们看一个第二个例子,其中协程实际上节省了我们一些代码。这个例子是我在 Facebook 工作时不得不解决的问题的一个简化版本(出于教学目的)。

Linux 内核日志包含几乎看起来与此类似但又不完全相同的行:

unrelated log messages 
sd 0:0:0:0 Attached Disk Drive 
unrelated log messages 
sd 0:0:0:0 (SERIAL=ZZ12345) 
unrelated log messages 
sd 0:0:0:0 [sda] Options 
unrelated log messages 
XFS ERROR [sda] 
unrelated log messages 
sd 2:0:0:1 Attached Disk Drive 
unrelated log messages 
sd 2:0:0:1 (SERIAL=ZZ67890) 
unrelated log messages 
sd 2:0:0:1 [sdb] Options 
unrelated log messages 
sd 3:0:1:8 Attached Disk Drive 
unrelated log messages 
sd 3:0:1:8 (SERIAL=WW11111) 
unrelated log messages 
sd 3:0:1:8 [sdc] Options 
unrelated log messages 
XFS ERROR [sdc] 
unrelated log messages 

有一大堆交错的内核日志消息,其中一些与硬盘有关。硬盘消息可能与其他消息交错,但它们以可预测的格式和顺序出现。对于每个硬盘,已知的序列号与总线标识符(如0:0:0:0)相关联。块设备标识符(如sda)也与该总线相关联。最后,如果驱动器的文件系统损坏,它可能会出现 XFS 错误。

现在,考虑到前面的日志文件,我们需要解决的问题是如何获取任何出现 XFS 错误的驱动器的序列号。这个序列号可能稍后会被数据中心的技术人员用来识别并更换驱动器。

我们知道我们可以使用正则表达式识别单独的行,但是我们将不得不在循环遍历行时更改正则表达式,因为我们将根据先前找到的内容寻找不同的东西。另一个困难的地方是,如果我们找到一个错误字符串,包含该字符串的总线以及序列号的信息已经被处理过。这可以通过以相反的顺序迭代文件的行来轻松解决。

在查看这个例子之前,请注意——基于协程的解决方案所需的代码量非常少:

import re
def match_regex(filename, regex):
    with open(filename) as file:
        lines = file.readlines()
    for line in reversed(lines):
        match = re.match(regex, line)
        if match:
 regex = yield match.groups()[0]
def get_serials(filename):
    ERROR_RE = "XFS ERROR (\[sd[a-z]\])"
    matcher = match_regex(filename, ERROR_RE)
    device = next(matcher)
    while True:
        try:
            bus = matcher.send(
                "(sd \S+) {}.*".format(re.escape(device))
            )
            serial = matcher.send("{} \(SERIAL=([^)]*)\)".format(bus))
 yield serial
            device = matcher.send(ERROR_RE)
        except StopIteration:
            matcher.close()
            return
for serial_number in get_serials("EXAMPLE_LOG.log"):
    print(serial_number)

这段代码将工作分成了两个独立的任务。第一个任务是循环遍历所有行并输出与给定正则表达式匹配的任何行。第二个任务是与第一个任务交互,并为其提供指导,告诉它在任何给定时间应该搜索什么正则表达式。

首先看match_regex协程。记住,它在构造时不执行任何代码;相反,它只创建一个协程对象。一旦构造完成,协程外部的某人最终会调用next()来启动代码运行。然后它存储两个变量filenameregex的状态。然后它读取文件中的所有行并以相反的顺序对它们进行迭代。将传入的每一行与正则表达式进行比较,直到找到匹配项。当找到匹配项时,协程会产生正则表达式的第一个组并等待。

在将来的某个时候,其他代码将发送一个新的正则表达式来搜索。请注意,协程从不关心它试图匹配的正则表达式是什么;它只是循环遍历行并将它们与正则表达式进行比较。决定提供什么正则表达式是别人的责任。

在这种情况下,其他人是get_serials生成器。它不关心文件中的行;事实上,它甚至不知道它们。它做的第一件事是从match_regex协程构造函数创建一个matcher对象,给它一个默认的正则表达式来搜索。它将协程推进到它的第一个yield并存储它返回的值。然后它进入一个循环,指示matcher对象基于存储的设备 ID 搜索总线 ID,然后基于该总线 ID 搜索序列号。

它在向外部for循环空闲地产生该序列号之前指示匹配器找到另一个设备 ID 并重复循环。

基本上,协程的工作是在文件中搜索下一个重要的行,而生成器(get_serial,它使用yield语法而不进行赋值)的工作是决定哪一行是重要的。生成器有关于这个特定问题的信息,比如文件中行的顺序。

另一方面,协程可以插入到需要搜索文件以获取给定正则表达式的任何问题中。

关闭协程和引发异常

普通的生成器通过引发StopIteration来信号它们的退出。如果我们将多个生成器链接在一起(例如,通过在另一个生成器内部迭代一个生成器),StopIteration异常将向外传播。最终,它将遇到一个for循环,看到异常并知道是时候退出循环了。

尽管它们使用类似的语法,协程通常不遵循迭代机制。通常不是通过一个直到遇到异常的数据,而是通常将数据推送到其中(使用send)。通常是负责推送的实体告诉协程何时完成。它通过在相关协程上调用close()方法来做到这一点。

当调用close()方法时,它将在协程等待发送值的点引发GeneratorExit异常。通常,协程应该将它们的yield语句包装在tryfinally块中,以便执行任何清理任务(例如关闭关联文件或套接字)。

如果我们需要在协程内部引发异常,我们可以类似地使用throw()方法。它接受一个异常类型,可选的valuetraceback参数。当我们在一个协程中遇到异常并希望在相邻的协程中引发异常时,后者是有用的,同时保持回溯。

前面的例子可以在没有协程的情况下编写,并且读起来几乎一样。事实上,正确地管理协程之间的所有状态是相当困难的,特别是当你考虑到上下文管理器和异常等因素时。幸运的是,Python 标准库包含一个名为asyncio的包,可以为您管理所有这些。一般来说,我建议您避免使用裸协程,除非您专门为 asyncio 编写代码。日志示例几乎可以被认为是一种反模式;一种应该避免而不是拥抱的设计模式。

协程、生成器和函数之间的关系

我们已经看到了协程的运行,现在让我们回到讨论它们与生成器的关系。在 Python 中,就像经常发生的情况一样,这种区别是相当模糊的。事实上,所有的协程都是生成器对象,作者经常交替使用这两个术语。有时,他们将协程描述为生成器的一个子集(只有从yield返回值的生成器被认为是协程)。这在 Python 中是技术上正确的,正如我们在前面的部分中看到的。

然而,在更广泛的理论计算机科学领域,协程被认为是更一般的原则,生成器是协程的一种特定类型。此外,普通函数是协程的另一个独特子集。

协程是一个可以在一个或多个点传入数据并在一个或多个点获取数据的例程。在 Python 中,数据传入和传出的点是yield语句。

函数,或子例程,是协程的最简单类型。您可以在一个点传入数据,并在函数返回时在另一个点获取数据。虽然函数可以有多个return语句,但对于任何给定的函数调用,只能调用其中一个。

最后,生成器是一种可以在一个点传入数据的协程,但可以在多个点传出数据的协程。在 Python 中,数据将在yield语句处传出,但无法再传入数据。如果调用send,数据将被悄悄丢弃。

因此,理论上,生成器是协程的一种类型,函数是协程的一种类型,还有一些既不是函数也不是生成器的协程。够简单了吧?那么,为什么在 Python 中感觉更复杂呢?

在 Python 中,生成器和协程都是使用类似于构造函数的语法构造的。但是生成的对象根本不是函数;它是一种完全不同类型的对象。函数当然也是对象。但它们有不同的接口;函数是可调用的并返回值,生成器使用next()提取数据,协程使用send推入数据。

还有一种使用asyncawait关键字的协程的替代语法。这种语法使得代码更清晰,表明代码是一个协程,并进一步打破了协程和生成器之间的欺骗性对称性。

案例研究

Python 目前最流行的领域之一是数据科学。为了纪念这一事实,让我们实现一个基本的机器学习算法。

机器学习是一个庞大的主题,但总体思想是利用从过去数据中获得的知识对未来数据进行预测或分类。这些算法的用途层出不穷,数据科学家每天都在找到应用机器学习的新方法。一些重要的机器学习应用包括计算机视觉(如图像分类或人脸识别)、产品推荐、识别垃圾邮件和自动驾驶汽车。

为了不偏离整本关于机器学习的书,我们将看一个更简单的问题:给定一个 RGB 颜色定义,人们会将该颜色定义为什么名字?

标准 RGB 颜色空间中有超过 1600 万种颜色,人类只为其中的一小部分取了名字。虽然有成千上万种名称(有些相当荒谬;只需去任何汽车经销商或油漆商店),让我们构建一个试图将 RGB 空间划分为基本颜色的分类器:

  • 红色
  • 紫色
  • 蓝色
  • 绿色
  • 黄色
  • 橙色
  • 灰色
  • 粉色

(在我的测试中,我将白色和黑色的颜色分类为灰色,棕色的颜色分类为橙色。)

我们需要的第一件事是一个数据集来训练我们的算法。在生产系统中,您可能会从颜色列表网站上获取数据,或者对成千上万的人进行调查。相反,我创建了一个简单的应用程序,它会呈现一个随机颜色,并要求用户从前面的八个选项中选择一个来分类。我使用了 Python 附带的用户界面工具包tkinter来实现它。我不打算详细介绍这个脚本的内容,但为了完整起见,这是它的全部内容(它有点长,所以您可能想从 Packt 的 GitHub 存储库中获取本书示例的完整内容,而不是自己输入):

import random
import tkinter as tk
import csv
class Application(tk.Frame):
    def __init__(self, master=None):
        super().__init__(master)
        self.grid(sticky="news")
        master.columnconfigure(0, weight=1)
        master.rowconfigure(0, weight=1)
        self.create_widgets()
        self.file = csv.writer(open("colors.csv", "a"))
    def create_color_button(self, label, column, row):
        button = tk.Button(
            self, command=lambda: self.click_color(label), text=label
        )
        button.grid(column=column, row=row, sticky="news")
    def random_color(self):
        r = random.randint(0, 255)
        g = random.randint(0, 255)
        b = random.randint(0, 255)
        return f"#{r:02x}{g:02x}{b:02x}"
    def create_widgets(self):
        self.color_box = tk.Label(
            self, bg=self.random_color(), width="30", height="15"
        )
        self.color_box.grid(
            column=0, columnspan=2, row=0, sticky="news"
        )
        self.create_color_button("Red", 0, 1)
        self.create_color_button("Purple", 1, 1)
        self.create_color_button("Blue", 0, 2)
        self.create_color_button("Green", 1, 2)
        self.create_color_button("Yellow", 0, 3)
        self.create_color_button("Orange", 1, 3)
        self.create_color_button("Pink", 0, 4)
        self.create_color_button("Grey", 1, 4)
        self.quit = tk.Button(
            self, text="Quit", command=root.destroy, bg="#ffaabb"
        )
        self.quit.grid(column=0, row=5, columnspan=2, sticky="news")
    def click_color(self, label):
        self.file.writerow([label, self.color_box["bg"]])
        self.color_box["bg"] = self.random_color()
root = tk.Tk()
app = Application(master=root)
app.mainloop()

如果您愿意,可以轻松添加更多按钮以获取其他颜色。您可能会在布局上遇到问题;create_color_button的第二个和第三个参数表示按钮所在的两列网格的行和列。一旦您将所有颜色放在位,您将希望将退出按钮移动到最后一行。

对于这个案例研究,了解这个应用程序的重要事情是输出。它创建了一个名为colors.csv逗号分隔值CSV)文件。该文件包含两个 CSV:用户为颜色分配的标签和颜色的十六进制 RGB 值。以下是一个示例:

Green,#6edd13
Purple,#814faf
Yellow,#c7c26d
Orange,#61442c
Green,#67f496
Purple,#c757d5
Blue,#106a98
Pink,#d40491
.
.
.
Blue,#a4bdfa
Green,#30882f
Pink,#f47aad
Green,#83ddb2
Grey,#baaec9
Grey,#8aa28d
Blue,#533eda

在我厌倦并决定开始对我的数据集进行机器学习之前,我制作了 250 多个数据点。如果您想使用它,我的数据点已经与本章的示例一起提供(没有人告诉我我是色盲,所以它应该是合理的)。

我们将实现一种更简单的机器学习算法,称为k 最近邻。该算法依赖于数据集中点之间的某种距离计算(在我们的情况下,我们可以使用三维版本的毕达哥拉斯定理)。给定一个新的数据点,它找到一定数量(称为k,这是k 最近邻中的k)的数据点,这些数据点在通过该距离计算进行测量时最接近它。然后以某种方式组合这些数据点(对于线性计算,平均值可能有效;对于我们的分类问题,我们将使用模式),并返回结果。

我们不会详细介绍算法的工作原理;相反,我们将专注于如何将迭代器模式或迭代器协议应用于这个问题。

现在让我们编写一个程序,按顺序执行以下步骤:

  1. 从文件中加载示例数据并构建模型。
  2. 生成 100 种随机颜色。
  3. 对每种颜色进行分类,并以与输入相同的格式输出到文件。

第一步是一个相当简单的生成器,它加载 CSV 数据并将其转换为符合我们需求的格式:

import csv
dataset_filename = "colors.csv"
def load_colors(filename):
    with open(filename) as dataset_file:
        lines = csv.reader(dataset_file)
 for line in lines:
            label, hex_color = line
 yield (hex_to_rgb(hex_color), label)

我们以前没有见过csv.reader函数。它返回文件中行的迭代器。迭代器返回的每个值都是一个由逗号分隔的字符串列表。因此,行Green,#6edd13返回为["Green", "#6edd13"]

然后load_colors生成器逐行消耗该迭代器,并产生 RGB 值的元组以及标签。这种方式将生成器链接在一起是非常常见的,其中一个迭代器调用另一个迭代器,依此类推。您可能希望查看 Python 标准库中的itertools模块,其中有许多等待您的现成生成器。

在这种情况下,RGB 值是 0 到 255 之间的整数元组。从十六进制到 RGB 的转换有点棘手,因此我们将其提取到一个单独的函数中:

def hex_to_rgb(hex_color):
    return tuple(int(hex_color[i : i + 2], 16) for i in range(1, 6, 2))

这个生成器表达式正在做很多工作。它以“#12abfe”这样的字符串作为输入,并返回一个类似(18, 171, 254)的元组。让我们从后往前分解。

range调用将返回数字[1, 3, 5]。这些数字代表十六进制字符串中三个颜色通道的索引。索引0被跳过,因为它代表字符“#”,而我们不关心这个字符。对于这三个数字中的每一个,它提取ii+2之间的两个字符的字符串。对于前面的示例字符串,这将是12abfe。然后将此字符串值转换为整数。作为int函数的第二个参数传递的16告诉函数使用基数 16(十六进制)而不是通常的基数 10(十进制)进行转换。

考虑到生成器表达式的阅读难度,您认为它应该以不同的格式表示吗?例如,它可以被创建为多个生成器表达式的序列,或者展开为一个带有yield语句的普通生成器函数。您更喜欢哪种?

在这种情况下,我相信函数名称能够解释这行丑陋代码在做什么。

现在我们已经加载了训练数据(手动分类的颜色),我们需要一些新数据来测试算法的工作效果。我们可以通过生成一百种随机颜色来实现这一点,每种颜色由 0 到 255 之间的三个随机数字组成。

有很多方法可以做到这一点:

  • 一个带有嵌套生成器表达式的列表推导:[tuple(randint(0,255) for c in range(3)) for r in range(100)]
  • 一个基本的生成器函数
  • 实现__iter____next__协议的类
  • 通过一系列协同程序将数据传递
  • 即使只是一个基本的for循环

生成器版本似乎最易读,所以让我们将该函数添加到我们的程序中:

from random import randint
def generate_colors(count=100):
    for i in range(count):
        yield (randint(0, 255), randint(0, 255), randint(0, 255))

注意我们如何对要生成的颜色数量进行参数化。现在我们可以在将来重用这个函数来执行其他生成颜色的任务。

现在,在进行分类之前,我们需要一个计算两种颜色之间距离的函数。由于可以将颜色看作是三维的(例如,红色、绿色和蓝色可以映射到xyz轴),让我们使用一些基本的数学:

def color_distance(color1, color2):
    channels = zip(color1, color2)
    sum_distance_squared = 0
    for c1, c2 in channels:
        sum_distance_squared += (c1 - c2) ** 2
    return sum_distance_squared

这是一个看起来非常基本的函数;它看起来甚至没有使用迭代器协议。没有yield函数,也没有推导。但是,有一个for循环,zip函数的调用也在进行一些真正的迭代(如果您不熟悉它,zip会产生元组,每个元组包含来自每个输入迭代器的一个元素)。

这个距离计算是你可能从学校记得的勾股定理的三维版本:a² + b² = c²。由于我们使用了三个维度,我猜实际上应该是a² + b² + c² = d²。距离在技术上是a² + b² + c²的平方根,但没有必要执行相对昂贵的sqrt计算,因为平方距离在大小上都是相同的。

现在我们已经有了一些基本的管道,让我们来实现实际的 k-nearest neighbor。这个例程可以被认为是消耗和组合我们已经看到的两个生成器(load_colorsgenerate_colors):

def nearest_neighbors(model_colors, target_colors, num_neighbors=5):
    model_colors = list(model_colors)
    for target in target_colors:
        distances = sorted(
            ((color_distance(c[0], target), c) for c in model_colors)
        )
        yield target, distances[:5]

首先,我们将model_colors生成器转换为列表,因为它必须被多次使用,每次用于target_colors中的一个。如果我们不这样做,就必须重复从源文件加载颜色,这将执行大量不必要的磁盘读取。

这种决定的缺点是整个列表必须一次性全部存储在内存中。如果我们有一个无法放入内存的大型数据集,实际上需要每次从磁盘重新加载生成器(尽管在这种情况下,我们实际上会考虑不同的机器学习算法)。

nearest_neighbors生成器循环遍历每个目标颜色(例如(255, 14, 168)的三元组),并在生成器表达式中调用color_distance函数。然后,sorted调用对该生成器表达式的结果按其第一个元素进行排序,即距离。这是一段复杂的代码,一点也不面向对象。您可能需要将其分解为普通的for循环,以确保您理解生成器表达式在做什么。

yield语句稍微复杂一些。对于target_colors生成器中的每个 RGB 三元组,它产生目标和num_neighbors(这是kk-nearest中,顺便说一下,许多数学家和数据科学家倾向于使用难以理解的单字母变量名)最接近的颜色的列表推导。

列表推导中的每个元素的内容是model_colors生成器的一个元素;也就是说,一个包含三个 RGB 值和手动输入的字符串名称的元组。因此,一个元素可能看起来像这样:((104, 195, 77), 'Green')。当我看到嵌套元组时,我首先想到的是,这不是正确的数据结构。RGB 颜色可能应该表示为一个命名元组,并且这两个属性可能应该放在一个数据类上。

我们现在可以添加另一个生成器到链中,以找出我们应该给这个目标颜色起什么名字:

from collections import Counter
def name_colors(model_colors, target_colors, num_neighbors=5):
    for target, near in nearest_neighbors(
        model_colors, target_colors, num_neighbors=5
    ):
        print(target, near)
        name_guess = Counter(n[1] for n in near).most_common()[0][0]
        yield target, name_guess

这个生成器将nearest_neighbors返回的元组解包成三元组目标和五个最近的数据点。它使用Counter来找到在返回的颜色中最常出现的名称。在Counter构造函数中还有另一个生成器表达式;这个生成器表达式从每个数据点中提取第二个元素(颜色名称)。然后它产生一个 RGB 值和猜测的名称的元组。返回值的一个例子是(91, 158, 250) Blue

我们可以编写一个函数,接受name_colors生成器的输出,并将其写入 CSV 文件,RGB 颜色表示为十六进制值:

def write_results(colors, filename="output.csv"):
    with open(filename, "w") as file:
        writer = csv.writer(file)
        for (r, g, b), name in colors:
            writer.writerow([name, f"#{r:02x}{g:02x}{b:02x}"])

这是一个函数,而不是一个生成器。它在for循环中消耗生成器,但它不产生任何东西。它构造了一个 CSV 写入器,并为每个目标颜色输出名称、十六进制值(例如Purple,#7f5f95)对的行。这里可能会让人困惑的唯一一件事是格式字符串的内容。与每个rgb通道一起使用的:02x修饰符将数字输出为前导零填充的两位十六进制数。

现在我们所要做的就是将这些不同的生成器和管道连接在一起,并通过一个函数调用启动整个过程:

def process_colors(dataset_filename="colors.csv"):
    model_colors = load_colors(dataset_filename)
    colors = name_colors(model_colors, generate_colors(), 5)
    write_results(colors)
if __name__ == "__main__":
    process_colors()

因此,这个函数与我们定义的几乎所有其他函数不同,它是一个完全正常的函数,没有yield语句或for循环。它根本不进行任何迭代。

然而,它构造了三个生成器。你能看到所有三个吗?:

  • load_colors返回一个生成器
  • generate_colors返回一个生成器
  • name_guess返回一个生成器

name_guess生成器消耗了前两个生成器。然后,它又被write_results函数消耗。

我写了第二个 Tkinter 应用程序来检查算法的准确性。它与第一个应用程序类似,只是它会渲染每种颜色及与该颜色相关联的标签。然后你必须手动点击是或否,以确定标签是否与颜色匹配。对于我的示例数据,我得到了大约 95%的准确性。通过实施以下内容,这个准确性可以得到提高:

  • 添加更多颜色名称
  • 通过手动分类更多颜色来添加更多的训练数据
  • 调整num_neighbors的值
  • 使用更高级的机器学习算法

这是输出检查应用的代码,不过我建议下载示例代码。这样打字会很麻烦:

import tkinter as tk
import csv
class Application(tk.Frame):
    def __init__(self, master=None):
        super().__init__(master)
        self.grid(sticky="news")
        master.columnconfigure(0, weight=1)
        master.rowconfigure(0, weight=1)
        self.csv_reader = csv.reader(open("output.csv"))
        self.create_widgets()
        self.total_count = 0
        self.right_count = 0
    def next_color(self):
        return next(self.csv_reader)
    def mk_grid(self, widget, column, row, columnspan=1):
        widget.grid(
            column=column, row=row, columnspan=columnspan, sticky="news"
        )
    def create_widgets(self):
        color_text, color_bg = self.next_color()
        self.color_box = tk.Label(
            self, bg=color_bg, width="30", height="15"
        )
        self.mk_grid(self.color_box, 0, 0, 2)
        self.color_label = tk.Label(self, text=color_text, height="3")
        self.mk_grid(self.color_label, 0, 1, 2)
        self.no_button = tk.Button(
            self, command=self.count_next, text="No"
        )
        self.mk_grid(self.no_button, 0, 2)
        self.yes_button = tk.Button(
            self, command=self.count_yes, text="Yes"
        )
        self.mk_grid(self.yes_button, 1, 2)
        self.percent_accurate = tk.Label(self, height="3", text="0%")
        self.mk_grid(self.percent_accurate, 0, 3, 2)
        self.quit = tk.Button(
            self, text="Quit", command=root.destroy, bg="#ffaabb"
        )
        self.mk_grid(self.quit, 0, 4, 2)
    def count_yes(self):
        self.right_count += 1
        self.count_next()
    def count_next(self):
        self.total_count += 1
        percentage = self.right_count / self.total_count
        self.percent_accurate["text"] = f"{percentage:.0%}"
        try:
            color_text, color_bg = self.next_color()
        except StopIteration:
            color_text = "DONE"
            color_bg = "#ffffff"
            self.color_box["text"] = "DONE"
            self.yes_button["state"] = tk.DISABLED
            self.no_button["state"] = tk.DISABLED
        self.color_label["text"] = color_text
        self.color_box["bg"] = color_bg
root = tk.Tk()
app = Application(master=root)
app.mainloop()

你可能会想,这与面向对象编程有什么关系?这段代码中甚至没有一个类! 从某些方面来说,你是对的;生成器通常不被认为是面向对象的。然而,创建它们的函数返回对象;实际上,你可以把这些函数看作构造函数。构造的对象有一个适当的__next__()方法。基本上,生成器语法是一种特定类型的对象的语法快捷方式,如果没有它,创建这种对象会非常冗长。

练习

如果你在日常编码中很少使用推导,那么你应该做的第一件事是搜索一些现有的代码,找到一些for循环。看看它们中是否有任何可以轻松转换为生成器表达式或列表、集合或字典推导的。

测试列表推导是否比for循环更快。这可以通过内置的timeit模块来完成。使用timeit.timeit函数的帮助文档找出如何使用它。基本上,编写两个做同样事情的函数,一个使用列表推导,一个使用for循环来迭代数千个项目。将每个函数传入timeit.timeit,并比较结果。如果你感到有冒险精神,也可以比较生成器和生成器表达式。使用timeit测试代码可能会让人上瘾,所以请记住,除非代码被执行了大量次数,比如在一个巨大的输入列表或文件上,否则代码不需要非常快。

玩转生成器函数。从需要多个值的基本迭代器开始(数学序列是典型的例子;如果你想不出更好的例子,斐波那契数列已经被过度使用了)。尝试一些更高级的生成器,比如接受多个输入列表并以某种方式产生合并值的生成器。生成器也可以用在文件上;你能否编写一个简单的生成器,显示两个文件中相同的行?

协程滥用迭代器协议,但实际上并不符合迭代器模式。你能否构建一个非协程版本的代码,从日志文件中获取序列号?采用面向对象的方法,以便在类上存储额外的状态。如果你能创建一个对象,它可以完全替代现有的协程,你将学到很多关于协程的知识。

本章的案例研究中有很多奇怪的元组传递,很难跟踪。看看是否可以用更面向对象的解决方案替换这些返回值。另外,尝试将一些共享数据的函数(例如model_colorstarget_colors)移入一个类中进行实验。这样可以减少大多数生成器需要传入的参数数量,因为它们可以在self上查找。

总结

在本章中,我们了解到设计模式是有用的抽象,为常见的编程问题提供最佳实践解决方案。我们介绍了我们的第一个设计模式,迭代器,以及 Python 使用和滥用这种模式的多种方式。原始的迭代器模式非常面向对象,但在代码上也相当丑陋和冗长。然而,Python 的内置语法将丑陋抽象化,为我们留下了这些面向对象构造的清晰接口。

理解推导和生成器表达式可以将容器构造与迭代结合在一行中。生成器对象可以使用yield语法构造。协程在外部看起来像生成器,但用途完全不同。

我们将在接下来的两章中介绍几种设计模式。

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
2月前
|
存储 数据挖掘 开发者
Python编程入门:从零到英雄
在这篇文章中,我们将一起踏上Python编程的奇幻之旅。无论你是编程新手,还是希望拓展技能的开发者,本教程都将为你提供一条清晰的道路,引导你从基础语法走向实际应用。通过精心设计的代码示例和练习,你将学会如何用Python解决实际问题,并准备好迎接更复杂的编程挑战。让我们一起探索这个强大的语言,开启你的编程生涯吧!
|
1月前
|
存储 数据采集 人工智能
Python编程入门:从零基础到实战应用
本文是一篇面向初学者的Python编程教程,旨在帮助读者从零开始学习Python编程语言。文章首先介绍了Python的基本概念和特点,然后通过一个简单的例子展示了如何编写Python代码。接下来,文章详细介绍了Python的数据类型、变量、运算符、控制结构、函数等基本语法知识。最后,文章通过一个实战项目——制作一个简单的计算器程序,帮助读者巩固所学知识并提高编程技能。
|
1月前
|
机器学习/深度学习 数据可视化 数据挖掘
使用Python进行数据分析的入门指南
本文将引导读者了解如何使用Python进行数据分析,从安装必要的库到执行基础的数据操作和可视化。通过本文的学习,你将能够开始自己的数据分析之旅,并掌握如何利用Python来揭示数据背后的故事。
|
3天前
|
存储 数据挖掘 数据处理
Python Pandas入门:行与列快速上手与优化技巧
Pandas是Python中强大的数据分析库,广泛应用于数据科学和数据分析领域。本文为初学者介绍Pandas的基本操作,包括安装、创建DataFrame、行与列的操作及优化技巧。通过实例讲解如何选择、添加、删除行与列,并提供链式操作、向量化处理、索引优化等高效使用Pandas的建议,帮助用户在实际工作中更便捷地处理数据。
12 2
|
9天前
|
人工智能 编译器 Python
python已经安装有其他用途如何用hbuilerx配置环境-附带实例demo-python开发入门之hbuilderx编译器如何配置python环境—hbuilderx配置python环境优雅草央千澈
python已经安装有其他用途如何用hbuilerx配置环境-附带实例demo-python开发入门之hbuilderx编译器如何配置python环境—hbuilderx配置python环境优雅草央千澈
python已经安装有其他用途如何用hbuilerx配置环境-附带实例demo-python开发入门之hbuilderx编译器如何配置python环境—hbuilderx配置python环境优雅草央千澈
|
1月前
|
IDE 程序员 开发工具
Python编程入门:打造你的第一个程序
迈出编程的第一步,就像在未知的海洋中航行。本文是你启航的指南针,带你了解Python这门语言的魅力所在,并手把手教你构建第一个属于自己的程序。从安装环境到编写代码,我们将一步步走过这段旅程。准备好了吗?让我们开始吧!
|
1月前
|
测试技术 开发者 Python
探索Python中的装饰器:从入门到实践
装饰器,在Python中是一块强大的语法糖,它允许我们在不修改原函数代码的情况下增加额外的功能。本文将通过简单易懂的语言和实例,带你一步步了解装饰器的基本概念、使用方法以及如何自定义装饰器。我们还将探讨装饰器在实战中的应用,让你能够在实际编程中灵活运用这一技术。
40 7
|
1月前
|
开发者 Python
Python中的装饰器:从入门到实践
本文将深入探讨Python的装饰器,这一强大工具允许开发者在不修改现有函数代码的情况下增加额外的功能。我们将通过实例学习如何创建和应用装饰器,并探索它们背后的原理和高级用法。
46 5
|
1月前
|
机器学习/深度学习 人工智能 算法
深度学习入门:用Python构建你的第一个神经网络
在人工智能的海洋中,深度学习是那艘能够带你远航的船。本文将作为你的航标,引导你搭建第一个神经网络模型,让你领略深度学习的魅力。通过简单直观的语言和实例,我们将一起探索隐藏在数据背后的模式,体验从零开始创造智能系统的快感。准备好了吗?让我们启航吧!
83 3
|
1月前
|
Python
Python编程入门:从零开始的代码旅程
本文是一篇针对Python编程初学者的入门指南,将介绍Python的基本语法、数据类型、控制结构以及函数等概念。文章旨在帮助读者快速掌握Python编程的基础知识,并能够编写简单的Python程序。通过本文的学习,读者将能够理解Python代码的基本结构和逻辑,为进一步深入学习打下坚实的基础。