秘密特工的-Python-指南-全-
秘密特工的 Python 指南(全)
原文:
zh.annas-archive.org/md5/6fc9e1ee56e86a1bad907f77155c987c译者:飞龙
前言
每个特工都需要一套好的工具和设备。当一个特工的任务涉及收集数据时,就需要进行高强度的数据处理。这本书将为你提供各种信息处理工具,帮助你收集、分析和传达总部所需的数据。
Python 允许特工编写简单的脚本来收集数据、执行复杂的计算并产生有用的结果。特工还可以使用 Python 从本地文件、HTTP 网页服务器和 FTP 文件服务器中提取数据。
Python 有许多附加包。这本书将探讨其中的两个:Pillow 允许进行复杂的图像转换和处理,而 BeautifulSoup 允许特工从 HTML 网页中提取数据。有特定需求的特工可能需要探索 Python 自然语言工具包(NLTK)、数值 Python(NumPy)甚至科学 Python(SciPy)。
本书涵盖的内容
第一章,我们的间谍工具包,揭示了安装和使用 Python 的基础知识。我们将编写脚本来帮助特工处理外币转换,并学习特工如何从 ZIP 存档中恢复丢失的密码。
第二章,获取情报数据,展示了我们如何使用 Python 从各种类型的文件服务器中提取信息。特工将学习如何与不同的互联网协议一起工作,并使用表示状态转移(REST)与网络服务进行交互。这包括处理加密货币,如比特币的技术。
第三章,使用隐写术编码秘密信息,展示了我们如何将 Pillow 工具集添加到图像处理中。拥有 Pillow 的特工可以创建缩略图,转换、裁剪和增强图像。我们还将探索一些隐写术算法,将我们的信息隐藏在图像文件中。
第四章,投递点、藏身之处、会面和巢穴,更深入地探讨了地理编码和地理定位。这包括使用网络服务将地址转换为经纬度。我们还将学习如何将经纬度转换回地址。我们将研究哈弗辛公式来获取地点之间的正确距离。我们还将探讨一些表示地理位置的方法,以便于整洁的存储和通信。
第五章,间谍大师的更敏感分析,展示了我们如何使用 Python 进行基本的数据分析。一个好的特工不仅仅是说出事实和数字;一个好的特工会进行足够的分析来确认数据是真实的。能够检查数据集之间的相关性是创造有价值的情报资产的关键。
你需要这本书的内容
一个神秘代理需要一个他们有管理权限的计算机。我们将安装额外的软件。如果没有管理密码,他们可能难以安装 Python 3、Pillow 或 BeautifulSoup。
对于使用 Windows 的代理,我们想要添加的包是预构建的。
对于使用 Linux 的代理,需要开发者工具。Linux 拥有一套完整的开发者工具,这些工具很常见。GNU C 编译器(GCC)是这些工具的支柱。
对于使用 Mac OS X 的代理,所需的开发者工具是 Xcode (developer.apple.com/xcode/)。我们还需要安装一个名为 homebrew (brew.sh) 的工具,以帮助我们向 Mac OS X 添加 Linux 包。
Python 3 可从 Python 下载页面 www.python.org/download 获取。
我们将下载并安装除了 Python 3.3 以外的几个东西:
-
setuptools 包,包括
easy_install-3.3,将帮助我们添加包。它可以从pypi.python.org/pypi/setuptools下载。 -
PIP 包也将帮助我们安装额外的包。一些经验丰富的现场代理更喜欢 PIP 而不是 setuptools。它可以从
pypi.python.org/pypi/pip/1.5.6下载。 -
Pillow 包将使我们能够处理图像文件。它可以从
pypi.python.org/pypi/Pillow/2.4.0下载。 -
BeautifulSoup 版本 4 包将使我们能够处理 HTML 网页。它可以从
pypi.python.org/pypi/beautifulsoup4/4.3.2下载。
从这里,我们将看到 Python 的可扩展性。几乎任何代理可能需要的功能可能已经编写好并通过 Python 包索引(PyPi)提供,可以从 pypi.python.org/pypi 下载。
这本书面向的对象
这本书是为那些不太了解 Python 但舒适安装新软件并准备好在 Python 中进行一些巧妙编程的神秘代理而写的。一个以前从未编程过的代理可能会发现其中的一些内容有点高级;一本涵盖 Python 基础的入门教程可能会有所帮助。
我们期望使用这本书的代理对简单的数学感到舒适。这包括货币转换的乘法和除法。它还包括多项式、简单的三角学和几个统计公式。
我们还期望使用这本书的神秘代理会进行自己的调查。这本书的示例旨在帮助代理开始开发有趣、有用的应用程序。每个代理都必须自己进一步探索。
习惯用法
在这本书中,你将找到多种文本样式,用以区分不同类型的信息。以下是一些这些样式的示例,以及它们的意义解释。
文本中的代码词汇、包名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名将如下所示:“size_list变量是由编码大小的字节组成的八个元组的序列。”
代码块将以如下方式设置:
message_bytes= message.encode("UTF-8")
bits_list = list(to_bits(c) for c in message_bytes )
len_h, len_l = divmod( len(message_bytes), 256 )
size_list = [to_bits(len_h), to_bits(len_l)]
bit_sequence( size_list+bits_list )
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
w, h = ship.size
for p,m in enumerate( bit_sequence(size_list+bits_list) ):
y, x = divmod( p, w )
r, g, b = ship.getpixel( (x,y) )
r_new = (r & 0xfe) | m
print( (r, g, b), m, (r_new, g, b) )
ship.putpixel( (x,y), (r_new, g, b) )
任何命令行输入或输出都应如下编写:
$ python3.3 -m doctest ourfile.py
新术语和重要词汇将以粗体显示。屏幕上看到的词汇,例如在菜单或对话框中,将以文本中的这种方式显示:“有一个定义这些文件关联的高级设置面板。”
注意
警告或重要注意事项将以这样的框显示。
提示
技巧和窍门将以这样的形式出现。
读者反馈
我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大收益的标题非常重要。
要发送给我们一般性的反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在邮件的主题中提及书名。
如果你在一个主题上具有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
既然你已经是 Packt Publishing 书籍的骄傲拥有者,我们有许多事情可以帮助你从购买中获得最大收益。
下载示例代码
你可以从你购买的所有 Packt Publishing 书籍的账户中下载示例代码文件。www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。任何现有勘误都可以通过从 www.packtpub.com/support 选择您的标题来查看。
盗版
在互联网上,版权材料的盗版是一个跨所有媒体的持续问题。在 Packt Publishing,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 与我们联系,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和提供有价值内容方面的帮助。
问题
如果你在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。
第一章. 我们的间谍工具包
间谍的工作是收集和分析数据。这要求我们使用计算机和软件工具。
普通的桌面工具(文字处理器、电子表格等)无法满足我们需要应对的工作类型。对于严肃的数据收集、分析和传播,我们需要更强大的工具。当我们考虑自动化数据收集时,我们无法轻易使用需要手动点击的桌面工具。我们希望有一个可以自主运行为我们工作的工具,而不需要有人在桌前操作。
我们可以使用的最强大的数据分析工具之一是 Python。我们将通过一系列使用 Python 进行实际数据收集和分析的示例来逐步介绍。本章将涵盖以下主题:
-
首先,我们将下载并安装最新的 Python 版本。
-
我们将在后续章节中用
easy_install(或pip)工具来补充 Python,帮助我们收集额外的软件工具。 -
我们将简要地查看 Python 的内部帮助系统。
-
我们将探讨 Python 如何处理数字。毕竟,特工收集数据的工作将涉及数字。
-
我们将花一些时间来学习编写 Python 应用程序的第一步。我们将把我们的应用程序称为脚本以及模块。
-
我们将把文件输入和输出分成几个部分。我们将有一个快速概述,以及深入查看 ZIP 存档文件。在后续章节中,我们将查看更多类型的文件。
-
我们的大任务是应用我们的技能来恢复 ZIP 文件的丢失密码。这不会很容易,但我们应该已经掌握了足够的基础知识来成功完成。
这将使我们具备足够的 Python 技能,以便我们可以在下一章中进入更复杂的任务。
获取行业工具 - Python 3.3
使用 Python 的第一步是将 Python 语言安装到我们的计算机上。如果你的计算机使用 Mac OS X 或 Linux,你可能已经安装了 Python。在撰写本文时,它是 Python 2.7,而不是 3.3。在这种情况下,我们除了已经安装的 Python 2.7 之外,还需要安装 Python 3.3。
Windows 特工通常没有 Python 的任何版本,需要安装 Python 3.3。
小贴士
Python 3 不是“Python 2.7 加上一些特性”。Python 3 是一种独立的语言。本书不涵盖 Python 2.7。示例在 Python 2 中真的无法工作。
Python 下载可在www.python.org/download/找到。
找到适合你计算机的正确版本。Windows、Linux 和 Mac OS X 有许多预构建的二进制文件。Linux 特工应关注适合其发行版的二进制文件。每个下载和安装都会略有不同;我们无法涵盖所有细节。
Python 有几种实现方式。我们将关注 CPython。对于某些任务,你可能想看看 Jython,它是使用 Java 虚拟机实现的 Python。如果你在使用其他 .NET 产品,你可能需要 Iron Python。在某些情况下,你可能对 PyPy 感兴趣,它是用 Python 实现的 Python。(是的,这似乎是循环和矛盾的。它真的很有趣,但超出了我们的关注范围。)
Python 不仅仅是一个工具。它是起点。在后面的章节中,我们将下载额外的工具。似乎我们作为秘密特工的工作有一半是寻找解决特定复杂问题所需的工具。另一半则是获取信息。
Windows 的秘密
为你的 Windows 版本下载 Python 3.3(或更高版本)的安装程序。当你运行安装程序时,你将需要回答一系列关于安装位置和安装内容的问题。
确保将 Python 安装在具有简单名称的目录中。在 Windows 中,常见的选项是 C:\Python33。使用名称中包含空格的 Windows 目录(如 Program Files、My Documents 或 Documents and Settings)可能会导致混淆。
一定要安装 Tcl/Tk 组件。这将确保你拥有支持 IDLE 所需的所有元素。IDLE 是 Python 附带的方便的文本编辑器。对于 Windows 用户,这通常包含在安装包中。你只需要确保在安装向导中勾选了它。
在 Windows 系统中,python.exe 程序的名称中不包含版本号,这是不寻常的。
Mac OS X 的秘密
在 Mac OS X 中,已经有一个 Python 安装,通常是 Python 2.7。这必须保持完整。
下载适用于 Mac OS X 的 Python 3.3(或更高版本)安装程序。当你运行这个程序时,你将添加 Python 的第二个版本。这意味着许多附加模块和工具必须与 Python 的正确版本相关联。这需要一点小心。使用模糊命名的工具如 easy_install 并不好。重要的是使用更具体的 easy_install-3.3,它可以识别你正在使用的 Python 版本。
命名为 python 的程序通常是一个指向 python2.7 的别名。这也必须保持完整。在这本书中,我们始终会明确使用 python3(也称为 python3.3)。你可以通过使用 shell 命令来确认这一点。
注意,Mac OS X 有几个版本的 Tcl/Tk 可用。Python 网站会指导你到特定的版本。在撰写本文时,这个版本是 ActiveTCL 8.5.14,来自 ActiveState 软件。你也需要安装这个版本。这款软件允许我们使用 IDLE。
访问 www.activestate.com/activetcl/downloads 获取正确的版本。
获取更多工具 – 文本编辑器
要创建 Python 应用程序,我们需要一个合适的程序员文本编辑器。文字处理器是不够的,因为文字处理器创建的文件太复杂了。我们需要简单的文本。我们强调的是简单性。Python3 在 Unicode 下工作,不需要加粗或斜体内容。(Python 2 与 Unicode 的兼容性不是很好。这也是我们放弃它的原因之一。)
如果你已经使用过文本编辑器或集成开发环境(IDEs),你可能已经有一个喜欢的。请随意使用它。一些流行的 IDE 支持 Python。
Python 被称为动态语言。在特定上下文中确定哪些名称或关键字是合法的并不总是简单。Python 编译器不执行很多静态检查。IDE 不能轻易地提供一个所有合法替代品的简短列表。一些 IDE 尝试提供逻辑建议,但它们并不一定是完整的。
如果你还没有使用过程序员编辑器(或 IDE),你的第一个任务是找到一个你可以使用的文本编辑器。Python 包含一个名为 IDLE 的编辑器,它易于使用。这是一个很好的开始地方。
Active State Komodo Edit 可能很合适(komodoide.com/komodo-edit/)。它是商业产品的轻量级版本。它有一些非常巧妙的方式来处理 Python 动态语言方面。
有许多其他的代码编辑器。你的第一个训练任务是找到一个你可以使用的。你将独立完成。我们对你有信心。
获取其他开发者工具
大多数 GNU/Linux 代理都有各种 C 编译器和其他开发者工具可用。许多 Linux 发行版已经配置为支持开发者,因此工具已经准备好了。
Mac OS X 代理通常需要 Xcode。从developer.apple.com/xcode/downloads/获取它。每个 Mac OS X 代理都应该有这个。
在安装此软件时,请确保还安装了命令行开发者工具。这又是一个比基本 Xcode 下载更大的下载。
Windows 代理通常会发现大多数感兴趣的软件包都有预构建的二进制文件。如果,在极少数情况下,预构建的代码不可用,可能需要使用 Cygwin 等工具。请参阅www.cygwin.com。
获取更多 Python 组件的工具
为了有效地简单地下载额外的 Python 模块,我们经常使用一个获取工具的工具。有两种流行的方法可以添加 Python 模块:PIP 和easy_install脚本。
要安装easy_install,请访问pypi.python.org/pypi/setuptools/3.6。
setuptools软件包将包括easy_install脚本,我们将使用它来添加 Python 模块。
如果您安装了多个版本的 Python,请确保下载并安装正确的 Python 3.3 版本的 easy install。这意味着您通常将使用 easy_install-3.3 脚本来添加新的软件工具。
要安装 PIP,请访问 pypi.python.org/pypi/pip/1.5.6。
我们将在 第三章 使用隐写术编码秘密信息 中添加 Pillow 包。我们还将添加 Beautiful Soup 包在 第四章 投递点、藏身之处、聚会和巢穴 中。
Python 3.4 分发应包括 PIP 工具。您不需要单独下载它。
确认我们的工具
为了确保我们有一个工作的 Python 工具,最好从命令提示符进行检查。我们将使用命令提示符来完成大部分工作。这涉及最少的开销,并且是与 Python 最直接的联系。
Python 3.3 程序显示一个启动信息,如下所示:
MacBookPro-SLott:Secret Agent's Python slott$ python3
Python 3.3.4 (v3.3.4:7ff62415e426, Feb 9 2014, 00:29:34)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type help, copyright, credits or license for more information.
>>>
我们已经展示了操作系统的提示(MacBookPro-SLott:Secret Agent's Python slott$),我们输入的命令(python3),以及 Python 的响应。
Python 提供了三条介绍信息,随后是其自己的 >>> 提示符。第一行显示它是 Python 3.3.4。第二行显示构建 Python 所使用的工具(GCC 4.2.1)。第三行提供了关于我们可能接下来要做什么的一些提示。
我们已经与 Python 交互。一切正常。
小贴士
下载示例代码
您可以从您在 www.packtpub.com 的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册以将文件直接通过电子邮件发送给您。
随意输入 版权, 致谢, 和 许可 到 >>> 提示符。它们可能很无聊,但它们作为确认事情正在工作的依据。
重要的是要注意,这些对象(版权, 致谢, 和 许可)在 Python 语言中不是命令或动词。它们是作为方便首次使用 Python 的代理而创建的全局变量。当评估时,它们会显示文本块。
我们还会使用另外两个启动对象:exit 和 help。这些提供了一些提示信息,提醒我们使用 help() 和 exit() 函数。
我们该如何停止?
我们可以始终输入 exit 来获取如何从交互式 Python 中退出的提示,如下所示:
>>> exit
使用 exit() 或 Ctrl + D(即 EOF (文件结束标志))来退出。
Windows 代理将看到他们必须使用 Ctrl + Z 和 Return 来退出。
Python 是一种编程语言,它也有一个 >>> 的交互式提示符。为了确认 Python 是否工作,我们正在响应那个提示符,使用一个称为 读取-执行-打印循环 (REPL)的功能。
从长远来看,我们将编写脚本来处理我们的数据。我们的数据可能是一张图片或一条秘密信息。脚本文件的结尾将退出 Python。这与按下 Ctrl + D(或在 Windows 上为 Ctrl + Z 和 Return)发送 EOF 序列相同。
使用 help() 系统帮助
Python 有一个帮助模式,它通过 help() 函数启动。help() 函数提供特定主题的帮助。在 Python 中看到的大部分内容都可以作为帮助的主题。
对于 Python 语法片段,如 + 运算符,你需要使用一个字符串表示,你应该在引号中包含语法。例如,help("+") 将提供关于运算符优先级的详细帮助。
对于其他对象(如数字、字符串、函数、类和模块),你只需直接在对象本身上请求帮助;不需要使用引号。Python 将定位对象的类并提供关于该类的帮助。
例如,help(3) 将提供关于整数的许多详细的技术帮助,如下面的片段所示:
>>> help(3)
Help on int object:
class int(object)
| int(x=0) -> integer
| int(x, base=10) -> integer
|
etc.
当从命令行使用 help() 模块时,输出将以分页形式呈现。在输出第一页的末尾,我们看到一种新的非 Python 提示符。这通常是 :, 但在 Windows 上可能是 -- More --。
Python 通常以 >>> 或 ... 提示我们。非 Python 提示符必须来自帮助查看器之一。
Mac OS 和 GNU/Linux 秘籍
在 POSIX 兼容的操作系统上,我们将与一个名为 less 的程序交互;对于文档的最后一页之外的所有页面,它将以 : 提示。对于最后一页,它将以 (END) 提示。
这个程序非常复杂;你可以在维基百科上了解更多信息:en.wikipedia.org/wiki/Less_(Unix)。
以下是最重要的四个命令:
-
q:此命令用于退出less帮助查看器 -
h:此命令用于获取所有可用命令的帮助 -
˽:此命令用于进入空格以查看下一页 -
b:此命令用于返回上一页
Windows 秘籍
在 Windows 上,我们通常会与一个名为 more 的程序交互;它将以 -- More -- 提示你。你可以在维基百科上了解更多关于它的信息:en.wikipedia.org/wiki/More_(command)。
这里三个重要的命令是:q、h 和 ˽。
使用帮助模式
当我们不带对象或字符串值输入 help() 时,我们将进入帮助模式。这使用 Python 的 help> 提示符来清楚地表明我们正在获取帮助,而不是输入 Python 语句。要返回普通 Python 编程模式,并输入 quit。
提示符随后将变回 >>> 以确认我们可以返回输入代码。
在我们继续前进之前,你的下一个训练任务是在使用 help() 函数和帮助模式之前进行实验。
背景简报 – 数学与数字
在我们开始任何更严肃的任务之前,我们将回顾 Python 编程的基础知识。如果你已经对 Python 有所了解,这将是一个复习。如果你对 Python 一无所知,这只是一个概述,许多细节将被省略。
如果你之前从未进行过任何编程,这份简介可能有点过于简略。你可能需要获取更深入的教程。如果你对编程一无所知,你可能想查看此页以获取额外的教程:wiki.python.org/moin/BeginnersGuide/NonProgrammers。若要开始学习专家级 Python 编程,请访问 www.packtpub.com/expert-python-programming/book。
常见的错误
Python 提供了常规的算术和比较运算符。然而,有一些重要的细节和特性。我们不会假设你已经了解它们,我们将回顾这些细节。
传统的算术运算符有:+、-、*、/、//、% 和 **。在除法上有两种变体:精确除法(/)和整数除法(//)。你必须选择你想要精确的浮点结果还是整数结果:
>>> 355/113
3.1415929203539825
>>> 355//113
3
>>> 355.0/113.0
3.1415929203539825
>>> 355.0//113.0
3.0
精确除法(/)从两个整数产生一个 float 结果。整数除法产生一个整数结果。当我们使用 float 值时,我们期望精确除法产生 float。即使对于两个浮点值,整数除法也会产生一个向下取整的浮点结果。
我们有这个额外的除法运算符,以避免使用诸如 int(a/b) 或 math.floor(a/b) 这样的冗长构造。
除了常规的算术运算外,还有一些额外的位操作运算符可用:&、|、^、>>、<< 和 ~。这些运算符作用于整数(和集合)。这些运算符绝对不是布尔运算符;它们不作用于 True 和 False 的狭窄领域。它们作用于整数的各个位。
我们将使用带有 0b 前缀的二进制值来展示运算符的作用,如下面的代码所示。我们将在稍后详细讨论这个 0b 前缀。
>>> bin(0b0101 & 0b0110)
'0b100'
>>> bin(0b0101 ^ 0b0110)
'0b11'
>>> bin(0b0101 | 0b0110)
'0b111'
>>> bin(~0b0101)
'-0b110'
& 运算符执行位运算 AND。^ 运算符执行位运算的排他 OR(XOR)。| 运算符执行包含 OR。~ 运算符是位的补码。结果有多个 1 位,并以负数的形式显示。
<< 和 >> 运算符用于执行位的左移和右移,如下面的代码所示:
>>> bin( 0b110 << 4 )
'0b1100000'
>>> bin( 0b1100000 >> 3 )
'0b1100'
这可能并不明显,但将 x 位左移相当于将其乘以 2**x,不过它可能运行得更快。同样,右移 b 位相当于除以 2**b。
我们还有所有常规的比较运算符:<、<=、>、>=、== 和 !=。
在 Python 中,我们可以组合比较运算符而不包括 AND 运算符:
>>> 7 <= 11 < 17
True
>>> 7 <= ll and 11 < 17
True
这种简化实际上实现了我们传统的数学理解,即比较可以如何书写。我们不需要说 7 <= 11 and 11 < 17。
在某些特定情况下,还有一个比较运算符被使用:is。目前,is 运算符看起来与 == 相同。试试看。3 is 3 和 3 == 3 好像做了同样的事情。稍后,当我们开始使用 None 对象时,我们会看到 is 运算符最常见的使用。对于更高级的 Python 编程,需要区分对同一对象的两个引用(is)和两个声称具有相同值的对象(==)。
数字的象牙塔
Python 给我们提供了各种数字,以及轻松添加新类型数字的能力。在这里,我们将关注内置数字。添加新类型的数字是更高级书籍中占据整个章节的内容。
Python 将数字排列成一种塔状结构。在顶部是具有最少特征的数字。每个子类都通过添加更多特征来扩展该数字。我们将从底部向上查看这座塔,从具有最多特征的整数开始,然后转向具有最少特征的复数。以下各节将介绍我们将需要使用的各种数字。
整数数字
我们可以用十进制、十六进制、八进制或二进制来表示整数值。十进制数字不需要前缀,其他基数将使用一个简单的双字符前缀,如下面的代码片段所示:
48813
0xbead
0b1011111010101101
0o137255
我们还有将数字转换为不同基数的方便字符串的函数。我们可以使用 hex()、oct() 和 bin() 函数来查看基于 16、8 或 2 的值。
整数大小的问题很常见。Python 的整数没有最大值。它们不是人为地限制在 32 或 64 位。试试这个:
>>> 2**256
115792089237316195423570985008687907853269984665640564039457584007913129639936
大数字是有效的。它们可能有点慢,但它们工作得非常好。
有理数
有理数并不常用。它们必须从标准库中导入。我们必须导入 fractions.Fraction 类定义。它看起来是这样的:
>>> from fractions import Fraction
一旦我们定义了 Fraction 类,我们就可以使用它来创建数字。假设我们被派去追踪一个丢失的设备。设备的详细信息是严格需要知道的。由于我们是新特工,总部只向我们发布了设备的总面积(平方英寸)。
这里是我们找到的设备的面积的确切计算。它测量为 4⅞" 乘以 2¼":
>>> length=4+Fraction("7/8")
>>> width=2+Fraction("1/4")
>>> length*width
Fraction(351, 32)
好吧,面积是 351/32,这在实际英寸和分数中是——什么?
我们可以使用 Python 的 divmod() 函数来解决这个问题。divmod() 函数会给我们一个商和一个余数,如下面的代码所示:
>>> divmod(351,32)
(10, 31)
大约是 5 × 2,所以这个值似乎符合我们的粗略估计。我们可以将其作为正确的结果传输。如果我们找到了正确的设备,我们会被告知如何处理它。否则,我们可能已经破坏了任务。
浮点数
我们可以用常规或科学记数法写出浮点值,如下所示:
3.1415926
6.22E12
小数点的存在区分了整数和浮点数。
这些是普通的双精度浮点数。重要的是要记住,浮点值只是近似值。它们通常有 64 位的实现。
如果你使用 CPython,它们明确基于在sys.version启动消息中显示的 C 编译器。我们还可以从platform包中获取信息,如下面的代码片段所示:
>>> import platform
>>> platform.python_build()
('v3.3.4:7ff62415e426', 'Feb 9 2014 00:29:34')
>>> platform.python_compiler()
'GCC 4.2.1 (Apple Inc. build 5666) (dot 3)'
这告诉我们使用了哪个编译器。反过来,这可以告诉我们使用了哪些浮点库。这可能有助于确定正在使用的底层数学库。
十进制数
我们需要小心对待金钱。生活箴言:监管间谍的会计师是一群吝啬鬼。
重要的是,浮点数是一个近似值。我们在处理金钱时不能依赖于近似值。对于货币,我们需要精确的十进制值,其他任何东西都不行。可以使用扩展模块来使用十进制数。我们将导入decimal.Decimal类定义来处理货币。它看起来是这样的:
>>> from decimal import Decimal
我们收买的线人为了找到设备而索要 50000 希腊德拉克马作为丢失设备的情报费。当我们提交费用时,我们需要包括一切,包括出租车费(23.50 美元)和我们必须为她购买的昂贵午餐(12,900 GRD)。
为什么线人不愿意接受美元或欧元?我们不想知道,我们只想得到他们的信息。最近,希腊德拉克马对美元的汇率为 247.616。
信息的确切预算是多少?以德拉克马和美元计算?
首先,我们将货币精确转换为美分的千分之一(美元的 1000 分之一):
>>> conversion=Decimal("247.616")
>>> conversion
Decimal('247.616')
我们午餐的账单,从德拉克马转换成美元,计算如下:
>>> lunch=Decimal("12900")
>>> lunch/conversion
Decimal('52.09679503747738433703799431')
什么?这个混乱如何能让会计师满意?
所有这些数字都是精确除法的结果:我们得到了很多小数位数的精度;并不是所有的它们都真正相关。我们需要正式化“四舍五入”值的概念,这样政府会计师才会满意。最接近的美分即可。在Decimal方法中,我们将使用quantize方法。术语quantize指的是对给定值进行舍入、截断。decimal模块提供了一系列量化规则。默认规则是ROUND_HALF_EVEN:四舍五入到最近的值;在出现平局的情况下,优先选择偶数值。代码如下:
>>> penny=Decimal('.00')
>>> (lunch/conversion).quantize(penny)
Decimal('52.10')
That's much better. How much was the bribe we needed to pay?
>>> bribe=50000
>>> (bribe/conversion).quantize(penny)
Decimal('201.93')
注意,涉及的除法是一个整数和一个小数。Python 对十进制的定义会默默地从一个整数创建一个新的十进制数,这样数学运算就会使用十进制对象进行。
出租车司机收了我们美元。我们不需要做太多的转换。因此,我们将把这个金额加到最终金额中,如下面的代码所示:
>>> cab=Decimal('23.50')
That gets us to the whole calculation: lunch plus bribe, converted, plus cab.
>>> ((lunch+bribe)/conversion).quantize(penny)+cab
Decimal('277.52')
等等。我们似乎差了一分钱。为什么我们没有得到 277.53 美元作为答案?
四舍五入。基本规则称为四舍六入五成双。每个单独的金额(52.10和201.93)都有一分钱的零头四舍五入。(更详细的价值是52.097和201.926。)在我们将德拉克马相加并在转换之前计算时,总数不包括两个分别四舍五入的五分钱值。
我们对此有非常精细的控制。有许多舍入方案,有许多定义何时以及如何舍入的方法。此外,可能需要一些代数运算来了解它们是如何结合在一起的。
复数
我们在 Python 中也有复数。它们由两部分组成:实部和虚部,如下面的代码所示:
>>> 2+3j
(2+3j)
如果我们将复数与其他大多数类型的数字混合,结果将是复数。例外的是十进制数字。但我们为什么要将工程数据和货币混合呢?如果任何任务涉及科学和工程数据,我们有一种处理复数的方法。
在数字之外
Python 包含各种数据类型,它们不是数字。在处理文本和字符串部分,我们将查看 Python 字符串。我们将在第二章,获取智能数据中查看集合。
布尔值True和False形成自己的小域。我们可以使用bool()函数从大多数对象中提取布尔值。以下是一些例子:
>>> bool(5)
True
>>> bool(0)
False
>>> bool('')
False
>>> bool(None)
False
>>> bool('word')
True
一般模式是大多数对象具有True的值,而少数异常对象具有False的值。空集合、0和None具有False的值。布尔值有自己的特殊运算符:and、or和not。这些运算符有一个附加功能。以下是一个例子:
>>> True and 0
0
>>> False and 0
False
当我们评估True and 0时,and运算符的两边都会被评估;右侧的值是结果。但当评估False and 0时,只有and的左侧被评估。由于它已经是False,就没有必要评估右侧了。
and和or运算符是短路运算符。如果and的左侧是False,那就足够了,右侧将被忽略。如果or的左侧是True,那就足够了,右侧将被忽略。
Python 的评估规则与数学实践紧密相关。算术运算具有最高优先级。比较运算符的优先级低于算术运算。逻辑运算符的优先级非常低。这意味着a+2 > b/3 or c==15将分阶段完成:首先算术运算,然后比较运算,最后逻辑运算。
数学规则遵循算术规则。**的优先级高于*、/、//或%。+和–运算符接下来。当我们写2*3+4时,必须首先执行2*3操作。位操作甚至更低。当你有一系列相同优先级的操作(a+b+c)时,计算是从左到右进行的。当然,如果有任何疑问,使用括号是明智的。
将值赋给变量
我们一直在使用 Python 工具集的 REPL 功能。从长远来看,这并不理想。我们将更乐意编写脚本。使用计算机进行情报收集的目的是自动化数据收集。我们的脚本将需要将变量赋值。它还需要显式输出和输入。
我们已经在几个例子中展示了简单的、明显的赋值语句。请注意,在 Python 中我们不需要声明变量。我们只是将值赋给变量。如果变量不存在,它就会被创建。如果变量已存在,其先前值将被替换。
让我们看看一些更复杂的技术来创建和更改变量。我们有多个赋值语句。以下代码将一次性为几个变量赋值:
>>> length, width = 2+Fraction(1,4), 4+Fraction(7,8)
>>> length
Fraction(9, 4)
>>> width
Fraction(39, 8)
>>> length >= width
False
我们设置了两个变量,length和width。然而,我们也犯了一个小错误。长度并不是较大的值;我们交换了length和width的值。我们可以非常简单地使用多重赋值语句来交换它们,如下所示:
>>> length, width = width, length
>>> length
Fraction(39, 8)
>>> width
Fraction(9, 4)
这是因为右侧是整体计算的。在这种情况下,这真的很简单。然后所有值都被分解并分配给命名变量。显然,右侧的值的数量必须与左侧变量的数量相匹配,否则这不会起作用。
我们还有增强型赋值语句。这些将算术运算符与赋值语句结合在一起。以下代码是+=的一个例子:使用带有加法的赋值增强。这里有一个从各个部分计算总和的例子:
>>> total= 0
>>> total += (lunch/conversion).quantize(penny)
>>> total += (bribe/conversion).quantize(penny)
>>> total += cab
>>> total
Decimal('277.53')
我们不必写total = total +...。相反,我们可以简单地写total += ...。这很好地阐明了我们的意图。
所有的算术运算符都可作为增强型赋值语句使用。我们可能很难找到%=或**=的用途,但语句是语言的一部分。
优雅的澄清的想法应该导致一些额外的思考。例如,名为conversion的变量是一个完全晦涩难懂的名字。对数据的保密是一回事:我们将探讨加密数据的方法。通过糟糕的数据处理来隐藏数据通常会导致噩梦般的混乱。也许我们应该给它起一个更清楚地定义其含义的名字。我们将在后面的例子中重新审视这个晦涩的问题。
编写脚本并查看输出
我们的大多数任务将涉及收集和分析数据。我们不会创建一个非常复杂的用户界面(UI)。Python 有用于构建网站和复杂图形用户界面(GUIs)的工具。这些主题的复杂性导致有整本书来涵盖 GUI 和 Web 开发。
我们不想在>>>提示符中逐个输入 Python 语句。这使学习 Python 变得容易,但我们的目标是创建程序。在 GNU/Linux 术语中,我们的 Python 应用程序程序可以被称为脚本。这是因为 Python 程序符合脚本语言的定义。
对于我们的目的,我们将专注于使用命令行界面(CLI)的脚本。我们将写的一切都将在一个简单的终端窗口中运行。这种方法的优势是速度和简单性。我们可以在以后添加图形用户界面。或者,一旦脚本工作,我们可以将其基本核心扩展为网络服务。
应用程序或脚本是什么?脚本只是一个纯文本文件。我们可以使用任何文本编辑器来创建这个文件。很少建议使用文字处理器,因为文字处理器不擅长生成纯文本文件。
如果我们不是从>>> REPL 提示符工作,我们需要显式地显示输出。我们将使用print()函数从脚本中显示输出。
这里有一个简单的脚本,我们可以用它来为贿赂(鼓励)我们的线人生成收据。
从decimal导入Decimal:
PENNY= Decimal('.00')
grd_usd= Decimal('247.616')
lunch_grd= Decimal('12900')
bribe_grd= 50000
cab_usd= Decimal('23.50')
lunch_usd= (lunch_grd/grd_usd).quantize(PENNY)
bribe_usd= (bribe_grd/grd_usd).quantize(PENNY)
print( "Lunch", lunch_grd, "GRD", lunch_usd, "USD" )
print( "Bribe", bribe_grd, "GRD", bribe_usd, "USD" )
print( "Cab", cab_usd, "USD" )
print( "Total", lunch_usd+bribe_usd+cab_usd, "USD" )
让我们分解这个脚本,以便我们可以跟随它。阅读脚本就像给线人加上尾巴一样。我们想看看脚本会去哪里,它会做什么。
首先,我们导入了Decimal定义。这对于处理货币是必不可少的。我们定义了一个值,PENNY,我们将用它来将货币计算四舍五入到最近的分。我们使用全大写字母的名称来使这个变量与众不同。它不是一个普通变量;在脚本中我们不应该再看到它在赋值语句的左侧。
我们创建了货币转换系数,并将其命名为grd_usd。这个名字在这个上下文中似乎比conversion更有意义。请注意,我们还为我们的金额名称添加了一个小的后缀。我们使用了诸如lunch_grd、bribe_grd和cab_usd这样的名称来强调正在使用哪种货币。这可以帮助防止混淆问题。
给定grd_usd转换系数,我们创建了两个额外的变量,lunch_usd和bribe_usd,金额已转换为美元并四舍五入到最近的分。如果会计师想要调整转换系数——也许他们可以使用不同于我们的间谍的银行——他们可以调整数字并准备不同的收据。
最后一步是使用print()函数来编写收据。我们打印了我们在上面花费金钱的三个项目,显示了 GRD 和 USD 的金额。我们还计算了总额。这将帮助会计师正确报销我们的任务。
我们将描述输出为 原始但可接受。毕竟,他们只是会计。我们将单独探讨格式化。
收集用户输入
收集输入的最简单方法是将其复制并粘贴到脚本中。这就是我们之前所做的那样。我们将希腊德拉克马转换粘贴到脚本中:grd_usd= Decimal('247.616')。我们可以用注释来帮助会计进行任何更改。
行尾的附加注释以 # 符号开始。它们看起来像这样:
grd_usd= Decimal('247.616') # Conversion from Mihalis Bank 5/15/14
这段额外的文本是应用程序的一部分,但它实际上并没有做什么。这是给我们自己、我们的会计、我们的处理者或在我们消失时接管我们任务的那些人的备注。
这种数据行很容易编辑。但有时我们合作的人希望有更多的灵活性。在这种情况下,我们可以从一个人那里收集这个值作为输入。为此,我们将使用 input() 函数。
我们通常将用户输入分解为两个步骤,如下所示:
entry= input("GRD conversion: ")
grd_usd= Decimal(entry)
第一行将写入一个提示并等待用户输入金额。金额将是一个字符字符串,分配给变量 entry。Python 不能直接在算术语句中使用这些字符,因此我们需要明确地将它们转换为有用的数值类型。
第二行将尝试将用户的输入转换为有用的 Decimal 对象。我们必须强调这个 try 部分。如果用户没有输入表示有效 Decimal 数字的字符串,将发生重大危机。试试看。
危机看起来会是这样:
>>> entry= input("GRD conversion: ")
GRD conversion: 123.%$6
>>> grd_usd= Decimal(entry)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
decimal.InvalidOperation: [<class 'decimal.ConversionSyntax'>]
而不是这样做,输入一个良好的数字。我们输入了 123.%$6。
以 Traceback 开头的错误信息表明 Python 抛出了一个异常。Python 中的危机总是导致抛出异常。Python 定义了各种异常,以便我们能够编写处理这些危机的脚本。
一旦我们了解了如何处理危机,我们就可以看看字符串数据以及一些简单的清理步骤,这些步骤可以使用户的生活变得轻松一些。我们无法修复他们的错误,但我们可以处理一些源于在键盘上尝试输入数字的常见问题。
处理异常
当 Decimal 类无法解析给定的字符串以创建有效的 Decimal 对象时,会引发 decimal.InvalidOperation 异常。对于这个异常我们能做什么?
我们可以忽略它。在这种情况下,我们的应用程序程序会崩溃。它停止运行,使用它的代理会感到不高兴。这并不是最好的方法。
捕获异常的基本技术如下:
entry= input("GRD conversion: ")
try:
grd_usd= Decimal(entry)
except decimal.InvalidOperation:
print("Invalid: ", entry)
我们将 Decimal() 转换和赋值包裹在 try: 语句中。如果 try: 块中的每个语句都正常工作,则 grd_usd 变量将被设置。另一方面,如果在 try: 块内部抛出 decimal.InvalidOperation 异常,则将处理 except 子句。这将写入一条消息,并且不会设置 grd_usd 变量。
我们可以用各种方式处理异常。最常见的异常处理类型是在某些失败的情况下进行清理。例如,尝试创建文件的脚本可能会在抛出异常时删除无用的文件。问题还没有解决:程序仍然必须停止。但它可以以一种干净、愉快的方式停止,而不是以一种混乱的方式。
我们也可以通过计算一个替代答案来处理异常。我们可能从各种网络服务中收集信息。如果其中一个没有及时响应,我们将得到超时异常。在这种情况下,我们可能会尝试另一个网络服务。
在另一个常见的异常处理情况中,我们可能会重置计算的状态,以便可以再次尝试某个操作。在这种情况下,我们将异常处理程序包裹在一个循环中,该循环可以反复要求用户输入,直到他们提供一个有效的数字。
这些选择不是互斥的,一些处理程序可以执行之前异常处理程序的组合。我们将详细查看第三个选择,即再次尝试。
循环尝试
这里有一个从用户获取输入的常见方法:
grd_usd= None
while grd_usd is None:
entry= input("GRD conversion: ")
try:
grd_usd= Decimal(entry)
except decimal.InvalidOperation:
print("Invalid: ", entry)
print( grd_usd, "GRD = 1 USD" )
我们将添加一个尾巴并跟随它一段时间。目标是获取我们货币转换的有效十进制值grd_usd。我们将该变量初始化为 Python 的特殊None对象。
while语句正式声明了我们的意图。我们将执行while语句的主体,直到grd_usd变量保持为None。注意,我们使用is运算符将grd_usd与None进行比较。我们在这里强调一个细节:Python 中只有一个None对象,我们正在使用那个单一实例。技术上可以调整==的定义;我们不能调整is的定义。
在while语句的末尾,grd_usd is None必须为False;我们可以说grd_usd is not None。当我们查看语句的主体时,我们可以看到只有一个语句设置了grd_usd,因此我们可以确信它必须是一个有效的Decimal对象。
在while语句的主体中,我们使用了我们的异常处理方法。首先,我们提示并获取一些输入,设置entry变量。然后,在try语句中,我们尝试将字符串转换为Decimal值。如果转换成功,则grd_usd将分配那个Decimal对象。该对象不会是None,循环将终止。胜利!
如果将输入转换为Decimal值失败,将会抛出异常。我们会打印一条消息,并保持grd_usd不变。它仍然将保持None的值。循环将继续,直到输入一个有效的值。
Python 还有其他类型的循环,我们将在本章的后面讨论。
处理文本和字符串
我们已经简要介绍了 Python 对字符串对象的使用。例如 Decimal('247.616') 和 input(GRD conversion: ) 的表达式涉及字符串字面值。Python 给我们几种方法将字符串放入程序中;有很多灵活性可用。
这里有一些字符串的例子:
>>> "short"
'short'
>>> 'short'
'short'
>>> """A multiple line,
... very long string."""
'A multiple line,\nvery long string.'
>>> '''another multiple line
... very long string.'''
'another multiple line\nvery long string.'
我们使用单引号和撇号来创建短字符串。这些必须在程序的单行内完整。我们使用三引号和三撇号来创建长字符串。这些字符串可以跨越程序的多行。
注意,Python 使用 \n 字符回显字符串以显示换行。这被称为字符转义。\ 字符转义了 n 的正常意义。序列 \n 并不意味着 n;\n 意味着通常不可见的换行符。Python 有许多转义字符。换行符可能是最常用的转义字符。
有时候我们需要使用不在我们电脑键盘上的字符。例如,我们可能想要打印各种 Unicode 特殊字符之一。
以下示例在我们知道特定符号的 Unicode 数字时效果很好:
>>> "\u2328"
'⌨'
以下示例更好,因为我们不需要知道符号的晦涩代码:
>>> "\N{KEYBOARD}"
'⌨'
介于数字和字符串之间的转换
我们有两种有趣的字符串转换:字符串转数字和数字转字符串。
我们已经看到了像 Decimal() 这样的函数,可以将字符串转换为数字。我们还有其他函数:int(), float(), fractions.Fraction() 和 complex()。当我们有不是十进制基数的数字时,我们也可以使用 int() 来转换,如下面的代码所示:
>>> int( 'dead', 16 )
57005
>>> int( '0b1101111010101101', 2 )
57005
我们也可以从数字创建字符串。我们可以使用 hex(), oct(), 和 bin() 函数来创建基数为 16、8 和 2 的字符串。我们还有 str() 函数,这是将任何 Python 对象转换为某种字符串的最通用函数。
比这些更有价值的是字符串的 format() 方法。它执行各种值到字符串的转换。它使用转换格式指定或模板字符串来定义结果字符串的形状。
这里有一个使用 format() 将几个值转换成单个字符串的例子。它使用了一个相当复杂的格式指定字符串:
>>> "{0:12s} {1:6.2f} USD {2:8.0f} GRD".format( "lunch", lunch_usd, lunch_grd )
'lunch 52.10 USD 12900 GRD'
格式字符串有三个转换指定:{0:12s}, {1:6.2f}, 和 {2:8.0f}。它还有一些文本,主要是空格,但 USD 和 GRD 是背景文本的一部分,数据将被合并到其中。
每个转换指定有两个部分:要转换的项目和该项目的格式。这两个部分用 {} 内的 : 分隔。我们将逐一查看每个转换:
-
项目
0使用12s格式进行转换。这个格式产生一个 12 位的字符串。字符串lunch被填充到 12 位。 -
项目
1使用6.2f格式进行转换。这种格式生成一个六位字符串。小数点右侧将有两位。lunch_usd的值就是使用这种方式进行格式化的。 -
项目
2使用8.0f格式进行转换。这种格式生成一个没有小数点右侧位置的八位字符串。lunch_grd的值就是按照这个规范进行格式化的。
我们可以做一些类似以下的事情来改进我们的收据:
receipt_1 = "{0:12s} {1:6.2f} USD"
receipt_2 = "{0:12s} {1:8.0f} GRD {2:6.2f} USD"
print( receipt_2.format("Lunch", lunch_grd, lunch_usd) )
print( receipt_2.format("Bribe", bribe_grd, bribe_usd) )
print( receipt_1.format("Cab", cab_usd) )
print( receipt_1.format("Total", lunch_usd+bribe_usd+cab_usd) )
我们使用了两个并行的格式规范。receipt_1 字符串可以用来格式化标签和单个美元值。receipt_2 字符串可以用来格式化标签和两个数值:一个以美元为单位,另一个以希腊德拉克马为单位。
这使收据看起来更好。这应该能让会计们远离我们,让我们专注于真正的工作:处理数据文件和文件夹。
解析字符串
字符串对象也可以被分解或解析成子字符串。我们可以轻松地写一整章关于字符串对象提供的所有各种解析方法。一个常见的转换是从字符串的开始和结束处去除多余的空白字符。想法是去除空格和制表符(以及一些其他不明显字符)。它看起来像这样:
entry= input("GRD conversion: ").strip()
我们已经应用了 input() 函数从用户那里获取一个字符串。然后我们应用了该字符串对象的 strip() 方法来创建一个新的字符串,去除了所有空白字符。我们可以像这样从 >>> 提示符尝试它:
>>> " 123.45 ".strip()
'123.45'
这显示了如何将包含杂项的字符串简化为基本要素。这可以简化用户的生活;一些额外的空格不会成问题。
另一种转换可能是将字符串分割成片段。这里只是众多可用技术中的一种:
>>> amount, space, currency = "123.45 USD".partition(" ")
>>> amount
'123.45'
>>> space
' '
>>> currency
'USD'
让我们详细看看。首先,这是一个多赋值语句,其中三个变量将被设置:amount、space 和 currency。
表达式 "123.45 USD".partition(" ") 通过应用 partition() 方法到字面字符串值来实现。我们将字符串分割在空格字符上。partition() 方法返回三样东西:分割前的子字符串、分割字符和分割后的子字符串。
实际的分割变量也可以被分配一个空字符串,''。试试这个:
amount, space, currency = "word".partition(" ")
amount、space 和 currency 的值是多少?
如果你使用 help(str),你会看到字符串可以执行的所有各种类型的事情。周围有 __ 的名字映射到 Python 操作符。例如,__add__() 是 + 操作符的实现方式。
组织我们的软件
Python 给我们提供了多种方式来将软件组织成概念单元。长而杂乱的脚本难以阅读、修复或扩展。Python 提供了包、模块、类和函数。我们将在整个代理训练过程中看到不同的组织技术。我们将从函数定义开始。
在前面的章节中,我们使用了许多 Python 的内置函数。定义我们自己的函数是通过def语句完成的。函数定义使我们能够总结(在某些情况下是一般化)一些处理。以下是一个我们可以用来从用户获取十进制值的简单函数:
def get_decimal(prompt):
value= None
while value is None:
entry= input(prompt)
try:
value= Decimal(entry)
except decimal.InvalidOperation:
print("Invalid: ", entry)
return value
这遵循了我们之前展示的设计,作为一个单独的函数打包。此函数将返回一个合适的Decimal对象:value变量的值。我们可以像这样使用我们的get_decimal()函数:
grd_usd= get_decimal("GRD conversion: ")
Python 允许在向函数提供参数值时有很大的灵活性。一种常见的技术是提供一个可选参数,可以使用关键字参数提供。print()函数具有此功能,我们可以通过提供关键字参数值来命名一个文件。
import sys
print("Error", file=sys.stderr)
如果我们不提供file参数,默认情况下将使用sys.stdout文件。
我们可以使用以下语法在我们的函数中这样做:
def report( grd_usd, target=sys.stdout ):
lunch_grd= Decimal('12900')
bribe_grd= 50000
cab_usd= Decimal('23.50')
lunch_usd= (lunch_grd/grd_usd).quantize(PENNY)
bribe_usd= (bribe_grd/grd_usd).quantize(PENNY)
receipt_1 = "{0:12s} {1:6.2f} USD"
receipt_2 = "{0:12s} {1:8.0f} GRD {2:6.2f} USD"
print( receipt_2.format("Lunch", lunch_grd, lunch_usd), file=target )
print( receipt_2.format("Bribe", bribe_grd, bribe_usd), file=target )
print( receipt_1.format("Cab", cab_usd), file=target )
print( receipt_1.format("Total", lunch_usd+bribe_usd+cab_usd), file=target )
我们定义了我们的report函数,它有两个参数。grd_usd参数是必需的。target参数有一个默认值,因此它是可选的。
我们还使用了一个全局变量,PENNY。这是我们在函数外部设置的。该值在函数内部可用。
四个print()函数使用关键字语法提供文件参数:file=target。如果我们为target参数提供了值,则将使用该值;如果没有提供target参数的值,则将使用sys.stdout文件的默认值。我们可以以多种方式使用此函数。以下是一个版本:
rate= get_decimal("GRD conversion: ")
print(rate, "GRD = 1 USD")
report(rate)
我们按位置提供了grd_usd参数值:它是第一个。我们没有为target参数提供值;将使用默认值。
这里是另一个版本:
rate= get_decimal("GRD conversion: ")
print(rate, "GRD = 1 USD", file=sys.stdout)
report(grd_usd=rate, target=sys.stdout)
在这个例子中,我们为grd_usd和target参数都使用了关键字参数语法。是的,target参数的值重复了默认值。我们将在下一节中查看如何创建我们自己的文件。
与文件和文件夹一起工作
我们的计算机充满了文件。我们操作系统最重要的特性之一是它处理文件和设备的方式。Python 为我们提供了对各种类型文件的高水平访问。
然而,我们必须画几条线。所有文件都由字节组成。这是一种还原论的观点,并不总是有帮助的。有时这些字节代表 Unicode 字符,这使得读取文件相对容易。有时这些字节代表更复杂的对象,这使得读取文件可能相当困难。
实际上,文件有各种各样的物理格式。我们的各种桌面应用程序(文字处理器、电子表格等)都有独特的数据格式。其中一些物理格式是专有产品,这使得它们特别难以处理。内容是神秘的(不安全的),而挖掘信息可能需要高昂的成本。我们总是可以求助于检查低级字节并以此方式恢复信息。
许多应用程序都使用广泛标准化的文件格式。这使得我们的生活变得更加简单。格式可能很复杂,但符合标准的事实意味着我们可以恢复所有数据。我们将探讨后续任务中的一些标准化格式。目前,我们需要掌握基础知识。
创建文件
我们将首先创建一个我们可以工作的文本文件。与文件一起工作有几个有趣的方面。我们将关注以下两个方面:
-
创建
文件对象。文件对象是 Python 对操作系统资源的视图。实际上它相当复杂,但我们可以非常容易地访问它。 -
使用文件上下文。文件有特定的生命周期:打开、读取或写入,然后关闭。为了确保我们关闭文件并正确地将操作系统资源从 Python 对象中分离出来,我们通常更喜欢将文件用作上下文管理器。使用
with语句可以保证文件被正确关闭。
我们创建文件的通用模板,以open("message1.txt", "w")为目标,看起来像这样:
print( "Message to HQ", file=target )
print( "Device Size 10 31/32", file=target )
我们将使用open()函数打开文件。在这种情况下,文件以写入模式打开。我们使用了print()函数将一些数据写入文件。
一旦程序完成with语句的缩进上下文,文件将被正确关闭,操作系统资源将被释放。我们不需要显式关闭文件对象。
我们还可以使用类似的方法来创建我们的文件:
text="""Message to HQ\n Device Size 10 31/32\n"""
with open("message1.txt", "w") as target:
target.write(text)
注意这里的重要区别。print()函数自动在每个行尾添加一个\n字符。文件对象的write()方法不添加任何内容。
在许多情况下,文件可能具有更复杂的物理格式。我们将在后面的章节中查看 JSON 或 CSV 文件。我们还将查看在第三章中读取和写入图像文件,使用隐写术编码秘密信息。
读取文件
我们读取文件的通用模板看起来像这样:
with open("message1.txt", "r") as source:
text= source.read()
print( text )
这将创建一个文件对象,但它将以读取模式打开。如果文件不存在,我们将得到一个异常。read()函数将整个文件吸入一个单独的文本块。一旦我们完成读取文件内容,我们也完成了with上下文。文件可以被关闭,资源可以被释放。我们创建的文本变量将包含文件内容,以便进行进一步处理。
在许多情况下,我们想要单独处理文本的行。为此,Python 为我们提供了for循环。这个语句与文件交互,遍历文件的每一行,如下面的代码所示:
with open("message1.txt", "r") as source:
for line in source:
print(line)
输出看起来有点奇怪,不是吗?
它是双倍间距的,因为从文件中读取的每一行末尾都包含一个\n字符。print()函数自动包含一个\n字符。这导致输出是双倍间距的。
我们有两个候选的修复方案。我们可以告诉print()函数不要包含\n字符。例如,print(line, end="")就是这样做的。
一个稍微好一点的修复方案是使用rstrip()方法从行的右侧移除尾随空格。这稍微好一些,因为我们在多个上下文中经常会这样做。试图抑制print()函数中额外的\n字符的输出过于特定于这种情况。
在某些情况下,我们可能需要过滤文件中的行,寻找特定的模式。我们可能有一个包含通过if语句进行条件处理的循环,如下面的代码所示:
with open("message1.txt", "r") as source:
for line in source:
junk1, keyword, size= line.rstrip().partition("Size")
if keyword != '':
print( size )
这显示了文本处理程序的典型结构。首先,我们通过with语句上下文打开文件;这确保无论发生什么情况,文件都将被正确关闭。
我们使用for语句遍历文件的所有行。每一行都有一个两步的过程:rstrip()方法移除尾随空格,partition()方法在关键字Size周围分割行。
if语句定义了一个条件(keyword != '')以及仅在条件为True时执行的一些处理。如果条件为False(keyword的值为''),则if语句的缩进主体将被静默跳过。
赋值和if语句构成了for语句的主体。这两个语句在文件的每一行上执行一次。当我们到达for语句的末尾时,我们可以确信所有行都已处理。
我们必须注意,我们可以使用break语句提前退出循环,打破常规的假设。我们更愿意避免使用break语句,这样就可以很容易地看到for语句适用于文件的每一行。
在for语句的末尾,我们完成了对文件的加工。我们也完成了with上下文。文件将被关闭。
定义更复杂的逻辑条件
如果我们寻找的图案比我们预期的多呢?如果我们正在处理更复杂的数据呢?
假设我们在文件中有类似以下的内容:
Message to Field Agent 006 1/2
Proceed to Rendezvous FM16uu62
Authorization to Pay $250 USD
我们正在寻找两个关键字:Rendezvous和Pay。Python 将elif子句作为if语句的一部分提供。这个子句提供了一种优雅地处理多个条件的方法。以下是一个解析总部发来的信息的脚本:
amount= None
location= None
with open("message2.txt", "r") as source:
for line in source:
clean= line.lower().rstrip()
junk, pay, pay_data= clean.partition("pay")
junk, meet, meet_data= clean.partition("rendezvous")
if pay != '':
amount= pay_data
elif meet != '':
location= meet_data
else:
pass # ignore this line
print("Budget", amount, "Meet", location)
我们正在搜索文件内容中的两块信息:会合地点和我们可以用以贿赂联系人的金额。实际上,我们将总结这个文件为两个简短的事实,丢弃我们不关心的部分。
与前面的示例一样,我们使用with语句创建一个处理上下文。我们还使用for语句遍历文件的所有行。
我们使用两步过程来清理每一行。首先,我们使用lower()方法创建一个字符串的小写版本。然后我们使用rstrip()方法从行中删除任何尾随空格。
我们对清理后的行应用了partition()方法两次。一个分区查找pay,另一个分区查找rendezvous。如果行可以在pay上分区,则pay变量(和pay_data)的值不会等于空字符串。如果行可以在rendezvous上分区,则meet变量(和meet_data)的值不会等于空字符串。Python 中的else, if被缩写为elif。
如果前面的所有条件都不成立,我们就不需要做任何事情。我们不需要else:子句。但我们决定包含else:子句,以防我们以后需要添加一些处理。目前,没有更多的事情要做。在 Python 中,pass语句什么都不做。它是一个语法占位符;当我们必须写点东西时可以写的东西。
解决问题——恢复丢失的密码
我们将应用许多技术来编写一个程序,帮助我们探索锁定 ZIP 文件内部。重要的是要注意,任何合格的加密方案都不会加密密码。密码在最坏的情况下,被简化为散列值。当有人输入密码时,会比较散列值。原始密码基本上是无法恢复的,除非通过猜测。
我们将探讨一种暴力破解密码的方案。它将简单地尝试字典中的所有单词。更复杂的猜测方案将使用字典单词和标点符号来形成越来越长的候选密码。更复杂的猜测甚至包括字符的leet speak替换。例如,使用1337 sp3@k代替leet speak。
在我们研究 ZIP 文件的工作原理之前,我们必须找到一个可用的词库。一个常见的词库替代品是拼写检查字典。对于 GNU/Linux 或 Mac OS X 计算机,可以在几个地方找到字典。三个常见的地方是:/usr/dict/words、/usr/share/dict/words,或者可能是/usr/share/myspell/dicts。
Windows 代理可能需要搜索类似字典资源。查看%AppData%\Microsoft\Spelling\EN作为可能的存储位置。字典通常是.dic文件。也可能有一个相关的.aff(词缀规则)文件,其中包含从.dic文件中的词根(或词元)构建单词的额外规则。
如果我们找不到可用的词汇语料库,最好安装一个独立的拼写检查程序及其词典。例如,aspell、ispell、Hunspell、Open Office 和 LibreOffice 包含各种语言的广泛拼写词典集合。
有其他方法可以获得各种词汇语料库。一种方法是在所有这些文件中搜索所有单词。我们用来创建密码的单词可能反映在我们实际使用的其他文件中的单词。
另一个好方法是使用 Python 的自然语言工具包(NLTK),它提供了处理自然语言处理的多种资源。当这本手册即将出版时,已经发布了一个与 Python3 兼容的版本。请参阅pypi.python.org/pypi/nltk。这个库提供了词典、几个单词列表语料库和比简单的拼写检查词典更好的单词词干工具。
你的任务是找到你电脑上的词典。如果你找不到,那么下载一个好的拼写检查程序并使用它的词典。通过搜索web2(韦伯斯特第二国际)可能找到可用的语料库。
阅读词汇语料库
我们需要做的第一件事是阅读我们的拼写检查语料库。我们将称之为语料库——一组词汇,而不是词典。示例将基于web2(韦伯斯特第二国际)中的全部 234,936 个单词。这通常在 BSD Unix 和 Mac OS X 中可用。
这是一个典型的脚本,它将检查语料库:
count= 0
corpus_file = "/usr/share/dict/words"
with open( corpus_file ) as corpus:
for line in corpus:
word= line.strip()
if len(word) == 10:
print(word)
count += 1
print( count )
我们已经打开了语料库文件并读取了所有行。通过从行中去除空白字符找到了单词;这移除了尾随的\n字符。使用if语句过滤了 10 个字母的单词。共有 30,878 个这样的单词,从 abalienate 到 Zyzzogeton。
这个小程序实际上并不是任何大型应用程序的一部分。它是一种技术尖峰——我们用它来锁定细节。在编写这样的小程序时,我们通常会跳过类或函数的仔细设计,只是将一些 Python 语句放入文件中。
在 POSIX 兼容的操作系统上,我们可以做两件事来使脚本更容易使用。首先,我们可以在文件的非常第一行添加一个特殊注释,以帮助操作系统确定如何处理它。该行看起来像这样:
#!/usr/bin/env python3
这告诉操作系统如何处理脚本。具体来说,它告诉操作系统使用env程序。然后env程序将定位我们的 Python 3 安装。责任将转交给python3程序。
第二步是将脚本标记为可执行。我们使用操作系统命令chmod +x some_file.py将 Python 文件标记为可执行脚本。
如果我们已经完成了这两个步骤,我们只需在命令提示符下键入其名称即可执行脚本。
在 Windows 中,文件扩展名(.py)与 Python 程序相关联。有一个高级设置面板定义了这些文件关联。当您安装 Python 时,这个关联是由安装程序建立的。这意味着您可以直接输入 Python 脚本的名称,Windows 会搜索您的PATH值中命名的目录,并正确执行该脚本。
读取 ZIP 归档
我们将使用 Python 的zipfile模块来处理 ZIP 归档。这意味着在我们可以做其他任何事情之前,我们需要使用import zipfile。由于 ZIP 归档包含多个文件,我们通常会想要获取归档中可用的文件列表。以下是调查归档的方法:
import zipfile
with zipfile.ZipFile( "demo.zip", "r" ) as archive:
archive.printdir()
我们打开了归档,创建了一个文件处理上下文。然后我们使用了归档的printdir()方法来输出归档的成员。
然而,我们无法提取任何文件,因为 ZIP 归档被加密了,我们丢失了密码。以下是一个尝试读取第一个成员的脚本:
import zipfile
with zipfile.ZipFile( "demo.zip", "r" ) as archive:
archive.printdir()
first = archive.infolist()[0]
with archive.open(first) as member:
text= member.read()
print( text )
我们使用打开的归档创建了一个文件处理上下文。我们使用归档的infolist()方法获取每个成员的信息。archive.infolist()[0]语句将从列表中选取第一个项目,即第一个项目。
我们尝试为这个特定成员创建一个文件处理上下文。而不是看到成员的内容,我们得到了一个异常。细节会有所不同,但您的异常信息将类似于以下内容:
RuntimeError: File <zipfile.ZipInfo object at 0x1007e78e8> is encrypted, password required for extraction
十六进制数(0x1007e78e8)可能不匹配您的输出,但您尝试读取加密的 ZIP 文件时仍会出错。
使用暴力搜索
为了恢复文件,我们需要求助于暴力搜索以找到一个可行的密码。这意味着将我们的语料库读取循环插入到我们的归档处理上下文中。这是一些闪亮的复制粘贴操作,导致以下脚本:
import zipfile
import zlib
corpus_file = "/usr/share/dict/words"
with zipfile.ZipFile( "demo.zip", "r" ) as archive:
first = archive.infolist()[0]
print( "Reading", first.filename )
with open( corpus_file ) as corpus:
for line in corpus:
word= line.strip().encode("ASCII")
try:
with archive.open(first, 'r', pwd=word) as member:
text= member.read()
print( "Password", word )
print( text )
break
except (RuntimeError, zlib.error, zipfile.BadZipFile):
pass
我们导入了两个库:zipfile以及zlib。我们添加zlib是因为我们发现,在猜测密码时,有时会看到zlib.error异常。我们为打开的归档文件创建了一个上下文。我们使用infolist()方法获取成员的名称,并从该列表中获取第一个文件。如果我们能读取一个文件,我们就能读取所有文件。
然后,我们打开了我们的语料库文件,并为该文件创建了一个文件处理上下文。对于语料库中的每一行,我们使用了两种方法:strip()方法将删除尾随的"\n",而encode("ASCII")方法将把行从 Unicode 字符转换为 ASCII 字节。我们需要这样做,因为 ZIP 库密码是 ASCII 字节,而不是正确的 Unicode 字符字符串。
try: 块尝试打开并读取第一个成员。我们在归档中为这个成员创建了一个文件处理上下文。我们尝试读取成员。如果在尝试读取加密成员的过程中发生任何错误,将会引发异常。当然,通常的罪魁祸首是尝试用错误的密码读取成员。
如果一切顺利,那么我们就猜对了正确的密码。我们可以打印出恢复的密码,以及成员的文本作为确认。
注意,我们已经使用 break 语句来结束语料库处理的 for 循环。这改变了 for 循环的语义,从 for all words 变为 there exists a word。break 语句意味着一旦找到有效的密码,循环就会结束。不需要进一步处理语料库中的其他单词。
我们列出了三种可能因尝试使用不良密码而引发的异常。为什么不同类型的异常可能由错误的密码引发并不明显。但进行一些实验来确认确实存在一个共同的根本问题引发了各种不同的异常是很容易的。
摘要
在本章中,我们了解了我们间谍工具包的基础:Python 和我们选择的文本编辑器。我们已经使用 Python 来操作数字、字符串和文件。我们看到了许多 Python 语句:赋值、while、for、if、elif、break 和 def。我们看到了一个表达式(例如 print("hello world"))如何被用作 Python 语句。
我们还研究了处理 ZIP 文件的 Python API。我们看到了 Python 如何与流行的文件归档格式协同工作。我们甚至看到了如何使用简单的单词语料库来恢复简单的密码。
现在我们有了基础知识,我们准备进行更高级的任务。接下来,我们必须开始使用万维网(WWW)来收集信息并将其带回我们的电脑。
第二章:获取情报数据
我们将从各种来源获取情报数据。我们可能会采访人们。我们可能会从秘密地下基地窃取文件。我们可能会搜索万维网(WWW),这是我们本章将关注的重点。使用我们自己的相机或录音设备是下一章的主题。
重要间谍目标包括自然资源、公众舆论和战略经济优势。这类背景信息在许多方面都很有用。世界上大量的数据已经存在于互联网上,其余的最终也会到达那里。任何现代的情报搜索都始于互联网。
我们可以使用 Python 库,如http.client和urllib,从远程服务器获取数据并将文件传输到其他服务器。一旦我们找到了感兴趣的远程文件,我们将需要多个 Python 库来解析和提取这些库中的数据。
在第一章《我们的间谍工具包》中,我们探讨了如何查看 ZIP 存档的内容。在本章中,我们将查看其他类型的文件。我们将重点关注 JSON 文件,因为它们在 Web 服务 API 中得到广泛应用。
在旅途中,我们将涵盖多个主题:
-
如何从 Python 访问在线数据。
-
HTTP 协议以及如何从我们的应用程序访问网站。
-
FTP 协议以及如何上传和下载大量批量数据。
-
许多核心 Python 数据结构将包括列表、元组、字典和集合,以及我们如何使用这些结构来组织和管理工作信息。
-
在本章结束时,我们将能够构建应用程序,从网络获取实时、最新、分秒必争的数据。一旦我们获取了数据,我们可以过滤和分析它,以创建有用的情报资产。
从互联网获取数据
万维网和互联网基于一系列称为请求评论(RFC)的协议。RFCs 定义了连接不同网络的标准和协议,即互联网互连的规则。万维网由这些 RFC 的一个子集定义,该子集指定了协议、主机和代理(服务器和客户端)的行为以及文件格式等细节。
从某种意义上说,互联网是一种受控的混乱。大多数软件开发者都同意遵循 RFCs。有些人则不然。如果他们的想法真的很好,它可能会流行起来,即使它并不完全遵循标准。我们经常在看到一些浏览器与某些网站不兼容时看到这种情况。这可能会引起困惑和疑问。我们经常不得不进行间谍活动和普通的调试,以确定给定网站上的可用信息。
Python 提供了一系列模块,实现了互联网 RFCs 中定义的软件。我们将探讨一些常见的协议,通过互联网收集数据以及实现这些协议的 Python 库模块。
背景简报——TCP/IP 协议
万维网背后的基本思想是互联网。互联网背后的基本思想是 TCP/IP 协议栈。这个协议栈中的 IP 部分是互联网互连协议。它定义了消息如何在网络之间路由。在 IP 之上是 TCP 协议,用于连接两个应用程序。TCP 连接通常通过一个称为 套接字 的软件抽象来实现。除了 TCP 之外,还有 UDP;它在我们感兴趣的万维网数据中并不常用。
在 Python 中,我们可以使用低级的 socket 库来处理 TCP 协议,但我们不会这样做。套接字是一个支持打开、关闭、输入和输出操作的文件类似对象。如果我们在一个更高的抽象级别上工作,我们的软件将会更简单。我们将使用的 Python 库将在幕后利用套接字概念。
互联网 RFC 定义了在 TCP/IP 套接字上构建的多个协议。这些是主机计算机(服务器)和用户代理(客户端)之间交互的有用定义。我们将查看其中的两个:超文本传输协议(HTTP)和文件传输协议(FTP)。
使用 http.client 进行 HTTP GET
网络流量的本质是 HTTP。这是建立在 TCP/IP 之上的。HTTP 定义了两个角色:主机和用户代理,也分别称为服务器和客户端。我们将坚持使用服务器和客户端。HTTP 定义了多种请求类型,包括 GET 和 POST。
网络浏览器是我们可以使用的一种客户端软件。该软件会发出 GET 和 POST 请求,并显示来自网络服务器的结果。我们可以使用两个库模块在 Python 中进行这种客户端处理。
http.client 模块允许我们进行 GET 和 POST 请求,以及 PUT 和 DELETE 请求。我们可以读取响应对象。有时,响应是一个 HTML 页面。有时,它是一个图形图像。还有其他一些东西,但我们主要对文本和图形感兴趣。
这是我们一直在试图找到的神秘设备的图片。我们需要将此图像下载到我们的计算机上,以便我们可以查看并发送给我们的线人。upload.wikimedia.org/wikipedia/commons/7/72/IPhone_Internals.jpg

这是我们需要追踪并支付的货币的图片:

我们需要下载这张图片。以下是链接:
upload.wikimedia.org/wikipedia/en/c/c1/1drachmi_1973.jpg
这就是我们如何使用 http.client 来获取这两个图像文件的方法:
import http.client
import contextlib
path_list = [
"/wikipedia/commons/7/72/IPhone_Internals.jpg",
"/wikipedia/en/c/c1/1drachmi_1973.jpg",
]
host = "upload.wikimedia.org"
with contextlib.closing(http.client.HTTPConnection( host )) as connection:
for path in path_list:
connection.request( "GET", path )
response= connection.getresponse()
print("Status:", response.status)
print("Headers:", response.getheaders())
_, _, filename = path.rpartition("/")
print("Writing:", filename)
with open(filename, "wb") as image:
image.write( response.read() )
我们正在使用 http.client 来处理 HTTP 协议的客户端部分。我们还在使用 contextlib 模块,在完成使用网络资源后,礼貌地将我们的应用程序与网络资源分离。
我们将路径列表赋值给 path_list 变量。这个例子介绍了列表对象,但没有提供任何背景信息。我们将在本章后面的 组织数据集合 部分回到列表。重要的是列表必须用 [] 括起来,并且项目之间用 , 分隔。是的,在末尾有一个额外的 ,。这在 Python 中是合法的。
我们使用主机计算机名创建了一个 http.client.HTTPConnection 对象。这个连接对象有点像文件;它将 Python 与我们本地计算机上的操作系统资源以及远程服务器连接起来。与文件不同,HTTPConnection 对象不是一个合适的上下文管理器。因为我们非常喜欢使用上下文管理器来释放资源,所以我们使用了 contextlib.closing() 函数来处理上下文管理细节。连接需要被关闭;closing() 函数通过调用连接的 close() 方法确保这一点会发生。
对于我们 path_list 中的所有路径,我们发送一个 HTTP GET 请求。这就是浏览器获取 HTML 页面中提到的图像文件的方式。我们从每个响应中打印了一些信息。如果一切正常,状态将是 200。如果状态不是 200,那么就出了问题,我们需要查阅 HTTP 状态码来了解发生了什么。
小贴士
如果你使用咖啡馆的 Wi-Fi 连接,可能你没有登录。你可能需要打开浏览器来设置连接。
HTTP 响应包括一些提供请求和响应额外细节的头部信息。我们打印了头部信息,因为它们在调试我们可能遇到的问题时可能很有帮助。最有用的头部信息之一是 ('Content-Type', 'image/jpeg')。这确认了我们确实得到了一个图像。
我们使用 _, _, filename = path.rpartition("/") 来定位路径中的最右侧 / 字符。回想一下,partition() 方法用于定位最左侧的实例。在这里我们使用最右侧的。我们将目录信息和分隔符赋值给变量 _。是的,_ 是一个合法的变量名。它很容易被忽略,这使得它成为表示“我们不关心”的便捷缩写。我们将文件名保存在 filename 变量中。
我们为生成的图像文件创建了一个嵌套的上下文。然后我们可以读取响应的主体——一组字节,并将这些字节写入图像文件。一气呵成,文件就归我们所有了。
HTTP GET 请求是 WWW 的基础。例如 curl 和 wget 这样的程序是这个例子的扩展。它们执行一系列 GET 请求来定位一个或多个页面内容。它们可以做很多事情,但这只是从 WWW 中提取数据的本质。
修改客户端信息
一个 HTTP GET 请求除了 URL 之外还包含几个头部信息。在之前的例子中,我们只是简单地依赖 Python http.client 库来提供一组合适的默认头部信息。我们可能有几个原因想要提供不同的或额外的头部信息。
首先,我们可能想要调整 User-Agent 头部来改变我们声称的浏览器类型。我们也可能需要为某些类型的交互提供 cookies。关于用户代理字符串的信息,请参阅 en.wikipedia.org/wiki/User_agent_string#User_agent_identification。
这个信息可能被网络服务器用来确定是否正在使用移动设备或桌面设备。我们可以使用类似的方法:
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.75.14 (KHTML, like Gecko) Version/7.0.3 Safari/537.75.14
这使得我们的 Python 请求看起来像是来自 Safari 浏览器而不是一个 Python 应用程序。我们可以在桌面计算机上使用类似的方法来模拟不同的浏览器:
Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:28.0) Gecko/20100101 Firefox/28.0
我们可以使用类似的方法来模拟 iPhone 而不是 Python 应用程序:
Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_1 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D201 Safari/9537.53
我们通过向请求中添加头部信息来做出这种改变。改变看起来是这样的:
connection.request( "GET", path, headers= {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_1 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D201 Safari/9537.53',
})
这将使网络服务器将我们的 Python 应用程序视为在 iPhone 上运行。这可能会导致比提供给完全桌面计算机的数据页面更紧凑。
头部信息是一个具有 { key: value, } 语法的结构。这是一个字典。我们将在接下来的 组织数据集合 部分回到字典。重要的是字典被 {} 包围,键和值由 : 分隔,每个键值对由 , 分隔。是的,结尾有一个额外的 ,。这在 Python 中是合法的。
我们可以提供许多其他的 HTTP 头部信息。User-Agent 头部可能是从网络服务器收集不同类型智能数据最重要的。
在 Python 中使用 FTP
FTP 指定了在计算机之间传输文件的方法。有两种主要变体:原始 FTP 和更安全的版本 FTPS。这个更安全的版本使用 SSL 来确保低级套接字完全加密。有时被称为 FTP_TLS,即带有传输层安全的 FTP。
SSH 标准包括一个文件传输协议,SFTP。这是 SSH 的一部分,与其他 FTP 变体分开。这由 ftplib 模块支持,尽管它实际上是一个不同的协议。
在某些情况下,FTP 访问是匿名的。不使用任何安全凭证(如用户名或密码)。这通常保留用于仅下载的内容。有时,匿名访问期望一个占位符用户名和密码——用户名应该是 anonymous,通常,你的电子邮件地址被用作密码。在其他情况下,我们需要有适当的凭证。我们将关注公开可访问的 FTP 服务器。
我们将要寻找《CIA 世界事实全书》。我们知道在 Project Gutenberg 中有副本。这使我们决定使用 ftp.ibiblio.org 服务器作为我们调查的目标。基本 URL 是 ftp://ftp.ibiblio.org/pub/docs/books/gutenberg/。
FTP 有自己的命令语言,用于检查远程(和本地)文件系统、创建和删除目录,以及获取和放置文件。其中一些语言通过 Python FTP 模块公开。其中一些则隐藏在幕后。
我们可以使用如下脚本查看 Project Gutenberg 服务器上可用的顶层文档。这是我们发现数据的初始步骤:
import ftplib
host = "ftp.ibiblio.org"
root = "/pub/docs/books/gutenberg/"
def directory_list( path ):
with ftplib.FTP(host, user="anonymous") as connection:
print("Welcome", connection.getwelcome())
for name, details in connection.mlsd(path):
print(name, details['type'], details.get('size'))
directory_list(root)
我们导入了 FTP 库。我们需要使用 FTP 协议做任何事情。我们将主机 host 和根路径 root 作为字符串分配。我们将在需要定义的几个函数中使用它。
我们定义了一个 directory_list() 函数,该函数将显示目录中的名称、类型和大小。这使我们能够探索本地目录中的文件。在找到候选文件所在的目录后,我们将使用不同的参数调用此函数。
directory_list() 函数使用 ftplib.FTP 对象打开一个上下文。我们不需要使用 contextlib.closing() 函数,因为这个上下文表现良好。此对象将管理用于与 FTP 服务器交换数据的各种套接字。其中一个方法,getwelcome(),检索任何欢迎消息。我们会看到这相当简短。有时,它们会更详细。
我们将输出顶层目录信息,显示各种文件、目录及其大小。details['type'] 语法是我们从字典中的键值对中选择特定名称的方式。details.get('size') 语法做类似的事情。使用 [] 获取项如果找不到名称将引发异常。使用 get() 方法获取项而不是异常提供默认值。除非指定其他值,否则默认值是 None。
我们声称 details 字典必须有一个 type 项。如果没有,程序将会崩溃,因为某些东西非常不对劲。我们还声称 details 字典可能或可能没有 size 项。如果大小不存在,则 None 值将代替。
这里有很多文件。《README》和 GUTINDEX.ALL 文件看起来很有希望;让我们检查一下。
通过 FTP 下载文件
FTP 库依赖于一种称为 回调函数 的技术来支持增量处理。下载一个 13 MB 的文件需要一些时间。让我们的电脑在下载时只是打盹是不礼貌的。提供一些关于进度(或缺乏进度)的持续状态是很好的。
我们可以以多种方式定义回调函数。如果我们打算使用类定义,回调函数将是类的另一个方法。类定义超出了我们书籍的范围。它们相当简单,但我们必须专注于间谍活动,而不是软件设计。这里有一个通用目的的 get() 函数:
import sys
def get( fullname, output=sys.stdout ):
download= 0
expected= 0
dots= 0
def line_save( aLine ):
nonlocal download, expected, dots
print( aLine, file=output )
if output != sys.stdout:
download += len(aLine)
show= (20*download)//expected
if show > dots:
print( "-", end="", file=sys.stdout )
sys.stdout.flush()
dots= show
with ftplib.FTP( host, user="anonymous" ) as connection:
print( "Welcome", connection.getwelcome() )
expected= connection.size( fullname )
print( "Getting", fullname, "to", output, "size", expected )
connection.retrlines( "RETR {0}".format(fullname), line_save )
if output != sys.stdout:
print() # End the "dots"
get() 函数包含一个嵌套在其内的函数定义。line_save() 函数是 retrlines() 函数使用的回调函数,用于 FTP 连接。来自服务器的每一行数据都将传递给 line_save() 函数进行处理。
我们的 line_save() 函数使用三个 nonlocal 变量:download、expected 和 dots。这些变量既不是全局的,也不是 line_save() 函数的局部变量。它们在下载任何行之前被初始化,并在 line_save() 函数中逐行更新。由于它们是 line_save() 函数的保存状态,我们需要通知 Python 在这些变量在赋值语句中使用时不要创建局部变量。
函数的主要任务是打印 output 变量中命名的行到文件。有趣的是,output 变量也是 nonlocal 的。由于我们从未尝试为这个变量分配新值,所以我们不需要通知 Python 在赋值语句中使用它。一个函数可以读取非局部变量;写入访问需要通过 global 或 nonlocal 语句的特殊安排。
如果输出文件是 sys.stdout,我们在控制台上显示文件。写入状态信息只会让人困惑。如果输出文件不是 sys.stdout,我们将保存文件。显示一些状态信息是有帮助的。
我们计算需要显示多少个点(从 0 到 19)。如果点的数量增加了,我们会打印另一个破折号。是的,我们调用了变量 dots 但决定打印破折号。神秘性从来都不是一件好事。你可能想独立执行任务并编写自己的版本,这个版本比这个更清晰。
get() 函数使用 ftplib.FTP 对象创建一个上下文。该对象将管理用于与 FTP 服务器交换数据的各种套接字。我们使用 getwelcome() 方法获取欢迎信息。我们使用 size() 方法获取我们即将请求的文件的大小。通过设置 expected 变量,我们可以确保显示最多 20 个破折号以显示下载的状态。
连接的 retrlines() 方法需要一个 FTP 命令和一个回调函数。它发送命令;响应的每一行都会发送到回调函数。
使用我们的 FTP get() 函数
我们可以使用这个 get() 函数从服务器下载文件。我们将从两个从 FTP 服务器提取文件的示例开始:
# show the README on sys.stdout
get(root+"README")
# get GUTINDEX.ALL
with open("GUTINDEX.ALL", "w", encoding="UTF-8") as output:
get(root+"GUTINDEX.ALL", output)
第一个例子是一个小文件。我们将显示 README 文件,它可能包含有用的信息。它通常很小,我们可以立即将其写入 stdout。第二个例子将打开一个文件处理上下文,将大的 GUTINDEX.ALL 文件本地保存以供进一步分析。它相当大,我们当然不想立即显示它。我们可以在索引文件中搜索中央情报局世界事实书。有几个事实书。
GUTINDEX.ALL 文件的介绍描述了文档编号如何转换为目录路径。例如,中央情报局世界事实书中之一是文档编号 35830。这变成了目录路径 3/5/3/35380/。文档将在这个目录中。
我们可以使用我们的 directory_list() 函数来查看还有其他什么内容:
directory_list( root+"3/5/8/3/35830/" )
这将显示有几个子目录和一个看起来包含图像的 ZIP 文件。我们将从文本文件开始。我们可以使用我们的 get() 函数在以下脚本中下载中央情报局事实书:
with open("35830.txt", "w", encoding="UTF-8") as output:
get(root+"3/5/8/3/35830/"+"35830.txt", output)
这为我们提供了一个中央情报局世界事实书。我们可以轻松追踪其他事实书。然后我们可以分析这些下载文档中的信息。
使用 urllib 进行 HTTP、FTP 或文件访问
urllib 包将 HTTP、FTP 和本地文件访问封装在一个单一、整洁的包中。在大多数常见情况下,这个包允许我们省略一些我们在前一个例子中看到的处理细节。
urllib 中通用方法的优点是我们可以编写小型程序,这些程序可以与来自各种位置的数据一起工作。我们可以依赖 urllib 无缝地处理 HTTP、FTP 或本地文件。缺点是我们不能进行一些更复杂的 HTTP 或 FTP 交互。以下是一个使用 urllib 版本的 HTTP get 函数下载两个图像的例子:
import urllib.request
url_list = [
"http://upload.wikimedia.org/wikipedia/commons/7/72/IPhone_Internals.jpg",
"http://upload.wikimedia.org/wikipedia/en/2/26/Common_face_of_one_euro_coin.jpg",
]
for url in url_list:
with urllib.request.urlopen( url ) as response:
print( "Status:", response.status )
_, _, filename = response.geturl().rpartition("/")
print( "Writing:", filename )
with open( filename, "wb" ) as image:
image.write( response.read() )
我们已定义了两个 URL。当使用 urllib 时,我们可以提供完整的 URL,而无需区分我们试图访问的主机和路径。
我们使用 urllib.request.urlopen() 创建一个上下文。这个上下文将包含从万维网获取文件所使用的所有资源。在 Python 术语中,response 对象被称为 文件类似对象。我们可以像使用文件一样使用它:它支持 read() 和 readline() 方法。它可以用在 for 语句中,以迭代文本文件的行。
使用 urllib 进行 FTP 访问
我们可以使用简单的 urllib.request 通过 FTP 获取文件。我们只需更改 URL 以反映我们使用的协议。以下类似的内容可以很好地工作:
import sys
import urllib.request
readme= "ftp://ftp.ibiblio.org/pub/docs/books/gutenberg/README"
with urllib.request.urlopen(readme) as response:
sys.stdout.write( response.read().decode("ascii") )
这将打开源文件并在 sys.stdout 上打印它。请注意,我们必须将字节从 ASCII 解码为以创建适当的 Unicode 字符,以便 Python 使用。如果我们发现有必要,我们可以打印其他状态和标题信息。
我们还可以使用本地文件 URL。其模式是 file: 而不是 http: 或 ftp:。通常省略主机名,因此导致类似这样的文件 URL:
local= "file:///Users/slott/Documents/Writing/Secret Agent's Python/currency.html"
使用urllib带来了一些方便的简化。我们可以用处理本地文件类似的代码来处理位于整个互联网上的资源。远程资源通常比本地文件慢;我们可能在一段时间后放弃等待。此外,还有网络断开连接的可能性。在处理远程数据时,我们的错误处理需要更加健壮。
使用 Python 中的 REST API
通过 REST API 可以获取大量的智能数据。大部分数据以简单的 JSON、CSV 或 XML 文档的形式提供。为了理解这些数据,我们需要能够解析这些各种类型的序列化格式。我们将重点关注 JSON,因为它被广泛使用。遗憾的是,它并不是通用的。
REST 协议本质上就是 HTTP。它将利用POST、GET、PUT和DELETE请求来实现持久数据生命周期的四个基本阶段:创建、检索、更新和删除(CRUD)规则。
我们将研究货币转换作为一个简单的 Web API。这不仅可以帮助我们贿赂我们的信息来源,还可以提供关于一个国家经济整体状况的重要信息。我们可以将国家经济相互比较,也可以将它们与非国家加密货币,如比特币进行比较。
我们将从www.coinbase.com获取汇率和货币信息。有许多类似的服务;这个看起来相当完整。他们似乎有最新的货币信息,我们可以将其作为整体情报评估的一部分报告给总部。
他们的 API 文档可在coinbase.com/api/doc找到。这告诉我们应该使用哪些 URL,应该与 URL 一起提供哪些数据,以及可以期待什么样的响应。
获取简单的 REST 数据
我们可以使用http.client或urllib.request模块来获取货币交换数据。这对我们来说并不陌生;我们已经在使用这两个库获取数据。这个网站的响应将是 JSON 格式。更多信息,请参阅www.json.org/。
要解析一个 JSON 文档,我们需要从标准库中导入json模块。我们从urllib获取的响应是一系列字节。我们需要解码这些字节以获取字符串。然后我们可以使用json.loads()函数从该字符串构建 Python 对象。看起来是这样的:
import urllib.request
import json
query_currencies= "http://www.coinbase.com/api/v1/currencies/"
with urllib.request.urlopen( query_currencies ) as document:
print(document.info().items())
currencies= json.loads( document.read().decode("utf-8") )
print(currencies)
我们导入了所需的两个库:urllib.request用于获取数据,json用于解析响应。
货币查询(/api/v1/currencies/)在 Coinbase 网站的 API 文档中有描述。当我们发起这个请求时,返回的文档将包含他们所知道的全部货币。
我们打印了document.info().items();这是随响应返回的头信息集合。有时,这些信息很有趣。在这种情况下,它们并没有告诉我们太多我们不知道的事情。重要的是Content-Type头信息具有application/json; charset=utf-8的值。这告诉我们如何解码字节。
我们读取了生成的文档(document.read()),然后将字节转换为字符。Content-Type头信息表明字符使用utf-8进行编码,因此我们将使用utf-8来解码字节并恢复原始字符序列。一旦我们有了字符,我们可以使用json.loads()从字符创建 Python 对象。
这将为我们提供一个我们可以工作的货币列表。响应对象看起来像这样:
[['Afghan Afghani (AFN)', 'AFN'], ['Albanian Lek (ALL)', 'ALL'],
['Algerian Dinar (DZD)', 'DZD'], … ]
这是一个列表列表,提供了 161 种货币的名称。
在下一节中,我们将探讨如何处理列表-元组结构。处理列表列表与处理列表元组非常相似。
为了使其更加灵活,我们需要将items()头信息列表转换为字典。从字典中,我们可以获取Content-Type值字符串。这个字符串可以被分割在;上以定位charset=utf-8子串。这个字符串随后可以被分割在=字符上以定位utf-8编码信息。这会比假设utf-8编码稍微好一些。第一步,从头信息创建字典,必须等到组织数据集合部分。首先,我们将查看如何使用 REST 协议获取其他信息。
使用更复杂的 RESTful 查询
一旦我们有了货币列表,我们就可以请求即期汇率。这涉及到一个稍微复杂一些的 URL。我们需要提供一个货币代码来获取该特定货币的当前比特币汇率。
虽然从 API 文档中并不完全清楚,但 Web 状态 RFC 指出我们应该将查询字符串作为我们处理的一部分进行编码。在这种情况下,查询字符串似乎不可能包含任何需要编码的字符。
我们将非常挑剔,并使用urllib模块正确地编码查询字符串。编码对于第四章中的许多示例至关重要,Drop、藏身之处、聚会和巢穴。
查询字符串编码使用urllib.parse模块完成。它看起来像这样:
scheme_netloc_path= "https://coinbase.com/api/v1/prices/spot_rate"
form= {"currency": currency}
query= urllib.parse.urlencode(form)
scheme_netloc_path变量包含 URL 的一部分。它有方案(http)、网络位置(coinbase.com)和路径(api/v1/prices/spot_rate)。这个 URL 片段没有查询字符串;我们将单独对其进行编码,因为它包含动态信息,这些信息会随着请求而变化。
技术上,查询字符串是一系列经过编码的参数,这样一些保留字符,如 ? 和 #,就不会对 Web 服务器造成任何混淆。实际上,这里使用的查询字符串非常简单,只有一个参数。
为了以通用方式处理查询字符串,我们使用字典定义了一个 HTML 表单,并将其分配给 form 变量。这个字典是 HTML 网页上表单的一个模型,包含一个输入字段。我们模拟了一个名为 currency 的输入字段,其值为 EUR。
urllib.parse.urlencode() 函数将表单的所有字段编码成一个整洁的表示形式,任何保留字符都得到适当的处理。在这种情况下,只有一个字段,并且字段名或字段值没有使用保留字符。
我们可以在交互式 Python 中玩弄这个:
>>> import urllib.parse
>>> form= {"currency": "EUR"}
>>> urllib.parse.urlencode(form)
'currency=EUR'
上一段代码显示了如何将表单对象作为字典构建,然后编码以创建有效的 URL 编码查询字符串。由于数据非常简单,编码也很简单。
下面是一个包含更复杂数据片段的示例:
>>> form['currency']= "Something with # or ?"
>>> urllib.parse.urlencode(form)
'currency=Something+with+%23+or+%3F'
首先,我们更新了表单,使用了不同的输入;我们将货币值更改为 Something with # or ?。我们将在下一节中查看字典更新。更新后的值中包含保留字符。当我们对表单进行编码时,结果显示了保留字符是如何通过 URL 编码处理的。
当我们开始处理更复杂的结构时,我们会发现内置的 print() 函数并不能完成我们需要的所有事情。在 pprint 模块中,pprint() 函数在处理复杂数据方面做得更好。我们可以使用这个来获取美观打印函数:
import pprint
我们可以使用我们的查询模板和编码后的数据如下:
with urllib.request.urlopen( scheme_netloc_path+"?"+query ) as document:
pprint.pprint( document.info().items() )
spot_rate= json.loads( document.read().decode("utf-8") )
表达式 scheme_netloc_path+"?"+query 从相对静态部分和动态查询字符串组装了完整的 URL。我们使用 with 语句确保在完成后所有网络资源都得到适当释放。我们使用 pprint() 函数显示头部信息,这些信息告诉我们内容类型。头部还包括三个 cookie,在这些示例中我们仔细地忽略了它们。
当我们打印 spot_rate 值时,我们看到 Python 对象看起来是这样的:
{'currency': 'USD', 'amount': '496.02'}
Or this
{'currency': 'EUR', 'amount': '361.56'}
这些是 Python 字典对象。我们需要了解更多关于字典的知识,以便能够处理这些响应。请关注 使用 Python 字典映射 部分。
通过 JSON 保存我们的数据
如果我们想保存下载的数据呢?这是 JSON 擅长的事情。我们可以使用 JSON 模块将对象序列化为字符串并将此字符串写入文件。
下面是如何将我们的两价货币汇率数据保存到 JSON 文档中的示例。首先,我们需要将 获取更多 RESTful 数据 部分的 spot_rate 示例转换为函数。它可能看起来是这样的:
def get_spot_rate( currency ):
scheme_netloc_path= "https://coinbase.com/api/v1/prices/spot_rate"
form= {"currency":currency}
query= urllib.parse.urlencode(form)
with urllib.request.urlopen( scheme_netloc_path+"?"+query ) as document:
spot_rate= json.loads( document.read().decode("utf-8") )
return spot_rate
这个函数需要一个货币代码作为参数。给定货币代码,它创建了一个小型的输入表单并将这个表单编码以创建查询字符串。在这种情况下,我们将这个字符串保存在query变量中。
我们从一个模板和数据中创建了 URL,这个 URL 被用作获取货币点价的请求。我们读取了整个响应并将字节字符串解码。一旦我们有了字符串,我们就使用这个字符串加载了一个 Python 字典对象。我们使用get_spot_rate()函数返回了这个字典。现在我们可以使用这个函数来获取一些点价字典对象:
rates = [
get_spot_rate("USD"), get_spot_rate("GBP"),
get_spot_rate("EUR") ]
这个语句从我们的三个点值字典中构建了一个列表-字典结构。它将这个集合分配给rates变量。一旦我们有了这个,我们就可以序列化它并创建一个包含一些有用汇率信息的文件。
下面是如何使用 JSON 将 Python 对象保存到文件中的示例:
with open("rate.json","w") as save:
json.dump( rates, save )
我们打开了一个文件来写入一些内容,并使用这个作为处理上下文以确保我们在完成后文件会被正确关闭。然后我们使用json.dump()函数将我们的rates对象转储到这个文件中。
重要的是,JSON 在我们将一个对象编码到文件时工作得最简单。在这种情况下,我们构建了一个包含单个对象的列表并将其编码到文件中。由于我们无法轻松地对对象进行任何类型的部分或增量编码到 JSON 文件中,所以我们构建了一个包含所有内容的列表。除非在数据量巨大的情况下,这种构建和转储列表的技术工作得非常好。
组织数据集合
我们在章节的早期介绍了一些数据集合。现在是时候坦白这些集合是什么以及我们如何有效地使用它们了。正如我们在第一章中观察到的,我们的间谍工具包,Python 提供了一座不同类型的数字宝塔。常用的数字是内置的;更专业的数字是从标准库中导入的。
以类似的方式,Python 有许多内置的集合。在标准库中还有大量额外的集合类型可用。我们将查看内置的列表、元组、字典和集合。这些涵盖了处理数据项组的基本基础。
使用 Python 列表
Python 列表类可以概括为一种可变序列。可变性意味着我们可以添加、更改和删除项目(列表可以更改)。序列意味着项目是通过它们在列表中的位置来访问的。
语法非常简单;我们将数据项放在[]中,并用,分隔项。我们可以在序列中使用任何 Python 对象。
总部希望了解选定奶酪品种的人均消费情况。虽然总部没有向现场特工透露太多信息,但我们知道他们经常想知道关于自然资源和战略经济优势的信息。
我们可以在www.ers.usda.gov/datafiles/Dairy_Data/chezcon_1_.xls找到奶酪消费数据。
很遗憾,数据是专有电子表格格式,并且相当难以处理。为了自动化数据收集,我们需要类似 Project Stingray 这样的工具来从该文档中提取数据。对于手动数据收集,我们可以复制并粘贴数据。
这里是从 2000 年开始到 2010 年结束的数据;我们将用它来展示一些简单的列表处理:
>>> cheese = [29.87, 30.12, 30.60, 30.66, 31.33, 32.62,
... 32.73, 33.50, 32.84, 33.02,]
>>> len(cheese)
10
>>> min(cheese)
29.87
>>> cheese.index( max(cheese) )
7
我们创建了一个列表对象,并将其分配给cheese变量。我们使用了min()函数,它揭示了29.87序列中的最小值。
index()方法在序列中搜索匹配的值。我们看到使用max()函数找到的最大消费量对应的索引是7,即 2007 年。之后,奶酪消费量略有下降。
注意,我们有一些前缀函数表示法(min()、max()、len()等),我们还有方法函数表示法,如cheese.index(),以及其他许多。Python 提供了丰富的表示法。没有严格遵循只使用方法函数的规则。
由于列表是可变的,我们可以向列表中添加额外的值。我们可以使用cheese.extend()函数将额外的值列表扩展到给定的列表中:
>>> cheese.extend( [32.92, 33.27, 33.51,] )
>>> cheese
[29.87, 30.12, 30.6, 30.66, 31.33, 32.62, 32.73, 33.5, 32.84, 33.02, 32.92, 33.27, 33.51]
我们也可以使用+运算符来合并两个列表。
我们可以使用以下代码重新排序数据,使其严格递增:
>>> cheese.sort()
>>> cheese
[29.87, 30.12, 30.6, 30.66, 31.33, 32.62, 32.73, 32.84, 32.92, 33.02, 33.27, 33.5, 33.51]
注意,sort()方法不返回值。它修改了列表对象本身;它不返回一个新列表。如果我们尝试sorted_cheese= cheese.sort(),我们会看到sorted_cheese有一个None值。这是由于sort()不返回值的结果;它修改了列表。
当处理时间序列数据时,这种转换会让人困惑,因为当我们对列表进行排序时,年份和奶酪消费量之间的关系就丢失了。
使用列表索引操作
我们可以使用cheese[index]表示法来访问单个项目:
>>> cheese[0]
29.87
>>> cheese[1]
30.12
这允许我们从列表中挑选特定的项目。由于列表已排序,项目0是最小的,项目1是下一个较大的值。我们可以从列表的末尾反向索引,如下面的代码所示:
>>> cheese[-2]
33.5
>>> cheese[-1]
33.51
在排序后的数据中,-2项是下一个最大的值;-1项是最后一个,即最大的值。在原始未排序的cheese[-2]数据将是 2009 年的数据。
我们也可以从列表中取一个切片。一些常见的切片操作如下所示:
>>> cheese[:5]
[29.87, 30.12, 30.6, 30.66, 31.33]
>>> cheese[5:]
[32.62, 32.73, 32.84, 32.92, 33.02, 33.27, 33.5, 33.51]
第一片选取了前五个值——奶酪消费量最少的值。由于我们已对时间序列数据进行排序,我们并不容易知道这些值是哪一年的。我们可能需要更复杂的数据收集。
当处理集合时,我们发现我们有一个新的比较运算符in。我们可以使用简单的in测试来查看值是否在任何集合中发生:
>>> 30.5 in cheese
False
>>> 33.5 in cheese
True
in运算符适用于元组、字典键和集合。
比较运算符按顺序比较两个序列中的元素,寻找两个序列之间第一个不等的元素。考虑以下示例:
>>> [1, 2, 1] < [1, 2, 2]
True
由于前两个元素相等,所以是第三个元素决定了两个列表之间的关系。这个规则也适用于元组。
使用 Python 元组
Python 的元组类可以概括为一个不可变序列。不可变性意味着一旦创建,元组就不能被更改。数字 3 的值是不可变的,也是:它始终是 3。序列意味着可以通过元组中元素的顺序来访问这些元素。
语法简单愉快;我们可能需要将数据项放在()中,并用,分隔项。我们可以使用任何 Python 对象作为序列。想法是创建一个看起来像数学坐标的对象:(3, 4)。
元组在 Python 的许多地方被底层使用。当我们使用多重赋值时,例如,以下代码的右侧创建了一个元组,而左侧将其分解:
power, value = 0, 1
右侧创建了一个二元组(0, 1)。语法不需要在元组周围使用()。左侧将二元组分解,将值分配给两个不同的变量。
我们通常在问题域中元素数量固定的情况下使用元组。我们经常使用元组来表示坐标对,如纬度和经度。我们不需要列表提供的灵活长度,因为元组的大小不能改变。当它应该只有两个值,纬度和经度时,三元组意味着什么?在涉及经度、纬度和海拔的情况下,我们可能面临不同类型的问题;在这种情况下,我们正在处理三元组。在这些例子中使用二元组或三元组是问题的一个基本特征:我们不会通过添加或删除值来修改对象。
当我们查看请求和响应中的 HTTP 头时,我们看到这些被表示为二元组的列表,例如('Content-Type', 'text/html; charset=utf-8')。每个二元组都有一个头名称('Content-Type')和一个头值('text/html; charset=utf-8')。
这里有一个使用二元组包含年份和奶酪消费量的例子:
year_cheese = [(2000, 29.87), (2001, 30.12), (2002, 30.6), (2003, 30.66),
(2004, 31.33), (2005, 32.62), (2006, 32.73), (2007, 33.5),
(2008, 32.84), (2009, 33.02), (2010, 32.92), (2011, 33.27),
(2012, 33.51)]
这种二元组列表结构允许我们执行稍微简单一点的数据分析。这里有两个例子:
>>> max( year_cheese, key=lambda x:x[1] )
(2012, 33.51)
>>> min( year_cheese, key=lambda x:x[1] )
(2000, 29.87)
我们将max()函数应用于我们的元组列表。max()函数的第二个参数是另一个函数——在这种情况下,是一个匿名lambda对象——它仅评估每个二元组的第二个值。
这里有两个更多示例,展示了lambda对象正在发生的事情:
>>> (2007, 33.5)[1]
33.5
>>> (lambda x:x[1])( (2007, 33.5) )
33.5
(2007, 33.5)二元组应用了[1]获取项操作;这将选择位置1的项,即33.5值。位置零是年份 2007。
(lambda x:x[1])表达式创建了一个匿名lambda函数。然后我们可以将这个函数应用于(2007, 33.5)双元组。由于x[1]表达式选择了索引位置1的项,我们得到了33.5值。
如果我们愿意,可以创建一个完全定义的、命名的函数而不是使用lambda,如下面的代码所示
def by_weight( yr_wt_tuple ):
year, weight = yr_wt_tuple
return weight
命名函数有两个优点:它有一个名称,并且可以有多个代码行。lambda函数的优点是当整个函数可以简化为一个表达式时,它非常小巧。
我们可以使用这种技术使用函数而不是lambda来对这些双元组进行排序,如下面的代码片段所示:
>>> by_cheese = sorted( year_cheese, key=by_weight )
>>> by_cheese
[(2000, 29.87), (2001, 30.12), (2002, 30.6), (2003, 30.66), (2004, 31.33), (2005, 32.62), (2006, 32.73), (2008, 32.84), (2010, 32.92), (2009, 33.02), (2011, 33.27), (2007, 33.5), (2012, 33.51)]
我们使用一个单独的函数来创建序列的排序副本。sorted()函数需要一个可迭代的项(在这种情况下是year_cheese列表)和一个键函数;它从旧序列中创建一个新的列表,该列表根据键函数排序。在这种情况下,我们的键函数是命名函数by_weight()。与list.sort()方法不同,sorted()函数不会修改原始序列;新列表包含对原始项的引用。
使用元组列表的生成器表达式
如果我们要定位给定年份的奶酪生产,我们需要在这个双元组序列中搜索匹配的年份。我们不能简单地使用list.index()函数来定位项,因为我们只使用了项的一部分。一种策略是使用生成器表达式从列表中提取年份,如下面的代码所示:
>>> years = [ item[0] for item in year_cheese ]
>>> years
[2000, 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012]
item[0] for item in year_cheese表达式是一个生成器。它遍历year_cheese列表,将每个项分配给名为item的变量。item[0]子表达式对每个item的值进行评估。这将分解双元组,从每个元组中返回一个值。结果收集到一个结果列表中,并分配给years变量。我们将在使用生成器函数转换序列部分回到这一点。
然后,我们可以使用years.index(2005)来获取给定年份的索引,如下面的代码所示:
>>> years.index(2005)
5
>>> year_cheese[years.index(2005)]
(2005, 32.62)
由于years.index(2005)给出了给定年份的位置,我们可以使用year_cheese[ years.index(2005) ]来获取 2005 年的year-cheese双元组。
将年份到奶酪消费的映射这一想法直接通过 Python 字典实现。
in运算符和其他比较运算符在元组上的工作方式与在列表上相同。它们通过在元组中的项之间进行简单的逐项比较,将目标元组与列表中的每个元组进行比较。
使用 Python 字典映射
字典包含从键到值的映射。Python 字典类可以概括为可变映射。可变性意味着我们可以添加、更改和删除项。映射意味着值是根据它们的键访问的。映射中不保留顺序。
语法简单愉快:我们在{}中放置键值对,用:将键与值分开,并用,将键值对分开。值可以是任何类型的 Python 对象。然而,键受到限制——它们必须是不可变对象。由于字符串和数字是不可变的,它们是完美的键。元组是不可变的,是一个好的键。列表是可变的,不能用作键。
在查看创建 HTTP 表单数据时,在获取更多 RESTful 数据部分,我们使用了从字段名到字段值的映射。我们得到了一个响应,它是一个从键到值的映射。响应看起来像这样:
>>> spot_rate= {'currency': 'EUR', 'amount': '361.56'}
>>> spot_rate['currency']
'EUR'
>>> spot_rate['amount']
'361.56'
>>> import decimal
>>> decimal.Decimal(spot_rate['amount'])
Decimal('361.56')
在创建spot_rate字典后,我们使用了dict[key]语法来获取两个键的值,即currency和amount。
由于字典是可变的,我们可以轻松地更改与键关联的值。以下是创建和修改表单的方法:
>>> form= {"currency":"EUR"}
>>> form['currency']= "USD"
>>> form
{'currency': 'USD'}
我们创建了form变量作为一个小的字典。我们可以使用这个字典来执行一个现货价格查询。然后我们更改了form字典中的值。我们可以使用这个更新的表单来执行第二个现货价格查询。
在获取值时,键必须存在;否则,我们会得到一个异常。如前所述,我们还可以使用dict.get(key, default)在键可能不在字典中时获取值。以下是一些示例:
>>> spot_rate['currency']
'EUR'
>>> spot_rate['oops']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'oops'
>>> spot_rate.get('amount')
'361.56'
>>> spot_rate.get('oops')
>>> spot_rate.get('oops', '#Missing')
'#Missing'
首先,我们获取了映射到currency键的值。我们尝试获取映射到oops键的值。我们得到了一个KeyError异常,因为oops键不在spot_rate字典中。
我们使用get()方法做了同样的事情。当我们执行spot_rate.get('amount')时,键值对存在,因此返回了值。
当我们执行spot_rate.get('oops')时,键不存在;默认返回值是None。Python 不打印None值,所以我们看不到任何明显的结果。当我们执行spot_rate.get('oops', '#Missing')时,我们提供了一个不是None的返回值,这显示了某些可见的内容。这个想法是,然后我们可以这样做来进行一系列相关的查询:
for currency in 'USD', 'EUR', 'UAH':
form['currency']= currency
data= urllib.parse.urlencode( form )
...etc...
for语句包含一个值元组:'USD', 'EUR', 'UAH'。在这种情况下,我们不需要在元组周围放置(),因为语法是明确的。
从字面元组中获取的每个值都用于设置表单中的货币值。然后我们可以使用urllib.parse.urlencode()函数构建查询字符串。我们可能会在urllib.urlopen()函数中使用它来获取该货币的比特币当前现货价格。
使用字典访问方法
字典映射的其他有趣方法包括keys()、values()和items()方法。以下是一些示例:
>>> spot_rate.keys()
dict_keys(['amount', 'currency'])
>>> spot_rate.items()
dict_items([('amount', '361.56'), ('currency', 'EUR')])
>>> spot_rate.values()
dict_values(['361.56', 'EUR'])
keys()方法给了我们一个dict_keys对象,它只包含一个简单的列表中的键。我们可以对这个列表进行排序或对字典外的其他数据进行处理。同样,values()方法给了我们一个dict_values对象,它只包含一个简单的列表中的值。
items()方法给了我们一个由两个元组组成的序列,如下面的代码所示:
>>> rate_as_list= spot_rate.items()
>>> rate_as_list
dict_items([('amount', '361.56'), ('currency', 'EUR')])
我们从spot_rate.items()的二元组列表中创建了rate_as_list变量。我们可以很容易地使用dict()函数将二元组列表转换为字典,反之亦然,如下面的代码所示:
>>> dict(rate_as_list)
{'amount': '361.56', 'currency': 'EUR'}
这为我们处理 161 种货币提供了一种方法。我们将在下一节使用生成器函数转换序列中探讨这一点。
注意,in运算符作用于字典的键,而不是值:
>>> 'currency' in spot_rate
True
>>> 'USD' in spot_rate
False
currency键存在于spot_rate字典中。USD值没有被in运算符检查。如果我们正在寻找特定的值,我们必须显式地使用values()方法:
'USD' in spot_rate.values()
其他比较运算符对于字典来说实际上没有意义。显式地比较字典的键、值或项是至关重要的。
使用生成器函数转换序列
www.coinbase.com/api/v1/currencies/上的数据,这是一个 RESTful 请求,是一个巨大的列表的列表。它开始如下所示:
>>> currencies = [['Afghan Afghani (AFN)', 'AFN'], ['Albanian Lek (ALL)', 'ALL'],
... ['Algerian Dinar (DZD)', 'DZD'],
... ]
如果我们将dict()函数应用于这个列表的列表,我们将构建一个字典。然而,这个字典并不是我们想要的;以下是如何显示它的代码:
>>> dict(currencies)
{'Afghan Afghani (AFN)': 'AFN', 'Albanian Lek (ALL)': 'ALL', 'Algerian Dinar (DZD)': 'DZD'}
这个字典中的键是长的country currency (code)字符串。值是三位货币代码。
我们可能希望将这个列表的键作为方便的查找表,供个人参考以追踪特定国家的适当货币。我们可能使用如下所示的方法:
>>> dict(currencies).keys()
dict_keys(['Afghan Afghani (AFN)', 'Albanian Lek (ALL)', 'Algerian Dinar (DZD)'])
这显示了如何从一个列表的列表创建一个字典,然后从这个字典中提取keys()。从某种意义上说,这是对简单结果进行过度处理。
我们在使用 Python 元组部分展示了使用生成器函数获取一些数据的例子。以下是我们将如何应用这个问题的解决方案。我们将使用生成器函数创建一个列表推导式。被[]包围的生成器将导致一个新的列表对象,如下面的代码所示:
>>> [name for name, code in currencies]
['Afghan Afghani (AFN)', 'Albanian Lek (ALL)', 'Algerian Dinar (DZD)']
currencies对象是原始的列表的列表。实际上有 161 个项目;我们在这里处理的是其中的一部分,以保持输出的小型化。
生成器表达式有三个子句。这些是针对源中的目标的子表达式。[]字符是用于从生成的值创建列表对象的分隔符号;它们本身不是生成器表达式的一部分。子表达式为每个目标值进行评估。目标变量被分配给源可迭代对象中的每个元素。货币列表中的每个二元组被分配给name和code目标变量。子表达式只是name。我们可以用这个来从货币到全名构建一个字典:
>>> codes= dict( (code,name) for name,code in currencies )
>>> codes
{'DZD': 'Algerian Dinar (DZD)', 'ALL': 'Albanian Lek (ALL)', 'AFN': 'Afghan Afghani (AFN)'}
>>> codes['AFN']
'Afghan Afghani (AFN)'
我们使用生成器函数来交换货币列表中每个项目的两个元素。目标是name和code;结果子表达式是(code,name)二元组。我们从这个构建了一个字典;这个字典将货币代码映射到国家名称。
使用defaultdict和Counter映射
标准库中包含许多复杂的映射。其中两个是defaultdict和Counter映射。defaultdict允许我们更灵活地处理不存在的键。
让我们看看我们用来恢复 ZIP 文件密码的单词corpus。我们可以用这个单词corpus用于其他目的。可以帮助加密部门解码消息的事情之一是了解在源文档中经常出现的双字母序列(digram或bigram)。
英语中最常见的两个字母的字母组合是什么?我们可以很容易地从我们的字典中收集这些信息,如下面的代码所示:
from collections import defaultdict
corpus_file = "/usr/share/dict/words"
digram_count = defaultdict( int )
with open( corpus_file ) as corpus:
for line in corpus:
word= line.lower().strip()
for position in range(len(word)-1):
digram= word[position:position+2]
digram_count[digram] += 1
我们需要从collections模块导入defaultdict类,因为它不是内置的。我们使用int作为初始化函数创建了一个空的defaultdict对象,名为digram_count。初始化函数处理缺失的键;我们将在下一节中查看细节。
我们打开了我们的单词corpus。我们遍历corpus中的每一行。我们将每一行转换为一个单词,通过删除尾随空格并将其映射为小写。我们使用range()函数生成一个从零到单词长度减一的序列(len(word)-1)。我们可以使用word[position:position+2]切片符号从每个单词中提取一个双字符字母组合。
当我们评估digram_count[digram]时,会发生以下两种情况之一:
-
如果键存在于映射中,则返回值,就像任何普通字典一样。然后我们可以将返回的值加一,从而更新字典。
-
如果键不存在于这个映射中,那么初始化函数将被评估以创建一个默认值。
int()的值是0,这对于计数事物来说很理想。然后我们可以将1加到这个值上并更新字典。
defaultdict类的酷特性是,对于缺失的键值不会引发异常。而不是引发异常,使用初始化函数。
这个defaultdict(int)类非常常见,我们可以使用Counter类定义来使用它。我们可以对前面的例子进行两个微小的更改。第一个更改如下:
from collections import Counter
第二个更改如下:
digram_count= Counter()
进行这种更改的原因是Counter类执行一些附加操作。特别是,我们经常想知道最常见的计数,如下面的代码所示:
>>> print( digram_count.most_common( 10 ) )
[('er', 42507), ('in', 33718), ('ti', 31684), ('on', 29811), ('te', 29443), ('an', 28275), ('al', 28178), ('at', 27276), ('ic', 26517), ('en', 25070)]
Counter对象的most_common()方法按降序返回计数。这表明er是最常见的英语双字母组合。这些信息可能有助于总部解码器。
使用 Python 集合
Python 的集合类是可变的;我们可以添加、更改和删除项目。项目要么存在,要么不存在。我们不使用位置或键;我们只是添加、删除或测试项目。这意味着集合没有固有的顺序。
语法简单明了;我们将数据项放在{}中,并用逗号,分隔项。我们可以使用任何不可变 Python 对象在集合中。重要的是要注意,项目必须是不可变的——我们可以包括字符串、数字和元组。我们不能在集合中包含列表或字典。
由于大括号{}字符既用于字典也用于集合,因此空对大括号{}的含义不清楚。这是空字典还是空集合?如果我们用dict()表示空字典,用set()表示空集合,那就更清晰了。
集合是事物的一个简单集合;它可能是可能的最简单的事物集合。
在查看双字母组合时,我们注意到有一些双字母组合,包括连字符-字符。字典中有多少个带连字符的单词?这是一个简单的集合处理示例:
corpus_file = "/usr/share/dict/words"
hyphenated = set()
with open( corpus_file ) as corpus:
for line in corpus:
word= line.lower().strip()
if '-' in word:
hyphenated.add(word)
我们创建了一个空集合并将其赋值给hyphenated变量。我们检查我们的单词集合中的每个单词,看-字符是否在字符集合中。如果我们找到连字符,我们可以将这个单词添加到我们的连字符单词集合中。
作者电脑上的单词“corpus”有两个带连字符的单词。这提出了比它回答的问题更多的问题。
in运算符对于处理集合是必不可少的。比较运算符实现了两个集合之间的子集和超集比较。a <= b操作询问a是否是b的子集,从数学上讲,
。
使用 for 语句与集合一起使用
for语句是遍历集合中项的主要工具。当与列表、元组或集合一起使用时,for语句将愉快地确保将集合中的所有值逐个分配给目标变量。如下所示的工作效果很好:
>>> for pounds in cheese:
... print( pounds )
...
29.87
etc.
33.51
for语句将奶酪序列中的每个项分配给目标变量。我们只需简单地从集合中打印每个值。
当处理元组列表结构时,我们可以做一些更有趣的事情,如下面的代码所示:
>>> for year, pounds in year_cheese:
... print( year, pounds )
...
2000 29.87
etc.
2012 33.51
在这个例子中,每个二元组都被分解,两个值被分配给目标变量,year和pounds。
我们可以利用这一点将Count对象转换为百分比。让我们看看我们的digram_count集合:
total= sum( digram_count.values() )
for digram, count in digram_count.items():
print( "{:2s} {:7d} {:.3%}".format(digram, count, count/total) )
首先,我们计算了集合中值的总和。这是在原始语料库中找到的双元组的总数。在这个例子中,它是 2,021,337。不同的语料库将会有不同数量的双元组。
for语句遍历由digram_count.items()创建的序列。items()方法产生一个包含键和值的二元组的序列。我们将这些分配给两个目标变量:digram和count。然后我们可以生成一个包含所有 620 个双元组、它们的计数和它们相对频率的格式化表格。
这正是加密部门的人喜欢的那种事情。
当我们将for语句直接应用于字典时,它仅遍历键。我们可以使用类似的方法遍历双元组计数:
for digram in digram_count:
print( digram, digram_count[digram], digram_count[digram]/total )
目标变量digram被分配给每个键。然后我们可以使用如digram_count[digram]这样的语法来提取这个键的值。
使用 Python 运算符在集合上操作
一些数学运算符可以与集合一起使用。我们可以使用+和*运算符与列表和元组等序列一起使用,如下面的代码所示:
>>> [2, 3, 5, 7] + [11, 13, 17]
[2, 3, 5, 7, 11, 13, 17]
>>> [2, 3, 5, 7] * 2
[2, 3, 5, 7, 2, 3, 5, 7]
>>> [0]*10
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
这些示例展示了我们如何连接两个列表并将一个列表乘以创建一个包含原始列表多个副本的长列表。[0]*10语句显示了一种更有用的技术,可以初始化一个具有固定数量值的列表。
集合有用于并集(|)、交集(&)、差集(-)和对称差集(^)的运算符。此外,比较运算符被重新定义以作为子集或超集比较。以下是一些示例:
>>> {2, 3, 5, 7} | {5, 7, 11, 13, 17}
{2, 3, 5, 7, 11, 13, 17}
>>> {2, 3, 5, 7} & {5, 7, 11, 13, 17}
{5, 7}
>>> {2, 3, 5, 7} - {5, 7, 11, 13, 17}
{2, 3}
>>> {2, 3, 5, 7} ^ {5, 7, 11, 13, 17}
{2, 3, 11, 13, 17}
>>> {2, 3} <= {2, 5, 7, 3, 11}
True
并集运算符|将两个集合合并。集合意味着一个元素只出现一次,所以在集合的并集中没有重复的元素。两个集合的交集&是两个集合中共同元素的集合。减法运算符-从左侧集合中删除元素。对称差集运算符^创建一个新的集合,该集合包含一个或另一个集合中的元素,但不包含两者;本质上,它与排他OR相同。
我们只展示了两个集合之间的一个比较运算符,即<=子集运算符。其他比较运算符的表现正如预期的那样。
解决问题——货币转换率
我们面临的问题是我们的信息提供者总是要求奇数或非同寻常的货币。这并不真的那么令人惊讶;我们正在处理逃犯和罪犯。他们似乎总是需要不为人知的国外货币来完成他们自己的邪恶项目。
我们可以使用如下代码获取一大堆国际汇率:
query_exchange_rates= "http://www.coinbase.com/api/v1/currencies/exchange_rates/"
with urllib.request.urlopen( query_exchange_rates ) as document:
pprint.pprint( document.info().items() )
exchange_rates= json.loads( document.read().decode("utf-8") )
查询字符串是一个简单的 URL。当我们发出请求时,我们得到一个长的字节字符串。我们将其解码以生成正确的字符串,并使用json.loads()构建 Python 对象。
问题是我们得到了一个巨大的字典对象,实际上并不那么有用。它看起来像这样:
{'aed_to_btc': '0.000552',
'aed_to_usd': '0.272246',
'afn_to_btc': '3.6e-05',
'afn_to_usd': '0.01763',
此外,它有 632 种不同的货币组合。
此映射的键涉及两个由_to_分隔的小写字母的货币。我们在早期示例中获得的货币代码(见使用 Python 中的 REST API部分)是大写的。我们需要做一些工作来正确匹配这些数据。
我们需要将这个长长的货币列表分解成子列表。处理这种方式的整洁方法是使用列表的字典。我们可以使用defaultdict类来构建这些列表。以下是一个典型的方法:
from collections import defaultdict
rates = defaultdict(list)
for conversion, rate in exchange_rates.items():
source, _, target = conversion.upper().partition("_TO_")
rates[source].append( (target, float(rate)) )
我们将rates变量设置为defaultdict(list)对象。当在这个字典中找不到键时,将为该缺失键构建一个空列表作为值。
我们可以遍历原始数据中的每个转换和汇率。我们将转换字符串转换为大写,然后根据_TO_字符串对转换进行分区。这将分离两个货币代码,将它们分配给source和target。由于它们是大写的,我们还可以将它们与我们的货币到国家代码列表相匹配。
我们还将汇率从字符串转换为更有用的float数字。字符串对进一步计算没有用。
我们可以在rates字典中为每种货币累积一个列表。如果source货币存在,我们将将其追加到字典中已经存在的列表中。如果source货币不存在,我们将创建一个空列表并将其追加到该空列表中。
我们将目标货币和转换率作为一个简单的二元组追加。
完成后,我们将拥有整洁、简短的列表。以下是我们可以选择一些货币并显示转换的方法:
for c in 'USD', 'GBP', 'EUR':
print( c, rates[c] )
对于少数几种货币,我们打印了货币和当前可用的转换率列表。
这显示了以下结果:
GBP [('BTC', 0.003396), ('USD', 1.682624)]
EUR [('BTC', 0.002768), ('USD', 1.371864)]
USD列表相当大,因为它包括了 159 个其他国家和货币。
从我们之前的查询中获取了货币详情后,我们可以这样做,使我们的输出更有用:
currency_details = dict( (code,name) for name,code in currencies )
for c in 'USD', 'GBP', 'EUR':
print( currency_details[c], rates[c] )
我们构建了一个将货币代码映射到货币全名的字典。当我们查找货币的详细信息时,我们的输出看起来更美观,如下面的片段所示:
British Pound (GBP) [('USD', 1.682624), ('BTC', 0.003407)]
Euro (EUR) [('USD', 1.371864), ('BTC', 0.002778)]
这类东西可以帮助我们将贿赂金额转换为总部会计会发现有用的预算数字。我们还可以使用这些信息根据当地货币的价值发送国家评估。
此外,我们可以为此目的购买和出售比特币。这可能会帮助我们领先一步,避免国际混乱。或者,它可能有助于我们利用加密货币的优势。
我们可以使用 json.dump(currency_details, some_open_file) 将我们的货币详细信息保存到文件中。参见“通过 JSON 保存我们的数据”部分中的示例,以刷新如何执行此操作的方法。
摘要
在本章中,我们了解了使用 Python 访问万维网上可用的数据的基本方法。我们使用了 HTTP 协议和 FTP 协议来传输文件。我们可以使用 HTTPS 和 FTPS 来确保我们的数据保持机密。
我们探讨了如何使用 RESTful 网络服务从具有定义好的 API 的信息源收集数据。许多类型的数据都提供了 RESTful 网络服务。它们使我们能够从各种来源收集和分析数据,而无需进行大量繁琐且容易出错的点击操作。
我们还了解了如何处理各种 Python 集合:列表、元组、字典和集合。这为我们提供了捕获和管理大量信息集合的方法。
我们研究了 Python 对象的 JSON 表示法。这是一种方便的方法,可以通过万维网传输对象。它也便于在我们个人电脑上本地保存对象。
在下一章中,我们将探讨如何处理图像文件。这些文件比 JSON 文件复杂一些,但 Python 的 pillow 包使得它们易于处理。我们将特别使用图像文件作为传输隐藏信息的方式。
在第四章“水坑、藏身之处、聚会和巢穴”中,我们将扩展本章中看到的网络服务。我们将使用地理编码网络服务并从更复杂的在线数据集中提取数据。
第三章。使用隐写术编码秘密信息
我们将从各种来源获取智能数据。在上一章中,我们在万维网(WWW)中进行了搜索。我们可能会使用自己的相机或录音设备。在本章中,我们将探讨图像处理和编码。
要在 Python 中处理图像,我们需要安装 Pillow。这个库为我们提供了处理图像文件的软件工具。Pillow 是较老 PIL 项目的分支;与 PIL 相比,Pillow 使用起来更方便。
在此过程中,我们将访问一些额外的 Python 编程技术,包括:
-
我们将回顾 Python 如何与操作系统文件协同工作,并查看一些常见的物理格式,包括 ZIP 文件、JSON 和 CSV。
-
我们将介绍 JPEG 文件,并学习使用 Pillow 处理它们。在我们可以使这成为可能之前,我们必须安装 Pillow。
-
我们将探讨几种图像转换,例如获取 EXIF 数据、创建缩略图、裁剪、增强和过滤。
-
我们将探讨如何调整构成整数值的各个单独的比特位。
-
我们还将了解如何与 Unicode 字符协同工作,以及字符如何编码成字节。
-
学习与 Unicode 字符协同工作将使我们能够在图像文件的像素中编码数据。我们将探讨两种常见的隐写术算法。
-
我们还将快速浏览一下安全散列。这将向我们展示如何创建在传输过程中无法更改的消息。
Python 是一种非常强大的编程语言。在本章中,我们将看到许多高级功能。我们还将为下一章查看网络服务和地理编码打下基础。
背景简报 - 处理文件格式
如我们所观察到的,我们的数据以广泛的物理格式存在。在 第一章,我们的间谍工具包 中,我们探讨了 ZIP 文件,它包含其他文件的存档。在 第二章,获取情报数据 中,我们探讨了 JSON 文件,它序列化了多种 Python 对象。
在本章中,我们将回顾一些以前的技术,然后具体探讨与 CSV 文件协同工作。重要的是要查看我们可能需要与之协同工作的各种图像文件。
在所有情况下,Python 鼓励将文件视为一种上下文。这意味着我们应该努力使用 with 语句打开文件,以确保在处理完成后文件能够正确关闭。这并不总是完美无缺,因此存在一些例外。
与操作系统文件系统协同工作
有许多用于处理文件的模块。我们将关注两个:glob 和 os。
glob
glob 模块实现了文件系统 globbing 规则。当我们在终端提示符中使用 *.jpg 时,标准的操作系统外壳工具将 glob 或将通配符名称扩展为实际文件名列表,如下面的代码片段所示:
MacBookPro-SLott:code slott$ ls *.jpg
1drachmi_1973.jpg IPhone_Internals.jpg
Common_face_of_one_euro_coin.jpg LHD_warship.jpg
POSIX 标准是,在运行 ls 程序之前,*.jpg 应由 shell 展开。在 Windows 上,情况并不总是如此。
Python 的 glob 模块包含一个 glob() 函数,可以在 Python 程序内部完成这项工作。以下是一个示例:
>>> import glob
>>> glob.glob("*.jpg")
['1drachmi_1973.jpg', 'Common_face_of_one_euro_coin.jpg', 'IPhone_Internals.jpg', 'LHD_warship.jpg']
当我们评估 glob.glob("*.jpg") 时,返回值是一个包含匹配文件名的字符串列表。
os
许多文件具有 path/name.extension 格式。对于 Windows,使用设备前缀和反斜杠(c:path\name.ext)。Python 的 os 包提供了一个 path 模块,其中包含许多用于处理文件名和路径的函数,无论标点符号如何。由于 path 模块位于 os 包中,组件将具有两层命名空间容器:os.path。
我们必须始终使用 os.path 模块中的函数来处理文件名。该模块提供了许多分割路径、连接路径和从相对路径创建绝对路径的函数。例如,我们应该使用 os.path.splitext() 来将文件名与扩展名分开。以下是一个示例:
>>> import os
>>> os.path.splitext( "1drachmi_1973.jpg" )
('1drachmi_1973', '.jpg')
我们已经将文件名与扩展名分开,而没有编写任何自己的代码。当标准库已经提供了这些功能时,就没有必要编写自己的解析器。
处理简单的文本文件
在某些情况下,我们的文件包含普通文本。在这种情况下,我们可以打开文件并按以下方式处理行:
with open("some_file") as data:
for line in data:
... process the line ...
这是最常见的处理文本文件的方式。for 循环处理的每一行都会包含一个尾随的 \n 字符。
我们可以使用简单的生成器表达式来去除每行的尾随空格:
with open("some_file") as data:
for line in (raw.rstrip() for raw in data):
... process the line ...
我们在 for 语句中插入了一个生成器表达式。生成器表达式有三个部分:一个子表达式(raw.rstrip())、一个目标变量(raw)和一个源可迭代集合(data)。源可迭代集合 data 中的每一行都分配给目标 raw,并评估子表达式。生成器表达式的每个结果都可供外部的 for 循环使用。
我们可以将生成器表达式单独写成一行代码:
with open("some_file") as data:
clean_lines = (raw.rstrip() for raw in data)
for line in clean_lines:
... process the line ...
我们将生成器表达式写在了 for 语句外面。我们将生成器——而不是结果集合——赋值给 clean_lines 变量,以明确其目的。生成器不会产生任何输出,直到另一个迭代器(在这种情况下是 for 循环)需要单独的行。实际上没有开销:处理只是从视觉上分开。
这种技术使我们能够将不同的设计考虑因素分开。我们可以将文本清理与 for 语句中的重要处理分开。
我们可以通过编写额外的生成器来扩展清理过程:
with open("some_file") as data:
clean_lines = (raw.rstrip() for raw in data)
non_blank_lines = (line for line in clean_lines if len(line) != 0)
for line in non_blank_lines:
... process the line ...
我们将两个预处理步骤分解为两个单独的生成器表达式。第一个表达式从每行的末尾删除 \n 字符。第二个生成器表达式使用可选的 if 子句——它将从第一个生成器表达式获取行,并且只有当长度不为 0 时才传递行。这是一个拒绝空白行的过滤器。最终的 for 语句只获取已经删除 \n 字符的非空白行。
与 ZIP 文件一起工作
一个 ZIP 存档包含一个或多个文件。要使用 with 与 ZIP 存档一起,我们需要导入 zipfile 模块:
import zipfile
通常,我们可以使用类似以下的方式打开一个存档:
with zipfile.ZipFile("demo.zip", "r") as archive:
这创建了一个上下文,这样我们就可以处理文件,并确保在缩进上下文结束时文件被正确关闭。
当我们想要创建一个存档时,我们可以提供一个额外的参数:
with ZipFile("test.zip", "w", compression=zipfile.zipfile.ZIP_DEFLATED) as archive:
这将创建一个使用简单压缩算法来节省空间的 ZIP 文件。如果我们正在读取 ZIP 存档的成员,我们可以使用嵌套上下文来打开此成员文件,如下面的代码片段所示:
with archive.open("some_file") as member:
...process member...
正如我们在 第一章 中所展示的,我们的间谍工具包,一旦我们为读取打开了成员,它就类似于一个普通的操作系统文件。嵌套上下文允许我们对该成员使用普通的文件处理操作。我们之前使用了以下示例:
import zipfile
with zipfile.ZipFile( "demo.zip", "r" ) as archive:
archive.printdir()
first = archive.infolist()[0]
with archive.open(first) as member:
text= member.read()
print( text )
我们使用上下文打开存档。我们使用嵌套上下文打开存档的一个成员。并不是所有文件都可以这样读取。例如,图像成员不能直接由 Pillow 读取;它们必须提取到临时文件中。我们会这样做:
import zipfile
with zipfile.ZipFile( "photos.zip", "r" ) as archive:
archive.extract("warship.png")
这将从存档中提取一个名为 warship.png 的成员并创建一个本地文件。Pillow 可以处理提取的文件。
与 JSON 文件一起工作
一个 JSON 文件包含一个以 JSON 语法序列化的 Python 对象。要处理 JSON 文件,我们需要导入 json 模块:
import json
文件处理上下文并不真正适用于 JSON 文件。我们通常在处理文件时不会长时间打开文件。通常,with 语句上下文只是一行代码。我们可能会创建一个像这样的文件:
...create an_object...
with open("some_file.json", "w") as output:
json.save(an_object, output)
创建 JSON 编码文件只需要这些。通常,我们会设法将我们要序列化的对象变成一个列表或字典,这样我们就可以在单个文件中保存多个对象。要检索对象,我们通常做的是类似的事情,如下面的代码所示:
with open("some_file.json") as input:
an_object= json.load(input)
...process an_object...
这将解码对象并将其保存到指定的变量中。如果文件包含一个列表,我们可以遍历对象以处理列表中的每个项目。如果文件包含一个字典,我们可能会处理这个字典的特定键值。
注意
应用到结果对象 an_object 上的处理是在 with 语句之外进行的。
一旦创建了 Python 对象,我们就不再需要文件上下文。与文件关联的资源可以被释放,我们可以将我们的处理步骤集中在结果对象上。
处理 CSV 文件
CSV代表逗号分隔值。虽然最常见的 CSV 格式使用引号字符和逗号,但 CSV 的概念可以轻松应用于任何具有列分隔符字符的文件。我们可能有一个文件,每个数据项由制表符字符分隔,在 Python 中写作\t。这也是一种使用制表符字符充当逗号角色的 CSV 文件。
我们将使用csv模块来处理这些文件:
import csv
当我们打开 CSV 文件时,我们必须创建一个读取器或写入器来解析文件中的各种数据行。假设我们下载了比特币价格的历史记录。您可以从coinbase.com/api/doc/1.0/prices/historical.html下载这些数据。有关更多信息,请参阅第二章,“获取情报数据”。
数据使用 CSV 格式表示。一旦我们读取了字符串,就需要在数据周围创建一个 CSV 读取器。由于数据刚刚被读入一个大字符串变量中,我们不需要使用文件系统。我们可以使用内存处理来创建一个类似文件的对象,如下面的代码所示:
import io
with urllib.request.urlopen( query_history ) as document:
history_data= document.read().decode("utf-8")
reader= csv.reader( io.StringIO(history_data) )
我们使用了urllib.request.urlopen()函数向给定的 URL 发起一个GET请求。响应将以字节形式返回。我们将从这些字节中解码字符并将它们保存在名为history_data的变量中。
为了使csv.Reader类能够处理,我们使用了io.StringIO类来包装数据。这创建了一个类似文件的对象,而实际上并不需要在磁盘上的某个位置创建文件,从而节省了时间。
我们现在可以像以下代码所示从reader对象中读取单个行。
for row in reader:
print( row )
这个for循环将遍历 CSV 文件的每一行。数据的不同列将被分隔;每一行将是一个包含单个列值的元组。
如果我们有制表符分隔的数据,我们可以通过提供有关文件格式的额外详细信息来修改读取器。例如,我们可以使用rdr= csv.reader(some_file, delimiter='\t')来指定存在制表符分隔值而不是逗号分隔值。
JPEG 和 PNG 图形 – 像素和元数据
图像由称为像素的图像元素组成。每个像素是一个点。对于计算机显示器,单个点使用红-绿-蓝(RGB)颜色进行编码。每个显示的像素是红、绿和蓝光级别的总和。对于打印,颜色可能被切换到青-品红-黄-黑(CMYK)颜色。
图像文件包含图像各种像素的编码。图像文件也可能包含有关图像的元数据。元数据信息有时被称为标签,甚至Exif 标签。
图像文件可以为每个像素使用各种编码。纯黑白图像只需要每个像素 1 位。高质量的摄影可能每个颜色使用 1 字节,导致每个像素 24 位。在某些情况下,我们可能会添加一个透明度蒙版或寻找更高分辨率的颜色。这导致每个像素 4 字节。
问题迅速转变为所需存储量的多少。充满 iPhone 显示屏的图片每英寸有 326 像素。显示屏有 1136 x 640 像素。如果每个像素使用 4 字节的颜色信息,那么图像将涉及 3 MB 的内存。
考虑一张扫描图像,其尺寸为 8 1/2 英寸 x 11 英寸,分辨率为每英寸 326 像素。该图像的像素为 2762 x 3586,总大小为 39 MB。一些扫描仪能够以每英寸 1200 像素的分辨率生成图像:这样的文件大小将是 673 MB。
不同的图像文件反映了不同的策略,在不丢失图像质量的情况下压缩如此大量的数据。
一个简单的压缩算法可以使文件稍微小一些。例如,TIFF 文件使用相当简单的压缩。然而,JPEG 使用的算法相当复杂,在保持大部分(但不全部)原始图像的同时,导致相对较小的文件大小。虽然 JPEG 在压缩方面非常好,但压缩后的图像并不完美——为了实现良好的压缩,细节会丢失。这使得 JPEG 在隐写术中较弱,因为我们将在图像中调整位以隐藏消息。
我们可以将 JPEG 压缩称为有损压缩,因为一些位可以丢失。我们可以将 TIFF 压缩称为无损压缩,因为所有原始位都可以恢复。一旦位丢失,它们就无法恢复。由于我们的消息将只调整几个位,JPEG 压缩可能会破坏我们隐藏的消息。
当我们在 Pillow 中处理图像时,将类似于处理 JSON 文件。我们将打开和加载图像。然后我们可以在程序中处理该对象。完成后,我们将保存修改后的图像。
使用 Pillow 库
我们将添加一些酷炫的 Python 软件来处理图像。Pillow 包是一个复杂的图像处理库。这个库提供了广泛的文件格式支持,高效的内部表示,以及相当强大的图像处理能力。更多信息,请访问pypi.python.org/pypi/Pillow/2.1.0。Pillow 文档将提供关于需要做什么的重要背景信息。PyPi 网页上的安装指南是必读的,你将在这里获得一些额外的细节。Pillow 的核心文档位于pillow.readthedocs.org/en/latest/。
注意,Pillow 将安装一个名为PIL的包。这确保了 Pillow(项目)创建的模块与Python Imaging Library(PIL)兼容。我们将从PIL包导入模块,尽管我们将安装由 Pillow 项目创建的软件。
添加所需的辅助库
如果你是一个 Windows 代理,那么你可以跳过这一部分。构建 Pillow 的人已经充分考虑了你的需求。对于其他人来说,你的操作系统可能还没有准备好 Pillow。
在安装 Pillow 之前,必须设置一些支持软件基础设施。一旦所有支持软件都准备就绪,然后才能安装 Pillow。
GNU/Linux 秘籍
我们需要在我们的 GNU/Linux 配置中拥有以下库。这些文件很可能已经存在于给定的发行版中。如果这些文件不存在,那么是时候进行一些升级或安装了。安装以下内容:
-
libjpeg: 这个库提供了对 JPEG 图像的访问;已测试版本 6b、8 和 9
-
zlib: 这个库提供了对压缩 PNG 图像的访问
-
libtiff: 这个库提供了对 TIFF 图像的访问;已测试版本 3.x 和 4.0
-
libfreetype: 这个库提供了与字体相关的服务
-
littlecms: 这个库提供了色彩管理
-
libwebp: 这个库提供了对 WebP 格式的访问
每个 Linux 发行版都有独特的安装和配置库的方法。我们无法涵盖所有内容。
一旦辅助库就绪,我们就可以使用 easy_install-3.3 pillow 命令。我们将在 安装和确认 Pillow 部分中回顾这一点。
Mac OS X 秘籍
要在 Mac 上安装 Pillow,我们需要执行三个初步步骤。我们需要 Xcode 和 homebrew,然后我们将使用 homebrew。
要获取 Mac OS X 的 Xcode,请访问 developer.apple.com/xcode/downloads/。每个 Mac OS X 代理都应该有 Xcode,即使他们不打算编写本机 Mac OS X 或 iOS 应用程序。
在安装 Xcode 时,我们必须确保我们还安装了命令行开发者工具。这超出了基本 XCode 下载之外的另一个大型下载。
一旦我们有了 Xcode 命令行工具,第二个初步步骤是从 brew.sh 安装 Homebrew。此应用程序为 Mac OS X 构建和安装 GNU/Linux 二进制文件。Homebrew 与 Python 没有直接关系;这是一个流行的 Mac OS X 开发者工具。
Homebrew 的安装是在终端窗口中输入的单行命令:
ruby -e "$(curl -fsSL https://raw.github.com/Homebrew/homebrew/go/install)"
这将使用 curl 程序从 GitHub 下载 Homebrew 安装套件。它将使用 Ruby 运行此安装程序,构建各种 Homebrew 工具和脚本。Homebrew 安装建议使用 brew doctor 来检查开发环境。在继续之前可能需要进行一些清理工作。
第三步是使用 brew 程序安装 Pillow 所需的附加库。该命令行将处理此事:
brew install libtiff libjpeg webp littlecms
定期,我们可能需要升级 Homebrew 所知的库。命令很简单:brew update。我们可能还需要升级我们安装的各种包。这是使用 brew upgrade libtiff libjpeg webp littlecms 完成的。
完成这三个初步步骤后,我们可以使用easy_install-3.3 pillow命令。我们将在安装和确认 Pillow部分进行回顾。
Windows 秘籍
Pillow 的 Windows 版本包含所有各种预先构建的库。套件将已经安装以下内容:
-
libjpeg
-
zlib
-
libtiff
-
libfreetype
-
littlecms
-
libwebp
安装完成后,这些模块都将存在并被 Pillow 使用。
安装和确认 Pillow
一旦所有必需的支持工具都已就绪(或者你是 Windows 代理),下一步就是安装 Pillow。
这应该对应以下命令:
sudo easy_install-3.3 pillow
Windows 代理必须省略easy_install命令前的sudo命令。
输出的一部分可能看起来像这样(具体细节会有所不同):
--------------------------------------------------------------------
PIL SETUP SUMMARY
--------------------------------------------------------------------
version Pillow 2.4.0
platform darwin 3.3.4 (v3.3.4:7ff62415e426, Feb 9 2014, 00:29:34)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)]
--------------------------------------------------------------------
--- TKINTER support available
--- JPEG support available
*** OPENJPEG (JPEG2000) support not available
--- ZLIB (PNG/ZIP) support available
--- LIBTIFF support available
*** FREETYPE2 support not available
*** LITTLECMS2 support not available
--- WEBP support available
--- WEBPMUX support available
--------------------------------------------------------------------
To add a missing option, make sure you have the required
library, and set the corresponding ROOT variable in the
setup.py script.
To check the build, run the selftest.py script.
这告诉我们一些库不可用,我们无法进行所有类型的处理。如果我们不打算处理 JPEG2000 文件或进行复杂色彩管理,这是可以接受的。另一方面,如果我们认为我们将进行更复杂的处理,我们可能需要追踪额外的模块并重新安装 Pillow。
Pillow 的安装创建了 PIL。顶级包将被命名为PIL。
我们可以使用 Pillow 自己的内部测试脚本PIL.selftest来测试 Pillow。否则,我们可以这样使用它:
>>> from PIL import Image
如果这行得通,那么 PIL 包已经安装。然后我们可以打开一个图片文件来查看是否一切正常。以下代码显示 PIL 愉快地为我们打开了一个图片文件:
>>> pix= Image.open("1drachmi_1973.jpg")
>>> pix
<PIL.JpegImagePlugin.JpegImageFile image mode=RGB size=198x194 at 0x10183BA90>
这表明 PIL 能够以不同的格式保存文件:
>>> pix.save("test.tiff")
这个看似简单的步骤需要 Pillow 进行大量的计算以将一种格式转换为另一种格式。
解码和编码图像数据
图片文件以一种便于读取和写入的形式编码,但并不适用于详细处理。我们需要将图片从文件格式解码为有用的内部表示。Pillow 大大简化了解码和编码图片文件的过程。我们处理图片文件的一般策略是以下步骤:
from PIL import Image
pix= Image.open("LHD_warship.jpg")
Pillow 从图片元数据中提取了许多有趣的属性。与图片一起还有复杂的信息层次结构。我们将详细回顾一些这些元数据。
在最高级别,有一些描述编码细节的信息。这些信息在pix对象的info属性中可用。我们可以使用字典的keys()方法查看元数据中有什么,如下面的代码片段所示:
>>> pix.info.keys()
dict_keys(['jfif_density', 'icc_profile', 'jfif_version', 'jfif', 'exif', 'jfif_unit', 'dpi'])
在这些键中,映射到exif键的值通常是最有趣的。这是提供关于图片额外详细信息的可交换图像文件格式数据。其他项是关于图片编码的技术细节。
Exif 数据不是由 Pillow 自动解码的。我们需要使用 _getexif() 方法来查看图像的 exif 键中有什么。注意这个名称前有一个下划线 _ 符号。这是不寻常的。这个方法会给我们一个标签和值的字典。以下是一个例子:
>>> exif= pix._getexif()
>>> exif.keys()
dict_keys([36864, 37121, 37378, 36867, 36868, 41989, 40960, 37383, 37385, 37386, 40962, 271, 272, 37521, 37522, 40963, 37396, 41495, 41988, 282, 283, 33434, 37500, 34850, 40961, 34853, 41986, 34855, 296, 34665, 41987, 41990, 42034, 33437, 305, 306, 42035, 42036, 41729])
这看起来不太有用。好消息是,这些数字代码定义在单独的模块中。我们可以使用字典查找将数字代码转换为文字。以下是一个例子:
>>> import PIL.ExifTags
>>> for k, v in pix._getexif().items():
... print( PIL.ExifTags.TAGS[k], v )
这将遍历 Exif 标签和值,将标签值转换为文字。现在我们可以找到关于图像的有用识别信息。输出显示了如下细节:
Software 7.1.1
DateTime 2014:05:10 09:59:22
LensMake Apple
LensModel iPhone 4 back camera 3.85mm f/2.8
在这些 Exif 标签中,数字 34853,GPSInfo 标签形成了一个包含更多神秘数字键的子字典。这组数字代码由 PIL.ExifTags.GPSTAGS 映射定义。
这将引导我们进行如下操作,以输出图像的信息:
img= Image.open(name)
print( name, img.format, img.mode, img.size )
for key in img.info:
if key == 'exif':
for k,v in img._getexif().items():
if k == 34853: # GPSInfo
print( " ", PIL.ExifTags.TAGS[k], v )
for gk, gv in v.items():
print( " ", PIL.ExifTags.GPSTAGS[gk], gv )
else:
print( " ", PIL.ExifTags.TAGS[k], v )
elif key == 'icc_profile':
print( key ) # Skip these details
else:
print( key, img.info[key] )
这将遍历与图像关联的顶层 .info 字典。在这个顶层 .info 字典中,如果键是 exif,我们将遍历 Exif 字典项。在 Exif 字典中,我们将数字键转换为有意义的字符串。如果我们找到键 34853 (GPSInfo),我们知道我们有一个更深嵌套的另一个字典。我们将使用另一个嵌套的 for 循环来遍历 GPSInfo 字典的项,将这些键转换为有用的字符串。
我们可能会看到这样的输出。
Common_face_of_one_euro_coin.jpg JPEG RGB (320, 312)
ExifOffset 26
ExifImageWidth 320
ExifImageHeight 312
jfif_version (1, 1)
jfif_unit 0
jfif_density (1, 1)
jfif 257
在这个输出中,Exif 数据并不太有趣。其他细节似乎也没有什么用处。
当我们查看一个详细且包含元数据的图片时,可能会有超过 30 个单独的 Exif 数据。例如,这是在一幅图像中找到的一些 Exif 数据的部分:
DateTimeOriginal 2009:03:18 04:24:24
DateTimeDigitized 2009:03:18 04:24:24
SceneCaptureType 0
MeteringMode 3
Flash 16
FocalLength (20, 1)
ApertureValue (35, 8)
FocalPlaneXResolution (257877, 53)
Make Canon
Model Canon EOS DIGITAL REBEL XSi
这可以告诉某人很多关于照片是如何拍摄的。
当我们使用带有 GPS 数据的现代相机(如手机相机)拍摄照片时,一些额外的信息会被打包到 Exif 中。对于某些相机,我们会找到如下这类信息:
GPSLatitudeRef N
GPSLatitude ((36, 1), (50, 1), (4012, 100))
GPSLongitudeRef W
GPSLongitude ((76, 1), (17, 1), (3521, 100))
相机的 GPS 坐标看起来有点奇怪。我们可以将这些元组结构转换为数字,例如 36°50′40.12″N 和 76°17′35.21″W。一旦我们有了位置,我们就可以确定照片是在哪里拍摄的。
在海图 12253 上快速查看显示,照片是在弗吉尼亚州诺福克的一个码头拍摄的。每个间谍都有一套海图,对吧?如果没有,请访问 www.nauticalcharts.noaa.gov/mcd/Raster/。
ICC 配置文件显示了图像的颜色和渲染细节。关于这里编码的数据的详细信息,请参阅 www.color.org/specification/ICC1v43_2010-12.pdf 中的适用规范。这些信息对我们所做的事情是否有帮助并不明确。
更有帮助的是查看图片。涂在船体上的 LHD 3 似乎很重要。
操作图像 – 调整大小和缩略图
Pillow 软件允许我们对图像执行多种操作。我们可以在不进行太多额外工作的前提下,调整大小、裁剪、旋转或对图像应用任何数量的过滤器。
使用 PIL 的最重要原因是,我们有一个可重复的、自动化的过程。我们可以找到许多种类的手动图像处理软件。这些桌面工具的问题在于,一系列的手动步骤是不可重复的。使用 Pillow 进行此操作的好处是我们确切地知道我们做了什么。
一种常见的调整大小是创建一个从较大图像中生成的缩略图图像。以下是我们可以创建一组图像的有用缩略图版本的方法:
from PIL import Image
import glob
import os
for filename in glob.glob("*.jpg"):
name, ext = os.path.splitext( filename )
if name.endswith("_thumb"):
continue
img = Image.open( filename )
thumb= img.copy()
w, h = img.size
largest = max(w,h)
w_n, h_n = w*128//largest, h*128//largest
print( "Resize", filename, "from", w,h, "to", w_n,h_n )
thumb.thumbnail( (w_n, h_n), PIL.Image.ANTIALIAS )
thumb.save( name+"_thumb"+ext )
我们导入了所需的模块:PIL.Image、glob和os。我们使用glob.glob("*.jpg")在当前工作目录中定位所有 JPEG 文件。我们使用os.path.splitext()将基本文件名和扩展名分开。如果文件名已经以_thumb结尾,我们将继续for循环。对于此文件名将不再进行任何处理;for语句将前进到 glob 序列中的下一个项目。
我们打开了图像文件,并立即创建了一个副本。这允许我们在需要时使用原始图像,同时也可以使用副本。
我们已经提取了原始图像的大小,并将元组中的每个项分配给两个单独的变量,w和h。我们使用max()函数选择了两个维度中较大的一个。如果图片是横向模式,宽度将是最大的;如果图片是纵向模式,高度将是最大的。
我们已经计算了缩略图图像的大小,w_t和h_t。这对计算将确保最大尺寸限制在 128 像素,较小的一维将按比例缩放。
我们使用了thumb对象的thumbnail()方法,它是原始图片的一个副本。我们提供了一个包含新尺寸的两个元组。确保在(w_n, h_n)周围包含()以创建一个元组,作为thumbnail()方法的第一个参数值。我们还提供了要使用的重采样函数;在这种情况下,我们使用了PIL.Image.ANTIALIAS函数,因为它产生良好的(但较慢的)结果。
这是我们的 LHD 战舰的缩略图:

这张图片相当小。这使得它非常适合附加到电子邮件中。然而,对于更严肃的情报工作,我们需要将其放大、裁剪和增强。
操作图像 – 裁剪
当我们查看我们的 LHD 战舰图像时,我们注意到船号几乎在船首可见。我们希望裁剪图像的这一部分,也许还可以将其放大。没有视觉编辑器的裁剪涉及一定程度的试错处理。
即使是从命令行,我们也可以通过使用图像的 show() 方法交互式地裁剪图像,如下面的代码所示:
>>> from PIL import Image
>>> ship= Image.open( "LHD_warship.jpg" )
>>> ship.size
(2592, 1936)
我们可以尝试不同的边界框,直到找到标志。一种开始的方法是将图像在每个方向上分成三部分;这导致九个部分,通过以下相对简单的规则计算得出:
>>> w, h = ship.size
>>> ship.crop( box=(w//3,0,2*w//3,h//3) ).show()
>>> ship.crop( box=(w//3,h//3,2*w//3,2*h//3) ).show()
裁剪操作的边界框需要一个包含左、上、右和下四个边的四个元组,顺序依次为。这些值必须是整数,并且需要使用 () 括号来创建四个元组,而不是四个单独的参数值。水平分隔线在 0、w//3、2*w//3 和 w。垂直分隔线在 0、h//3、2*h//3 和 h。我们可以使用各种组合来定位图像的各个部分并显示每个部分。
输入这样的公式容易出错。使用由一对左上角坐标定义的边界框会更好。我们可以调整宽度和计算高度以保持图片的比例。如果我们使用以下方法,我们只需要调整 x 和 y 坐标:
>>> p=h/w
>>> x,y=3*w//12, 3*h//12
>>> ship.crop( box=(x,y,x+600,int(y+600*p)) ).show()
>>> x,y=3*w//12, 5*h//12
>>> ship.crop( box=(x,y,x+600,int(y+600*p)) ).show()
我们可以调整 x 和 y 的值。然后我们可以使用上箭头键再次获取 ship.crop().show() 行。这允许我们手动遍历图像,只需更改 x 和 y。
我们可以更好地泛化图像部分的边界框。考虑以下分数列表:
>>> from fractions import Fraction
>>> slices = 6
>>> box = [ Fraction(i,slices) for i in range(slices+1) ]
>>> box
[Fraction(0, 1), Fraction(1, 6), Fraction(1, 3), Fraction(1, 2), Fraction(2, 3), Fraction(5, 6), Fraction(1, 1)]
我们已经定义了我们想要制作的切片数量。在这种情况下,我们将图像分成 1/6,得到 36 个独立的框。然后我们在
和
之间的位置计算了 slice+1 条线。这里有一个说明图像被切割成 6x6 网格的插图。每个单元格都有一个由 box 序列定义的边界:

这使用以下成对的嵌套 for 循环和 box 分数来生成图像各个部分的单独边界:
for i in range(slices):
for j in range(slices):
bounds = int(w*box[i]), int(h*box[j]), int(w*box[i+1]), int(h*box[j+1])
每个边界框都有左、上、右和下四个边作为四个元组。我们选取了两个变量的值来枚举从 (0,0) 到 (5,5) 的所有 36 种组合。我们从分数列表 lines 中选取了相邻的两个值。这将给我们从左上角到右下角的所有 36 个边界框。
然后,我们可以使用这些定义的框裁剪我们的原始图像,并显示所有 36 个切片,寻找最接近我们寻找的主题内容的切片。此外,我们可能还想调整每个图像的大小,使其变为原来的两倍。
我们可以使用以下方法来显示每个框:
print( bounds )
ship.crop( bounds ).show()
这将显示裁剪到每个切片的原始图像。bounds 对象是一个包含边界信息的四个元组。
我们可以使用 map() 函数稍微优化计算边界的表达式:
bounds = map( int, (w*box[i], h*box[j], w*box[i+1], h*box[j+1]) )
map() 函数将一个函数应用到相关集合的每个元素上。在这个例子中,我们将 int() 函数应用到边界框的每个值上。结果证明这正是我们想要的图片:
slices = 12
box = [ Fraction(i,slices) for i in range(slices+1) ]
bounds = map( int, (w*box[3], h*box[6], w*box[5], h*box[7]) )
logo= ship.crop( bounds )
logo.show()
logo.save( "LHD_number.jpg" )
我们使用两个相邻的框裁剪了图片。位于 (3,6) 和 (4,6) 的框很好地包含了船的识别号。我们创建了一个包含组合边界框的单个四元组,并将原始图片裁剪以仅获取标志。我们使用了 logo 对象的 show() 方法,这将弹出一个图片查看器。我们还保存了它,以便我们稍后可以工作。
我们可能想要调整裁剪图片的大小。我们可以使用如下代码放大图片:
w,h= logo.size
logo.resize( (w*3,h*3) )
这将使用原始大小作为基础,以便扩展的图片保留原始比例。与其他操作一样,大小以元组的形式给出,并且需要使用内层的 () 括号来定义元组。如果没有内层的 () 括号,这些将视为两个单独的参数值。
这是裁剪后的图片:

这有点模糊,难以处理。我们需要增强它。
操作图片 – 增强
原始图片相当粗糙。我们希望增强我们找到的切片的细节。Pillow 有许多过滤器可以帮助修改图片。与流行的电视节目和电影不同,没有增强功能可以神奇地使糟糕的图片变得精彩。
我们可以修改图片,有时,它变得更易用。我们也可以修改图片,使其不如我们找到它时那样好。第三种选择——通常不会提供给特工——是我们可能会使结果比原始图片更有艺术性。
在 Pillow 包中,我们有三个包含类似过滤处理的模块:
-
ImageEnhance -
ImageFilter -
ImageOps
ImageEnhance 模块包含 enhance 类定义。我们通过绑定一个增强器和一张图片来创建一个增强器对象。然后我们使用这个绑定对象来创建给定图片的增强版本。增强器允许我们对图片进行许多增量更改。我们可以将这些视为简单的旋钮,可以转动来调整图片。
ImageFilter 模块包含将修改图片的过滤器函数,这将创建一个新图片对象,我们可能需要保存。这些不同类型的过滤器对象被插入到图片的 filter() 方法中。过滤器可以想象成一种减少图片信息量的方式;过滤后的图片通常更简单。
ImageOps 模块包含将一个图片转换成新图片的函数。这些与过滤和增强不同。它们不一定减少数据量,也不是简单的旋钮来调整图片。ImageOps 模块倾向于执行更复杂的转换。
我们将从PIL.ImageEnhance模块中的简单增强器开始,特别是Contrast类。我们不会展示每个单独的类;更系统的探索留给现场特工。
我们将从四个增强器中的一个开始:Contrast类。下面是如何使用它的方法:
>>> from PIL import ImageEnhance
>>> e= ImageEnhance.Contrast(logo)
>>> e.enhance(2.0).show()
>>> e.enhance(4.0).show()
>>> e.enhance(8.0).show()
这基于特定的算法和我们要处理的图像构建了一个增强器。我们将这个增强器分配给了e变量。然后我们执行了一个带有特定参数值的enhance操作,并显示了结果图像。
最后一张图像相当不错。我们可以使用e.enhance(8.0).save( "LHD_Number_1.jpg" )来保存这张图像的副本。
下面是当Contrast增强设置为8时标志的看起来:

可能有人能够处理这张图像。作为一名现场特工,你需要尝试其他三个增强过滤器:颜色、亮度和锐度。你可能会从图像中提取更多细节。
这是定义可重复、自动化过程的第一步。使用 Python 命令行意味着我们记录了确切的操作。我们可以将这个过程简化为一个自动化脚本。
图像处理 – 过滤
我们已经查看ImageEnhance模块来改善图像。我们也可以通过图像的filter()方法进行过滤。ImageFilter模块定义了 18 种不同的过滤器。当我们使用过滤器时,我们将过滤器对象提供给Image.filter()方法。
我们将只选择这些过滤器中的一个。ImageFilter.EDGE_ENHANCE模块似乎有助于区分浅色字母和深色背景。强调颜色过渡可能会使字母更明显。
下面是一个在图像的filter()方法中使用ImageFilter.EDGE_ENHANCE过滤器的例子:
>>> from PIL import ImageFilter
>>> logo.filter( ImageFilter.EDGE_ENHANCE ).show()
我们已经使用filter方法创建并显示了一个新的图像。
虽然这样很好,但似乎我们之前的增强尝试使用ImageEnhance.Contrast类效果会更好。下面我们来看看如何应用一系列的转换。
以下代码将特定的过滤器应用于图像并创建了一个新的图像:
>>> e.enhance(8.0).filter( ImageFilter.EDGE_ENHANCE ).save( "LHD_Number_2.jpg" )
我们创建了一个增强图像,然后对其应用了一个过滤器。这创建了一个比原始图像更清晰、可能更实用的图像。
这是我们过滤和增强后的图像:

过滤器对之前保存的图像做了一些细微的修改。数字3下方的环边缘可能更加清晰。我们还需要做一些其他改变。
一些过滤器(如EDGE_ENHANCE对象)没有参数或选项。其他过滤器有参数,可以应用于改变它们的工作方式。例如,ModeFilter()将图像的某个部分减少到该部分中最常见的颜色值;我们提供了一个参数来指定在计算模式时考虑的像素数。
这里是一个将几个操作组合起来创建新图像的例子:
>>> p1= e.enhance(8.0).filter( ImageFilter.ModeFilter(8) )
>>> p1.filter( ImageFilter.EDGE_ENHANCE ).show()
这似乎正趋向于艺术,而不是正当的情报收集。然而,一个好的现场特工会使用一些额外的过滤器和过滤参数来寻找更好的增强技术。
图像操作 – 图像处理
我们已经研究了ImageEnhance模块来改善图像。我们还研究了ImageFilter模块中的几个其他过滤器。ImageOps模块提供了 13 种额外的转换,我们可以使用这些转换来改善我们的图像。
我们将查看以下代码片段中的ImageOps.autocontrast()函数。这将调整各种像素,使亮度级别填满从 0 到 255 的整个 8 位空间。一个暗或褪色的图像缺乏对比度,像素都堆积在光谱的暗端或亮端。
>>> from PIL import ImageOps
>>> ImageOps.autocontrast( logo ).show()
>>> logo.show()
这显示了应用了autocontrast的图像和原始图像。这显示了原始裁剪和使用了从暗到亮的全范围的图像之间的显著差异。这似乎正是 HQ 所想要的。
让我们进一步调整对比度,使数字更加突出:
>>> ac= ImageEnhance.Contrast( ImageOps.autocontrast( logo ) )
>>> ac.enhance( 2.5 ).save( "LHD_Number_3.jpg" )
这似乎是我们能做的最惊人的增强:

这可能已经足够好了。一个合格的现场特工应该尝试其他ImageOps转换,看看是否还有改进的空间。
到目前为止,我们已经有了一个可重复的、自动化的流程概要。我们确切地知道我们做了什么来增强图像。我们可以使用我们的实验系列来创建一个图像增强的自动化脚本。
隐写术的一些方法
我们可以用图像文件做很多事情。我们可以做的一件事是使用隐写术在图像文件中隐藏信息。由于图像文件大、复杂且相对嘈杂,添加一些额外的数据位不会对图像或文件造成太大的可见变化。
有时这可以总结为在图像上添加一个数字水印。我们将微妙地改变图像,以便我们可以在以后识别和恢复它。
在图像中添加信息可以被视为对图像的损失性修改。一些原始像素将无法恢复。由于 JPEG 压缩通常已经涉及轻微的损失,将图像作为隐写术的一部分进行调整将导致类似程度的图像损坏。
说到损失,JPEG 格式可以,并且将会调整我们图像中的一些位。因此,使用 JPEG 进行隐写术很困难。我们不会与 JPEG 的细节纠缠,我们将使用 TIFF 格式来隐藏我们的信息。
在图像中隐藏信息有两种常见的方法:
-
使用颜色通道:如果我们只覆盖一个颜色通道中的某些字节,我们将改变我们覆盖区域中几个像素的颜色的一部分。这只会是几百万像素中的一小部分,并且只会是三种(或四种)颜色中的一种。如果我们将调整限制在边缘,那么它不太会引人注目。
-
使用每个字节的最低有效位(LSBs):如果我们覆盖一系列字节中的最低有效位,我们将在图像中产生一个非常小的变化。我们必须限制我们信息的大小,因为我们只能每个像素编码一个字节。一张小图片,其尺寸为432 * 161 = 69,552像素,可以编码 8,694 字节的数据。如果我们使用 UTF-8 编码我们的字符,我们应该能够将一个 8 K 的消息塞入那张图片。如果我们使用 UTF-16,我们只能得到一个 4 K 的消息。这种技术在只有单一通道的灰度图像中也能工作。
除了 JPEG 压缩问题之外,还有一些颜色编码方案与这两种隐写术方法都不太兼容。被称为P、I和F的模式带来了一些问题。这些颜色模式涉及将颜色代码映射到调色板。在这些情况下,字节不是灰度级别或颜色级别;当使用调色板时,字节是颜色的参考。对 1 比特的更改可能会导致从底层调色板选择的颜色发生显著变化。颜色5可能是一种令人愉快的海藻绿,颜色4可能是一种糟糕的洋红色。5和4之间的 1 比特变化可能是一个明显的格格不入的点。
在应用我们的隐写术编码之前,为了我们的目的,我们可以将源图像切换到 RGB(或 CMYK)。基本颜色模式的变化可能对有机会访问原始图像的人来说是可见的。然而,除非他们也知道我们的隐写术算法,否则隐藏的信息将保持隐蔽。
我们的战略是这样的:
-
获取图像像素的字节。
-
将我们的秘密信息从 Unicode 字符串转换为一系列比特。
-
对于我们秘密信息的每一比特,我们需要在原始图像中篡改 1 个字节的值。由于我们在调整最低有效位,以下两种情况中的一种将会发生。
-
我们将图像像素值调整为偶数以编码秘密信息中的 0 比特
-
我们将图像像素值调整为奇数以编码秘密信息中的 1 比特
-
我们将处理两个平行的值序列:
-
来自图像的字节(理想情况下足够编码我们整个信息)
-
来自秘密信息的比特
策略是逐步遍历图像中的每个字节,并将秘密信息的 1 比特融入该图像字节。这个酷的特点是某些像素值可能实际上不需要改变。如果我们在一个已经是奇数的像素中编码一个字节,我们根本不会改变图像。
这意味着我们需要执行以下重要步骤:
-
获取图像红色通道中的字节
-
从 Unicode 消息中获取字节
-
从消息字节中获取位
-
使用消息位调整图像像素字节,并更新图像
我们将逐个解决这些问题,然后在最后将它们全部焊接在一起。
获取红色通道数据
让我们看看如何使用红色通道 LSB 编码在图像中编码我们的消息。为什么是红色?为什么不呢?男性可能有一定程度的红绿色盲;如果他们不太可能看到这个通道中的变化,那么我们就进一步隐藏了我们的图像,让一些好奇的眼睛难以察觉。
第一个问题:我们如何篡改原始图像的字节?
PIL Image 对象具有 getpixel() 和 putpixel() 方法,允许我们获取各种颜色通道值。
我们可以像这样从图像中提取单个像素:
>>> y = 0
>>> for x in range(64):
... print(ship.getpixel( (x,y) ))
...
(234, 244, 243)
(234, 244, 243)
(233, 243, 242)
(233, 243, 242)
etc.
我们已经向 getpixel() 方法提供了一个 (x,y) 二元组。这表明图像中的每个像素都是一个三元组。这三个数字是什么并不明显。我们可以使用 ship.getbands() 获取这些信息,如下面的代码片段所示:
>>> ship.getbands()
('R', 'G', 'B')
我们心中几乎没有怀疑,三个像素值分别是红色级别、绿色级别和蓝色级别。我们已经使用 getband() 方法从 Pillow 获取确认,我们关于图像编码通道的假设是正确的。
我们现在可以访问图像的各个字节。下一步是从我们的秘密消息中获取位,然后使用秘密消息位篡改图像字节。
从 Unicode 字符中提取字节
为了将我们的秘密消息编码到图像的字节中,我们需要将我们的 Unicode 消息转换为字节。一旦我们有一些字节,我们就可以进行一次额外的转换,以获得位序列。
第二个问题是,我们如何获取消息文本的各个位?这个问题的另一种形式是,我们如何将一串 Unicode 字符串转换为位字符串?
这是一个我们可以处理的 Unicode 字符串:www.kearsarge.navy.mil。我们将把转换分为两个步骤:首先转换为字节,然后转换为位。有几种方法可以将字符串编码为字节。我们将使用 UTF-8 编码,因为它非常流行:
>>> message="http://www.kearsarge.navy.mil"
>>> message.encode("UTF-8")
b'http://www.kearsarge.navy.mil'
那里似乎并没有发生太多事情。这是因为 UTF-8 编码恰好与 Python 字节字面量使用的 ASCII 编码相匹配。这意味着字符串的字节版本,恰好只使用 US-ASCII 字符,看起来会非常像原始的 Python 字符串。特殊 b' ' 引号的存在是提示,表明字符串仅是字节,而不是完整的 Unicode 字符。
如果我们的字符串中有一些非 ASCII 的 Unicode 字符,那么 UTF-8 编码将变得更加复杂。
仅供参考,以下是我们的消息的 UTF-16 编码:
>>> message.encode("UTF-16")
b'\xff\xfeh\x00t\x00t\x00p\x00:\x00/\x00/\x00w\x00w\x00w\x00.\x00k\x00e\x00a\x00r
\x00s\x00a\x00r\x00g\x00e\x00.\x00n\x00a\x00v\x00y\x00.\x00m\x00i\x00l\x00'
前一个编码的消息看起来像是一团糟。正如预期的那样,它的大小几乎是 UTF-8 的大小的两倍。
这是消息中各个字节的另一种视图:
>>> [ hex(c) for c in message.encode("UTF-8") ]
['0x68', '0x74', '0x74', '0x70', '0x3a', '0x2f', '0x2f', '0x77', '0x77', '0x77', '0x2e', '0x6b', '0x65', '0x61', '0x72', '0x73', '0x61', '0x72', '0x67', '0x65', '0x2e', '0x6e', '0x61', '0x76', '0x79', '0x2e', '0x6d', '0x69', '0x6c']
我们使用生成器表达式将hex()函数应用于每个字节。这为我们提供了如何继续进行的线索。我们的信息被转换成了 29 字节,即 232 位;我们希望将这些位放入图像的前 232 像素中。
操作位和字节
由于我们将要操作单个位,我们需要知道如何将 Python 字节转换成 8 位的元组。其逆操作是将 8 位元组转换回单个字节的技术。如果我们将每个字节扩展成一个八元组,我们就可以轻松调整位并确认我们正在做正确的事情。
我们需要一些函数来将字节列表扩展成位,并将位收缩回原始的字节列表。然后,我们可以将这些函数应用于我们的字节序列以创建单个位的序列。
重要的计算机科学将在下面解释:
一个数,
,是某个特定基数中的多项式。以下是 234 值以 10 为基数的这个多项式:

在 16 进制中,我们有
。在写十六进制时,我们使用字母表示 14 和 10 的数字:0xea。
这种多项式表示在二进制中是正确的。一个数,
,是二进制中的多项式。以下是 234 值的这个多项式:

这里有一种从数值中提取低 8 位的方法:
def to_bits( v ):
b= []
for i in range(8):
b.append( v & 1 )
v >>= 1
return tuple(reversed(b))
v&1表达式对数字执行位操作以提取最右边的位。我们将计算出的位值追加到b变量中。v >>= 1语句等同于v = v>>1;v>>1表达式将值v右移一位。这样做八次后,我们就提取了v值的最低位。我们在列表对象b中组装了这个位序列。
结果以错误的顺序累积,因此我们反转它们并创建一个整洁的八元组对象。我们可以将其与内置的bin()函数进行比较:
>>> to_bits(234)
(1, 1, 1, 0, 1, 0, 1, 0)
>>> bin(234)
'0b11101010'
对于大于 127 的值,bin()和to_bits()函数都产生 8 位的结果。对于较小的值,我们会看到bin()函数不产生 8 位;它只产生足够的位。
相反的转换评估多项式。我们可以进行一点代数运算来优化乘法次数:

由于分组,最左边的 1 最终乘以
。因为将位向左移位等同于乘以 2,我们可以根据以下方式从位元组重建字节值:
def to_byte( b ):
v= 0
for bit in b:
v = (v<<1)|bit
return v
(v<<1)|bit表达式将v左移 1 位,实际上执行了*2操作。一个OR操作将下一个位折叠到正在累积的值中。
我们可以用这样的循环来测试这两个函数:
for test in range(256):
b = to_bits(test)
v = to_byte(b)
assert v == test
如果所有 256 个字节值都转换为比特然后再转换回字节,我们就绝对确信我们可以将字节转换为比特。我们可以使用这一点来查看我们消息的扩展:
message_bytes = message.encode("UTF-8")
print( list(to_bits(c) for c in message_bytes) )
这将显示一个包含 8 元组的列表:
[(1, 1, 1, 1, 1, 1, 1, 1), (1, 1, 1, 1, 1, 1, 1, 0),
(0, 1, 1, 0, 1, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0),
...
(0, 1, 1, 0, 1, 1, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0)]
秘密信息的每个字节都变成了一个由单独的比特组成的八元组。
比特的组装
到目前为止,我们有两个并行序列的值:
-
来自图像的字节(理想情况下足够编码我们的整个消息)
-
来自我们的秘密消息的比特(在示例中,我们只有 29 字节,即 232 比特)
理念是遍历图像的每个字节,并将秘密消息的一个比特合并到该字节中。
在我们能够完全调整图像的各种字节与我们的信息比特之前,我们需要组装一个由单独的比特组成的长序列。我们有两个选择来做这件事。我们可以创建一个包含所有比特值的list对象。这会浪费一点内存,而且我们可以做得更好。
我们也可以创建一个生成器函数,它看起来像是一个包含所有比特的sequence对象。
这是一个生成器函数,我们可以用它来发出消息的整个比特序列:
def bit_sequence( list_of_tuples ):
for t8 in list_of_tuples:
for b in t8:
yield b
我们遍历了列表中由to_bits()函数创建的每个八元组。对于 8 元组中的每个比特,我们使用了yield语句来提供单独的比特值。任何期望可迭代序列的表达式或语句都将能够使用这个函数。
以下是我们可以如何使用这个方法来累积来自消息的所有 232 比特的序列:
print( list( bit_sequence(
(to_bits(c) for c in message_bytes)
) ) )
这将对消息的每个字节应用to_bits()函数,创建一个 8 元组的序列。然后它将对这个八元组的序列应用bit_sequence()生成器。输出是一个单独比特的序列,我们将其收集到一个list对象中。结果列表看起来像这样:
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0,
0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
...
0, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
我们可以看到由原始信息构建的 232 个单独比特的列表。
这是bit_sequence()函数的逆函数。它将比特序列分组到八元组中:
def byte_sequence( bits ):
byte= []
for n, b in enumerate(bits):
if n%8 == 0 and n != 0:
yield to_byte(byte)
byte= []
byte.append( b )
yield to_byte(byte)
我们使用了内置的enumerate()生成器函数为原始序列中的每个单独比特提供一个编号。从enumerate(bits)表达式中输出的结果是两个元组的序列;每个元组包含编号的比特数(从0到231)以及比特值本身(0或1)。
当比特编号是8的倍数时(n%8 == 0),我们就看到了一个完整的八比特批次。我们可以使用to_byte()将这些八比特转换成一个字节,产生字节值,并将我们的临时累加器byte重置为空。
注意
我们为第一个字节做了特殊例外。
enumerate()函数将分配第一个字节的编号0;由于0%8 == 0,这看起来我们刚刚积累了八个比特来形成一个字节。我们通过确保n不是 0;它不是第一个比特值来避免这种复杂性。我们也可以使用len(byte) != 0表达式来避免第一次的复杂性。
最后的 yield 语句对于成功至关重要。最后的比特批次将包含 n%8 个从 0 到 7 的值。当比特集合耗尽时,n%8 测试将不会被使用,但我们在 byte 变量中仍然会积累八个比特。我们将作为额外步骤提供这最后一批八个比特。
这看起来是这样的:
>>> list(byte_sequence(bits))
[255, 254, 104, 0, 116, 0, 116, 0, 112, 0, 58, 0, 47, 0, 47, 0, 119, 0, 119, 0, 119, 0, 46, 0, 107, 0, 101, 0, 97, 0, 114, 0, 115, 0, 97, 0, 114, 0, 103, 0, 101, 0, 46, 0, 110, 0, 97, 0, 118, 0, 121, 0, 46, 0, 109, 0, 105, 0, 108, 0]
我们已经将单独的比特序列收集成每批八个比特的字节。
信息编码
现在我们能够将任何 Unicode 字符串转换为比特,我们可以将信息编码成图片。最后的微妙之处在于如何界定信息。我们不希望不小心解码整个图片中的每一个字节。如果我们这样做,我们的信息将被随机字符填充。我们需要知道何时停止解码。
一种常见的技术是在字符串前包含一个终止字符。另一种常见的技术是在字符串前提供一个长度。我们将在字符串前包含一个长度,这样我们就不会受到字符串内容或从该字符串产生的编码字节的限制。
我们将在字符串前使用 2 字节长度;我们可以这样将其编码成字节和比特:
len_H, len_L = divmod( len(message), 256 )
size = [to_bits(len_H), to_bits(len_L)]
我们使用了 Python 的 divmod() 函数来计算除法后的商和余数。divmod(len(message), 256) 表达式的结果将是 len(message)//256 和 len(message)%256。我们可以从 len_H*256+len_L 表达式中恢复原始值。
size 变量被设置为一个由两个八元组组成的短序列,这些八元组是由 len_H 和 len_L 值构建的。
包括长度在内的完整字节序列看起来是这样的:
message_bytes= message.encode("UTF-8")
bits_list = list(to_bits(c) for c in message_bytes )
len_h, len_l = divmod( len(message_bytes), 256 )
size_list = [to_bits(len_h), to_bits(len_l)]
bit_sequence( size_list+bits_list )
首先,我们将信息编码成字节。根据涉及的 Unicode 字符和使用的编码,这可能会比原始信息更长。bits_list 变量是由编码信息的各个字节构建的八元组序列。
然后,我们创建了两个包含长度信息的额外字节并将它们转换为比特。size_list 变量是由编码长度的字节构建的八元组序列。
size_list+bits_list 表达式展示了如何连接两个序列以创建一个长序列的单独比特,我们可以将这些比特嵌入到我们的图片中。
这是使用 putpixel() 和 getpixel() 方法更新图片的方法:
w, h = ship.size
for p,m in enumerate( bit_sequence(size_list+bits_list) ):
y, x = divmod( p, w )
r, g, b = ship.getpixel( (x,y) )
r_new = (r & 0xfe) | m
print( (r, g, b), m, (r_new, g, b) )
ship.putpixel( (x,y), (r_new, g, b) )
我们提取了原始图片的大小;这告诉我们 x 轴有多长,这样我们就可以在必要时使用图片的多个行。如果我们的图片每行只有 128 像素,我们需要超过一行来容纳 292 比特的信息。
我们已经将 enumerate() 函数应用于 bit_sequence(size_list+bits_list) 值。这将提供原始信息的序列号和单个比特。序列号可以通过 divmod() 函数转换为行和列。我们将 y 设置为 sequence // width;我们将 x 设置为 sequence % width。
如果我们使用宽度为 128 像素的缩略图,前 128 位将进入行 0。接下来的 128 位将进入行 1。剩下的 292 位将最终落在行 3 上。
我们使用 ship.getpixel( (x,y) ) 从像素中获取 RGB 值。
我们已经突出显示了位操作的部分:r_new = (r & 0xfe) | m。这使用了一个 掩码 值 0xfe,即 0b11111110。这是因为 & 运算符有一个方便的特性。当我们使用 b&1 时,b 的值将被保留。当我们使用 b&0 时,结果是 0。
尝试以下代码,如图所示:
>>> 1 & 1
1
>>> 0 & 1
0
当我们移除最低位时,b 的值(无论是 1 还是 0)将被保留。同样,1 & 0 和 0 & 0 都是 0。
在 (r & 0xfe) 中使用掩码值意味着 r 的最高七位将被保留;最低位将被设置为 0。当我们使用 (r & 0xfe) | m 时,我们将 m 的值折叠到最低位。我们打印出旧的和新的像素值,以提供一些关于这是如何工作的细节。以下是输出中的两行:
(245, 247, 246) 0 (244, 247, 246)
(246, 248, 247) 1 (247, 248, 247)
我们可以看到红色通道的旧值是 245:
>>> 245 & 0xfe
244
>>> (245 & 0xfe) | 0
244
值 244 展示了如何从 245 中移除最低位。当我们折叠一个新的位值 0 时,结果仍然是 244。偶数值编码了我们秘密信息中的 0 位。
在这种情况下,红色通道的旧值是 246:
>>> 246 & 0xfe
246
>>> (246 & 0xfe) | 1
247
当我们移除最低位时,值保持为 246。当我们折叠一个新的位值 1 时,结果变为 247。奇数值编码了我们秘密信息中的 1 位。
注意
在图像前后使用 ship.show() 不会揭示图像有任何可感知的变化。
最终,我们只是在图像中调整了红色的级别,在 256 的尺度上加减 1,不到半百分比的改变。
解码一条信息
我们将分两步解码用隐写术隐藏的信息。第一步将解码长度信息的头两个字节,这样我们就可以恢复嵌入的信息。一旦我们知道我们要查找多少字节,我们就可以解码正确数量的位,只恢复我们的嵌入字符,不再有其他。
由于我们将要两次进入信息,编写一个位提取器会有所帮助。以下是用于从图像的红色通道中剥离位的函数:
def get_bits( image, offset= 0, size= 16 ):
w, h = image.size
for p in range(offset, offset+size):
y, x = divmod( p, w )
r, g, b = image.getpixel( (x,y) )
yield r & 0x01
我们定义了一个有三个参数的函数:一个图像、图像中的偏移量以及要提取的位数。长度信息是一个偏移量为零且长度为 16 位的偏移量。我们将这些值设置为默认值。
我们使用了一个常见的 divmod() 计算来将位置转换为基于图像总宽度的 y 和 x 坐标。y 值是 position//width;x 值是 position%width。这与将位嵌入信息时进行的计算相匹配。
我们使用图像的 getpixel() 方法提取三个颜色信息通道。我们使用 r & 0x01 来计算红色通道的最低位。
由于值是通过yield语句返回的,这是一个生成器函数:它提供一系列值。由于我们的byte_sequence()函数期望一系列值,我们可以将两者结合起来提取大小,如下面的代码所示:
size_H, size_L = byte_sequence( get_bits( ship, 0, 16 ) )
size= size_H*256+size_L
我们使用get_bits()函数从图像中提取了 16 个比特。这串比特被提供给byte_sequence()函数。比特被分成八元组,八元组被转换成单个值。然后我们可以将这些值相乘并相加以恢复原始消息的大小。
现在我们知道了要获取多少字节,我们也知道要提取多少比特。提取看起来像这样:
message= byte_sequence(get_bits(ship, 16, size*8))
我们使用get_bits()函数从第 16 位开始提取比特,直到找到总共size*8个单独的比特。我们将比特分成八元组,并将八元组转换为单个值。
给定一个字节序列,我们可以创建一个bytes对象,并使用 Python 的解码器恢复原始字符串。它看起来像这样:
print( bytes(message).decode("UTF-8") )
这将正确地将字节解码为字符,使用 UTF-8 编码。
检测和防止篡改
我们可以使用隐写术来确保我们的消息没有被篡改。如果我们不能正确找到我们正确编码的数字水印,我们知道我们的图片被修改了。这是检测篡改的一种方法。检测篡改的更稳健的技术是使用哈希总和。有许多哈希算法用于生成字节序列的摘要或签名。我们分别发送消息和哈希码。如果接收到的消息与哈希码不匹配,我们知道出了问题。哈希的一个常见用途是确认文件的正确下载。下载文件后,我们应该将我们得到的文件的哈希值与单独发布的哈希值进行比较;如果哈希值不匹配,文件有问题。我们可以在打开它之前将其删除。
虽然加密似乎可以防止篡改,但它需要仔细管理加密密钥。加密不是万能的。即使使用一个好的加密算法,也可能失去对密钥的控制,使加密变得无用。如果有人未经授权访问密钥,他们可以重写文件,而没有人会知道。
使用哈希总和验证文件
Python 的hashlib模块中有许多哈希算法可用。软件下载通常附有软件包的 MD5 哈希值。我们可以使用hashlib计算文件的 MD5 摘要,如下面的代码所示:
import hashlib
md5 = hashlib.new("md5")
with open( "LHD_warship.jpg", "rb" ) as some_file:
md5.update( some_file.read() )
print( md5.hexdigest() )
我们使用hashlib.new()函数创建了一个 MD5 摘要对象;我们命名了要使用的算法。我们以字节模式打开文件。我们将整个文件提供给摘要对象的update()方法。对于非常大的文件,我们可能希望分块读取文件而不是一次性将整个文件读入内存。最后,我们打印了摘要的十六进制版本。
这将提供 MD5 摘要的十六进制字符串版本,如下所示:
0032e5b0d9dd6e3a878a611b49807d24
有这个安全的哈希,我们可以确认文件在其从发送者到接收者的互联网旅程中没有被篡改。
使用密钥与摘要
通过向消息摘要添加密钥,我们可以提供相当多的安全性。这并不加密消息;它加密摘要以确保在传输过程中摘要不被篡改。
Python 标准库中的hmac模块为我们处理了这个问题,如下面的代码所示:
import hmac
with open( "LHD_warship.jpg", "rb" ) as some_file:
keyed= hmac.new( b"Agent Garbo", some_file.read() )
print( keyed.hexdigest() )
在这个例子中,我们创建了一个 HMAC 摘要对象,并将消息内容传递给该摘要对象。hmac.new()函数可以接受密钥(作为字节数组字符串)和消息体。
来自此 HMAC 摘要对象的十六进制摘要包括原始消息和我们所提供的密钥。以下是输出:
42212d077cc5232f3f2da007d35a726c
由于 HQ 知道我们的密钥,他们可以确认消息来自我们。
同样,HQ 在向我们发送消息时必须使用我们的密钥。然后,当我们读取消息时,我们可以使用我们的密钥来确认消息是由 HQ 发送给我们的。
解决问题——加密消息
为了进行适当的加密,可以使用 PyCrypto 包,该包可以从www.dlitz.net/software/pycrypto/下载。与 Pillow 一样,这是一个庞大的下载。
正如我们在第一章中看到的,我们的间谍工具包,一个糟糕的密钥选择将使任何加密方案基本上毫无价值。如果我们使用一个单词密钥来加密文件,而这个单词在现成的词库中很容易找到,我们实际上并没有使我们的数据非常安全。暴力攻击将破解加密。
我们可以将隐写术与创建ZipFile存档相结合,在 ZIP 文件中嵌入图像中的消息。由于 ZIP 文件可以有注释字符串,我们可以将 HMAC 签名作为 ZIP 存档的注释。
理想情况下,我们会使用 ZIP 加密。然而,Python 的ZipFile库不创建加密的 ZIP 文件。它只能读取加密的文件。
我们将定义一个如下所示的功能:
def package( text, image_source, key_hmac, filename ):
我们将提供我们的消息文本、图像源、我们将用于创建 HMAC 签名的密钥,以及输出文件名。这将输出一个包含图像和签名的 ZIP 文件。
我们package()函数的轮廓如下所示:
image= Image.open( image_source )
steg_embed( image, text )
image.save( "/tmp/package.tiff", format="TIFF" )
with open("/tmp/package.tiff","rb") as saved:
digest= hmac.new( key_hmac.encode("ASCII"), saved.read() )
with ZipFile( filename, "w" ) as archive:
archive.write( "/tmp/package.tiff", "image.tiff" )
archive.comment= digest.hexdigest().encode("ASCII")
os.remove( "/tmp/package.tiff" )
我们已经打开了源图像,并使用steg_embed()函数将我们的秘密消息放入图像中。我们将更新的图像保存到一个临时文件中。
在对图像文件进行其他操作之前,我们计算了其 HMAC 摘要。我们将摘要保存在digest变量中。
现在一切准备就绪,我们可以创建一个新的存档文件。我们可以将图像写入存档的一个成员中。当我们设置存档的comment属性时,这将确保在存档关闭时写入注释文本。
注意,我们必须将密钥转换为 ASCII 字节以创建摘要。HMAC 算法是为字节定义的,而不是 Unicode 字符。同样,结果 hexdigest() 字符串在放入存档之前必须转换为 ASCII 字节。ZIP 存档只支持字节,不能直接支持 Unicode 字符。
最后,我们删除了包含修改后的图像的临时文件。没有必要留下可能被指控的文件。
为了使这可行,我们需要完成实现我们的隐写术编码的函数 steg_embed()。有关如何实现这一点的详细信息,请参阅 一些隐写术方法 部分。
解包消息
我们还需要一个逆函数,可以解码 ZIP 存档中的消息。这个函数的定义可能如下所示:
def unpackage( filename, key_hmac ):
这需要一个 ZIP 文件名和一个密钥来验证签名。这可以返回两件事:嵌入的消息和将消息编码进其中的图像。
我们的 unpackage() 函数的轮廓如下:
try:
os.remove( "/tmp/image.tiff" )
except FileNotFoundError:
pass
with ZipFile( filename, "r" ) as archive:
with archive.open( "image.tiff", "r" ) as member:
keyed= hmac.new( key_hmac.encode("ASCII"), member.read() )
assert archive.comment == keyed.hexdigest().encode("ASCII"), "Invalid HMAC"
archive.extract( "image.tiff", "/tmp" )
image= Image.open( "/tmp/image.tiff" )
text= steg_extract( image )
os.remove( "/tmp/image.tiff" )
return text, image
我们将删除可能存在的任何临时文件。如果文件本身不存在,那是一件好事,但它将引发 FileNotFoundError 异常。我们需要捕获并抑制这个异常。
我们的第一步是打开 ZIP 文件,然后打开 ZIP 文件中的 image.tiff 成员。我们计算这个成员的 HMAC 摘要。然后,我们断言存档注释与所选成员的十六进制摘要相匹配。如果 assert 语句中的条件为假且 HMAC 密钥不匹配,则这将引发异常并停止脚本运行。这也意味着我们的消息已被破坏。如果 assert 语句中的条件为真,它将静默执行。
如果断言是真实的,我们可以将图像文件提取到 /tmp 目录中的某个位置。从这里,我们可以打开文件并使用 steg_extract() 函数来恢复图像中隐藏的消息。Windows 代理可以使用 os 模块来定位临时目录。os.environ['TEMP'] 的值将命名一个合适的临时目录。
一旦我们得到了消息,我们就可以删除临时文件。
为了使这可行,我们需要完成实现我们的隐写术解码的函数 steg_extract()。有关如何实现这一点的详细信息,请参阅 一些隐写术方法 部分。
摘要
在本章中,我们学习了如何在计算机文件系统和常见文件格式上工作。我们深入研究了图像文件。我们还看到了 Pillow 如何允许我们对图像应用裁剪、过滤和增强等操作。
我们介绍了 Python 位操作符,如 &、|、<< 和 >>。这些操作符作用于整数值的各个位。例如,bin(0b0100 & 0b1100) 将显示答案是基于对数字的每个单独位执行 AND 操作的结果。
我们还研究了如何将隐写术技术应用于在图像文件中隐藏信息。这涉及到在 Python 中对字节和位的操作。
在下一章中,我们将探讨如何将地理位置信息与我们的其他信息收集相结合。我们知道图片可以与地点相关联,因此地理编码和反向地理编码是必不可少的。我们还将探讨读取更复杂的在线数据集的方法,以及将多个网络服务组合成一个综合应用的方法。
第四章. 降落点、藏身之处、会面和巢穴
我们将扩展在第二章中介绍的一些技术,获取情报数据,用于进行 RESTful 网络服务请求进行地理编码。这将使我们能够精确地定位各种秘密地点。这还将基于第三章中的图像位置处理,使用隐写术编码秘密信息。
我们将查看一些在线数据集,这将引导我们了解更多数据收集技术。为了处理各种类型的数据,我们需要将 HTML 解析器添加到我们的工具包中。我们将下载 BeautifulSoup,因为它非常擅长追踪 HTML 页面中隐藏的信息。
在本章中,我们还将探讨一些更复杂的 Python 算法。我们将从地理编码服务开始,这些服务可以将地址和经纬度坐标进行转换。
我们将研究哈弗辛公式来计算地点之间的距离。这意味着使用math库来访问三角函数。
我们将了解各种类型的网格编码方案,这将帮助我们简化纬度和经度的复杂性。这些编码方案将向我们展示多种数据表示技术。本章将展示通过改变表示来压缩数字的方法。
我们将了解如何解析 HTML <table> 标签,并创建我们可以与之工作的 Python 集合。我们还将查看提供干净 JSON 格式数据的在线数据源。这可以更容易地收集和处理。
我们的目标是使用 Python 结合多个在线服务。这将使我们能够集成地理编码和数据分析。有了这些信息,我们可以在不远离我们的秘密行动基地的情况下找到与我们的联系人会面的最佳地点。
背景简报 – 纬度、经度和 GPS
在我们能够获取地理信息之前,我们需要回顾一些基本术语。一项帮助平民和秘密特工的现代技术是全球定位系统(GPS),这是一个基于卫星的系统,用于确定位置。GPS 允许地面设备在空间和时间上精确地确定其位置。
GPS 背后的理念非常优雅。每个卫星产生一个包含位置和超级精确时间戳的数据流。具有多个数据流的接收器可以将位置和时间戳插入到一个矩阵中,以确定接收器相对于各个卫星的位置。如果有足够的卫星,接收器可以精确地计算出纬度、经度、海拔高度,甚至当前时间。
更多信息请参阅en.wikipedia.org/wiki/Global_Positioning_System#Navigation_equations。
位置纬度是相对于赤道和极地的角度测量。我们必须提供这个角度的方向:纬度的 N(北)或 S(南)。例如,36°50′40.12″N 是以度(°)、分(′)和秒(″)给出的,其中至关重要的 N 表示位置位于赤道的哪一侧。
我们也可以用度和分来表示纬度,例如 36°50.6687′N;或者,我们可以使用 36.844478,这被称为使用十进制度数。指向北方的方向用正角度表示。指向南方的方向用负角度表示。底层的math库以弧度为单位工作,但弧度并不广泛用于向人类显示位置。
经度是相对于本初子午线或格林威治子午线的东向角度。格林威治以西的角度用负数表示。因此,76°17′35.21″W 也可以表示为-76.293114。
当我们观察地球仪时,我们会注意到纬线都与赤道平行。每个纬度大约是南北方向 60 海里。
然而,经线却在南北极相交。这些南北线并不平行。在地图或海图上,却使用了一种扭曲(实际上称为投影),使得经线相互平行。根据我们通常在陆地上驾驶短距离的经验,这种扭曲并不重要,因为我们通常被限制在蜿蜒于河流和山脉之间的公路上驾驶。重要的是,地图的矩形网格很方便,但具有误导性。简单的平面几何分析并不适用。因此,我们必须转向球面几何。
应对 GPS 设备限制
GPS 接收器需要同时接收来自多个卫星的数据;至少可以使用三个卫星进行三角测量。室内可能有微波信号的干扰,甚至在城市环境中,也可能难以(或不可能)获得足够的数据来正确计算接收器的位置。高楼大厦和其他障碍物,如墙壁,阻止了直接信号访问,这对于准确性是必要的。可能需要很长时间才能获得足够的高质量卫星信号来计算位置。
对于卫星可见性问题的一个常见解决方案是依靠蜂窝电话塔作为计算位置的一种方法,即使没有 GPS 卫星数据也能非常快速地计算位置。一部与多个蜂窝电话塔保持联系的手机可以根据重叠的传输模式进行位置三角测量。在许多电话设备中,在计算 GPS 位置之前,需要使用本地蜂窝电话塔。
有许多非手机 GPS 设备可以直接连接到计算机,以获取准确的 GPS 定位,而不依赖于蜂窝数据。导航计算机(航海员称之为海图定位仪)无需连接到蜂窝网络即可工作。在许多情况下,我们可以使用如pyserial之类的模块从这些设备中提取数据。
有关 pySerial 项目和如何通过串行到 USB 适配器从 GPS 设备读取数据的更多信息,请参阅pyserial.sourceforge.net。
处理政治——边界、选区、司法管辖区和社区
边界造成了无数的问题——有些是深刻的,有些是微妙的。整个人类历史似乎都围绕着边界和战争展开。社区边缘往往是主观的。在城市环境中,在讨论洛斯菲利斯和东好莱坞之间的差异时,一个或两个街区可能并不重要。另一方面,这种知识正是定义当地被居住者认可的当地餐馆的因素。
当涉及到更正式的定义时——例如城市、州和联邦层面的选举区——街道的一侧可能具有深远的影响。在一些城市,这种政治划分信息可以通过 RESTful 网络服务请求轻松获取。在其他地区,此类信息可能被埋藏在某个抽屉里,或者以某种难以处理的 PDF 文档形式发布。
一些媒体公司提供社区信息。例如,《洛杉矶时报》数据部门对洛杉矶大都会区周围的各个社区有一个相当严格的定义。有关如何处理此类信息的详细信息,请参阅www.latimes.com/local/datadesk/。
使用地理编码服务确定我们的位置
我们将使用一些地理编码服务来回答以下问题:
-
给定街道地址的纬度和经度是什么?这被称为地址地理编码,或者简单地称为地理编码。
-
哪个街道地址与给定的纬度和经度最接近?这被称为反向地理编码。
当然,我们还可以提出更多的问题。我们可能想知道在两个地址之间导航的路线。我们可能想知道从一地到另一地有哪些公共交通选择。目前,我们将限制自己只关注这两个基本的地理编码问题。
在互联网上(WWW)有许多可用的地理编码服务。有许多与地理编码相关的术语,包括地理营销、地理定位、地理位置和地理标记。它们本质上都是相似的;它们描述基于位置的信息。追踪具有我们所需功能的服务的间谍活动可能需要相当多的努力。
以下链接提供了一个服务列表:
geoservices.tamu.edu/Services/Geocode/OtherGeocoders/
这个列表远非详尽无遗。这里列出的某些服务可能效果不佳。一些大型公司并未列出;例如,MapQuest 似乎缺失。更多信息请见mapquest.com。
大多数地理编码服务都希望跟踪使用情况。对于大量请求,他们希望为他们提供的服务付费。因此,他们发放凭证(一个密钥),这个密钥必须包含在每一个请求中。获取密钥的流程因服务而异。
我们将仔细研究 Google 提供的服务。他们提供了一种有限的服务,无需请求凭证。他们不会要求我们获取密钥,如果我们过度使用他们的服务,他们会限制我们的请求。
地理编码地址
从地址到经纬度的正向地理编码服务可以通过 Python 的urllib.request模块访问。为了快速回顾,请参阅第二章中关于在 Python 中使用 REST API的部分,获取情报数据。这通常是一个三步过程。
定义 URL 的各个部分。这有助于将静态部分与动态查询部分分开。我们需要使用urllib.parse.urlencode()函数来编码查询字符串。
使用with语句上下文打开 URL。这将发送请求并获取响应。JSON 文档必须在with上下文中解析。
处理接收到的对象。这是在with上下文之外完成的。它看起来是这样的:
import urllib.request
import urllib.parse
import json
# 1\. Build the URL.
form = {
"address": "333 waterside drive, norfolk, va, 23510",
"sensor": "false",
#"key": Provide the API Key here if you're registered,
}
query = urllib.parse.urlencode(form, safe=",")
scheme_netloc_path = "https://maps.googleapis.com/maps/api/geocode/json"
print(scheme_netloc_path+"?"+query)
# 2\. Send the request; get the response.
with urllib.request.urlopen(scheme_netloc_path+"?"+query) as geocode:
print(geocode.info())
response= json.loads( geocode.read().decode("UTF-8") )
# 3\. Process the response object.
print(response)
我们创建了一个包含两个必需字段的字典:address和sensor。如果你想通过 Google 注册以获得额外的支持和更高频率的请求,你可以获取一个 API 密钥。它将成为请求字典中的第三个字段。我们使用#注释来包含关于使用密钥项的提醒。
一个 HTML 网页表单本质上是一种包含名称和值的字典。当浏览器发起请求时,表单在传输到 Web 服务器之前会被编码。我们的 Python 程序使用urllib.parse.urlencode()函数来将表单数据编码成 Web 服务器可以使用的格式。
注意
Google 要求我们使用safe=","参数。这确保了地址中的","字符将被保留,而不是被重写为"%2C"。
一个完整的 URL 包含一个方案、位置、路径和一个可选的查询。方案、位置和路径通常保持不变。我们通过固定部分和动态查询内容组装了一个完整的 URL,打印出来,并将其用作urllib.request.urlopen()函数的参数。
在with语句中,我们创建了一个处理上下文。这将发送请求并读取响应。在with上下文中,我们打印了头部信息以确认请求成功。更重要的是,我们加载了 JSON 响应,这将创建一个 Python 对象。我们将该对象保存在response变量中。
在创建 Python 对象后,我们可以释放与地理编码请求相关的资源。保留with语句的缩进块确保所有资源都得到释放,并且文件响应被关闭。
在with上下文之后,我们可以处理响应。在这种情况下,我们只是打印对象。稍后,我们将对响应进行更多操作。
我们将看到以下片段中的三件事——我们构建的 URL、HTTP 响应的头部信息,以及最终以 JSON 格式文档表示的地理编码输出:
https://maps.googleapis.com/maps/api/geocode/json?sensor=false&address=333+waterside+drive,+norfolk,+va,+23510
Content-Type: application/json; charset=UTF-8
Date: Sun, 13 Jul 2014 11:49:48 GMT
Expires: Mon, 14 Jul 2014 11:49:48 GMT
Cache-Control: public, max-age=86400
Vary: Accept-Language
Access-Control-Allow-Origin: *
Server: mafe
X-XSS-Protection: 1; mode=block
X-Frame-Options: SAMEORIGIN
Alternate-Protocol: 443:quic
Connection: close
{'results': [{'address_components': [{'long_name': '333',
'short_name': '333',
'types': ['street_number']},
{'long_name': 'Waterside Festival Marketplace',
'short_name': 'Waterside Festival Marketplace',
'types': ['establishment']},
{'long_name': 'Waterside Drive',
'short_name': 'Waterside Dr',
'types': ['route']},
{'long_name': 'Norfolk',
'short_name': 'Norfolk',
'types': ['locality', 'political']},
{'long_name': 'Virginia',
'short_name': 'VA',
'types': ['administrative_area_level_1',
'political']},
{'long_name': 'United States',
'short_name': 'US',
'types': ['country', 'political']},
{'long_name': '23510',
'short_name': '23510',
'types': ['postal_code']}],
'formatted_address': '333 Waterside Drive, Waterside Festival Marketplace, Norfolk, VA 23510, USA',
'geometry': {'location': {'lat': 36.844305,
'lng': -76.29111999999999},
'location_type': 'ROOFTOP',
'viewport': {'northeast': {'lat': 36.84565398029149,
'lng': -76.28977101970848},
'southwest': {'lat': 36.8429560197085,
'lng': -76.29246898029149}}},
'types': ['street_address']}],
'status': 'OK'}
{'lng': -76.29111999999999, 'lat': 36.844305}
可以使用json模块加载 JSON 文档。这将创建一个包含两个键的字典:results和status。在我们的例子中,我们将字典加载到名为response的变量中。response['results']的值是一个字典列表。由于我们只请求了一个地址,我们只期望这个列表中有一个元素。因此,我们想要的绝大部分信息都在response['results'][0]中。
当我们检查这个结构时,我们发现一个包含四个键的子字典。其中,'geometry'键包含地理编码的经纬度信息。
我们可以将此脚本扩展以使用以下代码访问位置详情:
print( response['results'][0]['geometry']['location'])
这为我们提供了一个看起来像这样的小字典:
{'lat': 36.844305, 'lng': -76.29111999999999}
这是我们想要了解的街道地址信息。
此外,作为一个关于 Python 语言的纯技术说明,我们包含了#注释来显示算法中的重要步骤。注释以#开头,直到行尾。在这个例子中,注释单独一行。通常,它们可以放在任何代码行的末尾。
具体来说,我们用注释指出了这一点:
form = {
"address": "333 waterside drive, norfolk, va, 23510",
"sensor": "false",
#"key": Provide the API Key here if you're registered,
}
表单字典有两个键。可以通过删除#注释指示符并填写 Google 提供的 API 密钥来添加第三个键。
反向地理编码经纬度点
反向地理编码服务从经纬度位置定位附近的地址。这类查询涉及一定程度的固有歧义。例如,位于两座大型建筑之间的点可能与其中一座或两座建筑相关联。此外,我们可能对不同的细节级别感兴趣:而不是街道地址,我们可能只想知道特定位置所在的州或国家。
这就是这个网络服务请求看起来像什么:
import urllib.request
import urllib.parse
import json
# 1\. Build the URL.
form = {
"latlng": "36.844305,-76.29112",
"sensor": "false",
#"key": Provide the API Key here if you're registered ,
}
query = urllib.parse.urlencode(form, safe=",")
scheme_netloc_path = "https://maps.googleapis.com/maps/api/geocode/json"
print(scheme_netloc_path+"?"+query)
# 2\. Send the request; get the response
with urllib.request.urlopen(scheme_netloc_path+"?"+query) as geocode:
print(geocode.info())
response= json.loads( geocode.read().decode("UTF-8") )
# 3\. Process the response object.
for alt in response['results']:
print(alt['types'], alt['formatted_address'])
表单有两个必填字段:latlng和sensor。
使用 Google 进行注册以获得额外支持和更高频率的请求需要一个 API 密钥。它将成为请求表单中的第三个字段;我们在代码中留下了一个#注释作为提醒。
我们将表单数据编码并分配给query变量。safe=","参数确保纬度-经度对中的","字符将被保留,而不是被重写为%2C转义代码。
我们从 URL 的固定部分(方案、网络位置和路径)以及动态查询内容组装了一个完整的地址。方案、位置和路径通常是固定的。查询是从表单数据编码的。
在with语句中,我们创建了一个处理上下文来发送请求并读取响应。在with上下文中,我们显示了头信息并加载了生成的 JSON 文档,创建了一个 Python 对象。一旦我们有了 Python 对象,我们就可以退出处理上下文并释放资源。
响应是一个 Python 字典。有两个键:'results'和'status'。response['results']的值是一个字典列表。results列表中有多个替代地址。每个结果都是一个字典,包含两个有趣的键:'types'键,显示地址类型,以及'formatted_address'键,这是一个接近给定位置的格式化地址。
输出看起来是这样的:
['street_address'] 333 Waterside Drive, Waterside Festival Marketplace, Norfolk, VA 23510, USA
['postal_code'] Norfolk, VA 23510, USA
['locality', 'political'] Norfolk, VA, USA
['administrative_area_level_1', 'political'] Virginia, USA
['country', 'political'] United States
每个选项都显示了地址嵌套的政治容器层次结构:邮政编码、地区、州(称为administrative_area_level_1)和国家。
多近?什么方向?
为了计算两点之间的距离,我们需要使用一些球面几何计算。我们必须克服的问题是我们的图表和地图是平的。但实际行星非常接近球形。虽然球面几何可能有点复杂,但编程相当简单。它将展示 Python math库的几个特性。
在球体上两点之间的距离定义为以下:

这个公式确定了两个位置之间的余弦值;与该余弦值相对应的角度乘以地球半径 R,以得到沿表面的距离。我们可以使用 R = 3,440 NM,R = 3,959 英里,或 R = 6,371 公里;我们得到合理准确的海里、英里或公里距离。
这个公式在小距离上效果不佳。水平距离公式更适合更精确地计算距离。以下是一些背景信息en.wikipedia.org/wiki/Haversine_formula。
根据 OED,术语“haversine”是在 1835 年由詹姆斯·英曼教授创造的。这个术语指的是正弦函数的使用方式。
水平距离计算通常表示为五个步骤:
这个公式所需的正弦、余弦和平方根部分是 Python 的math库的一部分。当我们查看正弦和余弦的定义时,我们看到它们是以弧度为单位的。我们需要将我们的纬度和经度值从度转换为弧度。规则很简单 (
),但math库包括一个函数radians(),它将为我们完成这个转换。
我们可以查看rosettacode.org/wiki/Haversine_formula#Python来从已有的示例中学习。
我们将使用这个作为两点之间的距离:
from math import radians, sin, cos, sqrt, asin
MI= 3959
NM= 3440
KM= 6371
def haversine( point1, point2, R=MI ):
"""Distance between points.
point1 and point2 are two-tuples of latitude and longitude.
R is radius, R=MI computes in miles.
"""
lat_1, lon_1 = point1
lat_2, lon_2 = point2
Δ_lat = radians(lat_2 - lat_1)
Δ_lon = radians(lon_2 - lon_1)
lat_1 = radians(lat_1)
lat_2 = radians(lat_2)
a = sin(Δ_lat/2)**2 + cos(lat_1)*cos(lat_2)*sin(Δ_lon/2)**2
c = 2*asin(sqrt(a))
return R * c
我们已经导入了进行此计算所需的math库中的五个函数。
我们已经定义了三个常数,它们以不同的单位表示地球的半径。我们可以将这些中的任何一个插入到我们的haversine()函数中作为R参数,以计算不同单位下的距离。这些值是近似值,但它们可以用来确定两点之间的距离。如果我们想要更精确的答案,我们可以插入更精确的值。由于地球不是完美的球形,我们必须确保使用平均半径值。
haversine()函数将接受两个必需的位置参数和一个可选参数。两个位置参数将是纬度和经度的两个元组。我们希望使用类似于(36.12, -86.67)的语法来将两个坐标绑定在单个 Python 值中。R参数是可选的,因为我们已经为它提供了一个默认值。我们可以使用这个函数以千米为单位而不是英里来获取距离:haversine( (36.12, -86.67), (33.94, -118.40), R=KM)。
我们函数的主体将两个元组分解为其纬度和经度值。我们通过减去并转换结果来计算Δ_lat变量。同样,我们通过减去并转换结果来计算Δ_lon变量。是的,以希腊字母Δ开头的变量名在 Python 中是完全有效的。在这之后,我们还可以将其他两个纬度转换为弧度。然后我们可以将这些值插入到其他公式中,以计算a、c,最后计算距离。
我们有一个基于 Rosetta Code 网站示例的测试用例:
>>> from ch_4_ex_3 import haversine
>>> round(haversine((36.12, -86.67), (33.94, -118.40), R=6372.8), 5)
2887.25995
注意,我们将答案四舍五入到小数点后五位。浮点数是近似值;在硬件和操作系统上,精确地工作可能会有所不同。通过限制自己到小数点后五位,我们确信硬件的变化不会影响测试用例。
我们可以使用这个haversine()函数与我们的地理编码结果来计算地点之间的距离;这将帮助我们找到最近的地点。
结合地理编码和 haversine
在地理编码和haversine()函数之间,我们有计算地址之间近似距离的工具。
让我们把 333 Waterside,Norfolk,Virginia 作为我们的当前作战基地。假设我们的信息提供者想要在 456 Granby 或 111 W Tazewell 见面。哪一个更近?
首先,我们需要清理我们的地理编码脚本,使其成为一个可用的函数。而不仅仅是打印一个结果,我们需要从结果字典中获取值,以形成一个包含纬度和经度响应二元组的序列。
这是我们需要添加的内容:
def geocode( address ):
... The previous processing ...
loc_dict= [r['geometry']['location'] for r in response['results']]
loc_pairs= [(l['lat'],l['lng']) for l in loc_dict]
return loc_pairs
我们使用了两个生成器表达式来分解结果。第一个生成器表达式从response['results']序列中的每个替代方案中提取位置信息。对于地理编码,应该只有一个表达式,但如果假设我们会得到多个响应,那么会更简单。
第二个生成器表达式将位置字典中的'lat'和'lng'元素转换为一个二元组。拥有纬度和经度二元组将很好地与我们的haversine()函数配合使用。
这样我们可以获取三个经纬度对:
base = geocode( "333 Waterside, Norfolk, VA, 23510" )[0]
loc1 = geocode( "456 Granby St, Norfolk, VA" )[0]
loc2 = geocode( "111 W Tazewell, Norfolk, VA" )[0]
我们已经应用了我们的geocode()函数来获取一个包含两个元组的列表,然后使用[0]从每个响应列表中选取第一个元素。
这是我们可以报告从基地到每个位置的距离的方法:
print("Base", base)
print("Loc1", loc1, haversine(base, loc1))
print("Loc2", loc2, haversine(base, loc2))
我们应用了我们的haversine()函数来计算距离。默认情况下,距离是以英里为单位,不过对于进行相对比较来说,单位并不重要。
这里是结果:
Base (36.8443027, -76.2910835)
Loc1 (36.8525159, -76.2890381) 0.578671972401055
Loc2 (36.8493341, -76.291527) 0.3485214316218753
我们可以看到,第二个位置(Loc2),Tazewell 地址,比 Granby 街地址更接近我们的基地。
此外,我们还可以看到,我们需要格式化这些数字,使它们看起来更好。因为我们只使用大约的平均地球半径(以英里为单位),所以大部分的这些小数位只是视觉噪音。
压缩数据以生成网格代码
纬度和经度在传输时体积较大。它们有很多数字和一些特殊的标点符号。多年来,一些替代方案已经出现,它们使用更简单的符号来缩写位置。基本思想是将纬度和经度数字从度-分-秒数字转换为表示相同信息的字母和数字序列。
我们将探讨三种压缩方案:GeoRef 系统、Maindenhead 定位器和 NAC。这些编码中的每一个都涉及到进行一些算术计算,将数字从十进制(基数 10)转换为另一个基数。我们还将使用一系列字符串操作来将数字转换为字符,以及将字符转换为数字。
另一个有趣的编程问题是,这些编码不能直接与纬度和经度一起使用。简单地使用纬度和经度的问题在于它们是有符号的数字:-90(S)到+90(N)和-180(W)到+180(E)。此外,经度有更大的范围(360 个值),而纬度有较小的范围(180 个值)。为了简化编码,我们将应用一个常见的编程技巧:我们将偏移和缩放值。我们将看到应用这种巧妙技术的方法。
实际上,缩放和偏移将地图的(0, 0)原点移动到南极洲的某个地方:在南极和 180°经度上。这些网格地图的中心位于西非海岸线附近,而上右角最终会在白令海,正好在北极和 180°经度旁边。
创建 GeoRef 代码
GeoRef 系统使用四个字母和多达八个数字来压缩经纬度位置。此系统还可以用于编码区域描述以及海拔。我们将坚持使用地表位置。
对于一些背景信息,请参阅en.wikipedia.org/wiki/Georef。
这个系统使用从 A 到 Z 的 24 个字母代码(省略 I 和 O)来编码十进制数。这意味着我们不能简单地依赖像string.ascii_uppercase这样的字母表来提供字母代码。我们将不得不定义我们自己的 GeoRef 字母。我们可以用以下表达式来计算字母:
>>> string.ascii_uppercase.replace("O","").replace("I","")
'ABCDEFGHJKLMNPQRSTUVWXYZ'
GeoRef 代码将世界地图切割成 12 x 24 的 15° x 15°网格。纬度从南极开始以正数测量。经度从国际日期线开始以正数测量。当我们把 180°的纬度分成 15°的步长时,我们可以使用从 A 到 M 的 12 个字母(省略 I)来编码这个三位数的一部分。当我们把 360°的经度分成 15°的步长时,我们可以使用从 A 到 Z 的 24 个字母(省略 I 和 O)来编码这个三位数的一部分。
然后,我们可以使用字母 A 到 Q(再次省略 I 和 O)将每个 15°的网格分成 15 个波段。这为纬度和经度的整个度数部分创建了一个四字符代码。
如果我们有 38°17′10″N 的纬度,我们将将其偏移到南极以北 128°,然后除以 15°:
>>> divmod(38+90,15)
(8, 8)
这些值被编码为 J 和 J。
76°24′42″W 的经度被编码,如下所示代码所示。这是-76.41167°,我们在使用divmod计算两个字符之前,将其偏移 180°:
>>> divmod( -76+180, 15 )
(6, 14)
这给我们字母 G 和 P。我们交错经度和纬度字符,使整个字符串为 GJPJ。我们已经将纬度和经度的六位数字编码为四个字符。
剩余的分钟和秒可以编码为两位、三位或四位数字。对于纬度,17′10″可以编码为 17.16 分钟。这是 17,一个中间值 171,或一个详细值 1716。
下面是 GeoRef 代码的整个编码器:
def ll_2_georef( lat, lon ):
f_lat, f_lon = lat+90, lon+180
lat_0, lat_1 = divmod( int(f_lat), 15 )
lon_0, lon_1 = divmod( int(f_lon), 15 )
lat_m, lon_m = 6000*(f_lat-int(f_lat)), 6000*(f_lon-int(f_lon))
return "{lon_0}{lat_0}{lon_1}{lat_1}{lon_m:04d}{lat_m:04d}".format(
lon_0= georef_uppercase[lon_0],
lat_0= georef_uppercase[lat_0],
lon_1= georef_uppercase[lon_1],
lat_1= georef_uppercase[lat_1],
lon_m= int(lon_m),
lat_m= int(lat_m),
)
我们对纬度和经度进行了偏移,这样我们就不必处理有符号的数字。我们使用了 divmod() 函数来除以 15°,并得到一个商和一个余数。然后我们可以使用我们的 georef_uppercase 字母来将数值商和余数转换为预期的字符代码。
例如,分数值 f_lat-int(f_lat) 被缩放为 6000,以创建一个介于 0000 和 5999 之间的数字,这仅仅是 100 分之分钟的数字。
我们使用了 format() 字符串方法将四字符代码和四数字代码组合成一个单一的字符串。前两个字母是经度和纬度,以提供最接近 15°的位置。接下来的两个字母有更多的经度和纬度细节,以将精度细化到最接近的 1°。数字分为两个四位数块,以提供详细的分钟数。
这里是一个更完整的输出示例。我们将编码 36°50.63′N 076°17.49′W:
lat, lon = 36+50.63/60, -(76+17.49/60)
print(lat, lon)
print(ll_2_georef(lat, lon))
我们已经将度和分钟转换为度。然后,我们将 GeoRef 转换应用于度数中的值。以下是输出结果:
36.843833333333336 -76.2915
GJPG42515063
代码 GJPG 是给定位置的近似值;在赤道附近可能会偏差高达 80 海里。误差随着接近两极而减小。代码 GJPG4250 使用两位数的整分钟编码,以将坐标误差缩小到几英里以内。
解码 GeoRef 代码
当我们解码 GeoRef 代码时,我们必须将两部分分开:前四个字符和末尾的数字细节。一旦我们分离出前四个字符,我们可以将剩余字符的数量除以二。一半的数字将是经度,其余的是纬度。
前四位必须在我们的特殊 GeoRef 字母表中查找。我们将找到每个字符在 ABCDEFGHJKLMNPQRSTUVWXYZ 字符串中的位置以获得一个数值。例如,georef_uppercase.find('Q') 给出 14:Q 在该字母表中的位置。然后我们可以将一个位置乘以 15°,将另一个位置数字加到一起,将两个字符转换为 GeoRef 的度数部分。
剩余的数字仅仅是分钟,它们是 1/60 度。在转换过程中,涉及到创建一个数字,可能还需要将其除以 10 或 100。最后一步是移除用于避免有符号算术的偏移量。
整个过程看起来是这样的:
def georef_2_ll( grid ):
lon_0, lat_0, lon_1, lat_1= grid[:4]
rest= grid[4:]
pos= len(rest)//2
if pos:
scale= { 4: 100, 3: 10, 2: 1 }[pos]
lon_frac, lat_frac = float(rest[:pos])/scale, float(rest[pos:])/scale
else:
lon_frac, lat_frac = 0, 0
lat= georef_uppercase.find(lat_0)*15+georef_uppercase.find(lat_1)+lat_frac/60
lon= georef_uppercase.find(lon_0)*15+georef_uppercase.find(lon_1)+lon_frac/60
return lat-90, lon-180
这通过将代码的前四位分成四个经度和纬度字符来实现。请注意,位置是交错排列的:经度先,纬度后。
字符串的其余部分被分成两半。如果有任何字符在第二半,那么第一半(两个、三个或四个字符)将是经度分钟;第二半将是纬度分钟。
我们使用了一个简单的从长度(两个、三个或四个)到缩放值(1、10 和 100)的映射。我们定义了一个从位置到缩放因子的映射字典,并将位置数pos应用于该字典。我们也可以使用算术计算来完成这个操作:10**(pos-1);这个计算也可以将pos转换为 10 的幂。
我们将字符字符串转换为浮点数,然后缩放以创建分钟的适当值。以下是一个缩放示例:
>>> float("5063")/100
50.63
else条件处理只有四个位置的网格代码的情况。如果这是真的,那么我们只有字母,分钟将为零。
我们可以使用一个字母乘以 15°,下一个字母乘以 1°,以及分钟乘以度的 60 分来计算偏移值。当然,最后一步是移除偏移值以创建预期的有符号数字。
考虑到我们使用以下内容:
print( georef_2_ll( "GJPG425506" ) )
我们将看到如下输出:
(36.843333333333334, -76.29166666666667)
我们将较长的 GeoRef 代码缩短为 10 位代码。这有两个 3 位分钟的编码。我们选择牺牲一些精度,但这也可以简化秘密信息的传输。
创建梅登黑德网格代码
与之前提到的相对简单的网格代码相比,我们有一个称为梅登黑德系统的替代表示法。这是由业余无线电操作员用来交换他们站点位置信息的。梅登黑德是英格兰的一个城镇;梅登黑德代码是IO91PM。
更多信息,请参阅en.wikipedia.org/wiki/Maidenhead_Locator_System。
梅登黑德算法涉及一些更复杂的数学,基于创建纬度和经度数字的基数为 240 的表示。我们可以使用字母-数字组合来编码基数 240 数字的每一位。我们将展示一个将浮点数转换为整数的常用技术,通过一系列步骤进行。
梅登黑德系统将世界地图切割成 180 × 180 的网格四边形;每个四边形在南北方向上有 1°,在东西方向上有 2°。我们可以使用基数为 240 的数字系统来编码这些四边形,其中字母和数字用于表示基数 240 系统中的每一位。由于网格只有 180×180,我们不需要基数 240 的全部范围。
为了更精确地指定位置,我们可以将网格的每个单元格切割成 240 x 240 更小的单元格。这意味着一个八位代码可以使我们在南北方向上达到 0.25 海里,在东西方向上达到 0.5 海里。对于业余无线电的目的,这可能是足够的。对于我们的地址级地理编码,我们需要更高的精度。
我们可以将相同的字母-数字操作进行第三次,将每个小矩形分割成 240 个更小的部分。这使我们达到了所需的精度以上。
我们在 240 进制系统中创建一个三位数,其中每个 240 的基数位由一个字母-数字对表示。我们执行以下计算来创建编码数字的三位数字
,
:

下面是这个整个过程的示例:
def ll_2_mh( lat, lon ):
def let_num( v ):
l, n = divmod( int(v), 10 )
return string.ascii_uppercase[l], string.digits[n]
f_lat= lat+90
f_lon= (lon+180)/2
y0, y1 = let_num( f_lat )
x0, x1 = let_num( f_lon )
f_lat= 240*(f_lat-int(f_lat))
f_lon= 240*(f_lon-int(f_lon))
y2, y3 = let_num( f_lat )
x2, x3 = let_num( f_lon )
f_lat= 240*(f_lat-int(f_lat))
f_lon= 240*(f_lon-int(f_lon))
y4, y5 = let_num( f_lat )
x4, x5 = let_num( f_lon )
return "".join( [
x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, x5, y5 ] )
我们在ll_2_mh()函数内部定义了一个内部函数let_num()。内部let_num()函数将 0 到 240 范围内的数字转换为一个字母和一个数字。它使用divmod()函数将数字分解为 0 到 24 的商和 0 到 9 的余数。然后,这个函数使用这两个数值作为string.ascii_uppercase和string.digits字符串的索引来返回两个字符。每个字母-数字对是 240 进制系统中单个数字的表示。我们不是发明 240 个数字符号,而是重新利用字母-数字对来写出 240 个不同的值。
第一步是将原始的、有符号的纬度和经度转换为我们的梅登黑德网格版本。f_lat变量是原始纬度,偏移 90 度使其严格为正,范围在 0 到 180 度之间。f_lon变量是原始经度偏移 180 度然后除以 2,使其严格为正,范围在 0 到 180 度之间。我们从这些初始的度数值:f_lat和f_lon中创建了初始的字母-数字配对。
这对于度数来说工作得很好。那么,对于度数的分数部分呢?这里有一个处理浮点值表示的常见技术。
如果我们使用类似lat-int(lat)的方法,我们将计算纬度的分数部分。如果我们将其乘以 240,我们将得到一个可以用divmod()函数来获取 240 个字母位置和一个数字的数字。表达式240*(f_lat-int(f_lat))将f_lat的分数部分扩展到 0 到 240 的范围内。以下是如何进行这种缩放的示例:
>>> f_lat= 36.84383
>>> 240*(f_lat-int(f_lat))
202.51919999999927
>>> 240*.84383
202.51919999999998
原始纬度是36.84383。f_lat-int(f_lat)的值将是该值的分数部分,即.84383。我们将其乘以240来得到值,大约的结果是202.5192。
我们使用let_num()函数创建了一个字母和数字的配对。剩余的分数值(0.5192)可以通过 240 倍缩放来得到另一个字母和数字的配对。
到这一点,细节已经达到了相关性的极限。1/240/240 度的精度大约是 6 英尺。大多数民用 GPS 仪器只能精确到大约 16 英尺。
最后一步是将经度和纬度字符交织在一起。我们通过创建一个字符列表来实现这一点,字符的顺序是预定的。string.join() 方法在将字符串列表组装成一个字符串时使用给定的字符串作为分隔符。通常使用 ", ".join(some_list) 来创建以逗号分隔的项目。我们使用了 "".join() 来组装没有分隔符的最终字符串。
这里是一个更完整的输出示例。我们将编码 36°50.63′N 076°17.49′W:
lat, lon = 36+50.63/60, -(76+17.49/60)
print( lat, lon )
print( ll_2_mh( lat, lon ) )
我们将度和分转换成了度。然后,我们将 Maidenhead 转换应用于度数值。输出看起来是这样的:
36.843833333333336 -76.28333333333333
FM16UU52AM44
我们可以使用这些部分以不同的精度进行编码。FM16 比较粗糙,而 FM16UU 则更精确。
解码 Maidenhead 网格代码
要解码 Maidenhead 代码,我们需要反转我们用来从纬度和经度创建代码的程序。我们需要将所有偶数位置作为数字序列来创建经度,将所有奇数位置作为数字序列来创建纬度。通过对照 string.ascii_uppercase 和 string.digits 进行查找,我们可以将字符转换成数字。
一旦我们有一个数字序列,我们可以应用一系列的权重因子并将结果相加。整个过程看起来是这样的:
def mh_2_ll( grid ):
lon= grid[0::2] # even positions
lat= grid[1::2] # odd positions
assert len(lon) == len(lat)
# Lookups will alternate letters and digits
decode = [ string.ascii_uppercase, string.digits,
string.ascii_uppercase, string.digits,
string.ascii_uppercase, string.digits,
]
lons= [ lookup.find(char.upper()) for char, lookup in zip( lon, decode ) ]
lats= [ lookup.find(char.upper()) for char, lookup in zip( lat, decode ) ]
weights = [ 10.0, 1.0,
1/24, 1/240,
1/240/24, 1/240/240, ]
lon = sum( w*d for w,d in zip(lons, weights) )
lat = sum( w*d for w,d in zip(lats, weights) )
return lat-90, 2*lon-180
我们使用了 Python 非常优雅的切片表示法来将字符串拆分成偶数和奇数位置。表达式 grid[0::2] 指定了一个 grid 字符串的切片。[0::2] 切片从位置 0 开始,扩展到非常末尾,并增加 2。[1::2] 切片从位置 1 开始,扩展到非常末尾,也增加 2。
decode 列表包含六个字符串,这些字符串将被用来将每个字符转换成数值。第一个字符将在 string.ascii_uppercase 中找到,第二个字符将在 string.digits 中找到。这些字符串中字符的位置将变成我们可以用来计算经纬度的数值。
例如,'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.find('M') 的值是 12。
我们使用生成器表达式和 zip() 函数来完成翻译的实际工作。zip() 函数将产生一系列的配对;每个配对将包含从输入网格代码中选择的一个字符和从 decode 列表中选择的一个查找字符串。然后我们可以使用 lookup.find(char.upper()) 来在给定的查找字符串中定位给定的字符。结果将是一个整数位置序列。
一旦我们有一个数值序列,我们可以应用一系列的权重来将每个位置转换为度或度的分数。同样,我们使用 zip() 来从数字和权重中创建双元组。然后生成器将数值乘以权重。内置的 sum() 函数从数字和权重中创建最终值。
例如,我们可能在lons变量中有[5, 1, 20, 6, 0, 0]这样的值。权重是[10.0, 1.0, 0.0416, 0.00416, 0.00017361, 1.7361e-05]。当我们使用zip()将这些两个序列压缩在一起时,我们会得到这样的配对:
[(5, 10.0), (1, 1.0), (20, 0.0416),
(6, 0.00416), (0, 0.00017361),
(0, 1.7361e-05)]
这些乘积看起来是这样的:
[50.0, 1.0, 0.832, 0.024959999999999996, 0.0, 0.0]
总和是51.85696。
最后一步是撤销我们用来强制纬度为正值以及经度在 0 到 180 之间而不是-180 到+180 之间的偏移量。中间经度结果51.85696变为-76.28608。
考虑到我们评估以下内容:
print( mh_2_ll( "FM16UU62" ) )
我们得到了以下解码的位置:
(36.84166666666667, -76.28333333333333)
这很好地解码了我们之前章节中编码的值。
创建自然区域代码
自然区域代码(NAC)是将地理位置信息编码为短字符字符串的另一种方式。整个 NAC 系统可以包括海拔信息以及表面位置。我们将重点关注 NAC 的纬度和经度转换。
更多信息请参阅www.nacgeo.com/nacsite/documents/nac.asp。
这使用的是 30 进制而不是 240 进制;我们可以使用大部分的字母和一些数字来表示一个 30 进制的数字。这个实现将展示一种将浮点数转换为整数近似的方法。这将把多个计算步骤组合成更长的表达式。
NAC 使用 30 字符编码,该编码使用数字和辅音字母。用于编码和解码的字符串是:
>>> nac_uppercase= "0123456789BCDFGHJKLMNPQRSTVWXZ"
>>> len(nac_uppercase)
30
>>> nac_uppercase[10]
'B'
>>> nac_uppercase.find('B')
10
我们可以取一个经度(-180 到+180),并添加一个偏移量将其放入 0 到 360 的范围内。如果我们将其乘以(30**4)/360,我们将得到一个介于 0 到 810000 之间的数字。这可以转换为四位数 30 进制数。
同样,我们可以取一个纬度(-90 到+90),并添加一个偏移量将其放入 0 到 180 的范围内。如果我们将其乘以(30**4)/180,同样,我们将得到一个可以转换为四位数 30 进制数的数字。这里的重大优势是我们用较短的 30 进制数字字符串替换了长串的 10 进制数字。
建议用于编码此信息的算法是:
def ll_2_nac( lat, lon ):
f_lon= (lon+180)/360
x0 = int( f_lon*30)
x1 = int(( f_lon*30-x0)*30)
x2 = int((( f_lon*30-x0)*30-x1)*30)
x3 = int(.5+(((f_lon*30-x0)*30-x1)*30-x2)*30)
f_lat= (lat+90)/180
y0 = int( f_lat*30 )
y1 = int(( f_lat*30-y0)*30)
y2 = int((( f_lat*30-y0)*30-y1)*30)
y3 = int(0.5+(((f_lat*30-y0)*30-y1)*30-y2)*30)
print( x0, x1, x2, x3, y0, y1, y2, y3 )
return "".join( [
nac_uppercase[x0], nac_uppercase[x1],
nac_uppercase[x2], nac_uppercase[x3],
" ",
nac_uppercase[y0], nac_uppercase[y1],
nac_uppercase[y2], nac_uppercase[y3],
])
我们通过添加偏移量并除以 360 来缩放经度。这创建了一个介于0和1.0之间的数字。然后我们可以通过大量的乘法和减法将这个数字编码为 30 进制。有几种方法可以优化这个过程。
每一步都遵循类似的模式。我们将逐步进行经度计算。以下是第一个字符的计算:
>>> lon= -151.3947
>>> f_lon= (lon+180)/360
>>> f_lon
0.07945916666666666
>>> x0 = int(f_lon*30)
>>> x0
2
第一步计算f_lon,即该经度(151.3947W)相对于 360 度的分数。当我们把f_lon乘以 30 时,得到2.383775。整数部分2将成为第一个字符。剩余的三个字符将用于编码分数。
这是基于第一个字符的下一个字符:
>>> x1 = int((f_lon*30-x0)*30)
>>> x1
11
(f_lon*30-x0)的计算得到分数,.383775。然后我们将其乘以30得到11.51325。整数部分11将成为第二个字符。分数将编码在剩余的两个字符中。
在每一步中,我们将所有之前的数字用于计算剩余的分数部分。以下是最后两个字符:
>>> x2 = int((( f_lon*30-x0)*30-x1)*30)
>>> x2
15
>>> x3 = int(0.5+(((f_lon*30-x0)*30-x1)*30-x2)*30)
>>> x3
12
每个字符都通过将原始数字(f_lon)与之前计算的数字之间的差来获取剩余的分数。最后一步涉及大量的乘法。在之前的创建梅登黑德网格码部分,我们展示了这个主题的一个变体,它没有使用那么多乘法操作。
例如,我们可能执行以下操作:
lat, lon = 43.6508, -151.3947
print( ll_2_nac( lat, lon ) )
这个输出的结果是:
2CHD Q87M
这是对纬度和经度的一个相当整洁的总结。
解码自然区域码
解码自然区域码实际上是将 30 进制数转换为 0 到 810,000 之间的值。然后将其缩放为适当的纬度或经度值。尽管 30 进制数看起来并不简单,但编程实际上相当简洁。以下是建议的算法:
def nac_2_ll( grid ):
X, Y = grid[:4], grid[5:]
x = [nac_uppercase.find(c) for c in X]
y = [nac_uppercase.find(c) for c in Y]
lon = (x[0]/30+x[1]/30**2+x[2]/30**3+x[3]/30**4)*360-180
lat = (y[0]/30+y[1]/30**2+y[2]/30**3+y[3]/30**4)*180-90
return lat, lon
我们已经将九字符 NAC 网格码的每一部分分解为经度子串和纬度子串。我们使用生成器函数在我们的nac_uppercase字母表中查找每个字符。这将每个字符映射到 0 到 29 之间的数字位置。
一旦我们有了四个 30 进制数字的序列,我们就可以从数字中计算出一个数。以下表达式完成了基本的工作:
(x[0]/30+x[1]/30**2+x[2]/30**3+x[3]/30**4)
上述表达式是对多项式的优化,
。Python 代码简化了每个项中的常数——而不是计算x[0]*30**3/30**4;这被简化为x[0]/30。
中间结果通过 360 或 180 进行缩放并偏移,以获得最终结果的预期有符号值。
考虑以下评估:
print( nac_2_ll( "2CHD Q87M" ) )
我们得到了以下结果:
(43.650888888888886, -151.39466666666667)
这显示了如何解码 NAC 以恢复位置的纬度和经度。
解决问题——最近的良好餐馆
我们想在离基地合理距离的好餐馆与我们的秘密线人见面。为了定位好餐馆,我们需要收集一些额外的信息。在这种情况下,好意味着来自卫生检查员的好成绩。
在我们甚至能够开会之前,我们需要使用基本的间谍技巧来定位当地餐馆的健康码调查结果。
我们将创建一个 Python 应用程序来结合许多东西以筛选结果。我们将执行以下步骤:
-
我们将从餐馆健康评分信息开始。
-
如果尚未完成,我们需要对餐馆地址进行地理编码。在某些情况下,地理编码已经为我们完成。在其他情况下,我们将使用网络服务来完成这项工作。
-
我们需要根据良好的评分过滤和组织餐厅。我们还需要使用我们的
haversine()函数来计算我们基地的距离。 -
最后,我们需要将这一点传达给我们的网络,理想情况下是使用嵌入在我们发布到社交媒体网站上的图片中的简短 NAC 代码。参见第三章,使用隐写术编码秘密信息,了解这一最终步骤的详细信息。
在许多城市,健康代码数据可在网上找到。仔细搜索将揭示一个有用的数据集。在其他城市,健康检查数据可能不容易在网上找到。我们可能需要深入挖掘以追踪我们运营基地附近的一些餐厅。
一些城市使用Yelp来公布餐厅健康代码检查数据。我们可以在以下链接上阅读关于 YELP API 的内容以搜索餐厅:
www.yelp.com/developers/documentation
我们也可能在 InfoChimps 上找到一些有用的数据,www.infochimps.com/tags/restaurant。
我们经常遇到的一个复杂性是使用基于 HTML 的 API 来处理这类信息。这并非有意混淆,但 HTML 的使用使得数据分析变得复杂。解析 HTML 以提取有意义的信息并不容易;我们需要额外的库来处理这一点。
我们将探讨两种方法:良好的、干净的数据和更复杂的 HTML 数据解析。在两种情况下,我们需要创建一个 Python 对象,作为属性集合的容器。首先,我们将转向查看SimpleNamespace类。然后,我们将使用这个类来收集信息。
创建简单的 Python 对象
我们有多种方式来定义自己的 Python 对象。我们可以使用内置的中心类型,如 dict,来定义一个具有属性值的集合的对象。在查看餐厅信息时,我们可以使用类似以下的方式:
some_place = { 'name': 'Secret Base', 'address': '333 Waterside Drive' }
由于这是一个可变对象,我们可以添加属性值并更改现有属性值。虽然语法有点笨拙,但以下是更新此对象的方式:
some_place['lat']= 36.844305
some_place['lng']= -76.29112
额外的[]方括号和''引号似乎是不必要的。我们希望有一个比这种非常通用的字典键值语法稍微干净一点的符号。
一种常见的解决方案是使用合适的类定义。其语法看起来像这样:
class Restaurant:
def __init__(self, name, address):
self.name= name
self.address= address
我们定义了一个具有初始化方法__init__()的类。初始化方法的名字是特殊的,只能使用这个名字。当对象被创建时,初始化方法会被评估以分配初始值给对象的属性。
这使我们能够更简洁地创建对象:
some_place= Restaurant( name='Secret Base', address='333 Waterside Drive' )
我们已经使用了显式的关键字参数。使用name=和address=不是必需的。然而,随着类定义变得更加复杂,使用关键字参数值通常更加灵活和清晰。
我们也可以很好地更新对象,如下所示:
some_place.lat= 36.844305
some_place.lng= -76.29112
当我们有很多与每个对象绑定的独特处理时,这效果最好。在这种情况下,我们实际上没有与属性关联的处理;我们只是想将这些属性收集在一个整洁的胶囊中。对于这样一个简单的问题,正式的类定义是过多的开销。
Python 还给我们提供了一个非常灵活的结构,称为命名空间。这是一个可变对象,我们可以使用简单的属性名称来访问它,如下面的代码所示:
from types import SimpleNamespace
some_place= SimpleNamespace( name='Secret Base', address='333 Waterside Drive' )
创建命名空间时必须使用关键字参数(name='The Name')。一旦我们创建了此对象,我们可以使用以下代码片段中所示愉快的方法来更新它:
some_place.lat= 36.844305
some_place.lng= -76.29112
SimpleNamespace类给我们提供了一个构建包含多个单独属性值的对象的方法。
我们还可以使用 Python 的**符号从字典创建命名空间。以下是一个示例:
>>> SimpleNamespace( **{'name': 'Secret Base', 'address': '333 Waterside Drive'} )
namespace(address='333 Waterside Drive', name='Secret Base')
**符号告诉 Python 字典对象包含函数的关键字参数。字典键是参数名称。这允许我们构建一个字典对象,然后将其用作函数的参数。
回想一下,JSON 倾向于将复杂的数据结构编码为字典。使用这个**技术,我们可以将 JSON 字典转换为SimpleNamespace,并用更干净的object.key表示法替换笨拙的object['key']表示法。
与 HTML 网络服务一起工作 – 工具
在某些情况下,我们想要的数据绑定在 HTML 网站上。例如,诺福克市依赖弗吉尼亚州的 VDH 健康门户来存储其餐馆健康代码检查数据。
为了理解在万维网上的 HTML 表示法中编码的智能,我们需要能够解析围绕数据的 HTML 标记。使用特殊的高能武器,在这个案例中是 BeautifulSoup,我们的工作大大简化了。
从pypi.python.org/pypi/beautifulsoup4/4.3.2或www.crummy.com/software/BeautifulSoup/开始。
如果我们有 Easy Install(或 PIP),我们可以使用这些工具来安装 BeautifulSoup。在第一章 我们的间谍工具包中,我们应该已经安装了其中一个(或两个)这些工具来安装更多工具。
我们可以使用 Easy Install 来安装 BeautifulSoup,如下所示:
sudo easy_install-3.3 beautifulsoup4
Mac OS X 和 GNU/Linux 用户需要使用sudo命令。Windows 用户不需要使用sudo命令。
一旦我们有了 BeautifulSoup,我们就可以用它来解析 HTML 代码,寻找隐藏在复杂的 HTML 标签混乱中的特定事实。
在我们继续之前,你需要阅读快速入门文档,并熟悉 BeautifulSoup。一旦你完成了这些,我们将转向从 HTML 网页中提取数据。
从 www.crummy.com/software/BeautifulSoup/bs4/doc/#quick-start 开始。
另一个工具是 scrapy。更多信息请参阅 scrapy.org。此外,阅读 Instant Scrapy Web Mining and Scraping,作者 Travis Briggs,由 Packt Publishing 出版,以了解使用此工具的详细信息。不幸的是,截至本文撰写时,scrapy 专注于 Python 2,而不是 Python 3。
与 HTML 网络服务一起工作 – 获取页面
在诺福克市 VDH 健康数据的情况下,HTML 抓取相对简单。我们可以利用 BeautifulSoup 的优势,非常优雅地深入 HTML 页面。
一旦我们从 HTML 页面创建了 BeautifulSoup 对象,我们将拥有一种优雅的技术来遍历 HTML 标签的层次结构。每个 HTML 标签名(html、body 等)也是 BeautifulSoup 查询,用于定位该标签的第一个实例。
例如,soup.html.body.table 这样的表达式可以定位 HTML <body> 标签中的第一个 <table>。在 VDH 餐厅数据的情况下,这正是我们想要的数据。
一旦我们找到了表格,我们需要提取行。每行的 HTML 标签是 <tr>,我们可以使用 BeautifulSoup 的 table.find_all("tr") 表达式来定位给定 <table> 标签内的所有行。每个标签的文本是一个属性,.text。如果标签有属性,我们可以将标签视为一个字典来提取属性值。
我们将 VDH 餐厅数据的处理分解为两部分:构建 Soup 的网络服务查询和 HTML 解析以收集餐厅信息。
下面是第一部分,即获取原始 BeautifulSoup 对象:
scheme_host= "http://healthspace.com"
def get_food_list_by_name():
path= "/Clients/VDH/Norfolk/Norolk_Website.nsf/Food-List-ByName"
form = {
"OpenView": "",
"RestrictToCategory": "FAA4E68B1BBBB48F008D02BF09DD656F",
"count": "400",
"start": "1",
}
query= urllib.parse.urlencode( form )
with urllib.request.urlopen(scheme_host + path + "?" + query) as data:
soup= BeautifulSoup( data.read() )
return soup
这重复了我们之前看到的网络服务查询。在这里,我们区分了三件事:scheme_host 字符串、path 字符串和 query。这样做的原因是,我们的整体脚本将使用 scheme_host 与其他路径一起使用。我们还将插入大量不同的查询数据。
对于这个基本的 food_list_by_name 查询,我们构建了一个表单,将获取 400 家餐厅的检查结果。表单中的 RestrictToCategory 字段有一个神奇的关键字,我们必须提供这个关键字才能获取诺福克餐厅。我们通过基本的网络间谍技术找到了这个关键字:我们在网站上四处浏览,并检查了我们点击每个链接时使用的 URL。我们还使用了 Safari 的开发者模式来探索页面源代码。
从长远来看,我们想要所有检查结果。为了开始,我们限制了自己只获取 400 条,这样我们就不需要花费太多时间等待脚本测试。
响应对象被 BeautifulSoup 用于创建网页的内部表示。我们将它分配给soup变量,并将其作为函数的结果返回。
除了返回soup对象外,打印它也可能很有帮助。这是一大堆 HTML。我们需要解析它以从标记中提取有趣的细节。
与 HTML 网络服务一起工作 - 解析表格
一旦我们将一页 HTML 信息解析成 BeautifulSoup 对象,我们就可以检查该页的细节。这里有一个函数,可以找到页面中隐藏的餐厅检查详情表。
我们将使用生成器函数来逐行生成表格中的每一行,如下面的代码所示:
def food_table_iter( soup ):
"""Columns are 'Name', '', 'Facility Location', 'Last Inspection',
Plus an unnamed column with a RestrictToCategory key
"""
table= soup.html.body.table
for row in table.find_all("tr"):
columns = [ td.text.strip() for td in row.find_all("td") ]
for td in row.find_all("td"):
if td.a:
url= urllib.parse.urlparse( td.a["href"] )
form= urllib.parse.parse_qs( url.query )
columns.append( form['RestrictToCategory'][0] )
yield columns
注意,这个函数以三引号字符串开始。这是一个文档字符串,它提供了关于函数的文档。良好的 Python 风格要求每个函数都有一个文档字符串。Python 帮助系统将显示函数、模块和类的文档字符串。我们省略了它们以节省空间。在这里,我们包括了它,因为这个特定迭代器的结果可能相当令人困惑。
这个函数需要一个解析后的 Soup 对象。该函数使用简单的标签导航来定位 HTML <body> 标签中的第一个<table>标签。然后它使用表的find_all()方法来定位该表中的所有行。
对于每一行,有两个处理步骤。首先,使用生成器表达式找到该行内的所有<td>标签。每个<td>标签的文本去除了多余的空白字符,并形成一个单元格值的列表。在某些情况下,这种处理就足够了。
然而,在这种情况下,我们还需要解码一个 HTML <a> 标签,该标签包含对给定餐厅详细信息的引用。我们使用第二个find_all("td")表达式再次检查每一列。在每一列中,我们使用简单的if td.a:循环检查是否存在<a>标签。如果存在<a>标签,我们可以获取该标签上href属性的值。当查看源 HTML 时,这是<a href="">引号内的值。
HTML href 属性的此值是一个 URL。我们实际上不需要整个 URL。我们只需要 URL 中的查询字符串。我们使用了urllib.parse.urlparse()函数来提取 URL 的各个部分。url.query属性的值只是?之后的查询字符串。
结果表明,我们甚至不需要整个查询字符串;我们只需要RestrictToCategory键的值。我们可以使用urllib.parse.parse_qs()来解析查询字符串,以获取类似表单的字典,并将其分配给变量form。这个函数是urllib.parse.urlencode()的逆函数。parse_qs()函数构建的字典将每个键与一个值列表关联。我们只想获取第一个值,因此使用form['RestrictToCategory'][0]来获取所需的餐厅键。
由于这个 food_table_iter() 函数是一个生成器,所以必须使用 for 语句或另一个生成器函数来使用它。我们可以使用 for 语句如下:
for row in food_table_iter(get_food_list_by_name()):
print(row)
这将打印 HTML 表格中的每一行数据。它开始如下:
['Name', '', 'Facility Location', 'Last Inspection']
["Todd's Refresher", '', '150 W. Main St #100', '6-May-2014', '43F6BE8576FFC376852574CF005E3FC0']
["'Chick-fil-A", '', '1205 N Military Highway', '13-Jun-2014', '5BDECD68B879FA8C8525784E005B9926']
这将继续进行 400 个位置。
结果并不令人满意,因为每一行都是一个属性的单层列表。名称在 row[0] 中,地址在 row[2] 中。这种通过位置引用列的方式可能很模糊。如果我们将结果转换为 SimpleNamespace 对象,我们就可以使用 row.name 和 row.address 语法。
从数据列创建一个简单的 Python 对象
我们真正想要的是一个具有易于记忆的属性名的对象,而不是一系列匿名的列名。这里有一个生成器函数,它将从函数(如 food_table_iter() 函数)产生的值序列中构建 SimpleNamespace 对象:
def food_row_iter( table_iter ):
heading= next(table_iter)
assert ['Name', '', 'Facility Location', 'Last Inspection'] == heading
for row in table_iter:
yield SimpleNamespace(
name= row[0], address= row[2], last_inspection= row[3],
category= row[4]
)
这个函数的参数必须是一个迭代器,如 food_table_iter(get_food_list_by_name())。该函数使用 next(table_iter) 来获取第一行,因为那将只是一堆列标题。我们将断言这些列标题确实是 VDH 数据中的标准列标题。如果断言失败,这可能意味着 VDH 网络数据已更改。
对于第一行之后的每一行,我们通过从每一行中获取特定列并将它们分配给好的名称来构建一个 SimpleNamespace 对象。
我们可以这样使用这个函数:
soup= get_food_list_by_name()
raw_columns= food_table_iter(soup)
for business in food_row_iter( raw_column ):
print( business.name, business.address )
处理现在可以使用好的属性名,例如,business.name,来引用我们从 HTML 页面提取的数据。这使得其余的编程更有意义和清晰。
也很重要的是,我们已经结合了两个生成器函数。food_table_iter() 函数将生成由 HTML 表格行构建的小列表。food_row_iter() 函数期望一个可迭代的列表序列,并将从该列表序列中构建 SimpleNamespace 对象。这定义了一种由较小步骤构建的复合处理管道。从 food_table_iter() 开始的 HTML 表格的每一行都会被 food_row_iter() 触及,并最终由 print() 函数处理。
用地理编码丰富 Python 对象
我们到目前为止得到的诺福克数据只是一个餐馆列表。我们还没有检查分数,也没有有用的地理编码。我们需要将这些详细信息添加到我们在初始列表中找到的每个企业。这意味着为每个企业再进行两次 RESTful 网络服务请求。
地理编码相对简单。我们可以使用一个简单的请求并更新我们用来模拟每个企业的 SimpleNamespace 对象。函数看起来像这样:
def geocode_detail( business ):
scheme_netloc_path = "https://maps.googleapis.com/maps/api/geocode/json"
form = {
"address": business.address + ", Norfolk, VA",
"sensor": "false",
#"key": An API Key, if you signed up for one,
}
query = urllib.parse.urlencode( form, safe="," )
with urllib.request.urlopen( scheme_netloc_path+"?"+query ) as geocode:
response= json.loads( geocode.read().decode("UTF-8") )
lat_lon = response['results'][0]['geometry']['location']
business.latitude= lat_lon['lat']
business.longitude= lat_lon['lng']
return business
我们正在使用之前使用过的 Google 地理编码 API。我们进行了一些修改。首先,form 变量中的数据包含来自 SimpleNamespace 对象的 business.address 属性。由于 VDH 地址中没有提供城市和州信息,我们不得不添加这些信息。
与之前的示例一样,我们只取了响应列表中的第一个位置,即 response['results'][0]['geometry']['location'],这是一个包含两个键的字典对象:lat 和 lon。我们通过设置这个小型字典中的值来更新代表我们业务的命名空间,添加了两个更多属性,business.latitude 和 business.longitude。
命名空间对象是可变的,因此这个函数将更新由变量 business 指向的对象。我们还返回了这个对象。return 语句不是必需的,但有时它很有用,因为它允许我们为一系列函数创建流畅的 API。
用健康评分丰富 Python 对象
坏消息是获取健康评分详情还需要更多的 HTML 解析。好消息是这些详情被放置在一个易于定位的 HTML <table> 标签中。我们将这个过程分为两个函数:一个用于获取 BeautifulSoup 对象的 Web 服务请求和更多的 HTML 解析来探索这个 Soup。
这是 URL 请求。这需要我们从之前显示的 food_table_iter() 函数中的 <a> 标签解析出的类别键:
def get_food_facility_history( category_key ):
url_detail= "/Clients/VDH/Norfolk/Norolk_Website.nsf/Food-FacilityHistory"
form = {
"OpenView": "",
"RestrictToCategory": category_key
}
query= urllib.parse.urlencode( form )
with urllib.request.urlopen(scheme_host + url_detail + "?" + query) as data:
soup= BeautifulSoup( data.read() )
return soup
这个请求,就像其他 HTML 请求一样,构建一个查询字符串,打开 URL 响应对象,并解析它以创建一个 BeautifulSoup 对象。我们只对 soup 实例感兴趣。我们返回这个值以用于 HTML 处理。
此外,请注意路径的一部分 Norolk_Website.nsf 存在拼写错误。现场的秘密特工负责在存在这类问题的前提下寻找信息。
我们将使用这个函数来更新我们用来模拟每个业务的 SimpleNamespace 对象。数据提取函数看起来像这样:
def inspection_detail( business ):
soup= get_food_facility_history( business.category )
business.name2= soup.body.h2.text.strip()
table= soup.body.table
for row in table.find_all("tr"):
column = list( row.find_all( "td" ) )
name= column[0].text.strip()
value= column[1].text.strip()
setattr( business, vdh_detail_translate[name], value )
return business
这个函数获取特定业务的 BeautifulSoup 对象。鉴于 Soup,它导航到 <body> 标签内的第一个 <h2> 标签。这应该重复业务名称。我们使用这个名称的第二个副本更新了 business 对象。
这个函数还通过 soup.body.table 表达式导航到 <body> 标签内的第一个 <table> 标签。HTML 表格有两列:左列包含标签,右列包含值。
为了解析这种表格,我们使用 table.find_all("tr") 遍历每一行。对于每一行,我们从一个 row.find_all( "td" ) 构建一个列表。这个列表中的第一个项目是包含名称的 <td> 标签。第二个项目是包含值的 <td> 标签。
我们可以使用一个字典 vdh_detail_translate 来将左列中的名称翻译成更好的 Python 属性名称,如下面的代码所示:
vdh_detail_translate = {
'Phone Number:': 'phone_number',
'Facility Type:': 'facility_type',
'# of Priority Foundation Items on Last Inspection:':
'priority_foundation_items',
'# of Priority Items on Last Inspection:': 'priority_items',
'# of Core Items on Last Inspection:': 'core_items',
'# of Critical Violations on Last Inspection:': 'critical_items',
'# of Non-Critical Violations on Last Inspection:': 'non_critical_items',
}
使用这样的字典,我们可以使用表达式vdh_detail_translate[name]来定位一个愉快的属性名(如core_item),而不是在原始 HTML 中显示的长字符串。
我们需要仔细查看用于更新business命名空间的setattr()函数的使用:
setattr( business, vdh_detail_translate[name], value )
在其他函数中,我们使用了一个简单的赋值语句,如business.attribute= value来设置命名空间对象的属性。隐式地,简单的赋值语句实际上意味着setattr(business, 'attribute', value)。我们可以将setattr(object, attribute_string, value)视为 Python 实现简单的variable.attribute= value赋值语句的原因。
在这个函数中,我们不能使用简单的赋值语句,因为属性名是通过翻译查找的字符串。我们可以使用setattr()函数,通过从vdh_detail_translate[name]计算出的属性名字符串来更新业务对象。
结合各个部分
现在,我们可以看看真正的问题:寻找高质量的餐厅。我们可以构建一个组合函数,结合我们之前的功能。这可以成为一个生成器函数,按顺序生成命名空间对象的详细信息,如下面的代码所示:
def choice_iter():
base= SimpleNamespace( address= '333 Waterside Drive' )
geocode_detail( base )
print( base ) # latitude= 36.844305, longitude= -76.29111999999999 )
soup= get_food_list_by_name()
for row in food_row_iter( food_table_iter( soup ) ):
geocode_detail( row )
inspection_detail( row )
row.distance= haversine(
(row.latitude, row.longitude),
(base.latitude, base.longitude) )
yield row
这将构建一个小的对象base来描述我们的基地。该对象最初只有address属性。在应用geocode_detail()函数后,它还将具有纬度和经度。
print()函数将生成如下所示的行:
namespace(address='333 Waterside Drive', latitude=36.844305, longitude=-76.29111999999999)
get_food_list_by_name()函数将获取一批餐厅。我们使用food_table_iter()获取 HTML 表格,并使用food_row_iter()从 HTML 表格构建单个SimpleNamespace对象。然后我们对这些SimpleNamespace对象进行一些更新,以提供餐厅检查结果和地理编码信息。我们再次更新对象,以添加从我们的基地到餐厅的距离。
最后,我们返回一个详细丰富的命名空间对象,它代表了我们了解一个企业所需知道的一切。
给定这个对象序列,我们可以应用一些过滤器来排除距离超过 0.75 英里或报告问题超过一个的地方:
for business in choice_iter():
if business.distance > .75: continue
if business.priority_foundation_items > 1: continue
if business.priority_items > 1: continue
if business.core_items > 1: continue
print( business )
此脚本将对每个响应应用四个不同的过滤器。例如,如果业务太远,continue语句将结束此项目的处理:for语句将前进到下一个。如果业务有太多项目,continue语句将拒绝此业务并前进到下一个项目。只有通过所有四个测试的业务才会被打印。
注意,我们通过 geocode_detail() 和 inspection_detail() 函数低效地处理了每个商业。一个更有效的算法会在处理早期应用距离过滤器。如果我们立即拒绝距离太远的地方,我们只需要为足够近的地方获取详细的餐厅健康数据。
这个例子序列的重要之处在于,我们集成了来自两个不同网络服务的数据,并将它们整合到我们自己的增值智能处理中。
与干净的数据门户合作
清洁数据门户的一个好例子是芝加哥市。我们可以通过简单的 URL 获取餐厅检查数据:
data.cityofchicago.org/api/views/4ijn-s7e5/rows.json?accessType=DOWNLOAD
这将下载所有餐厅检查信息,以整洁、易于解析的 JSON 文档形式。唯一的问题是大小。它包含超过 83,000 次检查,下载时间非常长。如果我们应用过滤器(例如,只检查今年完成的),我们可以将文档缩减到可管理的尺寸。有关支持的各类过滤器的更多详细信息,请参阅dev.socrata.com/docs/queries.html。
可用的复杂性相当多。我们将基于检查日期定义一个简单的过滤器,以限制自己只查看可用的餐厅检查子集。
获取数据的函数看起来是这样的:
def get_chicago_json():
scheme_netloc_path= "https://data.cityofchicago.org/api/views/4ijn-s7e5/rows.json"
form = {
"accessType": "DOWNLOAD",
"$where": "inspection_date>2014-01-01",
}
query= urllib.parse.urlencode(form)
with urllib.request.urlopen( scheme_netloc_path+"?"+query ) as data:
with open("chicago_data.json","w") as output:
output.write( data.read() )
schem_netloc_path 变量在路径中包含两个有趣的细节。4ijn-s7e5 是我们正在寻找的数据集的内部标识符,而 rows.json 指定了我们想要的数据格式。
我们构建的表单包括一个 $where 子句,这将减少数据量,仅保留最近的检查报告。Socrata API 页面显示我们在这里有很大的灵活性。
与其他网络服务请求一样,我们创建了一个查询,并使用 urllib.request.urlopen() 函数发出请求。我们打开了一个名为 chicago_data.json 的输出文件,并将文档写入该文件以进行进一步处理。这样我们就无需反复检索数据,因为它变化并不快。
我们通过嵌套的 with 语句进行处理,以确保文件被关闭并且网络资源得到适当释放。
从 JSON 文档创建简单的 Python 对象
JSON 文档包含大量的单个字典对象。虽然字典是一个方便的通用结构,但其语法有点笨拙。必须使用 object['some_key'] 是尴尬的。使用 SimpleNamespace 对象和愉快的属性名称会更方便。使用 object.some_key 会更愉快。
这里有一个函数,它将遍历包含所有检查详细信息的庞大 JSON 文档:
def food_row_iter():
with open( "chicago_data.json", encoding="UTF-8" ) as data_file:
inspections = json.load( data_file )
headings = [item['fieldName']
for item in inspections["meta"]["view"]["columns"] ]
print( headings )
for row in inspections["data"]:
data= SimpleNamespace(
**dict( zip( headings, row ) )
)
yield data
我们已经从源数据中的每一行构建了一个SimpleNamespace对象。JSON 文档的数据,在inspections["data"]中,是一个列表的列表。它很难解释,因为我们需要知道每个相关字段的定位。
我们根据在inspections["meta"]["view"]["columns"]中找到的字段名称创建了一个标题列表。字段名称似乎都是有效的 Python 变量名称,并且将作为SimpleNamespace对象中的良好 Python 属性名称。
给定这个标题列表,我们然后可以使用zip()函数来交错标题和每行找到的数据。这个由两个元组组成的序列可以通过使用dict(zip(headings, row))来创建一个字典。然后可以使用这个字典来构建SimpleNamespace对象。
**语法指定字典中的项将成为SimpleNamespace的单独关键字参数。这将优雅地将如{'zip': '60608', 'results': 'Fail', 'city': 'CHICAGO', ... }这样的字典转换为SimpleNamespace对象,就像我们写了SimpleNamespace(zip='60608', results='Fail', city='CHICAGO', ... )一样。
一旦我们有一个SimpleNamespace对象的序列,我们可以进行一些小的更新,使它们更容易处理。这里有一个函数,它对每个对象进行了一些调整:
def parse_details( business ):
business.latitude= float(business.latitude)
business.longitude= float(business.longitude)
if business.violations is None:
business.details = []
else:
business.details = [ v.strip() for v in business.violations.split("|") ]
return business
我们已经将经度和纬度值从字符串转换为浮点数。我们需要这样做才能正确使用haversine()函数来计算从我们的保密基地的距离。我们还已将business.violations值拆分为详细违规的列表。我们不清楚我们会做什么,但这可能有助于理解business.res ults值。
组合不同的部分
我们可以将处理组合成一个与之前在组合各个部分部分中显示的choice_iter()函数非常相似的功能。想法是创建看起来相似但以不同的源数据开始的代码。
这将遍历餐厅选择,取决于是否有更新过的SimpleNamespace对象:
def choice_iter():
base= SimpleNamespace( address="3420 W GRACE ST",
city= "CHICAGO", state="IL", zip="60618",
latitude=41.9503, longitude=-87.7138)
for row in food_row_iter():
try:
parse_details( row )
row.distance= haversine(
(row.latitude, row.longitude),
(base.latitude, base.longitude) )
yield row
except TypeError:
pass
# print( "problems with", row.dba_name, row.address )
这个函数定义了我们在 3420 W Grace St 的保密基地。我们已经计算出了纬度和经度,因此不需要为该位置进行地理编码请求。
对于food_row_iter()产生的每一行,我们使用了parse_details()来更新该行。我们需要使用try:块,因为一些行有无效的(或缺失的)纬度和经度信息。当我们尝试计算float(None)时,我们得到一个TypeError异常。我们只是跳过了这些位置。我们可以单独对它们进行地理编码,但这是芝加哥:街对面可能还有一家更好的餐厅。
这个函数的结果是一系列包含从我们的基地到距离和健康代码检查详情的对象。例如,我们可能应用一些过滤器来排除超过 0.25 英里远的地点或那些状态为Fail的地点:
for business in choice_iter():
if business.distance > .25: continue
if business.results == "Fail": continue
print( business.dba_name,
business.address, business.results,
len(business.details) )
这些例子的重要之处在于,我们利用了网络来源的数据,通过自己的智能处理增加了原始数据的价值。我们还把几个单独的步骤组合成一个更复杂的复合函数。
最终步骤
现在我们已经找到了可以见面的地方,我们还有两件事要做。首先,我们需要为我们选择的位置创建一个合适的网格代码。NAC 代码相当简洁。我们只需要与我们的信息提供者就我们将使用哪个代码达成一致。
其次,我们需要使用我们来自第三章的隐写术脚本,使用隐写术编码秘密信息,在图像中隐藏信息。同样,我们需要确保我们的信息提供者能够找到图像中的编码信息。
我们将把这些最终处理步骤的设计留给你自己来完成。
理解数据 - 模式和元数据
数据由额外的数据描述,我们通常称之为元数据。一个基本的数据可能为 6371。如果没有一些元数据,我们无法知道这代表什么。至少,元数据必须包括测量单位(在这种情况下为千米)以及被测量的对象(地球的平均半径)。
在不太客观的数据情况下,可能没有单位,而是一个可能的值域。对于餐馆来说,可能是一个 A-B-C 评分或通过-不通过的结果。追踪元数据以解释实际数据是很重要的。
另一个需要考虑的问题是模式问题。一组数据应由一些基本实体的多个实例组成。在我们的案例中,实体是特定餐馆的最新健康检查结果。如果每个实例都有一个一致的属性集合,我们可以称这些属性集合为该数据集的模式。
在某些情况下,数据可能不一致。可能存在多个模式,或者模式可能非常复杂,具有选项和替代方案。如果有良好的元数据,它应该解释模式。
芝加哥市的数据为餐馆健康检查信息提供了非常整洁和完整的元数据描述。我们可以在data.cityofchicago.org/api/assets/BAD5301B-681A-4202-9D25-51B2CAE672FF?download=true上阅读它。它解释了分配给设施的风险类别和最终结果(通过、有条件通过、不通过)。注意这个长而丑陋的 URL;像这样的不透明路径通常不是一个好主意。
弗吉尼亚州卫生部门的数据并不那么整洁或完整。我们最终可以弄清楚数据似乎意味着什么。要完全确定,我们需要联系数据管理员,以确切了解每个属性的含义。这涉及到与州级卫生部门的电子邮件交流。在数据名称模糊的情况下,现场代理可能觉得这种额外的工作是必要的。
摘要
在本章中,我们学习了如何使用网络服务执行地理编码和反向地理编码。我们还使用了网络服务来获取大量公开可用的信息。
我们使用math库实现了哈弗辛公式来计算地点之间的距离。我们看到了一些复杂的处理、编码和解码技术,并使用它们来缩写网格位置。
我们在数据收集方面看到了更多使用 BeautifulSoup HTML 解析器的技术。我们结合了多个网络服务,创建了真正复杂的应用程序,这些应用程序集成了地理编码和数据分析。
在下一章中,我们将通过执行更复杂的数据分析来提高灵敏度,使用更深入的统计技术。我们将计算平均值、众数和中位数,并查看数据项之间的相关性。
第五章。间谍大师的更敏感分析
我们之前的间谍任务大多集中在大量数据收集和处理上。HQ 并不总是需要细节。有时它需要摘要和评估。这意味着计算集中趋势、摘要、趋势和相关性,这意味着我们需要编写更复杂的算法。
我们将绕过一些非常强大的统计算法的边界。一旦我们越过这个前沿,我们就需要更强大的工具。为了支持复杂的数值处理,请检查www.numpy.org。对于某些分析,我们可能更成功地使用 SciPy 包(www.scipy.org)。一本好的参考书是 Learning SciPy for Numerical and Scientific Computing,作者 Francisco J. Blanco-Silva,出版社 Packt Publishing(www.packtpub.com/learning-scipy-for-numerical-and-scientific-computing/book)。
我们可能会被拉入的另一个方向包括自然语言文档的分析。报告、演讲、书籍和文章有时与基本的事实和数字一样重要。如果我们想处理文字和语言,我们需要使用自然语言工具包(NLTK)。更多信息可以在www.nltk.org找到。
在本章中,我们将探讨几个更高级的主题,特工需要掌握,例如:
-
计算我们所收集数据的集中趋势——平均值、中位数和众数。
-
询问 CSV 文件以提取信息。
-
更多使用 Python 生成器函数的技巧和技术。
-
设计高级结构,如 Python 模块、库和应用。
-
类定义的快速介绍。
-
计算标准差、标准化分数和相关性系数。这种分析为情报资产增加了价值。任何秘密特工都可以挖掘出原始数据。提供有用的摘要需要真正的技能。
-
如何使用 doctest 确保这些更复杂的算法真正有效。软件错误的产生会对报告数据的整体质量提出严重质疑。
成为特工并不全是汽车追逐和豪华餐厅里令人困惑的鸡尾酒配方。摇匀还是搅拌?谁能记得?
有时,我们需要处理一些相当复杂的问题,这些问题是 HQ 分配给我们的。我们如何处理人均奶酪消费、床上意外窒息和勒死,以及土木工程博士学位的数量?我们应该应用哪些 Python 组件来解决这个问题?
创建统计摘要。
一种基本的统计摘要类型是集中趋势的度量。这个主题有几个变体;平均值、众数和中位数,如下所述:
-
平均值,也称为平均值,将所有值组合成一个单一值。
-
中位数是中间值——数据必须排序以找到中间的值
-
众数是最常见的值
这些都不完美地描述了一组数据。真正随机的数据通常可以通过平均值来概括。然而,非随机数据可以通过中位数更好地概括。对于连续数据,每个值可能与其他值略有不同。在一个小样本集中,每个测量值可能都是唯一的,这使得众数变得没有意义。
因此,我们需要算法来计算这三个基本摘要的所有内容。首先,我们需要一些数据来处理。
在第二章中,获取情报数据,HQ 要求我们收集奶酪消费数据。我们使用了 URL www.ers.usda.gov/datafiles/Dairy_Data/chezcon_1_.xls。
很遗憾,数据格式我们无法轻易自动化,迫使我们复制粘贴年度奶酪消费数据。这就是我们得到的结果。希望复制粘贴过程中引入的错误不多。以下是我们收集到的数据:
year_cheese = [(2000, 29.87), (2001, 30.12), (2002, 30.6), (2003, 30.66),
(2004, 31.33), (2005, 32.62), (2006, 32.73), (2007, 33.5),
(2008, 32.84), (2009, 33.02), (2010, 32.92), (2011, 33.27),
(2012, 33.51)]
这将作为一个方便的数据集,我们可以使用。
注意,我们可以在>>>提示符下多行输入这些内容。Python 需要看到匹配的[和]对,才会认为语句已完整。匹配的[]规则允许用户舒适地输入长语句。
解析原始数据文件
我们被提供了使用 ICD 代码 W75 表示的死亡原因,即床上意外窒息和勒颈。然而,HQ 对此数据的含义并不完全清楚。然而,它已经变得很重要。我们访问了wonder.cdc.gov网站,以获取按年份汇总的死亡原因摘要。
我们最终得到了一个以如下方式开始的文件:
"Notes" "Cause of death" "Cause of death Code" "Year" "Year Code" Deaths Population Crude Rate
"Accidental suffocation and strangulation in bed" "W75" "2000" "2000" 327 281421906 0.1
"Accidental suffocation and strangulation in bed" "W75" "2001" "2001" 456 284968955 0.2
… etc. …
处理这个文件有点痛苦。它几乎——但并不完全——符合 CSV 格式。确实,逗号不多,但在 Python 中,这些逗号被编码为\t。这些字符足以构成一个 CSV 文件,其中制表符扮演了逗号的角色。
我们可以使用 Python 的csv模块和\t分隔符来读取这个文件:
import csv
with open( "Cause of Death by Year.txt" ) as source:
rdr= csv.DictReader( source, delimiter="\t" )
for row in rdr:
if row['Notes'] == "---": break
print(row)
这段代码将创建一个使用\t分隔符的csv.DictReader对象,而不是默认的,分隔符。一旦我们有了使用\t字符的读取器,我们就可以遍历文档中的行。每一行将显示为一个字典。第一行中找到的列标题将是字典中项的键。
我们使用row['Notes']表达式从每一行的Notes列获取值。如果注释等于---,这是数据的脚注的开始。接下来是大量的元数据。
结果数据集可以很容易地概括。首先,我们将创建一个生成器函数来解析我们的数据:
def deaths():
with open( "Cause of Death by Year.txt" ) as source:
rdr= csv.DictReader( source, delimiter="\t" )
for row in rdr:
if row['Notes'] == "Total": break
yield int(row['Year']), int(row['Deaths'])
我们用yield语句替换了print()函数。我们还用Total替换了---来从数据中剪除总计。我们可以自己计算总计。最后,我们将年份和死亡人数转换为整数值,以便我们可以对它们进行计算。
这个函数将遍历数据的不同行,产生包含年份和死亡人数的两个元组。
一旦我们有了这个生成器函数,我们就可以这样收集摘要:
year_deaths = list( deaths() )
我们得到的结果如下:
[(2000, 327), (2001, 456), (2002, 509), (2003, 497),
(2004, 596), (2005, 573), (2006, 661), (2007, 741),
(2008, 809), (2009, 717), (2010, 684)]
这似乎就是他们正在寻找的数据。它为我们提供了更多的工作数据。
寻找平均值
均值是通过一个看起来令人畏惧的公式定义的,它看起来像
。虽然公式看起来很复杂,但它的各个部分都是 Python 的一流内置函数。
大写的西格玛
是数学上对 Python sum()函数的表述。
给定一个值列表,均值是这样的:
def mean( values ):
return sum(values)/len(values)
我们提供两组数据作为包含年份和金额的两个元组。我们需要收集年份,将时间保留以供以后使用。我们可以使用一个简单的生成器函数来完成这个任务。我们可以使用表达式cheese for year, cheese in year_cheese来分离每个两个元组中的奶酪部分。
当我们使用生成器与我们的mean()函数一起使用时,会发生以下情况:
>>> mean( cheese for year, cheese in year_cheese )
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 2, in mean
TypeError: object of type 'generator' has no len()
等等。什么?
简单的生成器怎么可能不起作用呢?
实际上,它确实有效。但它并没有做我们假设的事情。
理解生成器表达式
有三个重要的规则适用于 Python 生成器:
-
许多(但不全是)函数可以与生成器对象一起工作。然而,有些函数与生成器不太兼容;它们需要一个序列对象。
-
生成器产生的对象只有在绝对必要时才会创建。我们可以将生成器描述为“懒惰”。例如,列表实际上包含对象。生成器表达式可以像列表一样操作,但对象实际上只有在需要时才会创建。
-
生成器函数只能使用一次。列表可以无限期地重复使用。
第一个限制特别适用于len()函数。这个函数适用于列表、元组和集合。然而,它不适用于生成器。我们无法知道生成器最终将创建多少项,因此len()无法返回大小。
第二个限制主要在尝试打印生成器的结果时相关。我们会看到类似<generator object <genexpr> at 0x1007b4460>这样的内容,直到我们实际评估生成器并创建对象。
第三个限制不太明显。我们需要一个例子。让我们尝试通过定义一个count()函数来绕过len()问题,该函数计算生成器函数产生的项数:
def count( values ):
return sum( 1 for x in values )
这将累加一系列 1,而不是实际值序列。
我们可以这样测试它:
>>> count( cheese for year, cheese in year_cheese )
13
这看起来似乎没问题,对吧?基于这个实验,我们可以尝试像这样重写mean()函数:
def mean2( values ):
return sum(values)/count(values)
我们使用了count(),它与生成器表达式一起工作,而不是len()。
当我们使用它时,我们会得到一个ZeroDivisionError: float division by zero错误。为什么count()在mean()的上下文中不起作用?
这揭示了单次使用规则。sum()函数消耗了生成器表达式。当count()函数需要评估时,没有数据了。生成器为空,sum( 1 for x in [] )为零。
我们能做什么?
我们有三个选择,如下所示:
-
我们可以编写自己的更复杂的
sum(),它从生成器的一次遍历中产生总和和计数。 -
或者,我们可以使用
itertools库将一个tee适配器放入生成器管道中,这样我们就有两个可迭代的副本。这实际上是一个非常高效的解决方案,但对于现场特工来说,这有点高级。 -
更简单地说,我们可以从生成器创建一个实际的列表对象。我们可以使用
list()函数或用[]包裹生成器表达式。
前两个选择对于我们来说太复杂了。第三个选择非常简单。我们可以这样做:
>>> mean( [cheese for year, cheese in year_cheese] )
32.076153846153844
>>> mean( [death for year, death in year_deaths] )
597.2727272727273
通过包含[],我们从一个生成器创建了一个列表对象。我们可以从列表对象中获取sum()和len()。这种方法非常好用。
它指出了编写函数文档字符串的重要性。我们真的需要这样做:
def mean(values):
"""Mean of a sequence (doesn't work with an iterable)"""
return sum(values)/len(values)
我们在这里提醒一下,该函数与一个序列对象一起工作,但它不与生成器表达式或其他仅可迭代的对象一起工作。当我们使用help(mean)时,我们会看到我们在文档字符串中留下的提醒。
这里有一个概念层次。可迭代性是许多 Python 对象的一个非常通用的特性。序列是许多可迭代 Python 对象中的一种。
查找中间的值
中位数是在一组排序后的值中间的值。为了找到中位数,我们需要对数据进行排序。
这是一个计算序列中位数的简单函数:
def median(values):
s = sorted(values)
if len(s) % 2 == 1: # Odd
return s[len(s)//2]
else:
mid= len(s)//2
return (s[mid-1]+s[mid])/2
这包括当样本数量为偶数时,平均两个中间值的常用技术。
我们使用len(s)%2来确定序列长度是否为奇数。在两个不同的地方,我们计算len(s)//2;看起来我们可能能够使用divmod()函数简化一些事情。
我们可以这样做:
mid, odd = divmod(len(s), 2)
这个更改移除了一点点重复计算len(s)//2的代码,但这真的更清晰吗?
这里有两个潜在问题与排序相关的开销:
-
首先,排序意味着有很多项目之间的比较。随着列表大小的增加,比较的项目数量增长得更快。此外,
sorted()函数会产生序列的副本,在处理非常大的列表时可能会浪费内存。 -
替代方案是对快速选择算法的一种巧妙变体。对于现场特工来说,这种复杂程度是不必要的。更多信息请参阅
en.wikipedia.org/wiki/Quickselect。
寻找最流行的值
模式值是集合中最受欢迎的单个值。我们可以使用 collections 模块中的 Counter 类来计算这个值。
这里有一个 mode 函数:
from collections import Counter
def mode(values):
c = Counter( values )
mode_value, count = c.most_common(1)[0]
return mode_value
Counter 类的 most_common() 方法返回一个包含两个元素的元组序列。每个元组包含一个值和该值出现的次数。就我们的目的而言,我们只需要值,因此我们必须从两个元素的元组序列中取出第一个元素。然后,我们必须将这对值和计数器分开。
演示的问题在于我们的数据集非常小,没有合适的模式。这里有一个人为的例子:
>>> mode( [1, 2, 3, 3, 4] )
3
这证明了 mode 函数是有效的,尽管它对我们奶酪消耗和死亡率数据来说没有意义。
创建 Python 模块和应用程序
我们在 Python 库中大量使用了模块。此外,我们还添加了几个包,包括 Pillow 和 BeautifulSoup。应该出现这样的问题,我们能否创建自己的模块?
答案当然是 是的。Python 模块只是一个文件。结果证明,每个示例脚本都是一个模块。我们可以更深入地了解如何创建我们自己的可重用编程模块。当我们查看 Python 程序时,我们会观察到三种类型的文件:
-
纯定义性的库模块
-
执行我们应用程序实际工作的应用程序模块
-
同时是应用程序和可以作为库使用的混合模块
创建 Python 模块的基本要素是将顶层脚本的 实际工作 与支持这一实际工作的各种定义分开。我们所有的定义示例都是使用 def 语句创建的函数。其他导入定义的例子是 class 定义,我们将在下一节中讨论。
创建和使用模块
要创建仅包含定义的模块,我们只需将所有函数和类定义放入一个文件中。我们必须给文件一个可接受的 Python 变量名。这意味着文件名应该看起来像 Python 变量;字母、数字和 _ 是完全合法的。Python 使用的运算符字符(+、-、/ 等)可能由我们的操作系统允许作为文件名,但这些字符不能用于命名模块文件。
文件名必须以 .py 结尾。这不是模块名称的一部分;这是为了操作系统的便利。
我们可能会将我们的统计函数收集到一个名为 stats.py 的文件中。此文件定义了一个名为 stats 的模块。
我们可以导入整个函数集或单个函数,或者我们可以将整个模块导入。使用以下代码:
>>> from stats import *
通过这种方式,我们导入了在 stats 模块中定义的所有函数(和类)。我们可以简单地使用如 mean( some_list ) 这样的名称。
考虑我们使用这个:
>>> from stats import mean, median
我们从 stats 模块中导入了两个特定的函数。我们忽略了该模块中可能存在的任何其他定义。
我们还可以使用以下方法:
>>> import stats
这将导入模块,但不会将任何名称放入我们通常使用的全局命名空间中。stats模块中的所有名称都必须使用限定名称访问,例如stats.mean( some_list )。在非常复杂的脚本中,使用限定名称有助于澄清特定函数或类是在哪里定义的。
创建应用程序模块
创建具有命令行界面(CLI)的应用程序的最简单方法是编写一个文件,并在命令行中运行它。考虑以下示例:
python3 basic_stats.py
当我们在终端窗口或命令窗口中输入时,我们使用 OS 的python3命令并提供一个文件名。在 Windows 中,python.exe有时用于 Python 3,因此命令可能是python basic_stats.py。在大多数其他操作系统上,通常会有python3和python3.3命令。在 Mac OS X 上,python命令可能指的是 Mac OS X 的一部分的旧Python2.7。
我们可以通过使用python -V命令来确定与名称python绑定的版本。
如前所述,我们希望将定义分开到文件中,然后将实际工作放在另一个文件中。当我们查看basic_stats.py内部时,我们可能会发现以下内容:
"""Chapter 5 example 2.
Import stats library functions from ch_5_ex_1 module.
Import data acquisition from ch_5_ex_1 module.
Compute some simple descriptive statistics.
"""
from ch_5_ex_1 import mean, mode, median
from ch_5_ex_1 import get_deaths, get_cheese
year_deaths = list( get_deaths() )
years = list( year for year, death in year_deaths )
deaths= list( death for year, death in year_deaths )
print( "Year Range", min(years), "to", max(years) )
print( "Average Deaths {:.2f}".format( mean( deaths ) ) )
year_cheese= get_cheese()
print( "Average Cheese Consumption",
mean( [cheese for year, cheese in year_cheese] ) )
文件以一个三引号字符串开头,就像函数的 docstring 一样,这是模块的 docstring。我们从另一个模块导入了某些函数。
然后,我们使用导入的函数完成了一些处理。这是简单命令行模块的常见结构。
我们也可以通过命令python3 -m basic_stats运行此操作。这将使用 Python 的内部搜索路径来定位模块,然后运行该模块。运行模块与运行文件略有不同,但最终效果相同;文件通过print()语句产生我们设计的输出。有关-m选项如何工作的详细信息,请参阅runpy模块的文档。
创建混合模块
对于之前显示的basic_stats.py模块,我们可以进行两个重要的改进:
-
首先,我们将所有处理放入一个函数定义中。我们称它为
analyze_cheese_deaths。 -
第二个是添加一个
if语句来确定模块被使用的上下文。
这是basic_stats.py的更复杂版本:
"""Chapter 5 example 3.
Import stats library functions from ch_5_ex_1 module.
Import data acquisition from ch_5_ex_1 module.
Compute some simple descriptive statistics.
"""
from ch_5_ex_1 import mean, mode, median
from ch_5_ex_1 import get_deaths, get_cheese
def analyze_cheese_deaths():
year_deaths = list( get_deaths() )
years = list( year for year, death in year_deaths )
deaths= list( death for year, death in year_deaths )
print( "Year Range", min(years), "to", max(years) )
print( "Average Deaths {:.2f}".format( mean( deaths ) ) )
year_cheese= get_cheese()
print( "Average Cheese Consumption",
mean( [cheese for year, cheese in year_cheese] ) )
if __name__ == "__main__":
analyze_cheese_deaths()
创建一个函数定义来封装实际工作,这为我们提供了扩展或重用此脚本的方法。我们可以比重用顶级脚本更容易地重用函数定义(通过import)。
__name__变量是一个 Python 设置的全球变量,用于显示处理上下文。顶级模块——在命令行上命名的模块——的__name__变量设置为__main__。所有其他模块导入的__name__变量都设置为模块名称。
是的,全局变量__name__前后都有双下划线。这标志着它是 Python 机制的一部分。同样,主模块名称的字符串值__main__也涉及双下划线。
这种技术允许我们创建一个可以作为命令运行并导入以提供定义的模块。想法是促进可重用编程。每次我们着手解决问题时,我们不需要重新发明轮子和其他相关技术。我们应该导入先前的工作并在此基础上构建。
创建我们自己的对象类
HQ 要求我们获取的两个数据值列表——奶酪消费和 W75 死亡人数——形成了两个非常相似的对象。它们似乎是同一类事物的两个实例。
在这种情况下,事物类似乎具有年度统计。它们是具有年份和测量的一致结构的集合。这两个年度统计对象都有一个共同的运算集。实际上,这些运算与测量紧密相关,并且与年份数字完全没有关系。
我们的统计函数集合与我们的数据几乎没有紧密的联系。
我们可以通过类定义来提高数据结构和处理之间的绑定。如果我们定义了一个可以称为“年度统计”的对象类的通用特征,我们可以创建这个类的两个实例,并使用定义的方法处理每个实例的独特数据。我们可以通过拥有多个同一类的对象来轻松重用我们的方法函数。
Python 中的类定义是一系列方法函数的集合。每个方法函数定义都有一个额外的参数变量,通常命名为self,它必须是每个函数的第一个参数。self变量是我们访问对象类每个实例的独特属性值的方式。
这是我们可能定义一个用于获取 HQ 要求的简单统计的类的示例:
from collections import Counter
class AnnualStats:
def __init__(self, year_measure):
self.year_measure = list(year_measure)
self.data = list(v for yr, v in self.year_measure)
self.counter= Counter(self.data)
def __repr__(self):
return repr(self.year_measure)
def min_year(self):
return min( yr for yr, v in self.year_measure )
def max_year(self):
return max( yr for yr, v in self.year_measure )
def mean(self):
return sum(self.data)/len(self.data)
def median(self):
mid, odd = divmod( len(self.data), 2 )
if odd:
return sorted(self.data)[mid]
else:
pair= sorted(self.data)[mid-1:mid+1]
return sum(pair)/2
def mode(self):
value, count = self.counter.most_common1)[0]
return value
class语句为我们定义提供了一个名称。在类语句缩进的主体中,我们为这个类中的每个方法函数提供def语句。每个def语句都包含实例变量self。
我们定义了两个具有特殊名称的方法,如下所示。这些名称包含双下划线,由 Python 固定,我们必须使用这些确切名称来正确初始化或打印对象:
-
__init__()方法在创建实例时隐式地用于初始化。我们将在下一节中展示实例创建的示例。当我们创建AnnualStats对象时,会创建三个内部属性,如下所示:-
self.year_measure实例变量包含作为参数值提供的数据。 -
self.data实例变量仅包含从年份数据二元组中提取的数据值。 -
self.counter实例变量包含一个由数据值构建的Counter对象
-
-
当我们尝试打印对象时,会隐式地使用
__repr__()方法。我们将内部self.year_measure实例变量的表示作为整个实例的表示返回。
其他方法函数看起来与之前展示的独立函数定义相似。这些方法函数都依赖于__init__()方法正确初始化实例变量。这些名称完全是我们软件设计的一部分;我们可以将它们命名为任何语法上合法且有意义的内容。
使用类定义
下面是如何使用我们的AnnualStats类定义:
from ch_5_ex_1 import get_deaths, get_cheese
deaths = AnnualStats( get_deaths() )
cheese = AnnualStats( get_cheese() )
print("Year Range", deaths.min_year(), deaths.max_year())
print("Average W75 Deaths", deaths.mean())
print("Median Cheese Consumption", cheese.median())
print("Mean Cheese Consumption", cheese.mean())
print(deaths )
我们创建了AnnualStats类的两个实例。deaths对象是由年份-死亡数据集构建的AnnualStats对象。同样,奶酪对象是由奶酪消费数据集构建的AnnualStats对象。
在这两种情况下,AnnualStats.__init__()方法使用给定的参数值进行评估。当我们评估AnnualStats( get_deaths() )时,get_deaths()的结果作为year_measure参数的值提供给AnnualStats.__init__()。然后__init__()方法的语句将设置三个实例变量的值。
当我们评估deaths.min_year()时,这将评估AnnualStats.min_year()方法函数。self变量将是deaths。这意味着self.year_measure表示由get_deaths()创建的原始对象。
当我们评估deaths.mean()时,这将评估AnnualStats.min_year()方法函数,其中self变量指向deaths。这意味着deaths.data是我们从get_deaths()创建的原始对象中导出的排序序列。
每个(deaths,cheese)实例都指的是由__init__()方法创建的实例变量。一个类封装了使用各种实例变量的方法函数的处理。封装的概念可以帮助我们设计更紧密聚焦且不太可能出现混淆性错误或不一致的软件。
比较和相关性
一个重要的统计问题集中在变量之间的相关性上。我们经常想知道两个值序列是否相关。如果我们有相关的变量,也许我们找到了一个有趣的因果关系。我们可能能够使用一个变量来预测另一个变量的值。我们也许还能证明它们是独立的,并且彼此无关。
这个统计工具的必要工具是相关系数。我们有几种计算这个系数的方法。一种解决方案是从以下链接下载 NumPy 或 SciPy:
-
docs.scipy.org/doc/scipy/reference/generated/scipy.stats.pearsonr.html -
docs.scipy.org/doc/numpy/reference/generated/numpy.corrcoef.html
然而,相关算法并不复杂。实现这两个计算将建立我们的基本数据收集间谍技能。我们将构建一些更基本的统计函数。然后,我们将构建相关计算,这将依赖于其他统计函数。
重要的数值依赖于计算平均值和标准差。我们之前已经讨论了平均值计算。我们将把标准差添加到我们的技巧包中。给定标准差,我们可以标准化每个值。我们将使用标准差作为距离的度量来计算与平均值的距离。然后,我们可以比较标准化分数,以查看两组数据是否相关。
计算标准差
为了计算相关系数,我们需要为数据集提供另一个描述性统计量:标准差。这是衡量数据分散程度的一个指标。当我们计算平均值时,我们找到了数据的一个中心。下一个问题是,值是如何紧密围绕中心的?
如果标准差小,数据紧密聚集。如果标准差大,数据分布广泛。标准差计算给出了一个数值范围,大约包含三分之二的数据值。
拥有标准差让我们能够发现异常数据。例如,平均奶酪消费量为每人 31.8 磅。标准差为 1.27 磅。我们预计大部分数据将聚集在 31.8 ± 1.27 的范围内,即介于 30.53 和 33.07 之间。如果我们的信息提供者试图告诉我们 2012 年的人均奶酪消费量为 36 磅,我们有充分的理由对报告表示怀疑。
计算标准差的主题有一些变体。还有一些与是否拥有整个总体或只是样本相关的统计细微差别。这里有一个标准公式
。符号
代表某个变量的标准差
。符号
代表变量的平均值。
我们有一个方法函数,mean(),它计算
值。我们需要实现标准差公式。
标准差公式使用了math.sqrt()和sum()函数。我们将在脚本中依赖import math。
我们可以直接将方程式翻译成 Python。以下是我们可以添加到AnnualStat类中的方法函数:
def stddev(self):
μ_x = self.mean()
n = len(self.data)
σ_x= math.sqrt( sum( (x-μ_x)**2 for x in self.data )/n )
return σ_x
我们评估了mean()方法函数以获取均值,如
所示,并将其分配给μ_x(是的,希腊字母对于 Python 变量名是合法的;如果你的操作系统没有提供对扩展 Unicode 字符的快速访问,你可能想使用mu代替)。我们还评估了len(data)以获取集合中元素的数量n。
我们可以将数学语言直接翻译成 Python。例如,
变成了sum((x-μ_x)**2 for x in self.data)。这种数学符号与 Python 之间的直接匹配使得验证 Python 编程是否与数学抽象相匹配变得容易。
这里是标准差的一个基于略微不同公式的版本:
def stddev2(self):
s_0 = sum(1 for x in self.data) # x**0
s_1 = sum(x for x in self.data) # x**1
s_2 = sum(x**2 for x in self.data)
return math.sqrt( s_2/s_0 - (s_1/s_0)**2 )
这有一个优雅的对称性。公式看起来像
。它不再高效或准确。它之所以有点酷,只是因为
,
和
之间的对称性。
计算标准化分数
一旦我们有了标准差,我们就可以将序列中的每个测量值标准化。这种标准化分数有时被称为 Z 分数。它是特定值与均值之间的标准差数。
在
中,标准化分数
是分数
与均值
之差除以标准差
。
如果我们有一个均值为 31.8,
,标准差为 1.27,
,那么一个测量值为 29.87 的值将有一个 Z 分数为-1.519。大约 30%的数据将超出均值 1 个标准差。当我们的信息提供者试图告诉我们人均消费量跳升到 36 磅奶酪时,我们可以计算这个值的 Z 分数,3.307,并建议这不太可能是有效数据。
将我们的价值观标准化以生成分数是生成器表达式的一个很好的用途。我们也将把它添加到我们的类定义中:
def stdscore(self):
μ_x= self.mean()
σ_x= self.stddev()
return [ (x-μ_x)/σ_x for x in self.data ]
我们计算了数据的均值并将其分配给μ_x。我们计算了标准差并将其分配给σ_x。我们使用生成器表达式评估(x-μ_x)/σ_x对于数据中的每个值x。由于生成器在[]中,我们将创建一个新的列表对象,包含标准化分数。
我们可以用以下方式展示它是如何工作的:
print( cheese.stdscore() )
我们将得到以下序列:
[-1.548932453971435, -1.3520949193863403, ... 0.8524854679667219]
比较序列和可迭代对象
当我们查看 stdscore() 方法的结果时,我们可以选择返回什么。在之前的例子中,我们返回了一个新的列表对象。我们实际上并不需要这样做。
我们可以在函数中使用这个序列来返回一个生成器而不是列表:
return ((x-μ_x)/σ_x for x in self.data)
函数的其余部分与之前相同。最好给这个版本一个不同的名字。将旧的函数命名为 stdscore2(),这样我们就可以比较列表和生成器版本。
生成器 stdscore() 函数现在返回一个可以用来生成值的表达式。对于我们的大多数计算,实际列表对象和可迭代值序列之间没有实际差异。
我们会注意到三个差异:
-
首先,我们不能在生成器结果上使用
len()。 -
其次,生成器在我们使用它在一个
for循环中或创建一个列表之前不会生成任何数据。 -
第三,一个可迭代对象只能使用一次。
尝试用这个简单的例子看看它是如何工作的:
print(cheese.stdscore())
我们将看到生成器表达式,而不是生成的值。以下是输出结果:
<generator object <genexpr> at 0x1007b4460>
我们需要这样做,以便将生成的值收集到一个对象中。list() 函数可以很好地完成这项工作。以下是评估生成器并实际生成值的操作方法:
print(list(cheese.stdscore()))
这将评估生成器,生成一个我们可以打印的列表对象。
计算相关系数
在比较两个数据序列时,一个重要的问题是如何评估它们之间的相关性。当一个序列呈上升趋势时,另一个序列是否也是如此?它们是否以相同的速率变化?我们可以通过计算基于标准化分数乘积的系数来测量这种相关性:

在这种情况下,
是每个单独值的标准化分数,
。我们对其他序列做同样的计算,并计算每一对的乘积。各种标准化分数乘积的平均值将在 +1 和 -1 之间。接近 +1 的值意味着两个序列很好地相关。接近 -1 的值意味着序列相互对立。一个序列上升时,另一个序列下降。接近 0 的值意味着序列不相关。
这是一个计算两个 AnnualStat 数据集合实例之间相关性的函数:
def correlation1( d1, d2 ):
n= len(d1.data)
std_score_pairs = zip( d1.stdscore(), d2.stdscore() )
r = sum( x*y for x,y in std_score_pairs )/n
return r
我们使用每个 AnnualStat 对象的 stdscore() 方法创建了一个标准化分数值的序列。
我们使用 zip() 函数创建了一个生成器,它将从两个不同的分数序列中产生二元组。这个乘积序列的平均值是两个序列之间的相关系数。我们通过求和并除以长度 n 来计算平均值。
编写高质量的软件
我们如何知道这些各种统计函数是如何工作的?这可能是非常棘手的编程,有很多机会出错。
确保软件工作的最佳工具是单元测试。单元测试背后的思想是将模块分解成单独的单元——通常是函数或类——并在隔离状态下测试每个单元。Python 为我们提供了两种执行单元测试的方法:
-
将示例放入模块、函数和类的文档字符串中
-
编写单独的
unittest.TestCase类
大多数秘密特工都会对文档字符串测试用例感到非常满意。它们很容易编写。我们将它们放在代码的文档字符串中。当我们使用help()函数时,它们是可见的。
我们通过从交互式 Python 复制和粘贴已知正确结果来创建这些文档字符串测试用例。复制和粘贴将包括>>>提示,以便轻松找到示例。当然,我们也包括预期的输出。一旦我们将这些包含在文档字符串中,doctest模块就会找到并使用这些示例。
在某些情况下,我们需要伪造预期的结果。实际上,在编写任何有效的 Python 代码之前,就已经计算出答案应该是怎样的,这是很常见的。如果我们确信文档字符串示例有预期的正确答案,我们可以利用这一点,并使用它来帮助调试代码。
让我们看看我们之前编写的一个简单函数:
def mean(values):
"""Mean of a sequence (doesn't work with an iterable)
>>> from ch_5_ex_1 import mean
>>> mean( [2, 4, 4, 4, 5, 5, 7, 9])
5.0
"""
return sum(values)/len(values)
我们将示例交互添加到函数的文档字符串中。我们包括了看起来像是复制和粘贴的交互序列,这些交互将测试这个函数。在某些情况下,我们根据我们计划编写的内容而不是已经编写的内容来构建这个序列。
我们可以练习几种不同的方法。最简单的是这个:
python3 -m doctest ch_5_ex_1.py
我们将doctest模块作为顶级主应用程序运行。这个应用程序的单个参数是包含 doctest 示例并粘贴到文档字符串中的 Python 应用程序的名称。
如果一切正常,则没有输出。如果我们好奇,我们可以要求更详细的输出:
python3 -m doctest -v ch_5_ex_1.py
这将产生大量的输出,显示模块文档字符串中找到的每个测试。
其他技术包括构建一个自测试模块和编写一个单独的脚本,该脚本仅运行测试。
构建一个自测试模块和一个测试模块
其中一种效果很好的技术是使用__name__ == "__main__"技术将测试脚本添加到库模块中。我们将评估doctest.testmod()函数来测试模块中定义的函数和类。
它看起来像这样:
if __name__ == "__main__":
import doctest
doctest.testmod()
如果这个模块是从命令行运行的,它就是主模块,全局__name__将被设置为"__main__"。当这是真的时,我们可以导入 doctest 模块并评估doctest.testmod()以确认模块中的其他一切正常。
我们也可以编写一个单独的测试脚本。我们可能称之为"test.py";它可能像这样简短:
import doctest
import ch_5_ex_1
doctest.testmod( ch_5_ex_1 )
这个简短的脚本导入了 doctest 模块。它还导入了我们要测试的模块。
我们使用doctest.testmod()函数在给定的模块中定位 doctest 示例。输出看起来像这样:
TestResults(failed=0, attempted=2)
这证实了有两个>>>示例行,并且一切工作得非常完美。
创建更复杂的测试
有时候,我们必须对 doctest 示例输出保持一定的谨慎。这些情况是 Python 的行为没有详细说明到我们可以复制粘贴交互结果而不考虑我们在做什么的程度。
当与字典和集合集合一起工作时,项的顺序是不保证的。
-
对于字典,doctest 字符串需要包含
sorted()来强制特定的顺序。使用sorted(some_dict.items())而不是简单地使用some_dict是至关重要的。 -
同样的考虑也适用于集合。我们必须使用类似
sorted(some_set)的东西而不是some_set。
一些内部函数,如id()和repr(),可以显示一个物理内存地址,这个地址在每次运行测试时可能不会相同。我们可以包含一个特殊的注释来提醒 doctest 跳过这些细节。我们将包含#doctest: +ELLIPSIS并将 ID 或地址替换为...(三个点)。
我们可能在输出非常长的输出时使用省略号来缩短。
例如,我们可能有一个模块文档字符串如下:
"""Chapter 5, example 1
Some simple statistical functions.
>>> from ch_5_ex_1 import mean, median
>>> data = [2, 4, 4, 4, 5, 5, 7, 9]
>>> data # doctest: +ELLIPSIS
[2, 4..., 9]
>>> mean( data )
5.0
>>> median( data )
4.5
"""
模块文档字符串必须是模块文件中的(几乎)第一行。可能出现在模块文档字符串之前的是一行#!注释。如果存在,#!注释行是针对操作系统 shell 的,它将文件的其余部分标识为 Python 脚本,而不是 shell 脚本。
我们在我们的测试中使用了# doctest: +ELLIPSIS指令。结果并不完整,预期结果中包含"..."以显示 doctest 应该忽略的部分。
浮点值在不同处理器和操作系统之间可能不完全相同。我们必须小心地显示带有格式化或四舍五入的浮点数。我们可能使用"{:.4f}".format(value)或round(value,4)来确保忽略不重要的数字。
将 doctest 案例添加到类定义中
我们研究了模块和函数中的 doctests。我们可以在类定义的几个地方放置 doctests。这是因为我们有几个地方可以放置文档字符串。
整个类可以在顶部有一个文档字符串。它是class语句之后的第一个行。此外,类中的每个单独的方法也可以有自己的私有文档字符串。
例如,我们可以在类定义的开始处包含一个全面的文档字符串:
class AnnualStats:
"""Collect (year, measurement) data for statistical analysis.
>>> from ch_5_ex_4 import AnnualStats
>>> test = AnnualStats( [(2000, 2),
... (2001, 4),
... (2002, 4),
... (2003, 4),
... (2004, 5),
... (2005, 5),
... (2006, 7),
... (2007, 9),] )
...
>>> test.min_year()
2000
>>> test.max_year()
2007
>>> test.mean()
5.0
>>> test.median()
4.5
>>> test.mode()
4
>>> test.stddev()
2.0
>>> list(test.stdscore())
[-1.5, -0.5, -0.5, -0.5, 0.0, 0.0, 1.0, 2.0]
"""
这提供了一个关于此类所有功能的完整概述。
小贴士
我们的数据样本导致标准差恰好为 2.0。这个技巧表明,通过巧妙的设计测试数据,我们可以绕过一些 doctest 浮点数输出的限制。
解决问题——分析一些有趣的数据库
2000 年至 2009 年间,人均奶酪消费与死亡代码 W75(床上意外窒息和勒死)之间的相关系数是多少?
记住,奶酪数据来自www.ers.usda.gov/datafiles/Dairy_Data/chezcon_1_.xls。
这是一段令人烦恼的数据,因为它在专有电子表格格式中。尽管我们不喜欢复制粘贴,但除此之外没有其他简单的方法来获取这些数据。
床上勒死的统计数据来自按年份分组的死亡原因 W75。数据请求过程从wonder.cdc.gov/controller/datarequest/D76开始。将需要一些额外的间谍工作来提交数据请求。如需更多帮助,请查看wonder.cdc.gov/wonder/help/ucd.html。
这种关联为何可能如此之高?
是什么使得奶酪消费和床上勒死之间的这种关联如此令人惊讶?
获取更多数据
HQ 似乎正在研究关于乳制品的理论。奶酪电子表格还有同一时期(2000 年至 2009 年)的莫扎雷拉奶酪消费数据。
我们被要求获取关于这一时期授予的土木工程博士学位的详细信息。
一些初步的间谍活动揭露了这一组数据:
www.nsf.gov/statistics/infbrief/nsf12303/
这是一个难以解析的表格。它稍微复杂一些,因为年份在列中,而我们正在寻找的数据在特定的行中,即th.text == "Civil engineering"的行。整个表格的标题在th.text == "Field"的行中。这意味着导航将相当复杂,以定位本页上正确的表格的Field行和Civil engineering行。
年人均莫扎雷拉奶酪消费量与土木工程博士学位之间有何关联?
这种关联为何可能如此之高?
跟奶酪、死亡和博士学位有什么关系呢?
进一步研究
这是否只是一个偶然的关联?
是否还有其他类似的关联?
我们还能从www.tylervigen.com/学到什么?
摘要
我们看到了如何轻松地将复杂的统计分析实现为简短的 Python 编程片段。我们将基本的统计洞察力应用于我们所有的情报收集。
我们学会了设计 Python 模块。这使我们能够通过我们自己的更专业化的模块扩展 Python 标准库。现在,我们可以轻松地将可重用软件打包成模块,用于我们自己的目的,以及分发到我们的代理网络。
除了设计模块,我们还看到了如何编写测试来确认我们的软件确实工作。除非有正式的单元测试来确认事物是否按正确的方式运行,否则无法信任软件。我们看到了 Python 语言的基本要素、标准库以及相关项目和工具的生态系统。Python 语言相当简单:它只有大约 22 条语句,我们看到了几乎所有这些语句的示例。
在这一点上,每个秘密代理的兴趣领域和专长将开始分化。有众多可供探索的软件包、库和应用领域。
由于我们的重点是让现场代理变得高效,所以我们仔细避免涉及更严肃的软件开发问题。特别是,我们避开了面向对象设计的主题。需要做更复杂处理的代理将需要编写更复杂的软件。一本像《Python 3 Object Oriented Programming》(作者:Dusty Phillips,出版社:Packt Publishing)这样的书对于学习这项重要技术是必不可少的。请查看www.packtpub.com/python-3-object-oriented-programming/book。
代理的兴趣和能力往往会导致不同的方向。一些代理可能想要建立网站;一本像《Python 3 Web Development Beginner's Guide》(作者:Michel Anders,出版社:Packt Publishing)这样的书可能会有所帮助。一些代理可能想要构建交互式应用程序;一本像《Instant Pygame for Python Game Development How-to》(作者:Ivan Idris,出版社:Packt Publishing)这样的书可以帮助掌握 Pygame 框架。这个框架不仅仅适用于游戏。
一些代理可能会追求自然语言处理。一本像《Python 3 Text Processing with NLTK 3 Cookbook》(作者:Jacob Perkins,出版社:Packt Publishing)这样的书可能会有所帮助。其他代理可能会使用Python Multimedia(作者:Ninad Sathaye,出版社:Packt Publishing)或可能使用Practical Maya Programming with Python(作者:Robert Galanakis,出版社:Packt Publishing)来追求更复杂的媒体。对地理空间分析感兴趣的代理可能会追求《Programming ArcGIS 10.1 with Python Cookbook》(作者:Eric Pimpler,出版社:Packt Publishing)。
熟练的代理会发现 Python 可以用于各种任务。







浙公网安备 33010602011771号