可复现的-Python-生物信息学指南-全-

可复现的 Python 生物信息学指南(全)

原文:zh.annas-archive.org/md5/b60bc8f12d7866ac9b399af89a674040

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

编程是一种力量倍增器。我们可以编写计算机程序来摆脱繁琐的手动任务并加速研究。编写任何语言的程序都可能提高您的生产力,但每种语言都有不同的学习曲线和工具,这些工具可以改善或阻碍编程过程。

商界有一句格言说你有三个选择:

  1. 快速

  2. 便宜

选择任意两个。

当涉及到编程语言时,Python 处于一个甜蜜点,因为它快速,因为它相当容易学习和编写一个工作原型的想法——基本上它总是我写任何程序时会使用的第一种语言。我觉得 Python 是便宜的,因为我的程序通常会在像我的笔记本电脑或小型 AWS 实例这样的商品硬件上运行得足够好。然而,我会争辩说,使用 Python 并不一定容易做出的程序,因为语言本身相对宽松。例如,它允许在操作中混合字符和数字,这会导致程序崩溃。

本书是为那些希望了解 Python 最佳实践和工具的初学生生物信息学程序员编写的,例如以下内容:

  • 自 Python 3.6 起,你可以添加类型提示,指示变量应该是类型,如数字或列表,并可以使用mypy工具确保正确使用这些类型。

  • pytest这样的测试框架可以使用好数据和坏数据来测试您的代码,以确保它以某种可预测的方式做出反应。

  • 类似pylintflake8的工具可以找出潜在错误和风格问题,这会使你的程序更难理解。

  • argparse模块可以记录和验证程序的参数。

  • Python 生态系统允许您利用数百个现有模块,如 Biopython,以缩短程序并使其更可靠。

通过单独使用这些工具实践,可以提高你的程序,但将它们全部结合起来会以复利的方式提升你的代码质量。这本书并非生物信息学教科书。重点在于 Python 提供了什么,使其适合编写可复现的科学程序。也就是说,我会向你展示如何设计和测试程序,使得在相同输入情况下总是产生相同输出的结果。生物信息学中充斥着写得很糟糕、未经记录的程序,我的目标是逐步扭转这一趋势,一次一个程序。

程序复现性的标准包括:

参数

所有程序参数都可以设置为运行时参数。这意味着没有硬编码的值,这需要更改源代码才能改变程序的行为。

文档

程序应该通过打印参数和用法来响应--help参数。

测试

你应该能够运行一个测试套件,以证明代码符合某些规范

你可能期望这会逻辑上导致也许是正确的程序,但遗憾的是,正如艾兹格·戴克斯特(Edsger Dijkstra)所说,“程序测试可以用来显示错误的存在,但永远不能用来显示其不存在!”

大多数生物信息学家要么是学习编程的科学家,要么是学习生物学的程序员(或者像我这样两者都要学习的人)。无论你如何进入生物信息学领域,我都想向你展示实用的编程技术,帮助你快速编写正确的程序。我将从如何编写能够文档化和验证其参数的程序开始。然后我将展示如何编写和运行测试,以确保程序实现其所述功能。

例如,第一章向你展示如何从 DNA 字符串中报告四核苷酸频率。听起来相当简单,对吧?这是一个微不足道的想法,但我将花费大约 40 页来展示如何构建、文档化和测试这个程序。我将花费大量时间来展示如何编写和测试该程序的多个不同版本,以便探索 Python 数据结构、语法、模块和工具的许多方面。

谁应该阅读这本书?

如果你关心编程的工艺,并且想学习如何编写能够生成文档、验证参数、优雅失败和可靠工作的程序,那么你应该阅读这本书。测试是理解你的代码和验证其正确性的关键技能。我将向你展示如何使用我编写的测试,以及如何为你的程序编写测试。

要充分利用这本书,你应该已经对 Python 有扎实的理解。我将在《Tiny Python Projects》(Manning, 2020)所教授的技能基础上展开讲解,例如如何使用 Python 数据结构如字符串、列表、元组、字典、集合和命名元组。你不必是 Python 的专家,但我一定会推动你理解我在那本书中介绍的一些高级概念,比如类型、正则表达式、以及关于高阶函数的想法,还有关于测试以及如何使用pylintflake8yapfpytest来检查风格、语法和正确性的工具。一个显著的不同之处在于,我将会在本书中始终使用类型注解,并使用mypy工具确保类型的正确使用。

编程风格:为什么我避免使用 OOP 和异常处理

我倾向于避免面向对象编程(OOP)。如果你不知道 OOP 是什么,没关系。Python 本身是一种面向对象的语言,几乎从字符串到集合的每个元素在技术上都是具有内部状态和方法的对象。你会遇到足够多的对象,以了解 OOP 的含义,但我介绍的程序大多数将避免使用对象来表示思想。

话虽如此,第一章展示了如何使用class来表示复杂的数据结构。class允许我定义带有类型注释的数据结构,以便我可以验证自己是否正确使用了数据类型。这确实有助于理解面向对象编程的一些内容。例如,类定义了对象的属性,类可以从父类继承属性,但这基本上描述了我在 Python 中使用面向对象编程的限制和原因。如果你现在不完全理解,不要担心,看到实例后你会理解的。

而非面向对象的代码,我展示了几乎完全由函数组成的程序。这些函数也是纯函数,它们只会对给定的值进行操作。也就是说,纯函数从不依赖于像全局变量这样的隐藏的可变状态,并且在给定相同参数时始终返回相同的值。此外,每个函数都将有一个相关联的测试,我可以运行以验证其行为是否可预测。我认为,这样可以比使用面向对象编程编写的解决方案更简短、更透明、更易于测试。你可能持有不同意见,当然可以按照自己喜欢的编程风格编写解决方案,只要它们能通过测试即可。Python 的函数式编程指南文档很好地阐述了为什么 Python 适合函数式编程(FP)。

最后,本书中的程序也避免了使用异常,我认为这对于个人使用的短程序是合适的。管理异常以确保它们不会中断程序的流程会增加另一层复杂性,我认为这会影响人们理解程序的能力。对于在 Python 中编写返回错误的函数,我通常感到不满意。许多人会引发异常,并让try/catch块处理错误。如果我觉得异常是合理的,我通常会选择捕获它,而是让程序崩溃。在这方面,我遵循了 Erlang 语言的创造者 Joe Armstrong 的一个想法,他说:“Erlang 的方式是编写快乐路径,而不是写满了错误修正代码的曲折小通道。”

如果你选择编写公开发布的程序和模块,你将需要更多地了解异常和错误处理,但这超出了本书的范围。

结构

本书分为两个主要部分。第一部分解决了Rosalind.info 网站上的 14 个编程挑战。第二部分展示了更复杂的程序,演示了我认为在生物信息学中重要的其他模式或概念。书中的每一章都描述了一个编程挑战,供你编写,并提供了一个测试套件,用于确定你是否编写了一个可工作的程序。

尽管“Python 之禅”说“应该有一种——最好只有一种——明显的方法来做到这一点”,但我相信通过尝试多种不同的方法来解决问题,你可以学到很多东西。Perl 是我进入生物信息学的门户,Perl 社区“多种方法来解决问题”(TMTOWTDI)的精神仍然深深地影响着我。我通常会按照主题与变体的方式来编写每一章,展示多种解决方案,探索 Python 语法和数据结构的不同方面。

测试驱动开发。

更重要的不仅仅是进行测试,更重要的是设计测试,这是已知的最好的缺陷预防方法之一。为了创建一个有用的测试而进行的思考可以在代码编写之前发现并消除错误——事实上,测试设计思维可以在软件创建的每个阶段,从概念、规范、设计、编码到其余阶段,发现并消除错误。

Boris Beizer,《软件测试技术》(Thompson Computer Press)。

在所有的实验过程中,我都会有测试套件,我会不断运行它们以确保程序继续正确运行。每当有机会,我都会尝试教授测试驱动开发(TDD),这个概念在肯特·贝克(Addison-Wesley, 2002)的同名书籍中有详细解释。TDD 主张在编写代码之前先编写测试。典型的循环包括以下步骤:

  1. 添加一个测试。

  2. 运行所有的测试,看看新的测试是否失败。

  3. 编写代码。

  4. 运行测试。

  5. 重构代码。

  6. 重复。

在该书的GitHub 代码库中,您将找到每个程序的测试。我将解释如何运行和编写测试,我希望在学习结束时您能相信使用 TDD 的常识和基本的正直。我希望先考虑测试会开始改变您理解和探索编码的方式。

使用命令行和安装 Python。

我在生物信息学中的经验一直集中在 Unix 命令行上。我日常工作的大部分时间都在某种 Linux 服务器上,使用 Shell 脚本、Perl 和 Python 拼接现有的命令行程序。虽然我可能会在我的笔记本上编写和调试程序或流水线,但我经常会将我的工具部署到高性能计算(HPC)集群中,调度程序将异步地运行我的程序,通常在深夜或周末,并且无需我的监督或干预。此外,我所有构建数据库和网站以及管理服务器的工作都完全通过命令行进行,因此我强烈认为你需要精通这个环境才能在生物信息学中取得成功。

我使用 Macintosh 编写和测试了本书的所有材料,macOS 具有 Terminal 应用程序,您可以在其中使用命令行。我还使用各种 Linux 发行版测试了所有程序,并且 GitHub 存储库包含有关如何使用 Linux 虚拟机与 Docker 的说明。此外,我使用 Windows 10 在 Ubuntu 分布的 Windows 子系统(WSL)版本 1 上测试了所有程序。我强烈建议 Windows 用户使用 WSL 以获得真正的 Unix 命令行,但 Windows Shell(如cmd.exe、PowerShell 和 Git Bash)有时对某些程序也能够工作得足够好。

我建议您探索集成开发环境(IDE),如 VS Code、PyCharm 或 Spyder,以帮助您编写、运行和测试程序。这些工具集成了文本编辑器、帮助文档和终端。尽管我使用vim编辑器在终端中编写了所有程序、测试甚至本书,但大多数人可能更喜欢至少使用像 Sublime、TextMate 或 Notepad++这样的现代文本编辑器。

我使用 Python 版本 3.8.6 和 3.9.1 编写和测试了所有示例。一些示例使用了 Python 语法,在 3.6 版本中不存在,因此我建议您不要使用该版本。Python 2.x 已不再受支持,不应使用。我倾向于从Python 下载页面获取最新版本的 Python 3,但我也成功地使用了Anaconda Python 发行版。您可能在 Ubuntu 上有像apt这样的软件包管理器,或者在 Mac 上有brew,它们可以安装最新版本,或者您可以选择从源代码构建。无论您的平台和安装方法如何,我建议您尝试使用最新版本,因为语言仍在不断变化,大多数情况下是变得更好。

请注意,我选择将程序呈现为命令行程序而不是 Jupyter Notebooks,原因有几个。我喜欢 Notebooks 用于数据探索,但 Notebooks 的源代码存储在 JavaScript 对象表示(JSON)中,而不是按行排列的文本。这使得使用diff等工具查找两个 Notebooks 之间的差异非常困难。此外,Notebooks 无法进行参数化,这意味着我无法从程序外部传递参数以更改行为,而是必须直接更改源代码。这使得程序缺乏灵活性,无法进行自动化测试。虽然我鼓励您探索 Notebooks,特别是作为运行 Python 的交互式方式,但我将专注于如何编写命令行程序。

获取代码和测试

所有代码和测试都可以从该书的 GitHub 存储库中获取。您可以使用程序 Git(可能需要安装)使用以下命令将代码复制到您的计算机上。这将在您的计算机上创建一个名为biofx_python的新目录,其中包含存储库的内容:

$ git clone https://github.com/kyclark/biofx_python

如果你喜欢使用集成开发环境(IDE),可能可以通过该界面克隆存储库,如图 P-1 所示。许多 IDE 可以帮助您管理项目并编写代码,但它们的工作方式都不同。为了保持简单,我将展示如何使用命令行来完成大多数任务。

mpfb 0001

图 P-1. PyCharm 工具可以直接为您克隆 GitHub 存储库

有些工具,如 PyCharm,可能会自动尝试在项目目录内创建虚拟环境。这是一种隔离 Python 版本和模块的方式,使其与计算机上的其他项目隔离开来。无论您是否使用虚拟环境都是个人偏好。这不是使用它们的要求。

你可能更喜欢在你自己的账户中复制代码,这样你就可以跟踪你的更改并与他人分享你的解决方案。这叫做分叉,因为你正在从我的代码中分叉出来,并将你的程序添加到存储库中。

要分叉我的 GitHub 存储库,请执行以下操作:

  1. 在 GitHub.com 上创建一个账户。

  2. 转到https://github.com/kyclark/biofx_python

  3. 单击右上角的 Fork 按钮(见图 P-2)将存储库复制到您的账户中。

mpfb 0002

图 P-2. 在我的 GitHub 存储库上的 Fork 按钮会在您的账户中复制代码

现在您在您的存储库中拥有了我所有代码的副本,您可以使用 Git 将该代码复制到您的计算机上。确保用您实际的 GitHub ID 替换*YOUR_GITHUB_ID*

$ git clone https://github.com/*YOUR_GITHUB_ID*/biofx_python

在您复制后,我可能会更新存储库。如果您希望能够获取这些更新,您需要配置 Git 将我的存储库设置为上游源。要这样做,在您将存储库克隆到计算机上后,进入您的biofx_python目录:

$ cd biofx_python

然后执行此命令:

$ git remote add upstream https://github.com/kyclark/biofx_python.git

每当您想要从我的更新存储库中更新您的存储库时,可以执行此命令:

$ git pull upstream main

安装模块

您需要安装几个 Python 模块和工具。我在存储库的顶层包含了一个requirements.txt文件。该文件列出了运行本书中程序所需的所有模块。一些 IDE 可能会检测到此文件并提供安装,或者您可以使用以下命令:

$ python3 -m pip install -r requirements.txt

或使用pip3工具:

$ pip3 install -r requirements.txt

有时pylint可能会抱怨程序中的一些变量名,而当您导入没有类型注释的模块时,mypy会引发一些问题。要消除这些错误,您可以在家目录中创建初始化文件,这些程序将使用它们来自定义其行为。在源存储库的根目录中,有名为pylintrcmypy.ini的文件,您应该像这样将它们复制到您的家目录中:

$ cp pylintrc ~/.pylintrc
$ cp mypy.ini ~/.mypy.ini

或者,您可以使用以下命令生成新的pylintrc

$ cd ~
$ pylint --generate-rcfile > .pylintrc

随意定制这些文件以适应您的喜好。

安装 new.py 程序

我写了一个名为new.py的 Python 程序,它可以创建 Python 程序。很元,我知道。我最初是为自己写的,然后把它给了我的学生,因为我认为从一个空白屏幕开始写程序相当困难。new.py程序将创建一个新的、结构良好的 Python 程序,使用argparse模块来解释命令行参数。它应该已经在前面的部分与模块依赖项一起安装了。如果没有,你可以使用pip模块来安装它,就像这样:

$ python3 -m pip install new-py

你现在应该能够执行new.py,看到类似这样的输出:

$ new.py
usage: new.py [-h] [-n NAME] [-e EMAIL] [-p PURPOSE] [-t] [-f] [--version]
              program
new.py: error: the following arguments are required: program

每个练习都会建议你使用new.py来开始编写你的新程序。例如,在第一章中,你将在01_dna目录下创建一个名为dna.py的程序,就像这样:

$ cd 01_dna/
$ new.py dna.py
Done, see new script "dna.py".

如果你然后执行./dna.py --help,你会看到它生成了关于如何使用程序的帮助文档。你应该在编辑器中打开dna.py程序,修改参数,并添加你的代码以满足程序和测试的要求。

请注意,使用new.py并不是必须的。我只是提供这个作为一个开始的辅助工具。这是我开始我自己的每一个程序的方式,但是,虽然我觉得它有用,你可能更喜欢走另一条路。只要你的程序通过测试套件,你可以按照你喜欢的方式编写它们。

我为什么写这本书?

理查德·哈明在贝尔实验室担任数学家和研究员数十年。他以寻找他不认识的人并询问他们的研究而闻名。然后他会问他们认为在他们领域里最大、最迫切的未解决问题是什么。如果他们对这两个问题的答案不同,他会问:“那你为什么不去解决呢?”

我觉得生物信息学中最迫切的问题之一是许多软件编写质量低劣,缺乏适当的文档和测试,如果有的话。我想向你展示,使用类型、测试、代码检查和格式化工具并不是那么困难,因为随着时间的推移,添加新功能和发布更多更好的软件会变得更容易。你将有信心确切地知道你的程序在某种程度上是正确的。

为此,我将演示软件开发的最佳实践。尽管我使用 Python 作为媒介,但这些原则适用于从 C 到 R 再到 JavaScript 的任何语言。你从这本书中最重要的学到的东西是开发、测试、文档化、发布和支持软件的技艺,这样我们就可以共同推进科学研究计算。

我在生物信息学领域的职业是漫游和幸福意外的产物。我在大学里学习了英国文学和音乐,然后开始使用数据库、HTML,并最终在 1990 年代中期在工作中学会了编程。到 2001 年,我已经成为一个不错的 Perl 黑客,并且设法在 Cold Spring Harbor Laboratory(CSHL)成为了 Dr. Lincoln Stein 的网页开发人员。他和我的老板 Dr. Doreen Ware 耐心地给我灌输了足够的生物学知识,以理解他们想要编写的程序。我在一个名为 Gramene.org 的比较植物基因组学数据库上工作了 13 年,学到了相当多的科学知识,同时继续探索编程语言和计算机科学。

林肯热衷于分享从数据和代码到教育的一切。他在 CSHL 开设了为期两周的集中课程,教授 Unix 命令行、Perl 编程和生物信息学技能的生物编程课程。尽管现在使用 Python 教学,但这门课程仍在进行中,我也有几次担任助教的机会。我一直觉得帮助别人学习他们将用于推进研究的技能是有意义的。

就是在我在 CSHL 的任期期间,我遇到了 Bonnie Hurwitz,她最终离开去亚利桑那大学(UA)攻读博士学位。当她在 UA 开设新实验室时,我是她的第一个雇员。我和 Bonnie 一起工作了几年,教学成为了我的工作中最喜欢的部分之一。与林肯的课程一样,我们向想要涉足更多计算方法的科学家介绍了基本的编程技能。

我为这些课程编写的一些材料成为了我第一本书《微型 Python 项目》的基础,我试图在其中教授 Python 语言语法的基本要素,以及如何使用测试来确保程序的正确性和可重复性——这些对科学编程至关重要。这本书从这里开始,重点介绍了将帮助您编写生物学程序的 Python 要素。

本书中使用的约定

本书使用以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名,以及密码子和 DNA 碱基。

固定宽度

用于程序清单,以及段落内用于引用诸如变量或函数名称、数据库、数据类型、环境变量、语句和关键字等程序元素。

固定宽度粗体

显示用户应该按字面输入的命令或其他文本。

固定宽度斜体

显示应该用用户提供的值或上下文确定的值替换的文本。

此元素表示提示或建议。

此元素表示一般注释。

此元素表示警告或注意事项。

使用代码示例

补充资料(代码示例、练习等)可在https://github.com/kyclark/biofx_python下载。

如果您有技术问题或使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。通常情况下,如果本书提供示例代码,您可以在程序和文档中使用它。除非您复制了大量代码片段,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序无需许可。销售或分发 O’Reilly 书籍的示例代码则需要许可。引用本书并引用示例代码回答问题无需许可。将本书大量示例代码整合到您产品的文档中则需要许可。

我们欣赏,但通常不要求归属。归属通常包括标题、作者、出版商和 ISBN。例如:“生物信息学 Python 大师,作者 Ken Youens-Clark(O’Reilly)。版权所有 2021 年 Charles Kenneth Youens-Clark,978-1-098-10088-9。”

如果您认为您对代码示例的使用超出了公平使用或上述许可的范围,请随时通过permissions@oreilly.com与我们联系。

O’Reilly Online Learning

40 多年来,O’Reilly Media提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境,以及来自 O’Reilly 和其他 200 多家出版商的广泛文本和视频集合。更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版社:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • 加州塞巴斯托波尔 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们有本书的网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/mastering-bioinformatics-python

通过电子邮件bookquestions@oreilly.com进行评论或咨询有关本书的技术问题。

获取关于我们书籍和课程的新闻和信息,请访问http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

关注我们的 Twitter:http://twitter.com/oreillymedia

观看我们的 YouTube 频道:http://www.youtube.com/oreillymedia

致谢

我要感谢所有审阅过这本书的人,包括我的编辑 Corbin Collins;整个制作团队,尤其是我的制作编辑 Caitlin Ghegan;我的技术审阅者 Al Scherer, Brad Fulton, Bill Lubanovic, Rangarajan Janani 和 Joshua Orvis;以及许多其他提供宝贵反馈的人,包括 Mark Henderson, Marc Bañuls Tornero 和 Scott Cain 博士。

在我的职业生涯中,我非常幸运地遇到了许多出色的老板、主管和同事,他们帮助我成长并推动我变得更好。Eric Thorsen 是第一个看出我有学习编程潜力的人,他帮助我学习了各种编程语言和数据库,以及关于销售和支持的重要经验教训。Steve Reppucci 是我在 boston.com 的老板,他使我更深入地理解了 Perl 和 Unix,以及如何成为一个诚实而周到的团队领导者。在 CSHL,Lincoln Stein 博士冒险聘请一个对生物学一无所知的人到他的实验室工作,他推动我创造了我未曾想象的程序。Doreen Ware 博士耐心地教导我生物学,并推动我承担领导角色和发表论文。Bonnie Hurwitz 博士在多年的高性能计算学习中一直支持我,教我更多编程语言,指导,教学和写作。在每个职位上,还有许多同事教会了我编程的同时也教会了我如何做人,我要感谢每一个在我成长路上帮助过我的人。

在我的个人生活中,如果没有我的家人,我可能一事无成。他们一直爱护和支持我。我的父母一直在我生活中给予巨大支持,如果没有他们,我肯定不会成为现在的我。Lori Kindler 和我已经结婚 25 年,我无法想象没有她的生活。我们一起育有三个孩子,他们是我无比欢乐和挑战的源泉。

^(1) 以罗莎琳德·弗兰克林命名,她因在发现 DNA 结构方面的贡献而本该获得诺贝尔奖。

第一部分:Rosalind.info 挑战

这部分的章节探索了 Python 语法和工具的要素,使您能够编写结构良好、文档完备、经过测试和可重现的程序。我将向您展示如何解决来自Rosalind.info的 14 个挑战。这些问题简短而集中,允许使用多种不同的解决方案,帮助您深入探索 Python。我还会教您如何逐步编写程序,并使用测试来指导您,让您知道何时完成。我鼓励您阅读每个问题的 Rosalind 页面,因为我没有足够的空间来重述所有的背景和信息。

第一章:四核苷酸频率:计数事物

在生物信息学中,计算 DNA 中的碱基可能是“Hello, World!”。Rosalind DNA 挑战描述了一个程序,它将获取一段 DNA 序列并打印出发现的ACGT的计数。在 Python 中计数事物有很多令人惊讶的方式,我将探索这种语言提供的内容。我还将演示如何编写结构良好、有文档化的程序,验证其参数以及编写和运行测试以确保程序正常工作。

在本章中,您将学到:

  • 如何使用new.py开始一个新程序

  • 如何定义和验证命令行参数使用argparse

  • 如何使用pytest运行测试套件

  • 如何迭代字符串的字符

  • 计算集合中元素的方法

  • 如何使用if/elif语句创建决策树

  • 如何格式化字符串

开始使用

在开始之前,请确保您已阅读了“获取代码和测试”部分。一旦您有了代码仓库的本地副本,请切换到01_dna目录:

$ cd 01_dna

这里您会找到几个solution*.py程序以及您可以用来查看程序是否正确工作的测试和输入数据。要了解您的程序应如何工作的概念,请从第一个解决方案复制到一个名为dna.py的程序:

$ cp solution1_iter.py dna.py

现在以无参数或使用-h--help标志运行程序。它将打印使用文档(注意usage是输出的第一个词):

$ ./dna.py
usage: dna.py [-h] DNA
dna.py: error: the following arguments are required: DNA

如果您遇到“权限被拒绝”的错误,请尝试运行chmod +x dna.py以添加可执行权限。

这是复制性的首要元素之一。程序应提供关于其运行方式的文档。虽然通常有类似于README文件或甚至是论文来描述一个程序,但程序本身必须提供关于其参数和输出的文档。我将向您展示如何使用argparse模块定义和验证参数,并生成文档,这意味着由程序生成的使用说明不可能是错误的。与README文件和更改日志等可能很快与程序开发脱节的情况形成对比,希望您能欣赏到这种文档的效果。

您可以从使用行看出,程序期望类似DNA的参数,因此让我们给它一个序列。正如在 Rosalind 页面上描述的那样,该程序按照顺序和用单个空格分隔的方式打印每个碱基ACGT的计数:

$ ./dna.py ACCGGGTTTT
1 2 3 4

当你前往解决Rosalind.info网站上的挑战时,程序的输入将作为下载文件提供;因此,我将编写程序以便它也能读取文件内容。我可以使用catconcatenate的缩写)命令来打印tests/inputs目录中一个文件的内容。

$ cat tests/inputs/input2.txt
AGCTTTTCATTCTGACTGCAACGGGCAATATGTCTCTGTGTGGATTAAAAAAAGAGTGTCTGATAGCAGC

这是在网站示例中显示的相同序列。因此,我知道程序的输出应该是这样的:

$ ./dna.py tests/inputs/input2.txt
20 12 17 21

在整本书中,我将使用pytest工具来运行确保程序按预期工作的测试。当我运行pytest命令时,它将递归搜索当前目录以寻找看起来像测试的测试和函数。请注意,如果你在 Windows 上,你可能需要运行python3 -m pytestpytest.exe。现在运行它,你应该会看到类似以下的东西,表明程序通过了tests/dna_test.py文件中的所有四个测试:

$ pytest
=========================== test session starts ===========================
...
collected 4 items

tests/dna_test.py ....                                              [100%]

============================ 4 passed in 0.41s ============================

软件测试的关键要素是使用已知的输入运行程序并验证它是否产生正确的输出。尽管这似乎是一个显而易见的想法,但我曾经反对仅仅运行程序而不验证其正确行为的“测试”方案。

使用new.py创建程序

如果你复制了前面部分显示的解决方案之一,那么删除该程序,以便你可以从头开始:

$ rm dna.py

在查看我的解决方案之前,请尝试解决这个问题。如果你认为你已经获得了所有需要的信息,请随意提前编写你自己版本的dna.py,使用pytest来运行提供的测试。如果你想一步步地与我学习如何编写程序和运行测试,请继续阅读。

本书中的每个程序都将接受一些命令行参数并创建一些输出,如命令行上的文本或新文件。我总是使用前言中描述的new.py程序来启动,但这不是必需的。你可以按照自己的喜好编写程序,从任何你想要的地方开始,但是你的程序应该具有相同的特性,如生成使用说明和正确验证参数。

01_dna目录中创建你的dna.py程序,因为这包含程序的测试文件。这是我如何启动dna.py程序的方式。--purpose参数将用于程序的文档:

$ new.py --purpose 'Tetranucleotide frequency' dna.py
Done, see new script "dna.py."

如果你运行新的dna.py程序,你会看到它定义了许多与命令行程序常见的不同类型的参数:

$ ./dna.py --help
usage: dna.py [-h] [-a str] [-i int] [-f FILE] [-o] str

Tetranucleotide frequency ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

positional arguments:
  str                   A positional argument ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

optional arguments:
  -h, --help            show this help message and exit ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
  -a str, --arg str     A named string argument (default: ) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
  -i int, --int int     A named integer argument (default: 0) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
  -f FILE, --file FILE  A readable file (default: None) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
  -o, --on              A boolean flag (default: False) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

1

这里使用new.py--purpose来描述程序。

2

该程序接受一个单一的位置字符串参数。

3

-h--help 标志是由 argparse 自动添加的,将触发使用说明。

4

这是一个命名选项,具有短 (-a) 和长 (--arg) 名称,用于字符串值。

5

这是一个命名选项,具有短 (-i) 和长 (--int) 名称,用于整数值。

6

这是一个命名选项,具有短 (-f) 和长 (--file) 名称,用于文件参数。

7

这是一个布尔标志,当 -o--on 存在时将为 True,当它们不存在时为 False

这个程序只需要 str 位置参数,并且你可以使用 DNA 作为 metavar 值,以便向用户指示参数的含义。删除所有其他参数。请注意,永远不要定义 -h--help 标志,因为 argparse 在内部使用它们来响应使用请求。看看你是否可以修改你的程序,直到它生成以下的使用情况(如果你暂时无法生成使用情况,请不要担心,我将在下一节中展示这个):

$ ./dna.py -h
usage: dna.py [-h] DNA

Tetranucleotide frequency

positional arguments:
  DNA         Input DNA sequence

optional arguments:
  -h, --help  show this help message and exit

如果你能够使其正常工作,我想指出这个程序将只接受一个位置参数。如果尝试使用其他数量的参数运行它,程序将立即停止并打印错误消息:

$ ./dna.py AACC GGTT
usage: dna.py [-h] DNA
dna.py: error: unrecognized arguments: GGTT

同样,该程序将拒绝任何未知的标志或选项。只需几行代码,你就建立了一个文档良好的程序,用于验证程序的参数。这是实现可重现性的一个非常基本且重要的步骤。

使用 argparse

new.py 创建的程序使用 argparse 模块来定义程序的参数,验证参数的正确性,并为用户创建使用文档。argparse 模块是 Python 的标准模块,这意味着它总是存在的。其他模块也可以执行这些操作,你可以自由选择任何方法来处理程序的这个方面。只需确保你的程序能够通过测试。

我为 Tiny Python Projects 写了一个 new.py 的版本,你可以在该书的 GitHub 仓库的 bin 目录找到。那个版本比我要求你使用的版本要简单一些。我将首先向你展示使用这个早期版本 new.py 创建的 dna.py 的一个版本:

#!/usr/bin/env python3 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
""" Tetranucleotide frequency """ ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

import argparse ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

# --------------------------------------------------
def get_args(): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    """ Get command-line arguments """ ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

    parser = argparse.ArgumentParser( ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
        description='Tetranucleotide frequency',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('dna', metavar='DNA', help='Input DNA sequence') ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

    return parser.parse_args() ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

# --------------------------------------------------
def main(): ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
    """ Make a jazz noise here """

    args = get_args() ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)
    print(args.dna)   ![11](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/11.png)

# --------------------------------------------------
if __name__ == '__main__': ![12](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/12.png)
    main()

1

俗称的shebang#!)告诉操作系统使用env命令(environment)来找到python3以执行程序的其余部分。

2

这是整个程序或模块的文档字符串(documentation string)。

3

我导入argparse模块来处理命令行参数。

4

我总是定义一个get_args()函数来处理argparse代码。

5

这是一个函数的文档字符串。

6

parser对象用于定义程序的参数。

7

我定义了一个dna参数,它将是位置参数,因为名称dna以破折号开头。metavar是参数的简短描述,将出现在短使用说明中。不需要其他参数。

8

该函数返回解析参数的结果。帮助标志或参数错误将导致argparse打印使用说明或错误消息并退出程序。

9

本书中的所有程序都将始于main()函数。

10

main()中的第一步始终是调用get_args()。如果此调用成功,则参数必定是有效的。

11

DNA值可以通过args.dna属性获得,因为这是参数的名称。

12

这是 Python 程序中的常见习语,用于检测程序是否正在执行(而不是被导入),并执行main()函数。

当以./dna.py这样的形式调用程序时,Unix shell 会使用 shebang 行。在 Windows 上,你需要运行python.exe dna.py来执行该程序。

虽然这段代码完全正常工作,但从get_args()返回的值是一个在程序运行时动态生成argparse.Namespace对象。也就是说,我正在使用像parser.add_argument()这样的代码在运行时修改这个对象的结构,因此 Python 无法在编译时确定解析参数中会有哪些属性或它们的类型。虽然你可能很明显只有一个必需的字符串参数,但代码中没有足够的信息让 Python 能够辨别这一点。

编译程序是将其转换为计算机可以执行的机器代码。某些语言(如 C)必须在运行之前单独编译。Python 程序通常在一步中编译并运行,但仍有编译阶段。有些错误可以在编译时捕获,而其他错误则直到运行时才会出现。例如,语法错误会阻止编译。最好在编译时有错误,而不是在运行时有错误。

要了解为什么这可能是个问题,我将修改main()函数,引入一个类型错误。也就是说,我会故意误用args.dna值的类型。除非另有说明,通过argparse从命令行返回的所有参数值都是字符串。如果我试图将字符串args.dna除以整数值 2,Python 将引发异常并在运行时崩溃程序:

def main():
    args = get_args()
    print(args.dna / 2) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

将字符串除以整数会产生一个异常。

如果我运行程序,它会如预期地崩溃:

$ ./dna.py ACGT
Traceback (most recent call last):
  File "./dna.py", line 30, in <module>
    main()
  File "./dna.py", line 25, in main
    print(args.dna / 2)
TypeError: unsupported operand type(s) for /: 'str' and 'int'

我们的大脑明白这是一个不可避免的错误,但 Python 看不到这个问题。我需要的是在程序运行时无法修改的静态参数定义。继续阅读,了解类型注解和其他工具如何检测这类错误。

查找代码中错误的工具

这里的目标是在 Python 中编写正确、可复现的程序。有没有办法发现并避免像在数值运算中误用字符串这样的问题?python3解释器没有找到阻止我运行代码的问题。也就是说,程序在语法上是正确的,因此在前一节中的代码产生了一个运行时错误,因为只有当我执行程序时才会出错。多年前我曾在一个团队工作,我们开玩笑说:“如果能编译通过,就发布吧!”这显然是一种短视的编码方式。

我可以使用诸如 linters 和类型检查器之类的工具来找出代码中的一些问题。Linters是一种检查程序风格和许多种错误的工具,超越了简单的语法错误。pylint工具是一个流行的 Python linter,我几乎每天都在使用。它能找到这个问题吗?显然不能,因为它给出了最大的赞扬:

$ pylint dna.py

-------------------------------------------------------------------
Your code has been rated at 10.00/10 (previous run: 9.78/10, +0.22)

flake8工具是另一个我经常与pylint结合使用的检查器,因为它会报告不同类型的错误。当我运行flake8 dna.py时,没有输出,这意味着它没有发现要报告的错误。

mypy工具是 Python 的静态类型检查器,意味着它旨在发现诸如试图将字符串除以数字等错误使用类型的问题。pylintflake8都不会捕获类型错误,所以我不能合理地惊讶它们错过了这个错误。那么mypy有什么要说的呢?

$ mypy dna.py
Success: no issues found in 1 source file

嗯,这有点令人失望;然而,你必须理解,mypy未能报告问题是因为没有类型信息。也就是说,mypy没有信息来表明将args.dna除以 2 是错误的。我很快就会修复这个问题。

引入命名元组

为了避免动态生成对象带来的问题,本书中的所有程序都将使用命名元组数据结构来静态定义从get_args()获取的参数。元组本质上是不可变的列表,通常用于表示 Python 中的记录型数据结构。这其中有很多内容需要理解,所以让我们先回到列表。

首先,列表是有序的项目序列。项目可以是异构的;理论上来说,这意味着所有项目可以是不同类型的,但实际上,混合类型通常是一个坏主意。我将使用python3 REPL 来演示列表的一些方面。我建议你使用help(list)阅读文档。

使用空方括号([])创建一个空列表,用于保存一些序列:

>>> seqs = []

list()函数还将创建一个新的空列表:

>>> seqs = list()

使用type()函数返回变量的类型来验证这是一个列表:

>>> type(seqs)
<class 'list'>

列表有一些方法可以在列表末尾添加值,比如list.append()来添加一个值:

>>> seqs.append('ACT')
>>> seqs
['ACT']

list.extend()来添加多个值:

>>> seqs.extend(['GCA', 'TTT'])
>>> seqs
['ACT', 'GCA', 'TTT']

如果在 REPL 中仅键入变量本身,它将被评估并转换为文本表示形式:

>>> seqs
['ACT', 'GCA', 'TTT']

这基本上就是当你print()一个变量时发生的事情:

>>> print(seqs)
['ACT', 'GCA', 'TTT']

可以使用索引原地修改任何值。请记住,Python 中的所有索引都是从 0 开始的,因此 0 是第一个元素。将第一个序列改为TCA

>>> seqs[0] = 'TCA'

验证已更改:

>>> seqs
['TCA', 'GCA', 'TTT']

与列表类似,元组是一种可能异构对象的有序序列。当你在一系列项目之间放置逗号时,你就创建了一个元组:

>>> seqs = 'TCA', 'GCA', 'TTT'
>>> type(seqs)
<class 'tuple'>

典型的做法是在元组值周围加上括号,以使其更加明确:

>>> seqs = ('TCA', 'GCA', 'TTT')
>>> type(seqs)
<class 'tuple'>

不像列表,元组一旦创建就无法更改。如果你阅读help(tuple),你会看到元组是一个内置的不可变序列,所以我无法添加值:

>>> seqs.append('GGT')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'tuple' object has no attribute 'append'

或修改现有的值:

>>> seqs[0] = 'TCA'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

在 Python 中,使用元组表示记录是相当常见的。例如,我可以表示一个Sequence,它有一个唯一的 ID 和一个碱基字符串:

>>> seq = ('CAM_0231669729', 'GTGTTTATTCAATGCTAG')

虽然可以像使用列表一样使用索引从元组中获取值,但这样做很麻烦且容易出错。命名元组允许我为字段指定名称,这使得它们更加易于使用。要使用命名元组,可以从collections模块导入namedtuple()函数:

>>> from collections import namedtuple

如图 1-2 所示,我使用namedtuple()函数创建了一个具有idseq字段的Sequence的概念:

>>> Sequence = namedtuple('Sequence', ['id', 'seq'])

mpfb 0102

图 1-2. namedtuple()函数生成一种方法,用于创建具有idseq字段的Sequence类对象

这里的Sequence究竟是什么?

>>> type(Sequence)
<class 'type'>

我刚创造了一个新的类型。你可以称Sequence()函数为工厂,因为它是用来生成Sequence类的新对象的函数。这是这些工厂函数和类名常见的命名约定,用以区分它们。

就像我可以使用list()函数创建一个新的列表一样,我可以使用Sequence()函数创建一个新的Sequence对象。我可以按位置传递idseq值,以匹配它们在类中定义的顺序:

>>> seq1 = Sequence('CAM_0231669729', 'GTGTTTATTCAATGCTAG')
>>> type(seq1)
<class '__main__.Sequence'>

或者我可以使用字段名称,并按任意顺序将它们作为键/值对传递:

>>> seq2 = Sequence(seq='GTGTTTATTCAATGCTAG', id='CAM_0231669729')
>>> seq2
Sequence(id='CAM_0231669729', seq='GTGTTTATTCAATGCTAG')

尽管可以使用索引访问 ID 和序列:

>>> 'ID = ' + seq1[0]
'ID = CAM_0231669729'
>>> 'seq = ' + seq1[1]
'seq = GTGTTTATTCAATGCTAG'

…命名元组的整个意义在于使用字段名称:

>>> 'ID = ' + seq1.id
'ID = CAM_0231669729'
>>> 'seq = ' + seq1.seq
'seq = GTGTTTATTCAATGCTAG'

记录的值保持不可变:

>>> seq1.id = 'XXX'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

我经常希望在我的代码中保证一个值不会被意外改变。Python 没有声明变量为常量或不可变的方式。元组默认是不可变的,我认为用一个不能被修改的数据结构来表示程序的输入是有道理的。这些输入是神圣的,几乎不应该被修改。

为命名元组添加类型

尽管namedtuple()很好用,但通过从typing模块导入NamedTuple类,可以使其变得更好。此外,可以使用此语法为字段分配类型。请注意,在 REPL 中需要使用空行来指示块已完成:

>>> from typing import NamedTuple
>>> class Sequence(NamedTuple):
...     id: str
...     seq: str
...

您看到的...是行继续符。REPL 显示到目前为止输入的内容不是完整的表达式。您需要输入一个空行来告诉 REPL 您已经完成了代码块。

namedtuple()方法一样,Sequence是一个新类型:

>>> type(Sequence)
<class 'type'>

实例化一个新的Sequence对象的代码是相同的:

>>> seq3 = Sequence('CAM_0231669729', 'GTGTTTATTCAATGCTAG')
>>> type(seq3)
<class '__main__.Sequence'>

我仍然可以通过名称访问字段:

>>> seq3.id, seq3.seq
('CAM_0231669729', 'GTGTTTATTCAATGCTAG')

由于我定义了两个字段都是str类型,你可能会认为这样会起作用:

>>> seq4 = Sequence(id='CAM_0231669729', seq=3.14)

很抱歉告诉你,Python 本身会忽略类型信息。你可以看到我希望是strseq字段实际上是一个float

>>> seq4
Sequence(id='CAM_0231669729', seq=3.14)
>>> type(seq4.seq)
<class 'float'>

那么这对我们有什么帮助呢?它在 REPL 中对我没有帮助,但是在我的源代码中添加类型将允许像mypy这样的类型检查工具找到这些错误。

使用命名元组表示参数

我希望表示程序参数的数据结构包括类型信息。与Sequence类一样,我可以定义一个派生自NamedTuple类型的类,在其中可以静态定义带有类型的数据结构。我喜欢称这个类为Args,但你可以根据喜好更改。我知道这可能看起来有点大材小用,但相信我,这种细节以后会有所回报。

最新的new.py使用了来自typing模块的NamedTuple类。我建议你这样定义和表示参数:

#!/usr/bin/env python3
"""Tetranucleotide frequency"""

import argparse
from typing import NamedTuple ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

class Args(NamedTuple): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    """ Command-line arguments """
    dna: str ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

# --------------------------------------------------
def get_args() -> Args: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Tetranucleotide frequency',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('dna', metavar='DNA', help='Input DNA sequence')

    args = parser.parse_args() ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

    return Args(args.dna) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

# --------------------------------------------------
def main() -> None: ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
    """ Make a jazz noise here """

    args = get_args()
    print(args.dna / 2) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

# --------------------------------------------------
if __name__ == '__main__':
    main()

1

typing模块导入NamedTuple类。

2

为参数定义一个基于NamedTuple类的class。请参阅以下注释。

3

类中有一个名为dna的单一字段,类型为str

4

get_args()函数的类型注解显示它返回一个Args类型的对象。

5

像以前一样解析参数。

6

返回一个包含args.dna单一值的新Args对象。

7

main()函数没有return语句,因此返回默认的None值。

8

这是早期程序的类型错误。

如果你在这个程序上运行pylint,可能会遇到错误“继承NamedTuple,这不是一个类(inherit-non-class)”和“太少的公共方法(0/2)(too-few-public-methods)”。你可以通过将“inherit-non-class”和“too-few-public-methods”添加到你的pylintrc文件的“disable”部分来禁用这些警告,或者使用 GitHub 仓库根目录中包含的pylintrc文件。

如果你运行这个程序,你会看到它仍然会创建相同的未捕获异常。flake8pylint都会继续报告程序看起来很好,但现在看看mypy告诉我的是什么:

$ mypy dna.py
dna.py:32: error: Unsupported operand types for / ("str" and "int")
Found 1 error in 1 file (checked 1 source file)

错误消息显示,在第 32 行存在问题,涉及除法 (/) 操作符的操作数。我混合了字符串和整数值。如果没有类型注解,mypy 将无法找到此错误。如果没有mypy 的警告,我将不得不运行我的程序来找到它,确保执行包含错误的代码分支。在这种情况下,这一切都显而易见且微不足道,但在一个具有数百或数千行代码(LOC)、许多函数和逻辑分支(如 if/else)的更大程序中,我可能不会偶然发现这个错误。我依赖于类型和像 mypy(还有 pylintflake8 等)这样的程序来修正这些类型的错误,而不是仅依赖于测试,更糟糕的是等待用户报告错误。

从命令行或文件读取输入

当您试图证明您的程序在 Rosalind.info 网站上运行时,您将下载一个包含程序输入的数据文件。通常,此数据要比问题描述中的示例数据大得多。例如,此问题的示例 DNA 字符串长度为 70 个碱基,但我下载的一个尝试的输入数据文件长度为 910 个碱基。

让程序同时从命令行和文本文件中读取输入,这样您就不必从下载的文件中复制和粘贴内容。这是我常用的一种模式,我更喜欢在 get_args() 函数内处理此选项,因为这涉及处理命令行参数。

首先,修正程序,以便打印 args.dna 的值而不进行除法操作:

def main() -> None:
    args = get_args()
    print(args.dna) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

修复除法类型错误。

检查它是否工作:

$ ./dna.py ACGT
ACGT

对于接下来的部分,您需要引入 os 模块以与操作系统交互。在其他 import 语句顶部添加 import os,然后将这两行代码添加到您的 get_args() 函数中:

def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Tetranucleotide frequency',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('dna', metavar='DNA', help='Input DNA sequence')

    args = parser.parse_args()

    if os.path.isfile(args.dna):  ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        args.dna = open(args.dna).read().rstrip()   ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    return Args(args.dna)

1

检查 dna 值是否是一个文件。

2

调用 open() 打开文件句柄,然后链式调用 fh.read() 方法返回一个字符串,接着链式调用 str.rstrip() 方法删除尾部空白。

fh.read() 函数将整个文件读入一个变量中。在这种情况下,输入文件很小,所以这应该没问题,但在生物信息学中处理数千兆字节大小的文件是非常普遍的。在大文件上使用 read() 可能会导致程序崩溃甚至整个计算机崩溃。稍后我将展示如何逐行读取文件以避免这种情况。

现在运行您的程序,以确保它能处理字符串值:

$ ./dna.py ACGT
ACGT

然后将文本文件用作参数:

$ ./dna.py tests/inputs/input2.txt
AGCTTTTCATTCTGACTGCAACGGGCAATATGTCTCTGTGTGGATTAAAAAAAGAGTGTCTGATAGCAGC

现在你有一个灵活的程序,可以从两个来源读取输入。运行mypy dna.py以确保没有问题。

测试你的程序

你从罗莎琳描述中了解到,给定输入ACGT,程序应该打印1 1 1 1,因为这是ACGT各自的数量。在01_dna/tests目录中,有一个名为dna_test.py的文件,其中包含对dna.py程序的测试。我为你编写了这些测试,这样你就可以看到使用一种方法开发程序,并能相当确信地告诉你程序是否正确的情况。这些测试非常基础——给定一个输入字符串,程序应该打印四种核苷酸的正确计数。当程序报告正确的数字时,它就正常工作了。

01_dna目录中,我想让你运行pytest(或在 Windows 上运行python3 -m pytestpytest.exe)。程序将递归搜索所有以test_开头或以_test.py结尾的文件,然后运行这些文件中以test_开头命名的函数。

当你运行pytest时,会看到大量输出,其中大多数是失败的测试。要理解为什么这些测试失败,让我们看一下tests/dna_test.py模块:

""" Tests for dna.py """ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

import os ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
import platform ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
from subprocess import getstatusoutput ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

PRG = './dna.py' ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
RUN = f'python {PRG}' if platform.system() == 'Windows' else PRG ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
TEST1 = ('./tests/inputs/input1.txt', '1 2 3 4') ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
TEST2 = ('./tests/inputs/input2.txt', '20 12 17 21')
TEST3 = ('./tests/inputs/input3.txt', '196 231 237 246')

1

这是该模块的文档字符串。

2

标准的os模块将与操作系统进行交互。

3

使用platform模块来确定是否在 Windows 上运行。

4

我从subprocess模块导入一个函数,用于运行dna.py程序并捕获输出和状态。

5

这些是程序的全局变量。我倾向于在我的测试中避免使用全局变量。在这里,我想定义一些将在函数中使用的值。我喜欢使用大写名称来突出显示全局可见性。

6

RUN变量确定如何运行dna.py程序。在 Windows 上,必须使用python命令来运行 Python 程序,但在 Unix 平台上,可以直接执行dna.py程序。

7

TEST*变量是元组,定义了包含 DNA 字符串的文件以及该字符串的预期输出。

pytest模块将按照测试文件中定义的顺序运行测试函数。我经常这样组织我的测试,从最简单的情况逐步进行,因此在失败后通常没有继续进行的必要。例如,第一个测试始终是检查要测试的程序是否存在。如果不存在,则没有继续运行更多测试的意义。我建议您在运行pytest时使用-x标志以在第一个失败的测试处停止,并使用-v标志以获取详细输出。

让我们看看第一个测试。函数名为test_exists(),这样pytest就能找到它。在函数体中,我使用一个或多个assert语句来检查某个条件是否真实。^(1) 这里我断言程序dna.py存在。这就是为什么您的程序必须存在于此目录中——否则测试将找不到它的原因:

def test_exists(): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Program exists """

    assert os.path.exists(PRG) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

函数名必须以test_开头,以便pytest能够找到它。

2

os.path.exists()函数如果给定的参数是文件则返回True。如果返回False,则断言失败,此测试将失败。

我编写的下一个测试总是检查程序是否会为-h--help标志生成使用语句。subprocess.getstatusoutput()函数将使用短和长帮助标志运行dna.py程序。在每种情况下,我希望看到程序打印以usage:开头的文本。这不是一个完美的测试。它不检查文档是否准确,只是看起来像是可能是使用语句。我不认为每个测试都需要完全详尽。以下是测试内容:

def test_usage() -> None:
    """ Prints usage """

    for arg in ['-h', '--help']: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        rv, out = getstatusoutput(f'{RUN} {arg}') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        assert rv == 0 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        assert out.lower().startswith('usage:') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

迭代短和长帮助标志。

2

使用参数运行程序并捕获返回值和输出。

3

验证程序报告的成功退出值为 0。

4

断言程序输出的小写结果以usage:开头。

命令行程序通常通过返回非零值向操作系统指示错误。如果程序成功运行,应该返回0。有时这个非零值可能与某些内部错误代码相关联,但通常它只是表示出现了问题。我编写的程序也会力求在成功运行时报告0,在出现错误时报告某个非零值。

接下来,我希望确保当没有给出参数时程序会退出:

def test_dies_no_args() -> None:
    """ Dies with no arguments """

    rv, out = getstatusoutput(RUN) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert rv != 0 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert out.lower().startswith('usage:') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

捕获运行程序时没有参数时的返回值和输出。

2

验证返回值为非零的失败代码。

3

检查输出是否像一个用法说明。

在测试到这一点时,我知道我有一个程序,它的名称正确,可以运行以生成文档。这意味着程序至少在语法上是正确的,这是一个不错的起点进行测试。如果您的程序存在拼写错误,则必须在达到这一点之前进行更正。

运行程序以测试输出

现在我需要看看程序是否按预期运行。测试程序有许多方法,我喜欢使用称为inside-outoutside-in的两种基本方法。Inside-out方法从测试程序内部的各个函数开始。这通常被称为单元测试,因为函数可以被认为是计算的基本单位,我将在解决方案部分详细介绍。我将从outside-in方法开始。这意味着我将像用户一样从命令行运行程序。这是一种整体方法,用来检查代码片段是否能够协同工作以创建正确的输出,因此有时被称为集成测试。

第一个这样的测试将 DNA 字符串作为命令行参数传递,并检查程序是否产生正确格式的计数:

def test_arg():
    """ Uses command-line arg """

    for file, expected in [TEST1, TEST2, TEST3]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        dna = open(file).read() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        retval, out = getstatusoutput(f'{RUN} {dna}') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        assert retval == 0 ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        assert out == expected ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

解压元组到包含 DNA 字符串和程序运行时此输入的expected值的file中。

2

打开文件并从内容中读取dna

3

使用函数subprocess.getstatusoutput()运行给定的 DNA 字符串的程序,该函数给出程序的返回值和文本输出(也称为STDOUT,发音为standard out)。

4

断言返回值为0,这表示成功(或 0 个错误)。

5

断言程序的输出是预期的数字字符串。

下一个测试几乎相同,但这次我将文件名作为程序的参数传递,以验证它是否正确地从文件中读取 DNA:

def test_file():
    """ Uses file arg """

    for file, expected in [TEST1, TEST2, TEST3]:
        retval, out = getstatusoutput(f'{RUN} {file}') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        assert retval == 0
        assert out == expected

1

与第一个测试的唯一区别是,我传递的是文件名而不是文件的内容。

现在你已经查看了测试,请返回并再次运行测试。这次使用pytest -xv,其中-v标志用于详细输出。由于-x-v都是短标志,你可以像-xv-vx一样组合它们。仔细阅读输出并注意它试图告诉你程序正在打印 DNA 序列,但测试期望一个数字序列:

$ pytest -xv
============================= test session starts ==============================
...

tests/dna_test.py::test_exists PASSED                                    [ 25%]
tests/dna_test.py::test_usage PASSED                                     [ 50%]
tests/dna_test.py::test_arg FAILED                                       [ 75%]

=================================== FAILURES ===================================
___________________________________ test_arg ___________________________________

    def test_arg():
        """ Uses command-line arg """

        for file, expected in [TEST1, TEST2, TEST3]:
            dna = open(file).read()
            retval, out = getstatusoutput(f'{RUN} {dna}')
            assert retval == 0
>           assert out == expected  ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
E           AssertionError: assert 'ACCGGGTTTT' == '1 2 3 4'  ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
E             - 1 2 3 4
E             + ACCGGGTTTT

tests/dna_test.py:36: AssertionError
=========================== short test summary info ============================
FAILED tests/dna_test.py::test_arg - AssertionError: assert 'ACCGGGTTTT' == '...
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
========================= 1 failed, 2 passed in 0.35s ==========================

1

此行开头的>显示这是错误的来源。

2

程序的输出是字符串ACCGGGTTTT,但期望值是1 2 3 4。由于它们不相等,引发了一个AssertionError异常。

让我们修复一下。如果你认为你知道如何完成程序,请立即采用你的解决方案。首先,也许尝试运行你的程序来验证它是否会报告正确数量的A

$ ./dna.py A
1 0 0 0

接着是C

$ ./dna.py C
0 1 0 0

以及继续进行GT。然后运行pytest,看看是否通过了所有测试。

完成一份可用版本后,请考虑尝试尽可能多地寻找得到相同答案的不同方法。这被称为重构程序。你需要从能够正确工作的东西开始,然后尝试改进它。改进可以通过多种方式衡量。也许你找到了用更少的代码写相同想法的方法,或者也许你找到了运行更快的解决方案。无论你使用什么标准,都要继续运行pytest来确保程序是正确的。

解决方案 1:迭代并计算字符串中的字符数

如果你不知道从哪里开始,我将与你一起解决第一个解决方案。目标是遍历 DNA 字符串中的所有碱基。因此,首先我需要通过在 REPL 中分配一些值来创建一个名为dna的变量:

>>> dna = 'ACGT'

注意,任何用引号括起来的值,无论是单引号还是双引号,都是字符串。在 Python 中,即使是单个字符也被视为字符串。我经常使用type()函数来验证变量的类型,这里我看到dnastr类(字符串类):

>>> type(dna)
<class 'str'>

在 REPL 中键入help(str)以查看你可以在字符串上执行的所有精彩操作。在基因组学中,字符串作为数据的重要组成部分,这一数据类型尤为重要。

在 Python 术语中,我想迭代字符串的字符,这些字符在这种情况下是 DNA 的核苷酸。使用for循环可以做到这一点。Python 将字符串视为有序的字符序列,for循环将从头到尾访问每个字符:

>>> for base in dna: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
...     print(base)  ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
...
A
C
G
T

1

dna字符串中的每个字符都将复制到base变量中。你可以称其为char,或者c表示character,或者其他你喜欢的名字。

2

每次调用print()都会以换行符结束,因此你会看到每个碱基占据一行。

后面你会看到for循环可以与列表、字典、集合和文件中的行一起使用——基本上任何可迭代的数据结构。

统计核苷酸

现在我知道如何访问序列中的每个碱基后,我需要计算每个碱基的数量而不是仅打印它们。这意味着我需要一些变量来跟踪每种四个核苷酸的数量。一种方法是创建四个整数计数的变量,每个变量对应一个碱基。我将通过将它们的初始值设置为0初始化这四个计数变量:

>>> count_a = 0
>>> count_c = 0
>>> count_g = 0
>>> count_t = 0

我可以使用我之前展示的元组解包语法来一行写完:

>>> count_a, count_c, count_g, count_t = 0, 0, 0, 0

我需要查看每个碱基并确定要增加的变量,使其值增加 1。例如,如果当前的baseC,那么我应该增加count_c变量。我可以这样写:

for base in dna:
    if base == 'C': ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        count_c = count_c + 1 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

==运算符用于比较两个值是否相等。这里我想知道当前的base是否等于字符串C

2

count_c设置为当前值加 1。

==运算符用于比较两个值是否相等。它用于比较两个字符串或两个数字。前面我展示了,如果混合字符串和数字,使用/会引发异常。如果你混合类型使用这个运算符会发生什么,例如 '3' == 3?在未比较类型的情况下,这是一个安全的运算符吗?

如图 1-3 所示,使用+=运算符来增加变量的值是一种更短的方式,它将右侧的值(通常标记为 RHS)添加到表达式的左侧(或 LHS)的内容中。

mpfb 0103

图 1-3. +=运算符将在左侧的变量上加上右侧的值

由于我有四个核苷酸要检查,我需要一种方法来结合另外三个if表达式。Python 中的语法是使用elif来表示else ifelse表示最后的或默认情况。以下是一个可以输入到程序或 REPL 中的代码块,实现了一个简单的决策树:

dna = 'ACCGGGTTTT'
count_a, count_c, count_g, count_t = 0, 0, 0, 0
for base in dna:
    if base == 'A':
        count_a += 1
    elif base == 'C':
        count_c += 1
    elif base == 'G':
        count_g += 1
    elif base == 'T':
        count_t += 1

我应该得到每个排序后的碱基的计数,分别为 1、2、3 和 4:

>>> count_a, count_c, count_g, count_t
(1, 2, 3, 4)

现在我需要向用户报告结果:

>>> print(count_a, count_c, count_g, count_t)
1 2 3 4

这就是程序期望的确切输出。注意,print()函数接受多个值作为参数并在每个值之间插入一个空格。如果在 REPL 中阅读help(print),你会发现可以使用sep参数来改变这个行为:

>>> print(count_a, count_c, count_g, count_t, sep='::')
1::2::3::4

print()函数还会在输出的末尾加上一个换行符,同样可以使用end选项来进行更改:

>>> print(count_a, count_c, count_g, count_t, end='\n-30-\n')
1 2 3 4
-30-

编写和验证解决方案

使用上述代码,你应该能够创建一个通过所有测试的程序。在编写过程中,我建议你定期运行pylintflake8mypy来检查源代码中的潜在错误。我甚至建议你为这些工具安装pytest扩展,以便能够定期执行此类测试:

$ python3 -m pip install pytest-pylint pytest-flake8 pytest-mypy

或者,我已经将requirements.txt文件放在 GitHub 仓库的根目录中,列出了我在整本书中将要使用的各种依赖项。你可以使用以下命令安装所有这些模块:

$ python3 -m pip install -r requirements.txt

有了这些扩展,你可以运行以下命令来执行不仅在tests/dna_test.py文件中定义的测试,还包括使用这些工具进行 linting 和类型检查的测试:

$ pytest -xv --pylint --flake8 --mypy tests/dna_test.py
========================== test session starts ===========================
...
collected 7 items

tests/dna_test.py::FLAKE8 SKIPPED                                  [ 12%]
tests/dna_test.py::mypy PASSED                                     [ 25%]
tests/dna_test.py::test_exists PASSED                              [ 37%]
tests/dna_test.py::test_usage PASSED                               [ 50%]
tests/dna_test.py::test_dies_no_args PASSED                        [ 62%]
tests/dna_test.py::test_arg PASSED                                 [ 75%]
tests/dna_test.py::test_file PASSED                                [ 87%]
::mypy PASSED                                                      [100%]
================================== mypy ==================================

Success: no issues found in 1 source file
====================== 7 passed, 1 skipped in 0.58s ======================

当缓存版本表明自上次测试以来没有任何更改时,某些测试将被跳过。使用--cache-clear选项强制运行测试。此外,如果代码格式不正确或缩进不正确,您可能会发现无法通过 linting 测试。您可以使用yapfblack自动格式化代码。大多数 IDE 和编辑器都会提供自动格式化选项。

这么多要打字,所以我在该目录中创建了一个Makefile的快捷方式供你使用:

$ cat Makefile
.PHONY: test

test:
	python3 -m pytest -xv --flake8 --pylint --pylint-rcfile=../pylintrc \
    --mypy dna.py tests/dna_test.py

all:
	../bin/all_test.py dna.py

你可以通过阅读附录 A 来了解这些文件的更多信息。现在,了解到如果你的系统安装了make,你可以使用命令make test来运行Makefile中的test目标。如果你没有安装make或者不想使用它,也没关系,但我建议你探索一下Makefile如何用于文档化和自动化流程。

有许多方法可以编写dna.py的通过版本,我想鼓励你在阅读解决方案之前继续探索。最重要的是,我希望你习惯于修改你的程序然后运行测试来验证其工作。这就是测试驱动开发的循环,我首先创建某些度量标准来判断程序何时正确工作。在这种情况下,就是由pytest运行的dna_test.py程序。

测试确保我不偏离目标,它们还让我知道何时满足程序的要求。它们是作为我可以执行的程序而具体化的规格(也称为规格)。否则,我怎么知道程序何时工作或何时完成呢?或者,正如路易斯·斯里格利所说的,“没有需求或设计,编程就是在一个空白文本文件中添加错误的艺术。”

测试对于创建可复制的程序至关重要。除非您能绝对自动地证明在运行良好和坏数据时程序的正确性和可预测性,否则您不是在编写优秀的软件。

其他解决方案

我在本章早些时候写过的程序是 GitHub 仓库中的solution1_iter.py版本,所以我不会再复习那个版本了。我想向你展示几个替代方案,从简单到复杂的想法。请不要误以为它们从差到好递进。所有版本都通过了测试,所以它们都同样有效。重点是探索 Python 在解决常见问题时的各种可能性。请注意,我将省略它们共有的代码,例如get_args()函数。

解决方案 2:创建一个count()函数并添加一个单元测试。

我想展示的第一个变体将把所有计数代码从main()函数中移到一个count()函数中。你可以在程序的任何地方定义这个函数,但我通常喜欢先写get_args(),然后是main(),然后是其他函数,最后是调用main()的最后一对语句之前。

对于以下函数,您还需要导入typing.Tuple值:

def count(dna: str) -> Tuple[int, int, int, int]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Count bases in DNA """

    count_a, count_c, count_g, count_t = 0, 0, 0, 0 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    for base in dna:
        if base == 'A':
            count_a += 1
        elif base == 'C':
            count_c += 1
        elif base == 'G':
            count_g += 1
        elif base == 'T':
            count_t += 1

    return (count_a, count_c, count_g, count_t) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

类型显示该函数接受一个字符串,并返回一个包含四个整数值的元组。

2

这是从main()中进行计数的代码。

3

返回一个包含四个计数的元组。

有许多理由将此代码移入函数中。首先,这是一个计算单元——给定一个 DNA 字符串,返回四核苷酸频率——因此封装它是有意义的。这将使main()更短更易读,并允许我为函数编写单元测试。由于函数名为count(),我喜欢将单元测试命名为test_count()。我将此函数放在了dna.py程序中,正好在count()函数后面,而不是放在dna_test.py程序中,这只是为了方便起见。对于简短的程序,我倾向于将函数和单元测试放在源代码中的一起,但随着项目变大,我会将单元测试分离到单独的模块中。以下是测试函数:

def test_count() -> None: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Test count """

    assert count('') == (0, 0, 0, 0) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert count('123XYZ') == (0, 0, 0, 0)
    assert count('A') == (1, 0, 0, 0) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert count('C') == (0, 1, 0, 0)
    assert count('G') == (0, 0, 1, 0)
    assert count('T') == (0, 0, 0, 1)
    assert count('ACCGGGTTTT') == (1, 2, 3, 4)

1

函数名必须以 test_ 开头,以便被 pytest 找到。这里的类型显示该测试不接受参数,并且因为没有 return 语句,返回默认值 None

2

我喜欢用预期和意外的值来测试函数,以确保它们返回合理的结果。空字符串应该返回全部零值。

3

其余的测试确保每个碱基在正确的位置上报告。

为了验证我的函数是否有效,我可以在 dna.py 程序上使用 pytest

$ pytest -xv dna.py
=========================== test session starts ===========================
...

dna.py::test_count PASSED                                           [100%]

============================ 1 passed in 0.01s ============================

第一个测试传递空字符串,并期望得到所有零的计数。这是一个判断调用,说实话。你可能决定你的程序应该向用户抱怨没有输入。也就是说,可以使用空字符串作为输入运行程序,而这个版本将报告如下:

$ ./dna.py ""
0 0 0 0

同样,如果我传递一个空文件,我会得到相同的答案。使用 touch 命令创建一个空文件:

$ touch empty
$ ./dna.py empty
0 0 0 0

在 Unix 系统上,/dev/null 是一个特殊的文件句柄,返回空值:

$ ./dna.py /dev/null
0 0 0 0

你可能觉得没有输入是一个错误,并报告它。测试的重要之处在于它迫使我考虑这个问题。例如,如果给定空字符串,count() 函数应该返回零还是引发异常?如果输入为空,程序应该崩溃并退出,还是以非零状态结束?这些都是你为程序需要做出的决定。

现在我在 dna.py 代码中有了一个单元测试,我可以在该文件上运行 pytest 看它是否通过:

$ pytest -v dna.py
============================ test session starts =============================
...
collected 1 item

dna.py::test_count PASSED                                              [100%]

============================= 1 passed in 0.01s ==============================

当我编写代码时,我喜欢编写只做一件事情的函数,尽可能少的参数。然后我喜欢在源代码中函数的后面写一个类似 test_ 加上函数名的命名测试。如果我发现我有很多这样的单元测试,我可能决定将它们移到一个单独的文件中,并让 pytest 执行该文件。

要使用这个新函数,修改 main() 如下:

def main() -> None:
    args = get_args()
    count_a, count_c, count_g, count_t = count(args.dna) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    print('{} {} {} {}'.format(count_a, count_c, count_g, count_t)) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

将从 count() 返回的四个值解包到单独的变量中。

2

使用 str.format() 创建输出字符串。

让我们稍微关注一下 Python 的str.format()。如图 1-4 所示,字符串'{} {} {} {}'是我想要生成的输出的模板,并且我直接在字符串字面值上调用str.format()函数。这是 Python 中的一个常见习惯用法,您还将在str.join()函数中看到。重要的是要记住,在 Python 中,即使是字面字符串(在引号中直接存在于您的源代码中的字符串),也是您可以调用方法的对象

mpfb 0104

图 1-4. str.format()函数使用花括号定义占位符,这些占位符将用参数的值填充。

每个字符串模板中的{}都是函数参数提供的某个值的占位符。使用这个函数时,您需要确保占位符的数量与参数的数量相同。参数按照它们提供的顺序插入。稍后我会详细介绍str.format()函数。

我不必展开count()函数返回的元组。如果我在元组前面加上一个星号(*)来splat它,我可以将整个元组作为参数传递给str.format()函数。这告诉 Python 将元组扩展为其值:

def main() -> None:
    args = get_args()
    counts = count(args.dna) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    print('{} {} {} {}'.format(*counts)) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

counts变量是包含整数基数计数的 4 元组。

2

*counts语法将元组扩展为格式字符串所需的四个值;否则,元组将被解释为单个值。

因为我只使用counts变量一次,我可以跳过赋值,将代码缩短为一行:

def main() -> None:
    args = get_args()
    print('{} {} {} {}'.format(*count(args.dna))) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

count()函数的返回值直接传递给str.format()方法。

第一种解决方案可能更易于阅读和理解,并且像flake8这样的工具将能够发现{}占位符数量与变量数量不匹配的情况。简单、冗长、明显的代码通常比紧凑、聪明的代码更好。尽管如此,了解元组解包和扩展变量的技术是很有用的,我将在后续程序中使用这些技术。

解决方案 3:使用str.count()

前面的count()函数结果相当冗长。我可以使用str.count()方法将这个函数写成单行代码。此函数将计算一个字符串在另一个字符串中出现的次数。让我在 REPL 中演示给你看:

>>> seq = 'ACCGGGTTTT'
>>> seq.count('A')
1
>>> seq.count('C')
2

如果未找到字符串,它将报告0,使其能够安全地计算所有四个核苷酸,即使输入序列缺少一个或多个碱基:

>>> 'AAA'.count('T')
0

这是使用这个思想的count()函数的新版本:

def count(dna: str) -> Tuple[int, int, int, int]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Count bases in DNA """

    return (dna.count('A'), dna.count('C'), dna.count('G'), dna.count('T')) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

签名与之前相同。

2

对每个四个碱基调用 dna.count() 方法。

这段代码更加简洁,我可以使用相同的单元测试来验证它的正确性。这是一个关键点:函数应该像黑匣子一样运行。也就是说,我不知道或不关心盒子里面发生了什么。输入一些东西,得到一个答案,我只关心答案是否正确。只要外部的约定——参数和返回值保持不变,我可以自由地改变盒子里面发生的事情。

这里是使用 Python 的 f-string 语法在 main() 函数中创建输出字符串的另一种方式:

def main() -> None:
    args = get_args()
    count_a, count_c, count_g, count_t = count(args.dna) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    print(f'{count_a} {count_c} {count_g} {count_t}') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

将元组解包为四个计数。

2

使用 f-string 执行变量插值。

它被称为 f-string,因为在引号之前有一个 f。我使用 format 这个助记符来提醒自己这是用来格式化字符串的。Python 还有一个 raw 字符串,以 r 开头,稍后我会讨论它。Python 中的所有字符串——裸字符串、f-字符串或 r-字符串——都可以用单引号或双引号括起来。这没有区别。

使用 f-string,{} 占位符可以执行 变量插值,这是一个术语,意味着将变量转换为其内容。这些大括号甚至可以执行代码。例如,len() 函数将返回字符串的长度,并且可以在大括号内执行:

>>> seq = 'ACGT'
>>> f'The sequence "{seq}" has {len(seq)} bases.'
'The sequence "ACGT" has 4 bases.'

我通常发现使用 str.format() 相比等效的代码更易读。选择哪种方式主要是风格上的决定。我建议选择能使你的代码更易读的那种方式。

解决方案 4:使用字典计算所有字符的数量

到目前为止,我讨论了 Python 的字符串、列表和元组。下一个解决方案介绍了 字典,它们是键/值存储。我想展示一个内部使用字典的 count() 函数版本,这样我可以强调一些重要的理解点:

def count(dna: str) -> Tuple[int, int, int, int]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Count bases in DNA """

    counts = {} ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    for base in dna: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        if base not in counts: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            counts[base] = 0 ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        counts[base] += 1 ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

    return (counts.get('A', 0), ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
            counts.get('C', 0),
            counts.get('G', 0),
            counts.get('T', 0))

1

在内部,我会使用一个字典,但函数签名不会改变。

2

初始化一个空字典来存储counts

3

使用for循环遍历序列。

4

检查字典中是否尚不存在这个碱基。

5

将这个碱基的值初始化为0

6

增加这个碱基的计数 1。

7

使用dict.get()方法获取每个碱基的计数或默认值0

再次强调,这个函数的契约——类型签名——没有改变。输入仍然是字符串,输出仍然是 4 个整数的元组。在函数内部,我将使用一个我将使用空花括号初始化的字典:

>>> counts = {}

我也可以使用dict()函数。两者都没有优势:

>>> counts = dict()

我可以使用type()函数来检查这是否是一个字典:

>>> type(counts)
<class 'dict'>

isinstance()函数是检查变量类型的另一种方式:

>>> isinstance(counts, dict)
True

我的目标是创建一个字典,其中每个碱基都作为,其出现次数作为。例如,对于序列ACCGGGTTT,我希望counts看起来像这样:

>>> counts
{'A': 1, 'C': 2, 'G': 3, 'T': 4}

我可以使用方括号和键名访问任何一个值,如下所示:

>>> counts['G']
3

如果尝试访问一个不存在的字典键,Python 将引发KeyError异常:

>>> counts['N']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'N'

我可以使用in关键字来查看字典中是否存在一个键:

>>> 'N' in counts
False
>>> 'T' in counts
True

当我遍历序列中的每个碱基时,我需要查看counts字典中是否存在该碱基。如果不存在,我需要将其初始化为0。然后,我可以安全地使用+=操作符将碱基的计数增加 1:

>>> seq = 'ACCGGGTTTT'
>>> counts = {}
>>> for base in seq:
...     if not base in counts:
...         counts[base] = 0
...     counts[base] += 1
...
>>> counts
{'A': 1, 'C': 2, 'G': 3, 'T': 4}

最后,我想返回每个碱基的 4 元组计数。你可能会认为这会起作用:

>>> counts['A'], counts['C'], counts['G'], counts['T']
(1, 2, 3, 4)

但是请问自己,如果序列中缺少其中一个碱基会发生什么?这段代码能通过我编写的单元测试吗?肯定不行。因为空字符串的第一个测试将生成KeyError异常。安全地询问字典值的方法是使用dict.get()方法。如果键不存在,则返回None

>>> counts.get('T')
4
>>> counts.get('N')

dict.get()方法接受一个可选的第二个参数,即在键不存在时返回的默认值,因此这是返回 4 个碱基计数的最安全方法:

>>> counts.get('A', 0), counts.get('C', 0), counts.get('G', 0),
    counts.get('T', 0)
(1, 2, 3, 4)

无论你在count()函数内写什么,确保它能通过test_count()单元测试。

解决方案 5:仅计算所需的碱基

前面的解决方案将计算输入序列中的每个字符,但如果我只想计算四个核苷酸呢?在这个解决方案中,我将初始化一个只包含所需碱基且值为0的字典。我还需要引入typing.Dict来运行这段代码:

def count(dna: str) -> Dict[str, int]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Count bases in DNA """

    counts = {'A': 0, 'C': 0, 'G': 0, 'T': 0} ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    for base in dna: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        if base in counts: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            counts[base] += 1 ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

    return counts ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

1

现在的签名表明我将返回一个具有字符串键和整数值的字典。

2

使用四个碱基作为键和值为 0 的方式初始化 counts 字典。

3

遍历碱基。

4

检查碱基是否作为键存在于 counts 字典中。

5

如果是这样,则将此碱基的 counts 增加 1。

6

返回 counts 字典。

由于 count() 函数现在返回的是一个字典而不是元组,需要更改 test_count() 函数:

def test_count() -> None:
    """ Test count """

    assert count('') == {'A': 0, 'C': 0, 'G': 0, 'T': 0} ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert count('123XYZ') == {'A': 0, 'C': 0, 'G': 0, 'T': 0} ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert count('A') == {'A': 1, 'C': 0, 'G': 0, 'T': 0}
    assert count('C') == {'A': 0, 'C': 1, 'G': 0, 'T': 0}
    assert count('G') == {'A': 0, 'C': 0, 'G': 1, 'T': 0}
    assert count('T') == {'A': 0, 'C': 0, 'G': 0, 'T': 1}
    assert count('ACCGGGTTTT') == {'A': 1, 'C': 2, 'G': 3, 'T': 4}

1

返回的字典将始终具有键 ACGT。即使对于空字符串,这些键也会存在并设置为 0

2

所有其他测试具有相同的输入,但现在我检查答案作为一个字典返回。

在编写这些测试时,请注意字典中键的顺序不重要。以下代码中的两个字典内容相同,即使定义方式不同:

>>> counts1 = {'A': 1, 'C': 2, 'G': 3, 'T': 4}
>>> counts2 = {'T': 4, 'G': 3, 'C': 2, 'A': 1}
>>> counts1 == counts2
True

我想指出 test_count() 函数测试函数以确保其正确性,并作为文档。阅读这些测试帮助我看到可能输入和函数预期输出的结构。

这是我需要更改 main() 函数以使用返回的字典的方式:

def main() -> None:
    args = get_args()
    counts = count(args.dna) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    print('{} {} {} {}'.format(counts['A'], counts['C'], counts['G'], ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                               counts['T']))

1

counts 现在是一个字典。

2

使用 str.format() 方法使用字典中的值创建输出。

解决方案 6:使用 collections.defaultdict()

通过使用 collections 模块中的 defaultdict() 函数,可以摆脱所有之前初始化字典和检查键等的工作:

>>> from collections import defaultdict

当我使用 defaultdict() 函数创建一个新字典时,我告诉它值的默认类型。我不再需要在使用之前检查键,因为 defaultdict 类型将自动使用默认类型的代表值创建我引用的任何键。对于计数核苷酸的情况,我希望使用 int 类型:

>>> counts = defaultdict(int)

默认的int值将是0。对不存在的键的任何引用将导致它被创建为值0

>>> counts['A']
0

这意味着我可以一步实例化并增加任何碱基:

>>> counts['C'] += 1
>>> counts
defaultdict(<class 'int'>, {'A': 0, 'C': 1})

这里是我如何使用这个思路重写count()函数的方式:

def count(dna: str) -> Dict[str, int]:
    """ Count bases in DNA """

    counts: Dict[str, int] = defaultdict(int) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    for base in dna:
        counts[base] += 1 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    return counts

1

counts将是一个带有整数值的defaultdict。这里的类型注释是mypy所需的,以确保返回的值是正确的。

2

我可以安全地增加这个碱基的counts

函数test_count()看起来相当不同。我一眼就能看出答案与先前版本非常不同:

def test_count() -> None:
    """ Test count """

    assert count('') == {} ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert count('123XYZ') == {'1': 1, '2': 1, '3': 1, 'X': 1, 'Y': 1, 'Z': 1} ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert count('A') == {'A': 1} ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert count('C') == {'C': 1}
    assert count('G') == {'G': 1}
    assert count('T') == {'T': 1}
    assert count('ACCGGGTTTT') == {'A': 1, 'C': 2, 'G': 3, 'T': 4}

1

给定一个空字符串,将返回一个空字典。

2

注意字符串中的每个字符都是字典中的一个键。

3

只有A存在,计数为 1。

鉴于返回的字典可能不包含所有碱基,main()中的代码需要使用count.get()方法来检索每个碱基的频率:

def main() -> None:
    args = get_args()
    counts = count(args.dna) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    print(counts.get('A', 0), counts.get('C', 0), counts.get('G', 0), ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
          counts.get('T', 0))

1

counts将是一个可能不包含所有核苷酸的字典。

2

使用dict.get()方法并设置默认值为0是最安全的。

解决方案 7:使用collections.Counter()

完美是在无需添加任何内容时实现的,而是在无需再去除任何内容时实现的。

安托万·德·圣埃克絮佩里

实际上我并不太喜欢最后三个解决方案,但我需要逐步介绍如何手动使用字典和defaultdict(),以及如何欣赏使用collections.Counter()的简便性:

>>> from collections import Counter
>>> Counter('ACCGGGTTT')
Counter({'G': 3, 'T': 3, 'C': 2, 'A': 1})

最佳代码是你永远不必写的代码,而Counter()是一个预打包的函数,它将返回一个包含你传递的可迭代对象中各项频率的字典。你可能也听说过这被称为多集。这里的可迭代对象是由字符组成的字符串,所以我得到的字典与前两个解决方案相同,但没有编写任何代码

它非常简单,你几乎可以避开count()test_count()函数,直接集成到你的main()中:

def main() -> None:
    args = get_args()
    counts = Counter(args.dna) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    print(counts.get('A', 0), counts.get('C', 0), counts.get('G', 0), ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
          counts.get('T', 0))

1

counts 将是一个包含 args.dna 中字符频率的字典。

2

使用 dict.get() 仍然是最安全的,因为我不能确定所有碱基都存在。

我可以说这段代码应该放在一个 count() 函数中并保留测试,但 Counter() 函数已经经过测试并具有良好定义的接口。我认为直接使用这个函数更有意义。

进一步探讨

这些解决方案仅处理以大写文本提供的 DNA 序列。看到这些序列用小写字母提供并不罕见。例如,在植物基因组学中,常用小写字母表示重复 DNA 区域。通过以下操作修改程序以处理大小写输入:

  1. 添加一个新的输入文件,混合大小写。

  2. tests/dna_test.py 中添加一个测试,使用这个新文件并指定对大小写不敏感的预期计数。

  3. 运行新的测试,并确保您的程序失败。

  4. 修改程序,直到它通过新的测试和所有以前的测试。

使用字典计算所有可用字符的解决方案似乎更灵活。也就是说,某些测试仅考虑 ACGT 作为碱基,但如果输入序列使用 IUPAC 代码 表示测序中的可能歧义,那么程序将需要完全重写。一个仅硬编码查看四个核苷酸的程序也对使用不同字母表的蛋白质序列无用。考虑编写一个程序的版本,它将以每个找到的字符作为第一列并在第二列中打印字符的频率输出两列。允许用户按任一列升序或降序排序。

复习

这一章有点庞大。接下来的章节将会稍短,因为我将在这里覆盖的许多基本思想上继续构建:

  • 你可以使用 new.py 程序创建一个基本的 Python 程序结构,使用 argparse 接受和验证命令行参数。

  • pytest 模块将运行所有以 test_ 开头的函数,并报告通过了多少个测试。

  • 单元测试用于函数,集成测试检查程序作为整体是否工作。

  • 类似 pylintflake8mypy 的程序可以找出代码中的各种错误。你还可以让 pytest 自动运行测试,看看你的代码是否通过了这些检查。

  • 复杂的命令可以存储为 Makefile 中的一个目标,并使用 make 命令执行。

  • 您可以使用一系列 if/else 语句创建决策树。

  • 有多种方法可以计算字符串中所有字符的数量。使用 collections.Counter() 函数可能是创建字母频率字典的最简单方法。

  • 你可以用类型注释变量和函数,并使用mypy来确保类型的正确使用。

  • Python REPL 是一个交互式工具,用于执行代码示例和阅读文档。

  • Python 社区通常遵循诸如 PEP8 之类的风格指南。像yapfblack这样的工具可以根据这些建议自动格式化代码,而pylintflake8等工具将报告与指南不符的偏差。

  • Python 的字符串、列表、元组和字典是非常强大的数据结构,每种都有有用的方法和丰富的文档。

  • 你可以创建一个自定义的、不可变的、基于命名元组的有类型class

也许你正在想哪一个是七种解决方案中最好的。像生活中的许多事情一样,这取决于情况。有些程序写起来更短,更容易理解,但当面对大数据集时可能表现不佳。在第二章中,我将向你展示如何对比程序,使用大输入进行多次运行以确定哪一个性能最佳。

^(1) 布尔类型是TrueFalse,但许多其他数据类型是truthy或反之falsey。空的str("")是 falsey,所以任何非空字符串是 truthy。数字0是 falsey,所以任何非零值是 truthy。空的listsetdict是 falsey,所以任何非空的这些都是 truthy。

第二章:将 DNA 转录为 mRNA:突变字符串,读写文件

为了表达维持生命所必需的蛋白质,DNA 区域必须被转录成一种称为messenger RNA(mRNA)的 RNA 形式。虽然 DNA 和 RNA 之间有许多迷人的生化差异,但就我们的目的而言,唯一的区别是 DNA 序列中代表嘧啶碱基的所有T字符需要更改为尿嘧啶字母U。正如在Rosalind RNA 页面上所描述的那样,我将向您展示如何编写一个接受像ACGT这样的 DNA 字符串并打印转录 mRNA ACGU的程序。我可以使用 Python 的str.replace()函数在一行中完成这个任务:

>>> 'GATGGAACTTGACTACGTAAATT'.replace('T', 'U')
'GAUGGAACUUGACUACGUAAAUU'

您已经在第一章中看到如何编写一个从命令行或文件接受 DNA 序列并打印结果的程序,因此如果再次这样做,您不会学到太多新东西。我将通过解决生物信息学中常见的模式来使这个程序更有趣。具体来说,我将展示如何处理一个或多个输入文件,并将结果放置在输出目录中。例如,将测序运行的结果作为包含需要进行质量检查和过滤的文件目录返回,将清理后的序列放入新的目录进行分析。这里的输入文件包含 DNA 序列,每行一个,我将 mRNA 序列写入到同名文件中。

在本章中,您将学习:

  • 如何编写一个需要一个或多个文件输入的程序

  • 如何创建目录

  • 如何读写文件

  • 如何修改字符串

入门

可能会有所帮助,先尝试运行其中一个解决方案,看看程序应该如何工作。首先切换到02_rna目录,并将第一个解决方案复制到程序rna.py

$ cd 02_rna
$ cp solution1_str_replace.py rna.py

使用-h标志请求程序的用法:

$ ./rna.py -h
usage: rna.py [-h] [-o DIR] FILE [FILE ...] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

Transcribe DNA into RNA

positional arguments: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
  FILE                  Input DNA file

optional arguments:
  -h, --help            show this help message and exit
  -o DIR, --out_dir DIR
                        Output directory (default: out) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

用方括号([])括起来的参数是可选的。[FILE ...]语法表示可以重复使用该参数。

2

输入FILE参数将是位置参数。

3

可选的输出目录默认为out

该程序的目标是处理一个或多个包含 DNA 序列的文件。以下是第一个测试输入文件:

$ cat tests/inputs/input1.txt
GATGGAACTTGACTACGTAAATT

运行rna.py程序,使用此输入文件,并注意输出:

$ ./rna.py tests/inputs/input1.txt
Done, wrote 1 sequence in 1 file to directory "out".

现在应该有一个名为out的目录,其中包含一个名为input1.txt的文件:

$ ls out/
input1.txt

文件的内容应与输入的 DNA 序列匹配,但所有的T都应更改为U

$ cat out/input1.txt
GAUGGAACUUGACUACGUAAAUU

您应该使用多个输入运行程序,并验证您在输出目录中获取多个文件。在这里,我将使用所有测试输入文件,输出目录称为rna。请注意摘要文本如何使用序列文件的正确单数/复数形式:

$ ./rna.py --out_dir rna tests/inputs/*
Done, wrote 5 sequences in 3 files to directory "rna".

我可以使用带有-l选项的wc(单词计数)程序来计算输出文件中的数,并验证rna目录中是否写入了五个序列到三个文件:

$ wc -l rna/*
       1 rna/input1.txt
       2 rna/input2.txt
       2 rna/input3.txt
       5 total

定义程序的参数

如您从前面的使用情况所见,您的程序应接受以下参数:

  • 一个或多个位置参数,必须是每个包含要转录的 DNA 字符串的可读文本文件。

  • 一个可选的-o--out_dir参数,用于命名一个输出目录以将 RNA 序列写入其中。默认值应为out

您可以自由编写和结构化您的程序(只要它们通过测试),但我总是会使用new.py和我在第一章展示的结构来启动程序。--force标志表示应覆盖现有的rna.py

$ new.py --force -p "Transcribe DNA to RNA" rna.py
Done, see new script "rna.py".

定义一个可选参数

修改get_args()函数以接受前面部分描述的参数。首先定义out_dir参数。我建议您将由new.py生成的-a|--arg选项更改为以下内容:

parser.add_argument('-o', ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                    '--out_dir', ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                    help='Output directory', ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
                    metavar='DIR', ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
                    type=str, ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
                    default='out') ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

1

这是短标志名称。短标志以单个破折号开头,后跟单个字符。

2

这是长标志名称。长标志以两个破折号开头,后跟比短标志更易记的字符串。这也将是argparse用来访问值的名称。

3

这将包含在使用说明中,以描述该参数。

4

metavar也是在使用中显示的简短描述。

5

所有参数的默认类型都是str(字符串),因此这在技术上是多余的,但仍然是记录文档的一个好主意。

6

如果在定义选项时未指定default属性,则默认值将为None

定义一个或多个必需的位置参数

对于FILE值,我可以修改默认的-f|--file参数如下:

parser.add_argument('file', ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                    help='Input DNA file(s)', ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                    metavar='FILE', ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
                    nargs='+', ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
                    type=argparse.FileType('rt')) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

移除-f短标记和--file中的两个破折号,使其成为一个位置参数称为file。可选参数以破折号开头,位置参数不会。

2

help字符串指示参数应为一个或多个包含 DNA 序列的文件。

3

此字符串在简短的使用说明中打印,表示该参数是一个文件。

4

这表明参数的数量。+表示需要一个或多个值。

5

这是argparse将强制执行的实际类型。我要求任何值都必须是可读的文本(rt)文件。

使用 nargs 定义参数的数量

我使用nargs来描述程序接受的参数数量。除了使用整数值来准确描述允许的数目外,还可以使用 Table 2-1 中显示的三个符号。

表 2-1. nargs的可能值

符号 意义
? 零个或一个
* 零个或多个
+ 一个或多个

当您在nargs中使用+时,argparse将提供参数作为一个列表。即使只有一个参数,也会得到包含一个元素的列表。您永远不会得到空列表,因为至少需要一个参数。

使用 argparse.FileType()验证文件参数

argparse.FileType()函数非常强大,使用它可以节省大量验证文件输入的时间。当您使用此类型定义参数时,如果任何参数不是文件,argparse将打印错误消息并停止程序的执行。例如,我会假设在您的02_dna目录中没有名为blargh的文件。请注意当我传递该值时的结果:

$ ./rna.py blargh
usage: rna.py [-h] [-o DIR] FILE [FILE ...]
rna.py: error: argument FILE: can't open 'blargh': [Errno 2]
No such file or directory: 'blargh'

在这里并不明显,但程序从未跳出get_args()函数,因为argparse执行了以下操作:

  1. 检测到blargh不是一个有效的文件

  2. 打印了简短的使用说明

  3. 打印了一个有用的错误消息

  4. 用非零值退出程序

这就是一个良好编写的程序应该如何工作,尽快检测和拒绝不良参数,并通知用户出现的问题。所有这些都是在我仅仅描述所需参数类型的情况下发生的。再次强调,最好的代码是您根本不需要编写的代码(或者像埃隆·马斯克说的那样,“最好的零件是没有零件,最好的流程是没有流程。”)

因为我使用了文件类型,列表的元素不会是表示文件名的字符串,而是打开的文件句柄。文件句柄是读写文件内容的机制。在上一章节中,当 DNA 参数是文件名时,我使用了文件句柄。

在源代码中定义这些参数的顺序在此情况下并不重要。您可以在位置参数之前或之后定义选项。只有当您有多个位置参数时,顺序才重要——第一个参数将用于第一个位置参数,第二个参数用于第二个位置参数,依此类推。

定义Args

最后,我需要一种方法来定义Args类,以表示参数:

from typing import NamedTuple, List, TextIO ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

class Args(NamedTuple):
    """ Command-line arguments """
    files: List[TextIO] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    out_dir: str ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

我将需要从typing模块导入两个新项,List用于描述列表,TextIO用于打开文件句柄。

2

files属性将是一个打开的文件句柄列表。

3

out_dir属性将是一个字符串。

我可以使用这个类来创建从get_args()返回的值。以下语法使用位置表示法,使得file是第一个字段,out_dir是第二个字段。当有一个或两个字段时,我倾向于使用位置表示法:

return Args(args.file, args.out_dir)

明确地使用字段名称更安全,而且在我有更多字段时,可能更容易阅读,也会变得至关重要:

return Args(files=args.file, out_dir=args.out_dir)

现在我已经具备了定义、记录和验证输入的所有代码。接下来,我将展示程序的其余部分应该如何工作。

使用伪代码概述程序:

我将在main()函数中勾画程序逻辑的基础,使用代码和伪代码混合来概述如何处理输入和输出文件。每当你在编写新程序时遇到困难时,这种方法都可以帮助你看到需要做什么。然后你可以找出如何做到这一点:

def main() -> None:
    args = get_args()

    if not os.path.isdir(args.out_dir): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        os.makedirs(args.out_dir) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    num_files, num_seqs = 0, 0 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    for fh in args.files: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        # open an output file in the output directory ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        # for each line/sequence from the input file:
            # write the transcribed sequence to the output file
            # update the number of sequences processed
        # update the number of files processed

    print('Done.') ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

1

os.path.isdir()函数将报告输出目录是否存在。

2

os.makedirs()函数将创建目录路径。

3

初始化变量以记录写入的文件数和序列数,以在程序退出时提供反馈。

4

使用for循环迭代args.files列表中的文件句柄。迭代变量fh有助于我记住其类型。

5

这是描述你需要对每个文件处理的伪代码步骤。

6

为用户打印一个摘要,让他们知道发生了什么。

os.makedirs() 函数将创建一个目录及其所有父目录,而 os.mkdir() 函数将在父目录不存在时失败。我在我的代码中只使用第一个函数。

如果你认为你知道如何完成程序,请随意继续。确保运行 pytest(或 make test)来确保你的代码是正确的。如果你需要关于如何读写文件的更多指导,请跟着我走。接下来我将处理伪代码部分。

迭代输入文件

请记住,args.files 是一个 List[TextIO],意味着它是一个文件句柄的列表。我可以使用 for 循环访问列表中任何可迭代元素:

for fh in args.files:

我想在这里强调,我选择了一个迭代器变量称为 fh,因为每个值都是文件句柄。我有时看到一些人总是在 for 循环中使用像 ix 这样的迭代器变量名,但这些都不是描述性的变量名。[¹] 我会承认,在迭代数字时使用像 n(代表 number)或 i(代表 integer)这样的变量名是非常常见的,比如:

for i in range(10):

有时我会使用 xxs(读作 exes)来代表某个通用值的 onemany

for x in xs:

否则,非常重要的是使用准确描述它们代表的内容的变量名。

创建输出文件名

根据伪代码,第一个目标是打开一个输出文件。为此,我需要一个文件名,该文件名将输入文件的基本名称与输出目录的名称结合起来。也就是说,如果输入文件是 dna/input1.txt,输出目录是 rna,那么输出文件路径应该是 rna/input1.txt

os 模块用于与操作系统(如 Windows、macOS 或 Linux)交互,而 os.path 模块有许多方便的函数可以使用,比如 os.path.dirname() 函数用于从文件路径中获取目录名称和 os.path.basename() 函数用于获取文件名称(见 Figure 2-1):

>>> import os
>>> os.path.dirname('./tests/inputs/input1.txt')
'./tests/inputs'
>>> os.path.basename('./tests/inputs/input1.txt')
'input1.txt'

mpfb 0201

图 2-1. os.path 模块包含诸如 dirname()basename() 这样的有用函数,用于从文件路径中提取部分

新序列将被写入到 args.out_dir 中的输出文件中。我建议您使用 os.path.join() 函数与输入文件的基本名称创建输出文件名,如 Figure 2-2 所示。这将确保输出文件名在 Unix 和 Windows 上都有效,因为它们使用不同的路径分隔符—斜杠 (/) 和反斜杠 (\)。您可能还想要研究类似功能的 pathlib 模块。

mpfb 0202

图 2-2. os.path.join() 将通过将输出目录与输入文件的基本名称组合来创建输出路径

你可以从文件句柄的 fh.name 属性获取文件路径:

for fh in args.files:
    out_file = os.path.join(args.out_dir, os.path.basename(fh.name))
    print(fh.name, '->', out_file)

运行你的程序以验证它是否如下所示:

$ ./rna.py tests/inputs/*
tests/inputs/input1.txt -> out/input1.txt
tests/inputs/input2.txt -> out/input2.txt
tests/inputs/input3.txt -> out/input3.txt

我在慢慢向程序应该做什么迈进。写入一两行代码然后运行你的程序来检查它是否正确是非常重要的。我经常看到学生尝试在运行之前写很多行代码 —— 整个程序甚至 —— 这从来不会有好结果。

打开输出文件

使用这个输出文件名,你需要使用 open() 函数。我在第一章中使用了这个函数来从输入文件中读取 DNA。默认情况下,open() 只允许我读取文件,但我需要写入文件。我可以通过传递一个可选的第二个参数来指示我想要以写入方式打开文件:字符串 w 代表写入

当你用 w 模式打开现有文件时,文件将会被覆盖,这意味着其先前的内容会立即且永久丢失。如果需要,你可以使用 os.path.isfile() 函数来检查你是否打开了一个已存在的文件。

如 表 2-2 所示,你还可以使用值 r 代表读取(默认值),使用 a追加,这样可以在现有文件末尾写入更多内容。

表 2-2. 文件写入模式

模式 含义
w 写入
r 读取
a 追加

表 2-3 显示你也可以使用 tb 模式来读取和写入文本或原始字节。

表 2-3. 文件内容模式

模式 含义
t 文本
b 字节

你可以结合使用这些,例如使用 rb 读取字节wt 写入文本,这正是我在这里想要的:

for fh in args.files:
    out_file = os.path.join(args.out_dir, os.path.basename(fh.name))
    out_fh = open(out_file, 'wt') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

注意,我将变量命名为 out_fh 是为了提醒自己这是输出文件句柄。

写入输出序列

再次查看伪代码,我有两层循环迭代 —— 一个用于每个输入文件句柄,然后一个用于文件句柄中的每行 DNA。要从打开的文件句柄中读取每行,我可以使用另一个 for 循环:

for fh in args.files:
    for dna in fh:

input2.txt 文件包含两个序列,每个序列以换行符结尾:

$ cat tests/inputs/input2.txt
TTAGCCCAGACTAGGACTTT
AACTAGTCAAAGTACACC

首先,我将展示如何将每个序列打印到控制台,然后演示如何使用 print() 将内容写入文件句柄。第一章 提到 print() 函数将自动追加换行符(在 Unix 平台上是 \n,在 Windows 上是 \r\n),除非我告诉它不要这样做。为了避免以下代码产生两个换行符,一个来自序列,一个来自 print(),我可以使用 str.rstrip() 函数删除序列中的换行符,如下所示:

>>> fh = open('./tests/inputs/input2.txt')
>>> for dna in fh:
...     print(dna.rstrip()) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
...
TTAGCCCAGACTAGGACTTT
AACTAGTCAAAGTACACC

1

使用 dna.rstrip() 去除末尾的换行符。

或者使用 print()end 选项:

>>> fh = open('./tests/inputs/input2.txt')
>>> for dna in fh:
...     print(dna, end='') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
...
TTAGCCCAGACTAGGACTTT
AACTAGTCAAAGTACACC

1

在结尾使用空字符串而不是换行符。

目标是将每个 DNA 序列转录为 RNA 并将结果写入 out_fh。在本章的介绍中,我建议您可以使用 str.replace() 函数。如果您在 REPL 中阅读 help(str.replace),您将看到它将“返回一个将所有出现的旧子字符串替换为新子字符串的副本”:

>>> dna = 'ACTG'
>>> dna.replace('T', 'U')
'ACUG'

有其他方法可以将 T 更改为 U,我稍后会探讨。首先,我想指出,在 Python 中,字符串是不可变的,这意味着它们不能在原地修改。也就是说,我可以检查 DNA 字符串中是否有字母 T,然后使用 str.index() 函数找到位置并尝试用字母 U 覆盖它,但这会引发异常:

>>> dna = 'ACTG'
>>> if 'T' in dna:
...     dna[dna.index('T')] = 'U'
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
TypeError: 'str' object does not support item assignment

相反,我将使用 str.replace() 创建一个新的字符串:

>>> dna.replace('T', 'U')
'ACUG'
>>> dna
'ACTG'

我需要将这个新字符串写入 out_fh 输出文件句柄。我有两个选项。首先,我可以使用 print() 函数的 file 选项来描述 在哪里 打印字符串。在 REPL 中请参阅 help(print) 文档:

print(...)
    print(value, ..., sep=' ', end='\n', file=sys.stdout, flush=False)

    Prints the values to a stream, or to sys.stdout by default.
    Optional keyword arguments:
    file:  a file-like object (stream); defaults to the current sys.stdout. ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    sep:   string inserted between values, default a space.
    end:   string appended after the last value, default a newline.
    flush: whether to forcibly flush the stream.

1

这是我需要将字符串打印到打开的文件句柄的选项。

我需要将 out_fh 文件句柄用作 file 参数。我想指出,默认的 file 值是 sys.stdout。在命令行上,STDOUT(读作 standard out)是程序输出的标准位置,通常是控制台。

另一个选项是直接使用文件句柄 out_fh.write() 方法本身,但请注意,此函数 不会 添加换行符。你需要自己决定何时添加换行符。在读取以换行符结尾的这些序列的情况下,它们是不需要的。

打印状态报告

当我的程序运行完成时,我几乎总是喜欢打印一些东西,这样我至少知道它们已经完成了。可能只是简单的“完成了!”然而,在这里,我想知道处理了多少个序列在多少个文件中。我还想知道在哪里找到输出,如果我忘记了默认输出目录的名称,这尤其有帮助。

测试期望您使用正确的语法^(2)来描述数字,例如 1 sequence1 file

$ ./rna.py tests/inputs/input1.txt
Done, wrote 1 sequence in 1 file to directory "out".

或者 3 sequences2 files

$ ./rna.py --out_dir rna tests/inputs/input[12].txt ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
Done, wrote 3 sequences in 2 files to directory "rna".

1

语法 input[12].txt 是一种说法,表示 1 或 2 可能出现,因此 input1.txtinput2.txt 都会匹配。

使用测试套件

您可以运行pytest -xv来运行tests/rna_test.py。通过的测试套件看起来像这样:

$ pytest -xv
======================= test session starts ========================
...

tests/rna_test.py::test_exists PASSED                        [ 14%] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
tests/rna_test.py::test_usage PASSED                         [ 28%] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
tests/rna_test.py::test_no_args PASSED                       [ 42%] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
tests/rna_test.py::test_bad_file PASSED                      [ 57%] ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
tests/rna_test.py::test_good_input1 PASSED                   [ 71%] ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
tests/rna_test.py::test_good_input2 PASSED                   [ 85%]
tests/rna_test.py::test_good_multiple_inputs PASSED          [100%]

======================== 7 passed in 0.37s =========================

1

rna.py 程序存在。

2

当请求时,程序打印用法说明。

3

当未提供参数时,程序以错误退出。

4

当提供错误文件参数时,程序打印错误消息。

5

接下来的测试都验证程序在给出良好输入时是否正常工作。

一般来说,我首先编写尝试破坏程序的测试,然后再给出良好输入。例如,我希望程序在没有文件或给出不存在的文件时失败。正如最好的侦探可以像罪犯一样思考,我尝试想象所有可能破坏我的程序的方法,并测试它们在这些情况下的可预测行为。

前三个测试与第一章完全相同。对于第四个测试,我传递一个不存在的文件,并期望非零退出值以及用法和错误消息。请注意,错误明确提到了有问题的值,即坏文件名。您应努力创建反馈,让用户准确地知道问题所在以及如何修复:

def test_bad_file():
    """ Die on missing input """

    bad = random_filename() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    retval, out = getstatusoutput(f'{RUN} {bad}') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert retval != 0 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert re.match('usage:', out, re.IGNORECASE) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    assert re.search(f"No such file or directory: '{bad}'", out) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

这是我编写的一个函数,用于生成一串随机字符。

2

用这个不存在的文件运行程序。

3

确保退出值不是0

4

使用正则表达式(regex)查找输出中的用法。

5

使用另一个正则表达式来查找描述坏输入文件名的错误消息。

我还没有介绍正则表达式,但它们将成为我后来编写的解决方案的核心。要了解它们为何有用,请查看以坏文件输入运行程序时的输出:

$ ./rna.py dKej82
usage: rna.py [-h] [-o DIR] FILE [FILE ...]
rna.py: error: argument FILE: can't open 'dKej82':
[Errno 2] No such file or directory: 'dKej82'

使用re.match()函数,我正在寻找以out文本开头的文本模式。使用re.search()函数,我正在寻找出现在out文本中的另一个模式。稍后我会详细介绍正则表达式,现在仅需指出它们非常有用。

我将展示最后一个测试,验证在提供良好输入时程序是否正确运行。有许多编写此类测试的方法,所以不要认为这是唯一的正确方法:

def test_good_input1():
    """ Runs on good input """

    out_dir = 'out' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    try: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        if os.path.isdir(out_dir): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            shutil.rmtree(out_dir) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

        retval, out = getstatusoutput(f'{RUN} {INPUT1}') ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        assert retval == 0
        assert out == 'Done, wrote 1 sequence in 1 file to directory "out".'
        assert os.path.isdir(out_dir) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
        out_file = os.path.join(out_dir, 'input1.txt')
        assert os.path.isfile(out_file) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
        assert open(out_file).read().rstrip() == 'GAUGGAACUUGACUACGUAAAUU' ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

    finally: ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
        if os.path.isdir(out_dir): ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)
            shutil.rmtree(out_dir)

1

这是默认的输出目录名称。

2

try/finally块有助于确保在测试失败时进行清理。

3

查看输出目录是否留有上次运行的残留物。

4

使用shutil.rmtree()函数删除目录及其内容。

5

用已知的良好输入文件运行程序。

6

确保已创建预期的输出目录。

7

确保已创建预期的输出文件。

8

确保输出文件的内容是正确的。

9

即使try块中出现失败,也会执行此finally块。

10

清理测试环境。

我想强调检查程序应该执行的每个方面是多么重要。在这里,程序应处理一些输入文件,创建一个输出目录,然后将处理后的数据放入输出目录中的文件中。我正在使用已知的输入来测试每一个这些要求,以验证是否创建了预期的输出。

还有另外几个测试我不会在这里详细介绍,因为它们与我已经展示的类似,但我鼓励你阅读整个tests/rna_test.py程序。第一个输入文件有一个序列。第二个输入文件有两个序列,我用它来测试是否将两个序列写入输出文件。第三个输入文件有两个非常长的序列。通过单独和结合使用这些输入,我试图测试我能想象到的程序的每个方面。

尽管你可以使用 pytest 运行 tests/rna_test.py 中的测试,我也建议你使用 pylintflake8mypy 来检查你的程序。make test 快捷方式可以为您执行此操作,因为它将使用额外的参数执行 pytest 来运行这些工具。您的目标应该是一个完全干净的测试套件。

你可能会发现 pylint 会抱怨像 fh 这样的变量名太短,或者不是 snake_case,即小写单词用下划线连接。我在 GitHub 仓库的顶层包含了一个 pylintrc 配置文件。将其复制到家目录下的文件 .pylintrc 中,以消除这些错误。

现在你应该有足够的信息和测试来帮助你完成这个程序。在你查看我的解决方案之前,如果你尝试自己编写工作程序,你将从这本书中获得最大的收益。一旦你有一个工作版本,尝试找到其他解决方法。如果你了解正则表达式,那是一个很好的解决方案。如果不了解,我将演示一个使用它们的版本。

解决方案

下面的两个解决方案仅在如何用 T 替换 U 方面有所不同。第一个使用 str.replace() 方法,第二个引入了正则表达式并使用了 Python 的 re.sub() 函数。

解决方案 1:使用 str.replace()

下面是一个完全使用我在本章介绍的 str.replace() 方法的解决方案的全部内容:

#!/usr/bin/env python3
""" Transcribe DNA into RNA """

import argparse
import os
from typing import NamedTuple, List, TextIO

class Args(NamedTuple):
    """ Command-line arguments """
    files: List[TextIO]
    out_dir: str

# --------------------------------------------------
def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Transcribe DNA into RNA',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('file',
                        help='Input DNA file',
                        metavar='FILE',
                        type=argparse.FileType('rt'),
                        nargs='+')

    parser.add_argument('-o',
                        '--out_dir',
                        help='Output directory',
                        metavar='DIR',
                        type=str,
                        default='out')

    args = parser.parse_args()

    return Args(args.file, args.out_dir)

# --------------------------------------------------
def main() -> None:
    """ Make a jazz noise here """

    args = get_args()

    if not os.path.isdir(args.out_dir):
        os.makedirs(args.out_dir)

    num_files, num_seqs = 0, 0 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    for fh in args.files: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        num_files += 1 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        out_file = os.path.join(args.out_dir, os.path.basename(fh.name))
        out_fh = open(out_file, 'wt') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

        for dna in fh: ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
            num_seqs += 1 ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
            out_fh.write(dna.replace('T', 'U')) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

        out_fh.close() ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

    print(f'Done, wrote {num_seqs} sequence{"" if num_seqs == 1 else "s"} '
          f'in {num_files} file{"" if num_files == 1 else "s"} '
          f'to directory "{args.out_dir}".') ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)

# --------------------------------------------------
if __name__ == '__main__':
    main()

1

初始化文件和序列的计数器。

2

迭代文件句柄。

3

递增文件计数器。

4

打开用于此输入文件的输出文件。

5

迭代输入文件中的序列。

6

递增序列的计数器。

7

将转录的序列写入输出文件。

8

关闭输出文件句柄。

9

打印状态。请注意,我依赖于 Python 隐式连接相邻字符串来创建一个输出字符串。

解决方案 2:使用 re.sub()

我之前建议过,你可以探索如何使用正则表达式来解决这个问题。正则表达式是一种描述文本模式的语言。它们存在已久,甚至在 Python 诞生之前就有了。虽然一开始它们可能看起来有些令人畏惧,但是学习正则表达式绝对是值得的。^(3)

要在 Python 中使用正则表达式,我必须导入re模块:

>>> import re

之前,我使用了re.search()函数在另一个字符串中查找文本模式。对于这个程序,我要找的模式是字母T,我可以直接写成一个字符串:

>>> re.search('T', 'ACGT') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
<re.Match object; span=(3, 4), match='T'> ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

在字符串ACGT中搜索模式T

2

因为找到了T,返回值是一个Re.Match对象,显示找到模式的位置。如果搜索失败,则返回None

span=(3, 4)报告了找到模式T的起始和停止索引位置。我可以使用这些位置来通过切片提取子字符串:

>>> 'ACGT'[3:4]
'T'

但不只是找到T,我想用字符串T替换为U。如图 2-3 所示,re.sub()substitute)函数可以实现这一点。

mpfb 0203

图 2-3. re.sub()函数将返回一个新字符串,其中所有模式的实例都被替换为新字符串

结果是一个新字符串,其中所有T都已替换为U

>>> re.sub('T', 'U', 'ACGT') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
'ACGU' ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

在字符串ACGT中用U替换每个T

2

结果是一个新字符串,其中进行了替换。

要使用这个版本,我可以修改内部的for循环,如所示。请注意,我选择使用str.strip()方法来删除输入 DNA 字符串末尾的换行符,因为print()会添加一个换行符:

for dna in fh:
    num_seqs += 1
    print(re.sub('T', 'U', dna.rstrip()), file=out_fh) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

dna中删除换行符,用U替换所有的T,并将结果打印到输出文件句柄。

Benchmarking

你可能会好奇哪个解决方案更快。比较程序的相对运行时间称为基准测试,我将展示一种简单的方法,使用一些基本的bash命令比较这两个解决方案。我将使用./tests/inputs/input3.txt文件,因为它是最大的测试文件。我可以在bash中编写一个for循环,几乎与 Python 的语法相同。请注意,我在这个命令中使用换行符以提高可读性,并用>表示行继续。你可以用分号(;)来将其写成一行:

$ for py in ./solution*
> do echo $py && time $py ./tests/inputs/input3.txt
> done
./solution1_str_replace.py
Done, wrote 2 sequences in 1 file to directory "out".

real	0m1.539s
user	0m0.046s
sys	    0m0.036s
./solution2_re_sub.py
Done, wrote 2 sequences in 1 file to directory "out".

real	0m0.179s
user	0m0.035s
sys	    0m0.013s

看起来第二种使用正则表达式的解决方案更快,但我没有足够的数据来确定。我需要一个更实质性的输入文件。在 02_rna 目录下,您会找到一个名为 genseq.py 的程序,我写了这个程序将在一个名为 seq.txt 的文件中生成 1,000,000 个碱基的 1,000 个序列。当然,您可以修改参数:

$ ./genseq.py --help
usage: genseq.py [-h] [-l int] [-n int] [-o FILE]

Generate long sequence

optional arguments:
  -h, --help            show this help message and exit
  -l int, --len int     Sequence length (default: 1000000)
  -n int, --num int     Number of sequences (default: 100)
  -o FILE, --outfile FILE
                        Output file (default: seq.txt)

使用默认设置生成的文件 seq.txt 大约为 95 MB。以下是程序在更现实的输入文件上的表现:

$ for py in ./solution*; do echo $py && time $py seq.txt; done
./solution1_str_replace.py
Done, wrote 100 sequences in 1 file to directory "out".

real	0m0.456s
user	0m0.372s
sys	0m0.064s
./solution2_re_sub.py
Done, wrote 100 sequences in 1 file to directory "out".

real	0m3.100s
user	0m2.700s
sys	0m0.385s

第一种解决方案似乎更快。说实话,我想出了几种其他解决方案,但所有这些解决方案都比这两种糟糕得多。我以为我正在创造越来越聪明的解决方案,最终会导致最佳性能。当我认为我最好的程序竟然比这两个慢了几个数量级时,我的自尊受到了严重打击。当你有假设时,应该像俗话说的那样,“信任,但验证”。

进一步探讨

修改您的程序以打印序列的长度到输出文件,而不是转录的 RNA。最终状态报告最大、最小和平均序列长度。

回顾

本章的要点:

  • argparse.FileType 选项将验证文件参数。

  • argparsenargs 选项允许您为参数定义有效参数的数量。

  • os.path.isdir() 函数可以检测目录是否存在。

  • os.makedirs() 函数将创建一个目录结构。

  • 默认情况下,open() 函数仅允许读取文件。必须使用 w 选项来写入文件句柄,a 选项用于将值附加到现有文件。

  • 文件句柄可以使用 t 选项打开文本(默认)或 b 选项打开字节,例如在读取图像文件时。

  • 字符串是不可变的,有许多方法可以将字符串更改为新字符串,包括 str.replace()re.sub()

^(1) 正如 Phil Karlton 所说,“计算机科学中只有两件难事:缓存失效和命名事物。”

^(2) 抱歉,但我无法停止做英语专业的人。

^(3) 精通正则表达式 由 Jeffrey Friedl(O’Reilly,2006)是我找到的最佳书籍之一。

第三章:DNA 的反向互补:字符串操作

Rosalind REVC 挑战解释 DNA 的碱基形成A-TG-C的配对。此外,DNA 具有方向性,通常从 5'-端(五端)向 3'-端(三端)读取。如图 Figure 3-1 所示,DNA 字符串AAAACCCGGT的互补是TTTTGGGCCA。然后我反转这个字符串(从 3'-端读取)以获得ACCGGGTTTT作为反向互补。

mpfb 0301

图 3-1. DNA 的反向互补是从相反方向读取的互补序列

虽然你可以找到许多现有工具来生成 DNA 的反向互补序列——我将透露最终解决方案将使用 Biopython 库中的一个函数——但编写我们自己的算法的目的是探索 Python。在本章中,您将学习:

  • 如何使用字典实现决策树作为查找表

  • 如何动态生成列表或字符串

  • 如何使用reversed()函数,这是迭代器的一个示例

  • Python 如何类似地处理字符串和列表

  • 如何使用列表推导生成列表

  • 如何使用str.maketrans()str.translate()来转换一个字符串

  • 如何使用 Biopython 的Bio.Seq模块

  • 真正的宝藏是你沿途结交的朋友

入门指南

此程序的代码和测试位于03_revc目录中。为了了解程序如何工作,请切换到该目录并将第一个解决方案复制到名为revc.py的程序中:

$ cd 03_revc
$ cp solution1_for_loop.py revc.py

运行程序时使用--help来查看使用方法:

$ ./revc.py --help
usage: revc.py [-h] DNA

Print the reverse complement of DNA

positional arguments:
  DNA         Input sequence or file

optional arguments:
  -h, --help  show this help message and exit

程序需要DNA并将打印反向互补序列,所以我会给它一个字符串:

$ ./revc.py AAAACCCGGT
ACCGGGTTTT

正如帮助文档所示,程序还将接受文件作为输入。第一个测试输入有相同的字符串:

$ cat tests/inputs/input1.txt
AAAACCCGGT

因此输出应该是相同的:

$ ./revc.py tests/inputs/input1.txt
ACCGGGTTTT

我想让程序的规格稍微难一点,这样测试就会通过大小写的混合。输出应该尊重输入的大小写:

$ ./revc.py aaaaCCCGGT
ACCGGGtttt

运行pytest(或make test)来查看程序应该通过哪些类型的测试。当你对程序的预期功能感到满意时,重新开始:

$ new.py -f -p 'Print the reverse complement of DNA' revc.py
Done, see new script "revc.py".

编辑get_args()函数,直到程序将打印前面的用法。然后修改您的程序,以便它将从命令行或输入文件中回显输入:

$ ./revc.py AAAACCCGGT
AAAACCCGGT
$ ./revc.py tests/inputs/input1.txt
AAAACCCGGT

如果你运行测试套件,你应该会发现你的程序通过了前三个测试:

$ pytest -xv
============================= test session starts ==============================
...

tests/revc_test.py::test_exists PASSED                                   [ 14%]
tests/revc_test.py::test_usage PASSED                                    [ 28%]
tests/revc_test.py::test_no_args PASSED                                  [ 42%]
tests/revc_test.py::test_uppercase FAILED                                [ 57%]

=================================== FAILURES ===================================
________________________________ test_uppercase ________________________________

    def test_uppercase():
        """ Runs on uppercase input """

        rv, out = getstatusoutput(f'{RUN} AAAACCCGGT')
        assert rv == 0
>       assert out == 'ACCGGGTTTT'
E       AssertionError: assert 'AAAACCCGGT' == 'ACCGGGTTTT'
E         - ACCGGGTTTT
E         + AAAACCCGGT

tests/revc_test.py:47: AssertionError
=========================== short test summary info ============================
FAILED tests/revc_test.py::test_uppercase - AssertionError: assert 'AAAACCCGG...
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
========================= 1 failed, 3 passed in 0.33s ==========================

程序正在传递输入字符串AAAACCCGGT,并且测试期望它打印ACCGGGTTTT。由于程序正在回显输入,这个测试失败了。如果您认为自己能写一个满足这些测试的程序,就去做吧。否则,我将向您展示如何创建 DNA 的反向互补序列,从简单的方法开始,逐步发展到更优雅的解决方案。

遍历反向字符串

在创建 DNA 的反向互补物时,首先反转序列再补充,或者反之,都没有关系。 无论哪种方式,您都会得到相同的答案,所以我将从如何反转字符串开始。 在第二章中,我展示了如何使用字符串切片获取字符串的一部分。 如果省略起始位置,它将从开头开始:

>>> dna = 'AAAACCCGGT'
>>> dna[:2]
'AA'

如果省略了停止位置,它将进行到末尾:

>>> dna[-2:]
'GT'

如果省略了起始和停止位置,它将返回整个字符串的副本:

>>> dna[:]
'AAAACCCGGT'

它还需要一个可选的第三个参数来指示步长。我可以使用没有参数的情况下开始和停止,步长为-1来反转字符串:

>>> dna[::-1]
'TGGCCCAAAA'

Python 还有一个内置的reversed()函数,所以我会尝试一下:

>>> reversed(dna)
<reversed object at 0x7ffc4c9013a0>

惊喜! 您可能希望看到字符串TGGCCCAAAA。 但是,如果您在 REPL 中阅读help(reversed),您会发现该函数将“返回给定序列的值的反向迭代器。”

什么是迭代器?Python 的函数式编程指南将迭代器描述为“代表数据流的对象。” 我提到过可迭代是 Python 可以单独访问的一些项目的集合;例如,字符串的字符或列表中的元素。 迭代器是一种在耗尽之前将生成值的东西。 就像我可以从字符串的第一个字符(或列表的第一个元素或文件的第一行)开始阅读直到字符串的结尾(或列表或文件)一样,迭代器可以从它产生的第一个值迭代到它完成的位置。

在这种情况下,reversed()函数返回一个承诺,即在出现需要时立即产生反转的值。 这是一个惰性函数的示例,因为它等待被迫执行任何工作。 强制从reversed()中获取值的一种方法是使用将消耗值的函数。 例如,如果唯一的目标是反转字符串,那么我可以使用str.join()函数。 我总觉得这个函数的语法是反向的,但您经常会在用于连接序列的字符串文字上调用str.join()方法:

>>> ''.join(reversed(dna)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
'TGGCCCAAAA'

1

使用空字符串连接 DNA 字符串的反转字符。

另一种方法使用list()函数强制reversed()生成值:

>>> list(reversed(dna))
['T', 'G', 'G', 'C', 'C', 'C', 'A', 'A', 'A', 'A']

等等,发生了什么? dna变量是一个字符串,但我得到了一个列表——不仅仅是因为我使用了list()函数。 reversed()的文档显示该函数接受一个序列,这意味着任何返回一个东西然后另一个东西的数据结构或函数。 在列表或迭代器上下文中,Python 将字符串视为字符列表:

>>> list(dna)
['A', 'A', 'A', 'A', 'C', 'C', 'C', 'G', 'G', 'T']

一个更长的构建反向 DNA 序列的方法是使用for循环来迭代逆转的碱基,并将它们附加到一个字符串中。首先我将声明一个rev变量,然后使用+=运算符以逆序附加每个碱基:

>>> rev = '' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
>>> for base in reversed(dna): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
...     rev += base ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
...
>>> rev
'TGGCCCAAAA'

1

用空字符串初始化rev变量。

2

逆转 DNA 的碱基。

3

将当前碱基附加到rev变量。

但由于我仍然需要补全碱基,所以还没有完全完成。

创建决策树

一共有八种互补:ATGC,包括大小写,然后反过来。我还需要处理字符不是 ACGT 的情况。我可以使用if/elif语句创建一个决策树。我将变量更改为revc,因为现在它是反向互补,我将找出每个碱基的正确互补。

revc = '' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
for base in reversed(dna): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    if base == 'A': ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        revc += 'T' ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    elif base == 'T':
        revc += 'A'
    elif base == 'G':
        revc += 'C'
    elif base == 'C':
        revc += 'G'
    elif base == 'a':
        revc += 't'
    elif base == 't':
        revc += 'a'
    elif base == 'g':
        revc += 'c'
    elif base == 'c':
        revc += 'g'
    else: ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        revc += base

1

初始化一个变量来保存反向互补字符串。

2

逆转 DNA 字符串中的碱基。

3

测试每个大写和小写碱基。

4

将补充的碱基附加到变量中。

5

如果碱基与这些测试中的任何一个不匹配,则使用原始碱基。

如果检查revc变量,它看起来是正确的:

>>> revc
'ACCGGGTTTT'

您应该能够将这些想法整合到一个能通过测试套件的程序中。要理解程序的确切预期结果,请查看tests/revc_test.py文件。在通过test_uppercase()函数后,查看test_lowercase()函数的预期结果:

def test_lowercase():
    """ Runs on lowercase input """

    rv, out = getstatusoutput(f'{RUN} aaaaCCCGGT') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert rv == 0 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert out == 'ACCGGGtttt' ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

使用小写和大写 DNA 字符串运行程序。

2

退出值应为0

3

程序的输出应该是指定的字符串。

下一步测试将使用文件名而不是字符串作为输入:

def test_input1():
    """ Runs on file input """

    file, expected = TEST1 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    rv, out = getstatusoutput(f'{RUN} {file}') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert rv == 0 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert out == open(expected).read().rstrip() ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

TEST1 元组是一个输入文件和一个期望输出文件。

2

使用文件名运行程序。

3

确保退出值为0

4

打开并读取预期文件,然后将其与输出进行比较。

阅读和理解测试代码同样重要,因为学习如何编写解决方案。当你编写程序时,你可能会发现可以从这些测试中借鉴许多想法,节省时间。

重构

尽管上一节中的算法将产生正确答案,但它并不是一个优雅的解决方案。然而,这是一个可以开始的地方,通过了测试。现在你也许对挑战有了更好的理解,是时候重构程序了。我提出的一些解决方案只有一两行代码。以下是你可以考虑的一些想法:

  • 使用字典作为查找表,而不是if/elif链。

  • for循环重写为列表推导。

  • 使用str.translate()方法来补充碱基。

  • 创建一个Bio.Seq对象并找到能为你完成此任务的方法。

没有必要急于向前阅读。花时间尝试其他解决方案。我还没有介绍所有这些想法,所以我鼓励你研究任何不明白的地方,看看你是否能够自己弄清楚。

我记得音乐学校的一位老师与我分享过这句话:

然后一位老师说,告诉我们如何教导。

他说:

没有人可以向你揭示除了已经半睡在你知识的黎明中的东西。

在寺庙的阴影中行走,他在追随者中间,不是给予他的智慧,而是给予他的信仰和爱。

如果他确实是聪明的,他不会命令你进入他智慧的殿堂,而是会引导你到你自己心灵的门槛。

卡里尔·贾伯兰

解决方案

所有解决方案都共享相同的get_args()函数,如下所示:

class Args(NamedTuple): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Command-line arguments """
    dna: str

# --------------------------------------------------
def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Print the reverse complement of DNA',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('dna', metavar='DNA', help='Input sequence or file')

    args = parser.parse_args()

    if os.path.isfile(args.dna): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        args.dna = open(args.dna).read().rstrip()

    return Args(args.dna) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

该程序的唯一参数是一个 DNA 字符串。

2

处理读取文件输入的情况。

3

返回一个Args对象,以符合函数签名。

解决方案 1:使用 for 循环和决策树

这是我的第一个解决方案,使用if/else决策树:

def main() -> None:
    args = get_args()
    revc = '' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    for base in reversed(args.dna): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        if base == 'A': ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            revc += 'T'
        elif base == 'T':
            revc += 'A'
        elif base == 'G':
            revc += 'C'
        elif base == 'C':
            revc += 'G'
        elif base == 'a':
            revc += 't'
        elif base == 't':
            revc += 'a'
        elif base == 'g':
            revc += 'c'
        elif base == 'c':
            revc += 'g'
        else:
            revc += base

    print(revc) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

初始化一个变量来保存反向互补序列。

2

遍历 DNA 参数的反向碱基。

3

创建一个if/elif决策树来确定每个碱基的互补碱基。

4

打印结果。

解决方案 2:使用字典查找

我提到过,if/else链是应该尽量替换的。这是 18 行代码(LOC),可以更轻松地使用字典查找表示:

>>> trans = {
...     'A': 'T', 'C': 'G', 'G': 'C', 'T': 'A',
...     'a': 't', 'c': 'g', 'g': 'c', 't': 'a'
... }

如果我使用for循环遍历 DNA 字符串,我可以使用dict.get()方法安全地请求 DNA 字符串中的每个碱基来创建互补序列(见图 3-1)。请注意,我将使用base作为dict.get()的可选第二个参数。如果碱基不存在于查找表中,则将默认使用碱基本身,就像第一个解决方案中的else情况一样:

>>> for base in 'AAAACCCGGT':
...     print(base, trans.get(base, base))
...
A T
A T
A T
A T
C G
C G
C G
G C
G C
T A

我可以创建一个complement变量来保存我生成的新字符串:

>>> complement = ''
>>> for base in 'AAAACCCGGT':
...     complement += trans.get(base, base)
...
>>> complement
'TTTTGGGCCA'

你之前看到过,在字符串上使用reversed()函数会以相反的顺序返回字符串的字符列表:

>>> list(reversed(complement))
['A', 'C', 'C', 'G', 'G', 'G', 'T', 'T', 'T', 'T']

我可以使用str.join()函数从列表创建一个新字符串:

>>> ''.join(reversed(complement))
'ACCGGGTTTT'

当我将所有这些想法结合在一起时,main()函数变得明显更短。这也更容易扩展,因为向决策树添加新分支只需要向字典添加新的键/值对:

def main() -> None:
    args = get_args()
    trans = { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        'A': 'T', 'C': 'G', 'G': 'C', 'T': 'A',
        'a': 't', 'c': 'g', 'g': 'c', 't': 'a'
    }

    complement = '' ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    for base in args.dna: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        complement += trans.get(base, base) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    print(''.join(reversed(complement))) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

这是一个字典,展示了如何将一个碱基翻译成其互补碱基。

2

初始化一个变量来保存 DNA 互补序列。

3

遍历 DNA 字符串中的每个碱基。

4

将碱基的翻译或碱基本身附加到互补序列中。

5

反转互补序列并在空字符串上连接结果。

Python 字符串和列表在某种程度上是可互换的。我可以将complement变量更改为列表,程序中的其他内容都不会改变:

def main() -> None:
    args = get_args()
    trans = {
        'A': 'T', 'C': 'G', 'G': 'C', 'T': 'A',
        'a': 't', 'c': 'g', 'g': 'c', 't': 'a'
    }

    complement = [] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    for base in args.dna:
        complement += trans.get(base, base)

    print(''.join(reversed(complement)))

1

将互补序列初始化为空列表而不是字符串。

我在这里强调 += 运算符可以用于字符串和列表,以在末尾追加一个新值。还有一个 list.append() 方法也可以实现相同的效果:

for base in args.dna:
    complement.append(trans.get(base, base))

reversed() 函数在列表上的效果与在字符串上的效果一样好。对我来说,使用两种不同类型的 complement 结果对代码的影响如此之小,这有些引人注目。

解决方案 3:使用列表推导式

我建议你使用列表推导式,而没有告诉你它是什么。如果你以前从未使用过,它本质上是在用于创建新列表的方括号([])内部写一个 for 循环的方式(见图 3-2)。当 for 循环的目标是构建一个新的字符串或列表时,使用列表推导式要明智得多。

mpfb 0302

图 3-2. 列表推导式使用 for 循环生成一个新列表

这将三行初始化 complement 并循环遍历 DNA 字符串的操作缩短为一行:

def main() -> None:
    args = get_args()
    trans = {
        'A': 'T', 'C': 'G', 'G': 'C', 'T': 'A',
        'a': 't', 'c': 'g', 'g': 'c', 't': 'a'
    }

    complement = [trans.get(base, base) for base in args.dna] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    print(''.join(reversed(complement)))

1

用列表推导式替换 for 循环。

由于 complement 变量只使用一次,我甚至可以通过直接使用列表推导式来进一步缩短这个过程:

print(''.join(reversed([trans.get(base, base) for base in args.dna])))

这是可以接受的,因为该行的长度小于 PEP8 建议的 79 个字符的最大值,但它不如较长版本易读。你应该使用你认为最容易理解的版本。

解决方案 4:使用 str.translate()

在第二章中,我使用 str.replace() 方法将所有的 T 替换为 U,当将 DNA 转录为 RNA 时。我能在这里使用吗?让我们试试。我将从初始化 DNA 字符串并将 A 替换为 T 开始。请记住,字符串是 不可变 的,这意味着我不能直接更改一个字符串,而是必须用新值覆盖字符串:

>>> dna = 'AAAACCCGGT'
>>> dna = dna.replace('A', 'T')

现在让我们看看 DNA 字符串:

>>> dna
'TTTTCCCGGT'

你能看到这里开始出错了吗?我现在将 T 补足为 A,看看你能否发现问题:

>>> dna = dna.replace('T', 'A')
>>> dna
'AAAACCCGGA'

正如图 3-3 所示,第一步中转变为 T 的所有 A 刚刚又变回了 A。哦,这会让人发疯的。

mpfb 0303

图 3-3. 迭代使用 str.replace() 导致值的双重替换和错误的答案

幸运的是,Python 有 str.translate() 函数,专门用于这个目的。如果你阅读 help(str.translate),你会发现该函数需要一个“必须是 Unicode 序数到 Unicode 序数、字符串或 None 的映射”的表格。trans 字典表将起到作用,但首先,它必须传递给 str.maketrans() 函数,以将补充表转换为使用键的 序数 值的形式:

>>> trans = {
...     'A': 'T', 'C': 'G', 'G': 'C', 'T': 'A',
...     'a': 't', 'c': 'g', 'g': 'c', 't': 'a'
... }
>>> str.maketrans(trans)
{65: 'T', 67: 'G', 71: 'C', 84: 'A', 97: 't', 99: 'g', 103: 'c', 116: 'a'}

你可以看到字符串键 A 被转换为整数值 65,这与 ord() 函数返回的值相同:

>>> ord('A')
65

这个值表示 ASCII(美国标准信息交换码表,发音为 as-key)表中字符A的序数位置。也就是说,A是表中的第 65 个字符。chr()函数将颠倒此过程,提供由序数值表示的字符:

>>> chr(65)
'A'

str.translate()函数要求互补表具有键的序数值,这正是我从str.maketrans()中获取的:

>>> 'AAAACCCGGT'.translate(str.maketrans(trans))
'TTTTGGGCCA'

最后,我需要反向互补。这里有一个融合了所有这些想法的解决方案:

def main() -> None:
    args = get_args()

    trans = str.maketrans({ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        'A': 'T', 'C': 'G', 'G': 'C', 'T': 'A',
        'a': 't', 'c': 'g', 'g': 'c', 't': 'a'
    })
    print(''.join(reversed(args.dna.translate(trans)))) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

创建用于str.translate()函数的翻译表。

2

使用trans表互补 DNA。反转并连接成新字符串。

不过,等等——还有更多!还有另一种更简短的写法。根据help(str.translate)文档:

如果只有一个参数,它必须是将 Unicode 序数(整数)或字符映射到 Unicode 序数、字符串或None的字典。字符键将被转换为序数。如果有两个参数,它们必须是长度相等的字符串,且在生成的字典中,x中的每个字符将映射到y中相同位置的字符。

因此,我可以删除trans字典,并像这样编写整个解决方案:

def main() -> None:
    args = get_args()
    trans = str.maketrans('ACGTacgt', 'TGCAtgca') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    print(''.join(reversed(args.seq.translate(trans)))) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

使用长度相等的两个字符串制作翻译表。

2

创建反向互补。

如果你想毁掉某人的一天——很可能那个人会是未来的你——你甚至可以将其压缩成一行代码。

解决方案 5:使用 Bio.Seq

在本章开头我告诉过你,最终的解决方案将涉及一个现有的函数。^(1) 许多从事生物信息学的 Python 程序员共同贡献了一套名为Biopython的模块。他们编写并测试了许多非常有用的算法,当你可以使用他人的代码时,自己编写代码很少有意义。

确保你已经先通过运行以下命令安装了biopython

$ python3 -m pip install biopython

我可以使用import Bio来导入整个模块,但只导入我需要的代码更加合理。这里我只需要Seq类:

>>> from Bio import Seq

现在我可以使用Seq.reverse_complement()函数:

>>> Seq.reverse_complement('AAAACCCGGT')
'ACCGGGTTTT'

这个最终的解决方案是我推荐的版本,因为它是最短的,而且使用了现有的、经过充分测试和文档化的模块,在 Python 生物信息学中几乎无处不在:

def main() -> None:
    args = get_args()
    print(Seq.reverse_complement(args.dna)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

使用 Bio.Seq.reverse_complement() 函数。

当您在此解决方案上运行 mypy 时(您确实在每一个程序上运行 mypy,对吗?),您可能会收到以下错误:

=================================== FAILURES ===================================
___________________________________ revc.py ____________________________________
6: error: Skipping analyzing 'Bio': found module but no type hints or library
    stubs
6: note: See https://mypy.readthedocs.io/en/latest/running_mypy.html#missing
    -imports
===================================== mypy =====================================
Found 1 error in 1 file (checked 2 source files)
mypy.ini: No [mypy] section in config file

=========================== short test summary info ============================
FAILED revc.py::mypy
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
========================= 1 failed, 1 skipped in 0.20s =========================

要消除此错误,您可以告诉 mypy 忽略缺少类型注解的导入文件。在本书的 GitHub 仓库的根目录 中,您将找到名为 mypy.ini 的文件,其内容如下:

$ cat mypy.ini
[mypy]
ignore_missing_imports = True

向任何工作目录添加 mypy.ini 文件允许您对 mypy 在相同目录中运行时使用的默认值进行更改。如果您希望进行全局更改,以便 mypy 无论您在哪个目录中都使用这些内容,则将相同内容放入 $HOME/.mypy.ini 中。

复习

手动创建 DNA 的反向互补序列是一种入门仪式。这是我展示的内容:

  • 您可以使用一系列 if/else 语句或使用字典作为查找表来编写决策树。

  • 字符串和列表非常相似。两者都可以使用 for 循环进行迭代,而 += 运算符可用于向两者附加元素。

  • 列表推导使用 for 循环来迭代一个序列并生成新列表。

  • reversed() 函数是一个惰性函数,它将以相反的顺序返回序列的迭代器。

  • 您可以在 REPL 中使用 list() 函数来强制执行惰性函数、迭代器和生成器,以生成它们的值。

  • str.maketrans()str.translate() 函数可以执行字符串替换并生成新字符串。

  • ord() 函数返回字符的序数值,而 chr() 函数则根据给定的序数值返回字符。

  • Biopython 是专门用于生物信息学的模块和函数集合。创建 DNA 的反向互补序列的首选方法是使用 Bio.Seq.reverse_complement() 函数。

^(1) 这有点像我的高中微积分老师花了一周时间教我们如何进行手动导数,然后展示了如何在 20 秒内通过拉下指数等方式完成。

第四章:创建斐波那契序列:编写、测试和基准算法

编写斐波那契序列的实现是成为编程英雄的旅程中的又一步。Rosalind 斐波那契描述指出,序列的起源是对一些重要(而不现实)假设进行数学模拟的兔子繁殖:

  • 第一个月从一对新生兔子开始。

  • 兔子可以在一个月后繁殖。

  • 每个月,具有生育能力的每只兔子都与另一只具有生育能力的兔子交配。

  • 兔子交配后一个月,它们产生与同等大小的一窝幼崽。

  • 兔子是不朽的,永远不会停止交配。

序列始终以数字 0 和 1 开始。随后的数字可以通过在列表中添加前两个立即前值来生成无限,如图 4-1 所示。

mpfb 0401

图 4-1。斐波那契序列的前八个数字——在初始的 0 和 1 之后,后续数字是通过将前两个数字相加而创建的

如果你搜索互联网上的解决方案,你会发现有几十种不同的生成序列的方式。我想专注于三种非常不同的方法。第一个解决方案使用命令式方法,其中算法严格定义了每一步。下一个解决方案使用生成器函数,最后一个将专注于递归解决方案。递归虽然有趣,但随着我尝试生成更多的序列,速度会显着减慢,但事实证明性能问题可以通过缓存来解决。

你将学到:

  • 如何手动验证参数并抛出错误

  • 如何使用列表作为堆栈

  • 如何编写生成器函数

  • 如何编写递归函数

  • 递归函数为什么可能会慢,以及如何使用记忆化来修复这个问题

  • 如何使用函数装饰器

入门

此章节的代码和测试位于04_fib目录中。首先将第一个解决方案复制到fib.py

$ cd 04_fib/
$ cp solution1_list.py fib.py

要求使用情况以查看参数的定义。你可以使用nk,但我选择使用名称generationslitter

$ ./fib.py -h
usage: fib.py [-h] generations litter

Calculate Fibonacci

positional arguments:
  generations  Number of generations
  litter       Size of litter per generation

optional arguments:
  -h, --help   show this help message and exit

这将是第一个接受非字符串参数的程序。Rosalind 挑战指出该程序应该接受两个正整数值:

  • n ≤ 40 代表代数的数量

  • k ≤ 5 代表配对产生的一窝幼崽的大小

尝试传递非整数值并注意程序的失败:

$ ./fib.py foo
usage: fib.py [-h] generations litter
fib.py: error: argument generations: invalid int value: 'foo'

你无法察觉,但除了打印简要的使用说明和有用的错误消息外,该程序还生成了一个非零的退出值。在 Unix 命令行上,退出值为0表示成功。我把这看作是“零错误”。在bash shell 中,我可以检查$?变量来查看最近进程的退出状态。例如,命令echo Hello应该以值0退出,确实如此:

$ echo Hello
Hello
$ echo $?
0

再次尝试之前失败的命令,然后检查 $?

$ ./fib.py foo
usage: fib.py [-h] generations litter
fib.py: error: argument generations: invalid int value: 'foo'
$ echo $?
2

退出状态为 2 并不像数值非零那样重要。这是一个表现良好的程序,因为它拒绝了无效的参数,打印了有用的错误消息,并以非零状态退出。如果该程序是数据处理步骤管道的一部分(比如Makefile,见附录 A),非零的退出值会导致整个流程停止,这是一件好事。接受无效值并悄悄失败或根本不失败的程序可能会导致无法重现的结果。程序正确验证参数并在无法继续时明确失败非常重要。

程序对接受的数字类型非常严格。值必须是整数。它还会排斥任何浮点数值:

$ ./fib.py 5 3.2
usage: fib.py [-h] generations litter
fib.py: error: argument litter: invalid int value: '3.2'

程序接收的所有命令行参数在技术上都作为字符串接收。即使命令行上的 5 看起来像数字 5,实际上是字符5。在这种情况下,我依赖 argparse 尝试将值从字符串转换为整数。当这种转换失败时,argparse 生成这些有用的错误消息。

此外,程序还会拒绝不在允许范围内的 generationslitter 参数的值。请注意,错误消息包括参数的名称和违规值,以提供足够的反馈,以便用户修复它:

$ ./fib.py -3 2
usage: fib.py [-h] generations litter
fib.py: error: generations "-3" must be between 1 and 40 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
$ ./fib.py 5 10
usage: fib.py [-h] generations litter
fib.py: error: litter "10" must be between 1 and 5 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

-3generations 参数不在指定的数值范围内。

2

litter 参数为 10 太高了。

查看解决方案的第一部分以了解如何使其工作:

import argparse
from typing import NamedTuple

class Args(NamedTuple):
    """ Command-line arguments """
    generations: int ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    litter: int ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Calculate Fibonacci',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('gen', ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
                        metavar='generations',
                        type=int, ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
                        help='Number of generations')

    parser.add_argument('litter', ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
                        metavar='litter',
                        type=int,
                        help='Size of litter per generation')

    args = parser.parse_args() ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

    if not 1 <= args.gen <= 40: ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
        parser.error(f'generations "{args.gen}" must be between 1 and 40') ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

    if not 1 <= args.litter <= 5: ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
        parser.error(f'litter "{args.litter}" must be between 1 and 5') ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)

    return Args(generations=args.gen, litter=args.litter) ![11](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/11.png)

1

generations 字段必须是 int 类型。

2

litter 字段也必须是 int 类型。

3

gen 位置参数首先定义,因此将接收第一个位置值。

4

type=int 表示所需数值的类别。请注意,int 表示的是类别本身,而不是类别的名称。

5

litter 位置参数其次定义,因此将接收第二个位置值。

6

尝试解析参数。任何失败都将导致错误消息,并以非零值退出程序。

7

args.gen的值现在是一个实际的int值,因此可以对其进行数值比较。检查它是否在可接受的范围内。

8

使用parser.error()函数生成错误并退出程序。

9

同样检查args.litter参数的值。

10

生成一个错误,其中包含用户需要修复问题的信息。

11

如果程序成功运行到这一点,则参数是接受范围内的有效整数值,因此返回Args

我可以在main()函数中检查generationslitter值是否在正确的范围内,但我更喜欢尽可能在get_args()函数内进行尽可能多的参数验证,以便我可以使用parser.error()函数生成有用的消息并以非零值退出程序。

删除fib.py程序,然后使用new.py或您喜欢的方法重新开始创建程序:

$ new.py -fp 'Calculate Fibonacci' fib.py
Done, see new script "fib.py".

您可以将get_args()定义替换为前面的代码,然后像这样修改您的main()函数:

def main() -> None:
    args = get_args()
    print(f'generations = {args.generations}')
    print(f'litter = {args.litter}')

使用无效输入运行您的程序,并验证您是否看到早期显示的错误消息类型。使用可接受的值尝试您的程序,并验证您是否看到此类输出:

$ ./fib.py 1 2
generations = 1
litter = 2

运行pytest来查看您的程序通过和未通过的测试。您应该通过前四个测试,并未通过第五个:

$ pytest -xv
========================== test session starts ==========================
...
tests/fib_test.py::test_exists PASSED                             [ 14%]
tests/fib_test.py::test_usage PASSED                              [ 28%]
tests/fib_test.py::test_bad_generations PASSED                    [ 42%]
tests/fib_test.py::test_bad_litter PASSED                         [ 57%]
tests/fib_test.py::test_1 FAILED                                  [ 71%] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

=============================== FAILURES ================================
________________________________ test_1 _________________________________

    def test_1():
        """runs on good input"""

        rv, out = getstatusoutput(f'{RUN} 5 3') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        assert rv == 0
>       assert out == '19' ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
E       AssertionError: assert 'generations = 5\nlitter = 3' == '19' ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
E         - 19    ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
E         + generations = 5 ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
E         + litter = 3

tests/fib_test.py:60: AssertionError
======================== short test summary info ========================
FAILED tests/fib_test.py::test_1 - AssertionError: assert 'generations...
!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!
====================== 1 failed, 4 passed in 0.38s ======================

1

第一个失败的测试。由于-x标志的存在,测试在此处停止。

2

该程序以5作为生成数量和3作为每胎大小来运行。

3

输出应为19

4

这显示了两个字符串的比较结果不相等。

5

预期值为19

6

这是接收到的输出。

pytest的输出非常努力地指出了发生了什么问题。它显示了程序的运行方式及期望的结果与实际产生的结果之间的差异。程序应该打印 19,这是使用每胎数量为 3 时斐波那契数列的第五个数字。如果您想独自完成程序,请直接开始。您应该使用pytest验证是否通过了所有测试。另外,运行make test以使用pylintflake8mypy检查您的程序。如果您需要一些指导,我将介绍我描述的第一种方法。

一种命令式方法

图 4-2 描述了斐波那契数列的增长。较小的兔子表示必须成熟为较大的繁殖对的非繁殖对。

mpfb 0402

图 4-2. 使用每胎数量为 1 的兔子配对作为斐波那契数列增长的可视化

您可以看到,要生成前两个数之后的任何数字,我需要知道前两个数。我可以使用这个公式来描述斐波那契数列(F)的任意位置n的值:

F n = F n-1 + F n-2

在 Python 中,哪种数据结构可以让我按顺序保留一系列数字并按其位置引用它们?列表。我将从F[1] = 0 和F[2] = 1 开始:

>>> fib = [0, 1]

F[3]值为F[2] + F[1] = 1 + 0 = 1。在生成下一个数字时,我将始终引用序列的最后两个元素。使用负索引最容易指示从列表末尾的位置。列表中的最后一个值始终位于位置-1

>>> fib[-1]
1

倒数第二个值是-2

>>> fib[-2]
0

我需要将这个值乘以每胎数量来计算该代产生的后代数量。首先,我将考虑每胎数量为 1:

>>> litter = 1
>>> fib[-2] * litter
0

我想要将这两个数字加在一起,并将结果附加到列表中:

>>> fib.append((fib[-2] * litter) + fib[-1])
>>> fib
[0, 1, 1]

如果我再做一次,我可以看到正确的序列正在出现:

>>> fib.append((fib[-2] * litter) + fib[-1])
>>> fib
[0, 1, 1, 2]

我需要重复这个动作generations次。(从技术上讲,实际上是generations-1 次,因为 Python 使用基于 0 的索引。)我可以使用 Python 的range()函数生成从0到结束值但不包括结束值的数字列表。我调用这个函数仅仅是为了迭代特定次数,所以不需要range()函数生成的值。习惯上使用下划线(_)变量表示忽略某个值的意图:

>>> fib = [0, 1]
>>> litter = 1
>>> generations = 5
>>> for _ in range(generations - 1):
...     fib.append((fib[-2] * litter) + fib[-1])
...
>>> fib
[0, 1, 1, 2, 3, 5]

这应该足够让您创建一个通过测试的解决方案。在下一节中,我将介绍另外两个解决方案,突出 Python 的一些非常有趣的部分。

解决方案

所有以下解决方案都共享相同的get_args()如前所示。

解决方案 1:使用列表作为堆栈的命令式解决方案

这是我写的命令式解决方案。我使用列表作为来跟踪过去的值。我不需要所有的值,只需要最后两个,但是保持列表不断增长并引用最后两个值是相当容易的:

def main() -> None:
    args = get_args()

    fib = [0, 1] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    for _ in range(args.generations - 1): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        fib.append((fib[-2] * args.litter) + fib[-1]) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    print(fib[-1]) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

01开始。

2

使用range()函数创建正确数量的循环。

3

将下一个值附加到序列中。

4

打印序列的最后一个数字。

for循环中使用_变量名表明我不打算使用该变量。下划线是一个有效的 Python 标识符,并且也是一种约定,用于表示丢弃值。例如,代码检查工具可能会看到我给变量赋了一个值但从未使用它,这通常会被视为可能的错误。下划线变量表明我不打算使用该值。在这种情况下,我仅仅使用range()函数是为了产生所需的循环次数。

这被认为是一个命令式解决方案,因为代码直接编码了算法的每一个指令。当你阅读递归解决方案时,你会看到算法可以以更声明性的方式编写,这也带来了我必须处理的意外后果。

稍微变化的方式是将这段代码放在一个我称之为fib()的函数中。请注意,在 Python 中可以在另一个函数内部声明函数,例如我将在main()内部创建fib()。我这样做是为了可以引用args.litter参数,因为函数正在捕获垃圾的运行时值,从而创建了一个闭包

def main() -> None:
    args = get_args()

    def fib(n: int) -> int: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        nums = [0, 1] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        for _ in range(n - 1): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            nums.append((nums[-2] * args.litter) + nums[-1]) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        return nums[-1] ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

    print(fib(args.generations)) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

1

创建一个名为fib()的函数,接受一个整数参数n并返回一个整数。

2

这与之前的代码相同。请注意,此列表称为nums,以避免与函数名冲突。

3

使用range()函数迭代生成。

4

函数引用args.litter参数,因此创建了一个闭包。

5

使用return将最终值发送回调用者。

6

使用args.generations参数调用fib()函数。

在前面的示例中,fib()函数的作用域仅限于main()函数。作用域指的是程序中特定函数名称或变量可见或合法的部分。

我不必使用闭包。以下是我如何使用标准函数表达相同的想法:

def main() -> None:
    args = get_args()

    print(fib(args.generations, args.litter)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

def fib(n: int, litter: int) -> int: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    nums = [0, 1]
    for _ in range(n - 1):
        nums.append((nums[-2] * litter) + nums[-1])

    return nums[-1]

1

函数fib()必须使用两个参数调用。

2

函数需要世代数和窝大小。函数体本质上相同。

在上述代码中,您可以看到我必须向fib()传递两个参数,而闭包只需要一个参数,因为捕获了litter。绑定值并减少参数数量是创建闭包的有效原因之一。另一个编写闭包的原因是限制函数的作用域。fib()函数的闭包定义仅在main()函数内有效,而前一个版本在整个程序中都可见。将一个函数隐藏在另一个函数中会使测试变得更加困难。在本例中,fib()函数几乎是整个程序,因此测试已在tests/fib_test.py中编写。

解决方案 2:创建生成器函数

在之前的解决方案中,我生成了请求值之前的斐波那契数列,然后停止;但是,这个序列是无限的。我能否创建一个可以生成所有序列数的函数?从技术上讲,可以,但它永远不会完成,毕竟它是无限的。

Python 有一种方法可以挂起生成可能无限序列的函数。我可以使用yield从函数中返回一个值,稍后再从函数中暂时离开,以在请求下一个值时恢复到相同状态。这种函数称为生成器,以下是我如何使用它生成序列:

def fib(k: int) -> Generator[int, None, None]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    x, y = 0, 1 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    yield x ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    while True: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        yield y ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        x, y = y * k, x + y ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

1

类型签名表明该函数接受参数k(窝大小),必须是int类型。它返回一种Generator类型的特殊函数,该函数生成int值且没有发送或返回类型。

2

我只需要跟踪最后两代,我将它们初始化为01

3

生成0

4

创建一个无限循环。

5

生成最后一代。

6

x(前两代)设置为当前代乘以幼崽数量。将y(前一代)设置为两个当前代的和。

生成器像迭代器一样工作,根据代码请求生成值,直到耗尽。由于这个生成器只生成 yield 值,因此发送和返回类型为None。除此之外,这段代码完全与程序的第一个版本相同,只是内部使用了一个花哨的生成器函数。查看图 4-3 以考虑该函数如何适用于两种不同的幼崽数量。

mpfb 0403

图 4-3. 展示了fib()生成器在时间上如何变化(n=5),适用于两个不同的幼崽数量(k=1 和 k=3)。

Generator的类型签名看起来有点复杂,因为它定义了 yield、send 和 return 的类型。我不需要在这里进一步深入,但建议您阅读关于typing 模块的文档

这里是如何使用的:

def main() -> None:
    args = get_args()
    gen = fib(args.litter) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    seq = [next(gen) for _ in range(args.generations + 1)] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    print(seq[-1]) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

fib()函数接受幼崽数量作为参数并返回一个生成器。

2

使用next()函数从生成器中获取下一个值。使用列表推导来正确次数生成序列,直至请求的值。

3

打印序列中的最后一个数字。

range()函数的功能不同,因为第一个版本已经包含了01。在这里,我必须额外调用两次生成器才能产生这些值。

尽管我更喜欢列表推导,但我不需要整个列表。我只关心最后的值,所以可以这样写:

def main() -> None:
    args = get_args()
    gen = fib(args.litter)
    answer = 0 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    for _ in range(args.generations + 1): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        answer = next(gen) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    print(answer) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

将答案初始化为0

2

创建正确数量的循环。

3

获取当前代的值。

4

打印答案。

恰好,经常多次调用函数生成列表,所以有一个函数来为我们做这个。itertools.islice() 函数将“生成一个迭代器,从可迭代对象中返回选定的元素。”这是我如何使用它的方法:

def main() -> None:
    args = get_args()
    seq = list(islice(fib(args.litter), args.generations + 1)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    print(seq[-1]) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

islice() 的第一个参数是将被调用的函数,第二个参数是调用它的次数。该函数是惰性的,因此我使用 list() 强制生成值。

2

打印最后一个值。

由于我只使用 seq 变量一次,我可以避免那个赋值。如果基准测试证明以下是性能最佳的版本,我可能愿意写成一行:

def main() -> None:
    args = get_args()
    print(list(islice(fib(args.litter), args.generations + 1))[-1])

聪明的代码很有趣,但可能变得难以阅读。^(1) 你已经被警告过了。

生成器很酷,但比生成列表更复杂。它们是生成非常大或潜在无限序列值的适当方式,因为它们是惰性的,在你的代码需要时才计算下一个值。

解决方案 3:使用递归和记忆化

虽然有许多更有趣的方法来编写生成无限数列的算法,但我只展示使用递归的另一种方法:

def main() -> None:
    args = get_args()

    def fib(n: int) -> int: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        return 1 if n in (1, 2) \ ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
            else fib(n - 2) * args.litter + fib(n - 1) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    print(fib(args.generations)) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

定义一个名为 fib() 的函数,它接受所需代数作为 int 并返回 int

2

如果代数为 12,返回 1。这是非常重要的基础情况,不会进行递归调用。

3

对于所有其他情况,调用 fib() 函数两次,一次用于前两代,另一次用于前一代。与以往一样考虑每胎的数量。

4

打印给定代数的 fib() 函数的结果。

这里又是一个例子,我在 main() 函数内部定义了一个作为闭包的 fib() 函数,以便在 fib() 函数内使用 args.litter 值。这样可以将 args.litter 绑定到函数中。如果我在 main() 函数外定义了该函数,我将不得不在递归调用时传递 args.litter 参数。

这是一个非常优雅的解决方案,在几乎每个计算机科学的入门课程中都会教授。研究起来很有趣,但事实证明它非常慢,因为我最终需要调用该函数很多次。也就是说,fib(5)需要调用fib(4)fib(3)来添加这些值。而fib(4)需要调用fib(3)fib(2),依此类推。图 4-4 显示,fib(5)导致 14 次函数调用以产生 5 个不同的值。例如,fib(2)被计算了三次,但我们只需要计算一次。

mpfb 0404

图 4-4. 调用fib(5)的调用堆栈导致多次递归调用函数,随着输入值的增加,递归调用次数增加大约呈指数级增长

为了说明问题,我将采样这个程序在最大n为 40 时完成所需的时间。同样,我将使用bash中的for循环来展示如何在命令行中常见地对这样一个程序进行基准测试:

$ for n in 10 20 30 40;
> do echo "==> $n <==" && time ./solution3_recursion.py $n 1
> done
==> 10 <==
55

real	0m0.045s
user	0m0.032s
sys	0m0.011s
==> 20 <==
6765

real	0m0.041s
user	0m0.031s
sys	0m0.009s
==> 30 <==
832040

real	0m0.292s
user	0m0.281s
sys	0m0.009s
==> 40 <==
102334155

real	0m31.629s
user	0m31.505s
sys	0m0.043s

n=30的 0.29 秒到n=40的 31 秒的跳跃是巨大的。想象一下去到 50 及以上。我需要找到一种方法来加快这个过程或放弃递归的所有希望。解决方法是缓存先前计算的结果。这被称为记忆化,有许多实现方法。以下是一种方法。注意,您需要导入typing.Callable

def memoize(f: Callable) -> Callable: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Memoize a function """

    cache = {} ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    def memo(x): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        if x not in cache: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            cache[x] = f(x) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        return cache[x] ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

    return memo ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

1

定义一个接受函数(即可调用对象)并返回函数的函数。

2

使用字典来存储缓存值。

3

memo()定义为对缓存的闭包。在调用时,该函数将接受某些参数x

4

检查缓存中是否存在参数值。

5

如果没有,使用参数调用函数,并将该参数值的缓存设置为结果。

6

返回参数的缓存值。

7

返回新函数。

注意,memoize()函数返回一个新函数。在 Python 中,函数被认为是一级对象,意味着它们可以像其他类型的变量一样使用——你可以将它们作为参数传递并覆盖它们的定义。memoize()函数是高阶函数(HOF)的一个例子,因为它接受其他函数作为参数。我将在本书中使用其他 HOF,如filter()map()

要使用memoize()函数,我将定义fib(),然后用记忆化版本重新定义它。如果你运行这个程序,无论n多大,你都会看到几乎瞬间的结果:

def main() -> None:
    args = get_args()

    def fib(n: int) -> int:
        return 1 if n in (1, 2) else fib(n - 2) * args.litter + fib(n - 1)

    fib = memoize(fib) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    print(fib(args.generations))

1

用记忆化函数覆盖现有的fib()定义。

实现此目标的首选方法是使用装饰器,即修改其他函数的函数:

def main() -> None:
    args = get_args()

    @memoize ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    def fib(n: int) -> int:
        return 1 if n in (1, 2) else fib(n - 2) * args.litter + fib(n - 1)

    print(fib(args.generations))

1

使用memoize()函数装饰fib()函数。

尽管编写记忆化函数很有趣,但事实证明这是一个常见的需求,其他人已经为我们解决了。我可以移除memoize()函数,而是导入functools.lru_cache(最近最少使用缓存)函数:

from functools import lru_cache

使用lru_cache()函数装饰fib()函数以实现记忆化,尽量减少干扰:

def main() -> None:
    args = get_args()

    @lru_cache() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    def fib(n: int) -> int:
        return 1 if n in (1, 2) else fib(n - 2) * args.litter + fib(n - 1)

    print(fib(args.generations))

1

通过lru_cache()函数装饰fib()函数进行记忆化。注意,Python 3.6 需要括号,但 3.8 及更高版本不需要。

测试解决方案的性能

哪个是最快的解决方案?我已经向你展示了如何在bash中使用for循环和time命令来比较命令的运行时间:

$ for py in ./solution1_list.py ./solution2_generator_islice.py \
./solution3_recursion_lru_cache.py; do echo $py && time $py 40 5; done
./solution1_list.py
148277527396903091

real	0m0.070s
user	0m0.043s
sys	0m0.016s
./solution2_generator_islice.py
148277527396903091

real	0m0.049s
user	0m0.033s
sys	0m0.013s
./solution3_recursion_lru_cache.py
148277527396903091

real	0m0.041s
user	0m0.030s
sys	0m0.010s

看起来使用 LRU 缓存的递归解决方案是最快的,但我只有很少的数据——每个程序只运行一次。此外,我需要凭眼观察数据并确定哪个最快。

有更好的方法。我安装了一个名为hyperfine的工具来多次运行每个命令并比较结果:

$ hyperfine -L prg ./solution1_list.py,./solution2_generator_islice.py,\
./solution3_recursion_lru_cache.py '{prg} 40 5' --prepare 'rm -rf __pycache__'
Benchmark #1: ./solution1_list.py 40 5
  Time (mean ± σ):      38.1 ms ±   1.1 ms    [User: 28.3 ms, System: 8.2 ms]
  Range (min … max):    36.6 ms …  42.8 ms    60 runs

Benchmark #2: ./solution2_generator_islice.py 40 5
  Time (mean ± σ):      38.0 ms ±   0.6 ms    [User: 28.2 ms, System: 8.1 ms]
  Range (min … max):    36.7 ms …  39.2 ms    66 runs

Benchmark #3: ./solution3_recursion_lru_cache.py 40 5
  Time (mean ± σ):      37.9 ms ±   0.6 ms    [User: 28.1 ms, System: 8.1 ms]
  Range (min … max):    36.6 ms …  39.4 ms    65 runs

Summary
  './solution3_recursion_lru_cache.py 40 5' ran
    1.00 ± 0.02 times faster than './solution2_generator_islice.py 40 5'
    1.01 ± 0.03 times faster than './solution1_list.py 40 5'

看起来hyperfine运行了每个命令 60-66 次,取平均结果,并发现solution3_recursion_lru_cache.py程序可能稍快。另一个你可能找到有用的基准工具是bench,但你可以在互联网上搜索其他更适合你口味的基准工具。无论你使用什么工具,基准测试以及测试对挑战代码假设至关重要。

我使用了 --prepare 选项告诉 hyperfine 在运行命令之前删除 pycache 目录。这是 Python 创建的目录,用于缓存程序的 bytecode。如果程序的源代码自上次运行以来没有改变,那么 Python 可以跳过编译,直接使用 pycache 目录中存在的 bytecode 版本。我需要删除它,因为 hyperfine 在运行命令时检测到统计异常值,可能是缓存效应导致的。

测试好的、坏的和丑陋的

对于每一个挑战,希望你花些时间阅读测试内容。学习如何设计和编写测试与我展示的任何其他内容同样重要。如我之前提到的,我的第一个测试检查预期的程序是否存在,并且在需要时产生使用说明。接着,我通常会输入无效的数据来确保程序失败。我想强调的是针对不良 nk 参数的测试。它们本质上是相同的,所以我只展示第一个作为示例,演示如何随机选择一个无效的整数值,例如可能是负数或者太大:

def test_bad_n():
    """ Dies when n is bad """

    n = random.choice(list(range(-10, 0)) + list(range(41, 50))) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    k = random.randint(1, 5) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    rv, out = getstatusoutput(f'{RUN} {n} {k}') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert rv != 0 ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    assert out.lower().startswith('usage:') ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    assert re.search(f'n "{n}" must be between 1 and 40', out) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

1

将两个无效数字范围列表连接起来,并随机选择一个值。

2

选择一个从 15(包括边界)的随机整数。

3

运行带有参数的程序,并捕获输出。

4

确保程序报告了失败(非零退出值)。

5

检查输出是否以使用说明开头。

6

寻找描述 n 参数问题的错误消息。

我经常喜欢在测试时使用随机选择的无效值。这在某种程度上是为了给学生编写测试,以防止他们在单个坏输入上失败,但我也发现这有助于我避免意外地编写特定输入值的代码。我还未介绍 random 模块,但它可以让你进行伪随机选择。首先,你需要导入该模块:

>>> import random

例如,你可以使用 random.randint() 来从给定范围内选择一个整数:

>>> random.randint(1, 5)
2
>>> random.randint(1, 5)
5

或者使用 random.choice() 函数从某个序列中随机选择一个值。在这里,我想构建一个不连续的负数范围,与一个正数范围分开:

>>> random.choice(list(range(-10, 0)) + list(range(41, 50)))
46
>>> random.choice(list(range(-10, 0)) + list(range(41, 50)))
-1

接下来的测试提供了程序的有效输入。例如:

def test_2():
    """ Runs on good input """

    rv, out = getstatusoutput(f'{RUN} 30 4') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert rv == 0 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert out == '436390025825' ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

这些是我在尝试解决 Rosalind 挑战时收到的值。

2

程序不应在此输入上失败。

3

这是 Rosalind 的正确答案。

测试,就像文档一样,是写给未来自己的一封情书。尽管测试看起来可能很乏味,但当您尝试添加功能并意外破坏以前工作的东西时,您会感激失败的测试。认真地编写和运行测试可以防止您部署损坏的程序。

在所有解决方案上运行测试套件

您已经看到,在每章中,我会写多个解决方案来探索解决问题的各种方法。我完全依赖我的测试来确保我的程序是正确的。您可能会好奇看看我如何自动化测试每个解决方案的过程。查看Makefile并找到all目标:

$ cat Makefile
.PHONY: test

test:
	python3 -m pytest -xv --flake8 --pylint --mypy fib.py tests/fib_test.py

all:
    ../bin/all_test.py fib.py

程序all_test.py会在运行测试套件之前用每个解决方案覆盖程序fib.py。这可能会覆盖您的解决方案。在运行make all之前,请确保您将您的版本提交到 Git 或至少复制一份,否则可能会丢失您的工作。

下面是由all目标运行的all_test.py程序。我将其分成两部分,从第一部分到get_args()。大部分内容现在应该已经很熟悉了:

#!/usr/bin/env python3
""" Run the test suite on all solution*.py """

import argparse
import os
import re
import shutil
import sys
from subprocess import getstatusoutput
from functools import partial
from typing import NamedTuple

class Args(NamedTuple):
    """ Command-line arguments """
    program: str ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    quiet: bool ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

# --------------------------------------------------
def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Run the test suite on all solution*.py',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('program', metavar='prg', help='Program to test') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    parser.add_argument('-q', '--quiet', action='store_true', help='Be quiet') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    args = parser.parse_args()

    return Args(args.program, args.quiet)

1

要测试的程序名称,在本例中为fib.py

2

一个布尔值TrueFalse来创建更多或更少的输出。

3

默认类型是str

4

action='store_true'使其成为布尔标志。如果标志存在,则值为True;否则为False

main()函数是进行测试的地方:

def main() -> None:
    args = get_args()
    cwd = os.getcwd() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    solutions = list( ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        filter(partial(re.match, r'solution.*\.py'), os.listdir(cwd))) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    for solution in sorted(solutions): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        print(f'==> {solution} <==')
        shutil.copyfile(solution, os.path.join(cwd, args.program)) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        subprocess.run(['chmod', '+x', args.program], check=True) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
        rv, out = getstatusoutput('make test') ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
        if rv != 0: ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
            sys.exit(out) ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)

        if not args.quiet: ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)
            print(out)

    print('Done.') ![11](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/11.png)

1

获取当前工作目录,如果您在运行命令时位于该目录中,则为04_fib目录。

2

查找当前目录中的所有solution*.py文件。

3

filter()partial()都是高阶函数;接下来我将解释它们。

4

文件名将以随机顺序排列,因此需要遍历排序后的文件。

5

solution*.py文件复制到测试文件名。

6

让程序可执行。

7

运行make test命令,并捕获返回值和输出。

8

检查返回值是否不为0

9

退出此程序时,打印测试输出并返回非零值。

10

除非程序需要静默运行,否则打印测试输出。

11

让用户知道程序正常结束。

在前面的代码中,我使用sys.exit()来立即停止程序,打印错误消息,并返回一个非零的退出值。如果查阅文档,您会发现可以用没有参数、一个整数值或像字符串这样的对象调用sys.exit(),这正是我所使用的:

exit(status=None, /)
    Exit the interpreter by raising SystemExit(status).

    If the status is omitted or None, it defaults to zero (i.e., success).
    If the status is an integer, it will be used as the system exit status.
    If it is another kind of object, it will be printed and the system
    exit status will be one (i.e., failure).

前面的程序还使用了filter()partial()函数,这两者都是高阶函数。我将解释我如何以及为什么使用它们。首先,os.listdir()函数将返回目录的全部内容,包括文件和子目录:

>>> import os
>>> files = os.listdir()

这里有很多内容,所以我将从pprint模块导入pprint()函数以漂亮打印它:

>>> from pprint import pprint
>>> pprint(files)
['solution3_recursion_memoize_decorator.py',
 'solution2_generator_for_loop.py',
 '.pytest_cache',
 'Makefile',
 'solution2_generator_islice.py',
 'tests',
 '__pycache__',
 'fib.py',
 'README.md',
 'solution3_recursion_memoize.py',
 'bench.html',
 'solution2_generator.py',
 '.mypy_cache',
 '.gitignore',
 'solution1_list.py',
 'solution3_recursion_lru_cache.py',
 'solution3_recursion.py']

我想要过滤出以solution开头且以.py结尾的文件名。在命令行中,我可以使用solution*.py这样的文件通配符模式,其中*表示任意数量的任何字符.是一个字面上的点。这个模式的正则表达式版本稍微复杂一些,是solution.*\.py,其中.(点)是正则表达式元字符,代表任何字符*(星号)表示零个或多个(见图 4-5)。为了表示字面上的点,我需要用反斜杠进行转义(\.)。注意,最好使用 r 字符串(原始字符串)来包围这个模式。

mpfb 0405

图 4-5. 用于找到与文件通配符solution*.py匹配的文件的正则表达式

当匹配成功时,将返回一个re.Match对象:

>>> import re
>>> re.match(r'solution.*\.py', 'solution1.py')
<re.Match object; span=(0, 12), match='solution1.py'>

当匹配失败时,将返回None值。我必须在这里使用type(),因为在 REPL 中不显示None值:

>>> type(re.match(r'solution.*\.py', 'fib.py'))
<class 'NoneType'>

我想将此匹配应用于os.listdir()返回的所有文件。我可以使用filter()lambda关键字来创建一个匿名函数。将files中的每个文件名作为name参数传递给匹配。filter()函数将只返回给定函数中返回真值的元素,因此那些无法匹配时返回None的文件名将被排除:

>>> pprint(list(filter(lambda name: re.match(r'solution.*\.py', name), files)))
['solution3_recursion_memoize_decorator.py',
 'solution2_generator_for_loop.py',
 'solution2_generator_islice.py',
 'solution3_recursion_memoize.py',
 'solution2_generator.py',
 'solution1_list.py',
 'solution3_recursion_lru_cache.py',
 'solution3_recursion.py']

你可以看到re.match()函数接受两个参数——模式和要匹配的字符串。partial()函数允许我部分应用函数,并返回一个新函数。例如,operator.add()函数期望两个值并返回它们的和:

>>> import operator
>>> operator.add(1, 2)
3

我可以创建一个函数,用于将任何值加1,就像这样:

>>> from functools import partial
>>> succ = partial(op.add, 1)

succ()函数需要一个参数,并返回其后继:

>>> succ(3)
4
>>> succ(succ(3))
5

同样,我可以创建一个函数f(),部分应用re.match()函数的第一个参数,即正则表达式模式:

>>> f = partial(re.match, r'solution.*\.py')

f()函数正在等待一个字符串来应用匹配:

>>> type(f('solution1.py'))
<class 're.Match'>
>>> type(f('fib.py'))
<class 'NoneType'>

如果你不带参数调用它,将会收到一个异常:

>>> f()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: match() missing 1 required positional argument: 'string'

我可以用部分应用函数替换lambda作为filter()的第一个参数:

>>> pprint(list(filter(f, files)))
['solution3_recursion_memoize_decorator.py',
 'solution2_generator_for_loop.py',
 'solution2_generator_islice.py',
 'solution3_recursion_memoize.py',
 'solution2_generator.py',
 'solution1_list.py',
 'solution3_recursion_lru_cache.py',
 'solution3_recursion.py']

我的编程风格在很大程度上倾向于纯函数式编程思想。我发现这种风格就像玩乐高积木一样——小而明确定义的测试函数可以组合成运行良好的更大程序。

深入了解

编程有许多不同的风格,如过程化、函数式、面向对象等等。即使在像 Python 这样的面向对象语言中,我也可以使用非常不同的编程方法。第一种解决方案可以被认为是动态规划方法,因为你首先通过解决较小的问题来解决更大的问题。如果你觉得递归函数有趣,汉诺塔问题是另一个经典练习。像 Haskell 这样的纯函数式语言大多避免像for循环这样的构造,而是严重依赖递归和高阶函数。口语和编程语言塑造了我们思考问题的方式,我鼓励你尝试使用你了解的其他语言解决这个问题,看看你可能会写出不同的解决方案。

复习

本章的要点:

  • get_args()函数内部,您可以对参数进行手动验证,并使用parser.error()函数手动生成argparse错误。

  • 您可以通过推送和弹出元素来使用列表作为堆栈。

  • 在函数中使用yield将其转换为生成器。当函数生成一个值时,该值被返回,并保留函数的状态,直到下一个值被请求。生成器可用于创建潜在的无限值流。

  • 递归函数调用自身,递归可能导致严重的性能问题。一种解决方法是使用记忆化来缓存值并避免重新计算。

  • 高阶函数是接受其他函数作为参数的函数。

  • Python 的函数装饰器将高阶函数应用于其他函数。

  • 基准测试是确定最佳性能算法的重要技术。hyperfinebench 工具允许您比较多次迭代的命令运行时间。

  • random 模块提供了许多用于伪随机选择值的函数。

^(1) 正如传奇般的大卫·圣·哈宾斯和奈杰尔·塔夫内尔所观察到的,“愚蠢和聪明之间只有一线之隔。”

第五章:计算 GC 含量:解析 FASTA 和分析序列

在第一章中,你统计了 DNA 字符串中的所有碱基。在这个练习中,你需要计算序列中的GC的数量,并除以序列的长度,以确定 GC 含量,如Rosalind GC 页面所述。GC 含量在几个方面具有信息性。较高的 GC 含量水平表明在分子生物学中相对较高的熔解温度,并且编码蛋白质的 DNA 序列倾向于在富含 GC 的区域中找到。解决此问题的方法有很多种,它们都始于使用 Biopython 解析 FASTA 文件,这是生物信息学中的一个关键文件格式。我将向你展示如何使用Bio.SeqIO模块迭代文件中的序列,以识别具有最高 GC 含量的序列。

你将学到:

  • 如何使用Bio.SeqIO解析 FASTA 格式

  • 如何读取STDIN(读音为standard in

  • 使用列表理解、filter()map()表达for循环的几种方法

  • 如何解决运行时挑战,如解析大文件时的内存分配。

  • 关于sorted()函数的更多信息

  • 如何在格式字符串中包含格式化指令

  • 如何使用sum()函数将一组数字相加

  • 如何使用正则表达式在字符串中计算模式的出现次数

入门指南

此程序的所有代码和测试位于05_gc目录中。虽然我想将此程序命名为gc.py,但事实证明这与一个非常重要的名为gc.py的 Python 模块发生了冲突,该模块用于垃圾回收,例如释放内存。相反,我将使用cgc.py代替calculate GC

如果我将我的程序命名为gc.py,我的代码将会遮蔽内置的gc模块,使其不可用。同样,我可以创建具有名称lendict的变量和函数,这将遮蔽那些内置函数。这将导致许多不良情况发生,因此最好避免使用这些名称。诸如pylintflake8之类的程序可以发现这样的问题。

首先复制第一个解决方案并要求用法:

$ cp solution1_list.py cgc.py
$ ./cgc.py -h
usage: cgc.py [-h] [FILE] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

Compute GC content

positional arguments:
  FILE        Input sequence file (default: <_io.TextIOWrapper ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
              name='<stdin>' mode='r' encoding='utf-8'>)

optional arguments:
  -h, --help  show this help message and exit

1

注意,位置参数[FILE]位于方括号中,表示它是可选的。

2

这是一个相当丑陋的消息,试图解释默认输入是STDIN

如第二章所述,该程序期望一个文件作为输入,并将拒绝无效或无法读取的文件。为了说明第二点,使用touch创建一个空文件,然后使用chmod(改变模式)将权限设置为000(所有读/写/执行位关闭):

$ touch cant-touch-this
$ chmod 000 cant-touch-this

注意,错误消息明确告诉我我缺少读取文件的权限:

$ ./cgc.py cant-touch-this
usage: cgc.py [-h] [FILE]
cgc.py: error: argument FILE: can't open 'cant-touch-this': [Errno 13]
Permission denied: 'cant-touch-this'

现在用有效的输入运行程序,并观察程序打印具有最高 GC 百分比的记录的 ID:

$ ./cgc.py tests/inputs/1.fa
Rosalind_0808 60.919540

该程序还可以从STDIN中读取。仅仅因为我觉得有趣,我会向你展示如何在bash shell 中使用管道操作符(|)将一个程序的STDOUT输出路由到另一个程序的STDIN中。例如,cat程序会将文件的内容打印到STDOUT

$ cat tests/inputs/1.fa
>Rosalind_6404
CCTGCGGAAGATCGGCACTAGAATAGCCAGAACCGTTTCTCTGAGGCTTCCGGCCTTCCC
TCCCACTAATAATTCTGAGG
>Rosalind_5959
CCATCGGTAGCGCATCCTTAGTCCAATTAAGTCCCTATCCAGGCGCTCCGCCGAAGGTCT
ATATCCATTTGTCAGCAGACACGC
>Rosalind_0808
CCACCCTCGTGGTATGGCTAGGCATTCAGGAACCGGAGAACGCTTCAGACCAGCCCGGAC
TGGGAACCTGCGGGCAGTAGGTGGAAT

使用管道,我可以将其提供给我的程序:

$ cat tests/inputs/1.fa | ./cgc.py
Rosalind_0808 60.919540

我也可以使用<操作符从文件中重定向输入:

$ ./cgc.py < tests/inputs/1.fa
Rosalind_0808 60.919540

要开始,请删除此程序并重新开始:

$ new.py -fp 'Compute GC content' cgc.py
Done, see new script "cgc.py".

以下显示如何修改程序的第一部分以接受一个单一的位置参数,该参数是一个有效的可读文件:

import argparse
import sys
from typing import NamedTuple, TextIO, List, Tuple
from Bio import SeqIO

class Args(NamedTuple):
    """ Command-line arguments """
    file: TextIO ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Compute GC content',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('file',
                        metavar='FILE',
                        type=argparse.FileType('rt'), ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                        nargs='?',
                        default=sys.stdin,
                        help='Input sequence file')

    args = parser.parse_args()

    return Args(args.file)

1

Args类的唯一属性是一个文件句柄。

2

创建一个位置参数文件,如果提供,必须是可读的文本文件。

很少将位置参数设为可选,但在这种情况下,我想要处理一个单个文件输入或者从STDIN读取。为此,我使用nargs='?'指示该参数应接受零个或一个参数(见表 2-2 中的“打开输出文件”)并设置default=sys.stdin。在第二章中,我提到sys.stdout是一个始终打开写入的文件句柄。类似地,sys.stdin是一个始终打开读取STDIN的文件句柄。这是使你的程序能够从文件或STDIN中读取的所有所需代码,我觉得这相当整洁和清晰。

修改你的main()函数以打印文件的名称:

def main() -> None:
    args = get_args()
    print(args.file.name)

确保它能正常工作:

$ ./cgc.py tests/inputs/1.fa
tests/inputs/1.fa

运行pytest来查看你的进展。你应该通过前三个测试,但在第四个测试上失败:

$ pytest -xv
============================ test session starts ============================
....

tests/cgc_test.py::test_exists PASSED                                 [ 20%]
tests/cgc_test.py::test_usage PASSED                                  [ 40%]
tests/cgc_test.py::test_bad_input PASSED                              [ 60%]
tests/cgc_test.py::test_good_input1 FAILED                            [ 80%]

================================= FAILURES ==================================
_____________________________ test_good_input1 ______________________________

    def test_good_input1():
        """ Works on good input """

        rv, out = getstatusoutput(f'{RUN} {SAMPLE1}') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        assert rv == 0
>       assert out == 'Rosalind_0808 60.919540' ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
E       AssertionError: assert './tests/inputs/1.fa' == 'Rosalind_0808 60.919540'
E         - Rosalind_0808 60.919540 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
E         + ./tests/inputs/1.fa ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

tests/cgc_test.py:48: AssertionError
========================== short test summary info ==========================
FAILED tests/cgc_test.py::test_good_input1 - AssertionError: assert './tes...
!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!
======================== 1 failed, 3 passed in 0.34s ========================

1

测试正在使用第一个输入文件运行程序。

2

预期输出是给定的字符串。

3

这是预期的字符串。

4

这是打印出的字符串。

到目前为止,你已经通过相对较少的工作创建了一个语法正确、结构良好并且有文档的程序来验证文件输入。接下来,你需要找出如何找到 GC 含量最高的序列。

使用 Biopython 解析 FASTA 文件

来自传入文件或STDIN的数据应该是以 FASTA 格式表示的序列数据,这是表示生物序列的常见方式。让我们看看第一个文件,以了解格式:

$ cat tests/inputs/1.fa
>Rosalind_6404 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
CCTGCGGAAGATCGGCACTAGAATAGCCAGAACCGTTTCTCTGAGGCTTCCGGCCTTCCC ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
TCCCACTAATAATTCTGAGG
>Rosalind_5959
CCATCGGTAGCGCATCCTTAGTCCAATTAAGTCCCTATCCAGGCGCTCCGCCGAAGGTCT
ATATCCATTTGTCAGCAGACACGC
>Rosalind_0808
CCACCCTCGTGGTATGGCTAGGCATTCAGGAACCGGAGAACGCTTCAGACCAGCCCGGAC
TGGGAACCTGCGGGCAGTAGGTGGAAT

1

FASTA 记录以行首的>开始。序列 ID 是直到第一个空格的任何后续文本。

2

序列可以是任意长度,可以跨多行或放在单行上。

FASTA 文件的头部可能会非常混乱,非常快速。我鼓励您从国家生物技术信息中心(NCBI)下载真实序列或查看17_synth/tests/inputs目录中的文件以获取更多示例。

虽然教您如何手动解析此文件可能很有趣(对于某些有趣的价值观来说),但我将直接使用 Biopython 的Bio.SeqIO模块:

>>> from Bio import SeqIO
>>> recs = SeqIO.parse('tests/inputs/1.fa', 'fasta') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

第一个参数是输入文件的名称。由于此函数可以解析许多不同的记录格式,因此第二个参数是数据的格式。

我可以像往常一样使用type()来检查recs的类型:

>>> type(recs)
<class 'Bio.SeqIO.FastaIO.FastaIterator'>

我已经几次展示了迭代器,甚至在第四章中创建了一个。在那个练习中,我使用next()函数从斐波那契数列生成器中获取下一个值。我将在这里做同样的事情,以获取第一个记录并检查其类型:

>>> rec = next(recs)
>>> type(rec)
<class 'Bio.SeqRecord.SeqRecord'>

要了解有关序列记录的更多信息,我强烈建议您阅读SeqRecord 文档,此外还可以在 REPL 中查看文档,您可以使用help(rec)查看。必须解析FASTA 记录的数据,这意味着从其语法和结构中辨别数据的含义。如果您在 REPL 中查看rec,您将看到类似于字典的输出。此输出与repr(seq)的输出相同,后者用于“返回对象的规范字符串表示”:

SeqRecord(
  seq=Seq('CCTGCGGAAGATCGGCACTAGAATAGCCAGAACCGTTTCTCTGAGGCTTCCGGC...AGG'), ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
  id='Rosalind_6404', ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
  name='Rosalind_6404', ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
  description='Rosalind_6404',
  dbxrefs=[])

1

序列的多行被连接成一个由Seq对象表示的单个序列。

2

FASTA 记录的ID是从>之后开始的标题中的所有字符,直到第一个空格。

3

SeqRecord对象还可以处理具有更多字段的数据,例如namedescription和数据库交叉引用(dbxrefs)。由于这些字段在 FASTA 记录中不存在,ID 会被复制为namedescription,而dbxrefs的值则为空列表。

如果打印序列,这些信息将被字符串化,因此更容易阅读。这个输出与str(rec)的输出相同,后者旨在提供对象的有用字符串表示:

>>> print(rec)
ID: Rosalind_6404
Name: Rosalind_6404
Description: Rosalind_6404
Number of features: 0
Seq('CCTGCGGAAGATCGGCACTAGAATAGCCAGAACCGTTTCTCTGAGGCTTCCGGC...AGG')

对于这个程序来说,最突出的特性是记录的序列。你可能期望这是一个str,但实际上它是另一个对象:

>>> type(rec.seq)
<class 'Bio.Seq.Seq'>

使用help(rec.seq)查看Seq对象提供的属性和方法。我只想要 DNA 序列本身,可以通过str()函数将序列强制转换为字符串来获取:

>>> str(rec.seq)
'CCTGCGGAAGATCGGCACTAGAATAGCCAGAACCGTTTCTCTGAGGCTTCCGGCCTT...AGG'

注意,这是我在上一章的最后一个解决方案中使用的相同类,用于创建反向互补序列。我可以在这里这样使用它:

>>> rec.seq.reverse_complement()
Seq('CCTCAGAATTATTAGTGGGAGGGAAGGCCGGAAGCCTCAGAGAAACGGTTCTGG...AGG')

Seq对象还有许多其他有用的方法,我鼓励你探索文档,因为这些方法可以节省大量时间。^(1) 现在,你可能已经有足够的信息来完成挑战了。你需要遍历所有序列,确定GC碱基的百分比,并返回具有最大值的记录的 ID 和 GC 含量。我建议你自己编写一个解决方案。如果需要更多帮助,我会展示一种方法,并介绍几种解决方案的变体。

使用for循环迭代序列

到目前为止,我已经展示了SeqIO.parse()接受文件名作为第一个参数,但args.file参数将是一个打开的文件句柄。幸运的是,该函数也接受这种形式:

>>> from Bio import SeqIO
>>> recs = SeqIO.parse(open('./tests/inputs/1.fa'), 'fasta')

我可以使用for循环来遍历每个记录,打印出 ID 和每个序列的前 10 个碱基:

>>> for rec in recs:
...     print(rec.id, rec.seq[:10])
...
Rosalind_6404 CCTGCGGAAG
Rosalind_5959 CCATCGGTAG
Rosalind_0808 CCACCCTCGT

请花一点时间再次运行这些代码,并注意什么也不会被打印出来:

>>> for rec in recs:
...     print(rec.id, rec.seq[:10])
...

我之前展示了recs是一个Bio.SeqIO.FastaIO.FastaIterator,像所有迭代器一样,它会产生值直到耗尽。如果想要再次循环遍历记录,需要重新使用SeqIO.parse()函数创建recs对象。

暂时假设序列如下:

>>> seq = 'CCACCCTCGTGGTATGGCT'

我需要找出字符串中有多少个CG。我可以使用另一个for循环来迭代序列的每个碱基,并在碱基为GC时增加一个计数器:

gc = 0 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
for base in seq: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    if base in ('G', 'C'): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        gc += 1 ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

初始化一个变量用于计算G/C碱基的计数。

2

迭代每个序列中的每个碱基(字符)。

3

看看元组中是否包含GC的碱基。

4

增加 GC 计数器。

要找出 GC 含量的百分比,将 GC 计数除以序列的长度:

>>> gc
12
>>> len(seq)
19
>>> gc / len(seq)
0.631578947368421

程序输出应为具有最高GC计数的序列的 ID,一个空格,并截断为六个有效数字的 GC 含量。格式化数字的最简单方法是了解更多关于str.format()的信息。help中没有太多文档,因此建议您阅读PEP 3101以了解高级字符串格式化。

在第一章中,我展示了如何使用{}作为占位符来插入变量,无论是使用str.format()还是 f-string。您可以在花括号中的冒号(:)后添加格式化指令。这种语法看起来像类 C 语言中的printf()函数使用的语法,因此{:0.6f}是一个六位数的浮点数:

>>> '{:0.6f}'.format(gc * 100 / len(seq))
'63.157895'

或者,直接在 f-string 内执行代码:

>>> f'{gc * 100 / len(seq):0.06f}'
'63.157895'

要找出具有最大GC计数的序列,您有几个选项,我将在解决方案中都展示:

  • 制作所有 ID 及其 GC 含量的列表(元组列表会很好用)。按照 GC 含量排序并取最大值。

  • 记录最大值的 ID 和 GC 含量。在发现新的最大值时覆盖它。

我认为这应该足以让您完成一个解决方案。您可以做到。害怕是心灵的杀手。继续努力直到通过所有测试,包括那些用于检查代码风格和类型的测试。您的测试输出应该看起来像这样:

$ make test
python3 -m pytest -xv --disable-pytest-warnings --flake8 --pylint
--pylint-rcfile=../pylintrc --mypy cgc.py tests/cgc_test.py
=========================== test session starts ===========================
...
collected 10 items

cgc.py::FLAKE8 SKIPPED                                              [  9%]
cgc.py::mypy PASSED                                                 [ 18%]
tests/cgc_test.py::FLAKE8 SKIPPED                                   [ 27%]
tests/cgc_test.py::mypy PASSED                                      [ 36%]
tests/cgc_test.py::test_exists PASSED                               [ 45%]
tests/cgc_test.py::test_usage PASSED                                [ 54%]
tests/cgc_test.py::test_bad_input PASSED                            [ 63%]
tests/cgc_test.py::test_good_input1 PASSED                          [ 72%]
tests/cgc_test.py::test_good_input2 PASSED                          [ 81%]
tests/cgc_test.py::test_stdin PASSED                                [ 90%]
::mypy PASSED                                                       [100%]
================================== mypy ===================================

Success: no issues found in 2 source files
====================== 9 passed, 2 skipped in 1.67s =======================

解决方案

与以往一样,所有解决方案共享相同的get_args(),因此只显示差异。

解决方案 1:使用列表

让我们来看看我的第一个解决方案。我总是试图从最明显和简单的方法开始,您会发现这通常是最冗长的。一旦理解了逻辑,希望您能够理解更强大和更简洁的表达相同想法的方式。对于这个第一个解决方案,确保还从typing模块中导入ListTuple

def main() -> None:
    args = get_args()
    seqs: List[Tuple[float, str]] = [] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    for rec in SeqIO.parse(args.file, 'fasta'): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        gc = 0 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        for base in rec.seq.upper(): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            if base in ('C', 'G'): ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
                gc += 1 ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
        pct = (gc * 100) / len(rec.seq) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
        seqs.append((pct, rec.id)) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

    high = max(seqs) ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
    print(f'{high[1]} {high[0]:0.6f}') ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)

1

初始化一个空列表以保存 GC 含量和序列 ID 的元组。

2

遍历输入文件中的每个记录。

3

初始化一个 GC 计数器。

4

遍历每个序列,将其大写以防止可能的混合大小写输入。

5

检查基序是否为CG

6

增加 GC 计数器。

7

计算 GC 含量。

8

添加一个新的元组,包括 GC 含量和序列 ID。

9

取最大值。

10

打印最高值的序列 ID 和 GC 含量。

变量seqs的类型注解List[Tuple[float, str]]不仅提供了一种通过像mypy这样的工具来程序化地检查代码的方式,还增加了一层文档说明。代码的读者不必跳到前面去看将要添加到列表中的数据类型,因为已经通过类型显式地描述了。

在这个解决方案中,我决定制作一个包含所有 ID 和 GC 百分比的列表,主要是为了展示如何创建元组列表。然后我想指出 Python 排序的一些神奇属性。让我们从sorted()函数开始,它在处理字符串时与你想象的一样有效:

>>> sorted(['McClintock', 'Curie', 'Doudna', 'Charpentier'])
['Charpentier', 'Curie', 'Doudna', 'McClintock']

当所有值都是数字时,它们将按数字顺序排序,所以我得到了这个有点不错的功能:

>>> sorted([2, 10, 1])
[1, 2, 10]

注意,相同的值作为字符串会按字典顺序排序:

>>> sorted(['2', '10', '1'])
['1', '10', '2']

现在考虑一个元组列表,其中第一个元素是float,第二个元素是strsorted()将如何处理这个列表?首先按第一个元素数字排序,然后按第二个元素字典顺序排序:

>>> sorted([(0.2, 'foo'), (.01, 'baz'), (.01, 'bar')])
[(0.01, 'bar'), (0.01, 'baz'), (0.2, 'foo')]

seqs结构化为List[Tuple[float, str]]利用了sorted()的内置行为,允许我快速地按 GC 含量对序列进行排序并选择最高值:

>>> high = sorted(seqs)[-1]

这与找到最高值相同,max()函数可以更容易地实现:

>>> high = max(seqs)

high是一个元组,第一个位置是序列 ID,零位置是需要格式化的 GC 含量:

print(f'{high[1]} {high[0]:0.6f}')

解决方案 2:类型注解和单元测试

for循环中隐藏着一个计算 GC 含量的代码片段,需要将其提取到一个带有测试的函数中。遵循测试驱动开发(TDD)的思想,我将首先定义一个find_gc()函数:

def find_gc(seq: str) -> float: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Calculate GC content """

    return 0\. ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

此函数接受一个str并返回一个float

2

目前我返回 0。请注意,末尾的 . 告诉 Python 这是一个 float。这是 0.0 的简写。

接下来,我将定义一个函数作为单元测试。因为我使用 pytest,所以这个函数的名称必须以 test_ 开头。因为我正在测试 find_gc() 函数,所以我将函数命名为 test_find_gc。我将使用一系列 assert 语句来测试函数对给定输入返回的预期结果。请注意,这个测试函数既作为正式测试,又作为额外的文档片段,因为读者可以看到输入和输出:

def test_find_gc():
    """ Test find_gc """

    assert find_gc('') == 0\. ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert find_gc('C') == 100\. ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert find_gc('G') == 100\. ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert find_gc('CGCCG') == 100\. ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    assert find_gc('ATTAA') == 0.
    assert find_gc('ACGT') == 50.

1

如果一个函数接受一个 str,我总是从空字符串开始测试,以确保它返回一些有用的东西。

2

单个 C 应为 100% GC。

3

单个 G 也是如此。

4

各种其他测试混合各种百分比的碱基。

几乎不可能彻底检查函数的每个可能输入,因此我通常依赖于抽样检查。请注意,hypothesis 模块 可以生成随机值进行测试。可以假设 find_gc() 函数足够简单,这些测试就足够了。我编写函数的目标是使其尽可能简单,但不简化。正如 Tony Hoare 所说,“编写代码有两种方式:编写简单到明显没有错误的代码,或者编写复杂到明显没有明显错误的代码。”

find_gc()test_find_gc() 函数在 cgc.py 程序中,而不在 tests/cgc_test.py 模块中。要执行单元测试,我在源代码上运行 pytest期望测试失败

$ pytest -v cgc.py
============================ test session starts ============================
...

cgc.py::test_find_gc FAILED                                           [100%] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

================================= FAILURES ==================================
__________________________________ test_gc __________________________________

    def test_find_gc():
        """ Test find_gc """

        assert find_gc('') == 0\. ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
>       assert find_gc('C') == 100\. ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
E       assert 0 == 100.0
E         +0
E         -100.0

cgc.py:74: AssertionError
========================== short test summary info ==========================
FAILED cgc.py::test_gc - assert 0 == 100.0
============================= 1 failed in 0.32s =============================

1

单元测试如预期般失败。

2

第一个测试通过,因为预期结果是0

3

这个测试失败,因为它应该返回 100

现在我已经建立了一个基准,可以继续进行。我知道我的代码未能达到某些期望,这是通过测试正式定义的。为了解决这个问题,我将main()中的所有相关代码移动到函数中:

def find_gc(seq: str) -> float:
    """ Calculate GC content """

    if not seq: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        return 0 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    gc = 0 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    for base in seq.upper():
        if base in ('C', 'G'):
            gc += 1

    return (gc * 100) / len(seq)

1

当序列为空字符串时,防止尝试除以 0。

2

如果没有序列,GC 含量为 0。

3

这与之前的代码相同。

然后再次运行pytest检查函数是否工作:

$ pytest -v cgc.py
============================ test session starts ============================
...

cgc.py::test_gc PASSED                                                [100%]

============================= 1 passed in 0.30s =============================

这是 TDD:

  • 定义一个测试函数。

  • 编写测试。

  • 确保函数未通过测试。

  • 使函数工作。

  • 确保函数通过测试(并且所有之前的测试仍然通过)。

如果以后遇到导致我的代码出现错误的序列,我会修复代码并将其添加为更多测试。我不应该担心像find_gc()函数接收None或整数列表这样的奇怪情况因为我使用了类型注解。测试是有用的。类型注解是有用的。结合测试和类型可以使代码更易于验证和理解。

我想对这个解决方案进行另一个添加:一个自定义类型来记录持有 GC 含量和序列 ID 的元组。我将其称为MySeq,只是为了避免与Bio.Seq类产生混淆。我在Args定义下面添加了这个:

class MySeq(NamedTuple):
    """ Sequence """
    gc: float ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    name: str ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

GC 含量是一个百分比。

2

我更愿意使用字段名id,但那与内置于 Python 中的id()标识函数冲突。

下面是如何将其合并到代码中的方法:

def main() -> None:
    args = get_args()
    seqs: List[MySeq] = [] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    for rec in SeqIO.parse(args.file, 'fasta'):
        seqs.append(MySeq(find_gc(rec.seq), rec.id)) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    high = sorted(seqs)[-1] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    print(f'{high.name} {high.gc:0.6f}') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

使用MySeq作为类型注解。

2

使用来自find_gc()函数的返回值和记录 ID 创建MySeq

3

这仍然有效,因为MySeq是一个元组。

4

使用字段访问而不是元组的索引位置。

这个版本的程序可能更容易阅读。您可以创建尽可能多的自定义类型来更好地记录和测试您的代码。

解决方案 3:保持运行的最大变量

前一个解决方案效果不错,但有点啰嗦,并且在我只关心最大值时无谓地跟踪所有序列。考虑到测试输入很小,这永远不会成为问题,但生物信息学始终关注扩展。试图存储所有序列的解决方案最终会导致内存耗尽。考虑处理 100 万、10 亿或 1000 亿序列。最终,内存会耗尽。

这是一个能够适应任意序列数量的解决方案,因为它只分配一个单个元组来记住最高值:

def main():
    args = get_args()
    high = MySeq(0., '') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    for rec in SeqIO.parse(args.file, 'fasta'):
        pct = find_gc(rec.seq) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        if pct > high.gc: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            high = MySeq(pct, rec.id) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    print(f'{high.name} {high.gc:0.6f}') ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

初始化一个变量来记住最高值。类型注释是多余的,因为mypy将期望此变量始终保持此类型。

2

计算 GC 含量。

3

查看 GC 百分比是否大于最高值。

4

如果是,使用这个百分比 GC 和序列 ID 覆盖最高值。

5

打印最高值。

对于此解决方案,我也采用了稍微不同的方法来计算GC含量:

def find_gc(seq: str) -> float:
    """ Calculate GC content """

    return (seq.upper().count('C') + ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
            seq.upper().count('G')) * 100 / len(seq) if seq else 0 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

使用str.count()方法来查找序列中的CG

2

由于序列状态有两个条件——空字符串或非空字符串——我更喜欢使用一个if表达式编写单个return

我将用最后一个解决方案来进行基准测试。首先,我需要生成一个包含大量序列的输入文件,比如 10K。在05_gc目录中,您会找到一个类似于我在02_rna目录中使用的genseq.py文件。这个文件生成一个 FASTA 文件:

$ ./genseq.py -h
usage: genseq.py [-h] [-l int] [-n int] [-s sigma] [-o FILE]

Generate long sequence

optional arguments:
  -h, --help            show this help message and exit
  -l int, --len int     Average sequence length (default: 500)
  -n int, --num int     Number of sequences (default: 1000)
  -s sigma, --sigma sigma
                        Sigma/STD (default: 0.1)
  -o FILE, --outfile FILE
                        Output file (default: seqs.fa)

这是我生成输入文件的方式:

$ ./genseq.py -n 10000 -o 10K.fa
Wrote 10,000 sequences of avg length 500 to "10K.fa".

我可以使用hyperfine来比较这两种实现:

$ hyperfine -L prg ./solution2_unit_test.py,./solution3_max_var.py '{prg} 10K.fa'
Benchmark #1: ./solution2_unit_test.py 10K.fa
  Time (mean ± σ):      1.546 s ±  0.035 s    [User: 2.117 s, System: 0.147 s]
  Range (min … max):    1.511 s …  1.625 s    10 runs

Benchmark #2: ./solution3_max_var.py 10K.fa
  Time (mean ± σ):     368.7 ms ±   3.0 ms    [User: 957.7 ms, System: 137.1 ms]
  Range (min … max):   364.9 ms … 374.7 ms    10 runs

Summary
  './solution3_max_var.py 10K.fa' ran
    4.19 ± 0.10 times faster than './solution2_unit_test.py 10K.fa'

看起来第三个解决方案比在 10K 序列上运行的第二个解决方案快大约四倍。您可以尝试生成更多和更长的序列进行自己的基准测试。我建议您创建一个包含至少一百万序列的文件,并将您的第一个解决方案与此版本进行比较。

解决方案 4:使用带有保护条件的列表推导式

图 5-1 显示了在序列中查找所有CG的另一种方法是使用列表推导和第一个解决方案中的if比较,这被称为guard

mpfb 0501

图 5-1. 一个带有 guard 的列表推导将只选择对于if表达式返回真值的元素

列表推导仅产生通过检查base是否在字符串'CG'中的 guard 的元素:

>>> gc = [base for base in 'CCACCCTCGTGGTATGGCT' if base in 'CG']
>>> gc
['C', 'C', 'C', 'C', 'C', 'C', 'G', 'G', 'G', 'G', 'G', 'C']

由于结果是一个新列表,我可以使用len()函数来找出有多少个CG

>>> len(gc)
12

我可以将这个想法整合到find_gc()函数中:

def find_gc(seq: str) -> float:
    """ Calculate GC content """

    if not seq:
        return 0

    gc = len([base for base in seq.upper() if base in 'CG']) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    return (gc * 100) / len(seq)

1

另一种计算CG数量的方法是使用带有 guard 的列表推导来选择它们。

Solution 5: 使用 filter()函数

带有 guard 的列表推导的概念可以使用高阶函数filter()来表达。在本章的早些时候,我使用map()函数将int()函数应用于列表的所有元素,以产生一个新的整数列表。filter()函数的工作方式类似,接受一个函数作为第一个参数和一个可迭代对象作为第二个参数。但它有所不同,只有在应用函数时返回真值的元素才会被返回。由于这是一个惰性函数,因此在 REPL 中我需要用list()来强制转换:

>>> list(filter(lambda base: base in 'CG', 'CCACCCTCGTGGTATGGCT'))
['C', 'C', 'C', 'C', 'C', 'C', 'G', 'G', 'G', 'G', 'G', 'C']

所以这里是表达与上一个解决方案相同的另一种方式:

def find_gc(seq: str) -> float:
    """ Calculate GC content """

    if not seq:
        return 0

    gc = len(list(filter(lambda base: base in 'CG', seq.upper()))) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    return (gc * 100) / len(seq)

1

使用filter()选择仅匹配CG的碱基。

Solution 6: 使用 map()函数和布尔值求和

map()函数是我喜欢的一个函数,所以我想展示另一种使用它的方法。我可以使用map()将每个碱基转换为 1(如果它是CG),否则为0

>>> seq = 'CCACCCTCGTGGTATGGCT'
>>> list(map(lambda base: 1 if base in 'CG' else 0, seq))
[1, 1, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0]

然后计算CG的数量只是简单地对这个列表求和,我可以使用sum()函数来实现:

>>> sum(map(lambda base: 1 if base in 'CG' else 0, seq))
12

我可以缩短我的map()以返回比较的结果(它是一个bool但也是一个int),然后将其求和:

>>> sum(map(lambda base: base in 'CG', seq))
12

这里是我如何将这个想法整合进来:

def find_gc(seq: str) -> float:
    """ Calculate GC content """

    if not seq:
        return 0

    gc = sum(map(lambda base: base in 'CG', seq.upper())) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    return (gc * 100) / len(seq)

1

根据它们与碱基CG的比较将序列转换为布尔值,然后对True值求和以得到计数。

Solution 7: 使用正则表达式查找模式

到目前为止,我已经向您展示了多种手动迭代字符串中字符序列的方法,以挑选出匹配CG的字符。这就是模式匹配,而这正是正则表达式所做的。对您来说,学习另一种领域特定语言(DSL)的成本是必要的,但由于正则表达式在 Python 之外广泛使用,这是值得的。首先导入re模块:

>>> import re

你应该阅读help(re),因为这是一个非常有用的模块。我想使用re.findall()函数来查找字符串中模式的所有出现。我可以通过使用方括号将要包含的任何字符括起来来为正则表达式引擎创建字符类模式。类[GC]表示匹配 G 或 C

>>> re.findall('[GC]', 'CCACCCTCGTGGTATGGCT')
['C', 'C', 'C', 'C', 'C', 'C', 'G', 'G', 'G', 'G', 'G', 'C']

如前所述,我可以使用len()函数找出有多少个CG。以下代码显示了我如何将其合并到我的函数中。请注意,如果序列为空字符串,我使用if表达式返回0,这样可以避免在len(seq)0时进行除法:

def find_gc(seq: str) -> float:
    """ Calculate GC content """

    return len(re.findall('[GC]', seq.upper()) * 100) / len(seq) if seq else 0

请注意,重要的是从main()中调用此函数时要显式强制rec.seq值(即Seq对象)转换为字符串,使用str()

def main() -> None:
    args = get_args()
    high = MySeq(0., '')

    for rec in SeqIO.parse(args.file, 'fasta'):
        pct = find_gc(str(rec.seq)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        if pct > high.gc:
            high = MySeq(pct, rec.id)

    print(f'{high.name} {high.gc:0.6f}')

1

强制将序列转换为字符串值,否则将传递Seq对象。

解决方案 8:一个更复杂的find_gc()函数

在这个最终的解决方案中,我将几乎所有代码从main()移动到find_gc()函数中。我希望该函数接受一个SeqRecord对象而不是序列的字符串,并且希望它返回MySeq元组。

首先我将更改测试:

def test_find_gc() -> None:
    """ Test find_gc """

    assert find_gc(SeqRecord(Seq(''), id='123')) == (0.0, '123')
    assert find_gc(SeqRecord(Seq('C'), id='ABC')) == (100.0, 'ABC')
    assert find_gc(SeqRecord(Seq('G'), id='XYZ')) == (100.0, 'XYZ')
    assert find_gc(SeqRecord(Seq('ACTG'), id='ABC')) == (50.0, 'ABC')
    assert find_gc(SeqRecord(Seq('GGCC'), id='XYZ')) == (100.0, 'XYZ')

这些基本上与以前相同的测试,但我现在传递的是SeqRecord对象。为了使其在 REPL 中工作,您需要导入一些类:

>>> from Bio.Seq import Seq
>>> from Bio.SeqRecord import SeqRecord
>>> seq = SeqRecord(Seq('ACTG'), id='ABC')

如果你查看对象,它看起来与我从输入文件中读取的数据非常相似,因为我只关心seq字段:

SeqRecord(seq=Seq('ACTG'),
  id='ABC',
  name='<unknown name>',
  description='<unknown description>',
  dbxrefs=[])

如果你运行pytest,你的test_find_gc()函数应该失败,因为你还没有修改find_gc()函数。以下是我编写它的方式:

def find_gc(rec: SeqRecord) -> MySeq: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Return the GC content, record ID for a sequence """

    pct = 0\. ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    if seq := str(rec.seq): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        gc = len(re.findall('[GC]', seq.upper())) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        pct = (gc * 100) / len(seq)

    return MySeq(pct, rec.id) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

该函数接受一个SeqRecord并返回一个MySeq

2

将其初始化为浮点数0.

3

此语法是 Python 3.8 新增的,允许在一行中使用海象操作符(:=)进行变量赋值(第一步)和测试(第二步)。

4

这段代码与以前的代码相同。

5

返回一个MySeq对象。

PEP 572提出了海象运算符:=,指出=运算符只能在语句形式下命名表达式的结果,“使其在列表推导和其他表达式上下文中不可用”。这个新操作符结合了两个动作,即将表达式的值赋给变量,然后评估该变量。在前述代码中,seq被赋予字符串化序列的值。如果评估结果为真值,例如非空字符串,则将执行以下代码块。

这会根本改变main()函数。for循环可以包含一个map()函数,将每个SeqRecord转换为MySeq

def main() -> None:
    args = get_args()
    high = MySeq(0., '') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    for seq in map(find_gc, SeqIO.parse(args.file, 'fasta')): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        if seq.gc > high.gc: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            high = seq ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    print(f'{high.name} {high.gc:0.6f}') ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

初始化high变量。

2

使用map()将每个SeqRecord转换为MySeq

3

将当前序列的 GC 含量与运行时高值进行比较。

4

覆盖该值。

5

打印结果。

扩展find_gc()函数的要点是隐藏更多程序的内部细节,这样我可以编写更具表现力的程序。也许你会有不同看法,但我认为这是程序中最易读的版本。

基准测试

那么谁是赢家呢?有一个bench.sh程序将在所有solution*.pyseqs.fa文件上运行hyperfine。以下是结果:

Summary
  './solution3_max_var.py seqs.fa' ran
    2.15 ± 0.03 times faster than './solution8_list_comp_map.py seqs.fa'
    3.88 ± 0.05 times faster than './solution7_re.py seqs.fa'
    5.38 ± 0.11 times faster than './solution2_unit_test.py seqs.fa'
    5.45 ± 0.18 times faster than './solution4_list_comp.py seqs.fa'
    5.46 ± 0.14 times faster than './solution1_list.py seqs.fa'
    6.22 ± 0.08 times faster than './solution6_map.py seqs.fa'
    6.29 ± 0.14 times faster than './solution5_filter.py seqs.fa'

进一步

尝试编写一个 FASTA 解析器。创建一个名为faparser的新目录:

$ mkdir faparser

切换到该目录,并使用-t|--write_test选项运行new.py

$ cd faparser/
$ new.py -t faparser.py
Done, see new script "faparser.py".

现在您应该有一个包含tests目录和一个起始测试文件的结构:

$ tree
.
├── Makefile
├── faparser.py
└── tests
    └── faparser_test.py

1 directory, 3 files

你可以运行make testpytest来验证至少一切都在运行。将05_gc目录中的tests/inputs目录复制到新的tests目录中,以便有一些测试输入文件。现在考虑一下你希望你的新程序如何工作。我想它将接受一个(或多个)可读的文本文件作为输入,因此您可以相应地定义您的参数。然后,您的程序会对数据做什么?例如,您是否希望打印每个序列的 ID 和长度?现在编写测试和代码来手动解析输入的 FASTA 文件并打印输出。挑战自己。

复习

本章的要点:

  • 你可以从打开的文件句柄sys.stdin读取STDIN

  • Bio.SeqIO.parse()函数将解析 FASTA 格式的序列文件为记录,这样可以访问记录的 ID 和序列。

  • 您可以使用多种结构来访问可迭代对象的所有元素,包括for循环、列表推导式以及filter()map()函数。

  • 带守卫条件的列表推导式仅会生成在守卫条件返回真值的元素。这也可以使用filter()函数来表达。

  • 避免编写试图存储输入文件中所有数据的算法,因为这样可能超出机器可用的内存。

  • sorted()函数将按字典顺序和数字顺序分别对字符串和数字的同类列表进行排序。它还可以按顺序对元组列表的每个位置进行排序。

  • 字符串格式化模板可以包含类似于printf()的指令,以控制输出值的呈现方式。

  • sum()函数将对一组数字进行求和。

  • Python 中的布尔值实际上是整数。

  • 正则表达式可以找到文本的模式。

^(1) 俗话说,“几周的编码可以节省几小时的规划。”

第六章:找到海明距离:计算点突变

海明距离,以前言中提到的理查德·哈明的名字命名,是将一个字符串转变为另一个所需的编辑次数。这是衡量序列相似性的一种度量。我为此编写了几个其他度量标准,从第一章开始使用四核苷酸频率,继续到第五章使用 GC 含量。尽管后者在实际中可能是有信息量的,因为编码区域倾向于富含 GC,但四核苷酸频率远远不能称得上是有用的。例如,序列AAACCCGGGTTTCGACGATATGTC完全不同,但产生相同的碱基频率:

$ ./dna.py AAACCCGGGTTT
3 3 3 3
$ ./dna.py CGACGATATGTC
3 3 3 3

单独观察,四核苷酸频率使这些序列看起来相同,但很明显它们会产生完全不同的蛋白质序列,因此在功能上是不同的。图 6-1 显示了这 2 个序列的对齐,表明只有 12 个碱基中的 3 个是相同的,这意味着它们只有 25%的相似性。

mpfb 0601

图 6-1. 显示匹配碱基的垂直条的两个序列的对齐

另一种表达这个概念的方式是说,12 个碱基中有 9 个需要改变才能将其中一个序列变成另一个。这就是海明距离,它在生物信息学中与单核苷酸多态性(SNP,发音为snips)或单核苷酸变异(SNVs,发音为snivs)有些类似。该算法只考虑将一个碱基更改为另一个值,并且远远不能像序列对齐那样识别插入和删除。例如,图 6-2 显示,当对齐时序列AAACCCGGGTTTAACCCGGGTTTA是 92%相似的(在左边),因为它们仅相差一个碱基。然而,海明距离(在右边)只显示有 8 个碱基是相同的,这意味着它们只有 66%的相似性。

mpfb 0602

图 6-2. 这些序列的对齐显示它们几乎相同,而海明距离发现它们只有 66%的相似性

该程序将始终严格比较字符串从它们的开头开始,这限制了实际应用于真实世界生物信息学的可能性。尽管如此,这个天真的算法事实证明是衡量序列相似性的一个有用的度量,并且编写实现在 Python 中提出了许多有趣的解决方案。

在本章中,您将学到:

  • 如何使用abs()min()函数

  • 如何组合两个可能长度不等的列表的元素

  • 如何使用lambda或现有函数编写map()

  • 如何使用operator模块中的函数

  • 如何使用itertools.starmap()函数

入门

您应该在存储库的06_hamm目录中工作。我建议您先了解这些解决方案的工作原理,然后将其中一个复制到hamm.py程序中并请求帮助:

$ cp solution1_abs_iterate.py hamm.py
$ ./hamm.py -h
usage: hamm.py [-h] str str

Hamming distance

positional arguments:
  str         Sequence 1
  str         Sequence 2

optional arguments:
  -h, --help  show this help message and exit

该程序需要两个位置参数,即要比较的两个序列,并应打印汉明距离。例如,要将其中一个序列更改为另一个序列,我需要进行七次编辑:

$ ./hamm.py GAGCCTACTAACGGGAT CATCGTAATGACGGCCT
7

运行测试(使用pytestmake test)以查看通过的测试套件。一旦您了解了预期的内容,请删除此文件并从头开始:

$ new.py -fp 'Hamming distance' hamm.py
Done, see new script "hamm.py".

定义参数,以便程序需要两个位置参数,这两个参数是字符串值:

import argparse
from typing import NamedTuple

class Args(NamedTuple): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Command-line arguments """
    seq1: str
    seq2: str

# --------------------------------------------------
def get_args():
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Hamming distance',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('seq1', metavar='str', help='Sequence 1') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    parser.add_argument('seq2', metavar='str', help='Sequence 2')

    args = parser.parse_args()

    return Args(args.seq1, args.seq2) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

程序参数将包含两个字符串值,用于这两个序列。

2

这两个序列是必需的位置字符串值。

3

实例化Args对象,使用这两个序列。

定义位置参数的顺序必须与在命令行上提供参数的顺序相匹配。也就是说,第一个位置参数将保存第一个位置参数,第二个位置参数将匹配第二个位置参数,依此类推。定义可选参数的顺序无关紧要,可选参数可以在位置参数之前或之后定义。

更改main()函数以打印这两个序列:

def main():
    args = get_args()
    print(args.seq1, args.seq2)

此时,您应该有一个打印用法的程序,验证用户提供了两个序列,并打印这些序列:

$ ./hamm.py GAGCCTACTAACGGGAT CATCGTAATGACGGCCT
GAGCCTACTAACGGGAT CATCGTAATGACGGCCT

如果您运行pytest -xvv(两个v增加输出的详细程度),您应该会发现程序通过了前三个测试。它应该在test_input1测试中失败,并显示类似以下的消息:

=================================== FAILURES ===================================
_________________________________ test_input1 __________________________________

    def test_input1() -> None:
        """ Test with input1 """

>       run(INPUT1)

tests/hamm_test.py:47:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

file = './tests/inputs/1.txt' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    def run(file: str) -> None:
        """ Run with input """

        assert os.path.isfile(file)
        seq1, seq2, expected = open(file).read().splitlines() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

        rv, out = getstatusoutput(f'{RUN} {seq1} {seq2}') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        assert rv == 0
>       assert out.rstrip() == expected ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
E       AssertionError: assert 'GAGCCTACTAACGGGAT CATCGTAATGACGGCCT' == '7' ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
E         - 7
E         + GAGCCTACTAACGGGAT CATCGTAATGACGGCCT

tests/hamm_test.py:40: AssertionError
=========================== short test summary info ============================
FAILED tests/hamm_test.py::test_input1 - AssertionError: assert 'GAGCCTACTAAC...
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
========================= 1 failed, 3 passed in 0.27s ==========================

1

测试的输入来自文件./tests/inputs/1.txt

2

打开文件并读取两个序列和预期结果。

3

使用这两个序列运行程序。

4

当程序的输出与预期答案不匹配时,assert会失败。

5

具体来说,当程序应该打印两个序列时,它却打印了7

迭代两个字符串的字符。

现在来找到这两个序列之间的汉明距离。首先,考虑这两个序列:

>>> seq1, seq2 = 'AC', 'ACGT'

距离为 2,因为你需要在第一个序列中添加GT或从第二个序列中删除GT使它们相同。我建议基线距离是它们长度的差异。请注意,Rosalind 挑战假设两个相等长度的字符串,但我想使用这个练习来考虑长度不同的字符串。

根据做减法的顺序,可能会得到一个负数:

>>> len(seq1) - len(seq2)
-2

使用abs()函数获取绝对值:

>>> distance = abs(len(seq1) - len(seq2))
>>> distance
2

现在我将考虑如何迭代它们共有的字符。我可以使用min()函数找到较短序列的长度:

>>> min(len(seq1), len(seq2))
2

我可以使用range()函数与这个一起,以获取相同字符的索引:

>>> for i in range(min(len(seq1), len(seq2))):
...     print(seq1[i], seq2[i])
...
A A
C C

当这两个字符相等时,应增加distance变量,因为我必须更改一个值以使其匹配另一个。请记住,Rosalind 挑战总是从它们的开头比较这两个序列。例如,序列ATTGTTG在一个碱基上不同,因为我可以从第一个中删除A或将其添加到第二个中以使它们匹配,但这个特定挑战的规则会说正确答案是 3:

$ ./hamm.py ATTG TTG
3

我相信这些信息足以帮助您编写一个通过测试套件的解决方案。一旦您有了可用的解决方案,请探索一些其他编写算法的方法,并使用测试套件不断检查您的工作。除了通过pytest运行测试外,确保使用make test选项验证您的代码也通过各种 linting 和类型检查测试。

解决方案

本节将介绍如何计算汉明距离的八种变体,从完全手动计算几行代码到将几个函数合并在一行的解决方案。

解决方案 1:迭代和计数

第一个解决方案来自前一节的建议:

def main():
    args = get_args()
    seq1, seq2 = args.seq1, args.seq2 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    l1, l2 = len(seq1), len(seq2) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    distance = abs(l1 - l2) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    for i in range(min(l1, l2)): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        if seq1[i] != seq2[i]: ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
            distance += 1 ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

    print(distance) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

1

将两个序列复制到变量中。

2

由于我会多次使用长度,我将它们存储在变量中。

3

基础距离是两个长度之间的差异。

4

使用较短的长度来找到共有的索引。

5

检查每个位置的字母。

6

将距离增加 1。

7

打印距离。

此解决方案非常明确,列出了比较两个字符串所有字符所需的每个单独步骤。接下来的解决方案将开始缩短许多步骤,所以请确保你对这里展示的内容非常熟悉。

解决方案 2:创建单元测试

第一个解决方案让我感到有些不舒服,因为计算汉明距离的代码应该在一个带有测试的函数中。我将首先创建一个名为hamming()的函数,放在main()函数之后。就风格而言,我喜欢先放get_args(),这样我一打开程序就能立即看到。我的main()函数总是其次,其他所有函数和测试都在其后。

我将从想象函数的输入和输出开始:

def hamming(seq1: str, seq2: str) -> int: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Calculate Hamming distance """

    return 0 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

该函数将接受两个字符串作为位置参数,并返回一个整数。

2

要开始,该函数将始终返回0

我想强调的是,该函数并不打印答案,而是作为结果返回。如果你写了这个函数来print()距离,你将无法编写单元测试。你必须完全依赖于集成测试来查看程序是否打印了正确的答案。尽可能地,我鼓励你编写纯函数,它们只对参数进行操作,没有副作用。打印是副作用,尽管程序最终确实需要打印答案,但这个函数的任务仅仅是在给定两个字符串时返回一个整数。

我已经展示了一些测试用例,你可以自行添加其他测试:

def test_hamming() -> None:
    """ Test hamming """

    assert hamming('', '') == 0 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert hamming('AC', 'ACGT') == 2 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert hamming('GAGCCTACTAACGGGAT', 'CATCGTAATGACGGCCT') == 7 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

我总是认为将空字符串发送给字符串输入是一种良好的实践。

2

差异仅仅是长度的不同。

3

这是文档中的示例。

我知道这可能看起来有点极端,因为这个函数本质上就是整个程序。我几乎是在复制集成测试,我知道,但我用它来指出编写程序的最佳实践。hamming()函数是一个很好的代码单元,并且应该与测试一起放入函数中。在一个更大的程序中,这可能是数十到数百个其他函数之一,每个函数都应该封装文档化测试

遵循测试驱动的原则,在程序上运行pytest以确保测试失败:

$ pytest -v hamm.py
========================== test session starts ==========================
...

hamm.py::test_hamming FAILED                                      [100%]

=============================== FAILURES ================================
_____________________________ test_hamming ______________________________

    def test_hamming() -> None:
        """ Test hamming """

        assert hamming('', '') == 0
>       assert hamming('AC', 'ACGT') == 2
E       assert 0 == 2
E         +0
E         -2

hamm.py:69: AssertionError
======================== short test summary info ========================
FAILED hamm.py::test_hamming - assert 0 == 2
=========================== 1 failed in 0.13s ===========================

现在从main()中复制代码以修复函数:

def hamming(seq1: str, seq2: str) -> int:
    """ Calculate Hamming distance """

    l1, l2 = len(seq1), len(seq2)
    distance = abs(l1 - l2)

    for i in range(min(l1, l2)):
        if seq1[i] != seq2[i]:
            distance += 1

    return distance

验证函数的正确性:

$ pytest -v hamm.py
========================== test session starts ==========================
...

hamm.py::test_hamming PASSED                                      [100%]

=========================== 1 passed in 0.02s ===========================

您可以像这样将其整合到您的main()函数中:

def main():
    args = get_args()
    print(hamming(args.seq1, args.seq2))  ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

打印给定序列的函数返回值。

这将程序的复杂性隐藏在一个命名、文档化和测试过的单元中,缩短了程序的主体并提高了可读性。

解决方案 3:使用zip()函数

下面的解决方案使用zip()函数将两个序列的元素组合起来。结果是一个包含每个位置字符的元组列表(见图 6-3)。请注意,zip()是另一个惰性函数,因此我将使用list()在 REPL 中强制执行值:

>>> list(zip('ABC', '123'))
[('A', '1'), ('B', '2'), ('C', '3')]

mpfb 0603

图 6-3。元组由共同位置的字符组成。

如果我使用ACACGT序列,您将注意到zip()会停在较短的序列处,如图 6-4 所示:

>>> list(zip('AC', 'ACGT'))
[('A', 'A'), ('C', 'C')]

mpfb 0604

图 6-4。zip()函数将在最短序列处停止

我可以使用for循环遍历每一对。到目前为止,在我的for循环中,我使用单个变量表示列表中的每个元素,就像这样:

>>> for tup in zip('AC', 'ACGT'):
...     print(tup)
...
('A', 'A')
('C', 'C')

在第一章,我展示了如何将元组中的值解包为单独的变量。Python 的for循环允许我将每个元组解包为两个字符,如下所示:

>>> for char1, char2 in zip('AC', 'ACGT'):
...     print(char1, char2)
...
A A
C C

zip()函数省去了第一个实现中的几行:

def hamming(seq1: str, seq2: str) -> int:
    """ Calculate Hamming distance """

    distance = abs(len(seq1) - len(seq2)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    for char1, char2 in zip(seq1, seq2): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        if char1 != char2: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            distance += 1 ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    return distance

1

从长度的绝对差开始。

2

使用zip()将两个字符串的字符配对。

3

检查两个字符是否不相等。

4

增加距离。

解决方案 4:使用zip_longest()函数

下一个解决方案从 itertools 模块导入 zip_longest() 函数。顾名思义,它将把列表压缩到最长列表的长度。Figure 6-5 显示当较短的序列已用尽时,该函数将插入 None 值:

>>> from itertools import zip_longest
>>> list(zip_longest('AC', 'ACGT'))
[('A', 'A'), ('C', 'C'), (None, 'G'), (None, 'T')]

mpfb 0605

图 6-5. zip_longest() 函数将在最长序列处停止

我不再需要从序列长度开始减去。相反,我将初始化一个 distance 变量为 0,然后使用 zip_longest() 创建要比较的碱基元组:

def hamming(seq1: str, seq2: str) -> int:
    """ Calculate Hamming distance """

    distance = 0 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    for char1, char2 in zip_longest(seq1, seq2): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        if char1 != char2: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            distance += 1 ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    return distance

1

初始化距离为 0

2

将序列压缩到最长的长度。

3

比较字符。

4

增加计数器。

Solution 5: 使用列表推导式

到目前为止,所有的解决方案都使用了 for 循环。我希望你开始预见我接下来会展示如何将其转换为列表推导式。当目标是创建一个新列表或将值列表缩减为某个答案时,通常使用列表推导式会更短和更可取。

第一个版本将使用一个 if 表达式,如果两个字符相同则返回 1,如果它们不同则返回 0

>>> seq1, seq2, = 'GAGCCTACTAACGGGAT', 'CATCGTAATGACGGCCT'
>>> [1 if c1 != c2 else 0 for c1, c2 in zip_longest(seq1, seq2)]
[1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 1, 0]

然后,汉明距离是这些的总和:

>>> sum([1 if c1 != c2 else 0 for c1, c2 in zip_longest(seq1, seq2)])
7

另一种表达这个想法的方式是通过使用 保护 子句,即在列表推导式末尾的条件语句,决定是否允许特定元素:

>>> ones = [1 for c1, c2 in zip_longest(seq1, seq2) if c1 != c2] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
>>> ones
[1, 1, 1, 1, 1, 1, 1]
>>> sum(ones)
7

1

if 语句是保护子句,如果两个字符不相等,则产生值 1

您还可以使用我在 Chapter 5 中展示的布尔值/整数强制转换,其中每个 True 值将被视为 1,而 False0

>>> bools = [c1 != c2 for c1, c2 in zip_longest(seq1, seq2)]
>>> bools
[True, False, True, False, True, False, False, True, False, True, False,
False, False, False, True, True, False]
>>> sum(bools)
7

任何这些想法都将函数简化为一行代码,以通过测试:

def hamming(seq1: str, seq2: str) -> int:
    """ Calculate Hamming distance """

    return sum([c1 != c2 for c1, c2 in zip_longest(seq1, seq2)])

Solution 6: 使用 filter() 函数

章节 4 和 5 表明,带有保护子句的列表推导式也可以使用 filter() 函数来表达。语法有些难看,因为 Python 不允许将 zip_longest() 中的元组解包为单独的变量。也就是说,我想编写一个 lambda 函数,将 char1char2 解包为单独的变量,但这是不可能的:

>>> list(filter(lambda char1, char2: char1 != char2, zip_longest(seq1, seq2)))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: <lambda>() missing 1 required positional argument: 'char2'

相反,我通常会将lambda变量称为tupt,以提醒我这是一个元组。我将使用位置元组表示法将零位置的元素与一位置的元素进行比较。filter()只会生成那些元素不同的元组:

>>> seq1, seq2 = 'AC', 'ACGT'
>>> list(filter(lambda t: t[0] != t[1], zip_longest(seq1, seq2)))
[(None, 'G'), (None, 'T')]

然后,汉明距离就是这个列表的长度。请注意,len()函数不会促使filter()生成值:

>>> len(filter(lambda t: t[0] != t[1], zip_longest(seq1, seq2)))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: object of type 'filter' has no len()

这是代码必须使用list()来强制惰性filter()函数生成结果的一个例子。以下是我如何将这些思想整合到一起的方式:

def hamming(seq1: str, seq2: str) -> int:
    """ Calculate Hamming distance """

    distance = filter(lambda t: t[0] != t[1], zip_longest(seq1, seq2)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    return len(list((distance))) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

使用filter()查找不同字符的元组对。

2

返回结果列表的长度。

解决方案 7:使用map()函数和zip_longest()函数

此解决方案使用map()而不是filter(),只是为了向您展示元组无法解包的相同情况。我想使用map()来生成一个布尔值列表,指示字符对是否匹配:

>>> seq1, seq2 = 'AC', 'ACGT'
>>> list(map(lambda t: t[0] != t[1], zip_longest(seq1, seq2)))
[False, False, True, True]

这个lambda与用作谓词来确定哪些元素被允许通过的filter()中的 lambda 完全相同。在这里,代码转换元素成为应用lambda函数到参数后的结果,如 Figure 6-6 所示。记住,map()将始终返回与其消耗相同数量的元素,但filter()可能返回较少或根本不返回。

mpfb 0606

图 6-6. map()函数将每个元组转换为表示两个元素不等的布尔值

我可以将这些布尔值求和,得到不匹配对的数量:

>>> seq1, seq2, = 'GAGCCTACTAACGGGAT', 'CATCGTAATGACGGCCT'
>>> sum(map(lambda t: t[0] != t[1], zip_longest(seq1, seq2)))
7

这是带有此想法的函数:

def hamming(seq1: str, seq2: str) -> int:
    """ Calculate Hamming distance """

    return sum(map(lambda t: t[0] != t[1], zip_longest(seq1, seq2)))

尽管这些函数已经从 10 多行代码减少到一行,但将其作为具有描述性名称和测试的函数仍然是有意义的。最终,您将开始创建可在项目间共享的可重用代码模块。

解决方案 8:使用starmap()operator.ne()函数

我承认,我展示最后几个解决方案只是为了建立到这个最后一个解决方案。让我首先展示如何将lambda分配给一个变量:

>>> not_same = lambda t: t[0] != t[1]

这不是推荐的语法,并且pylint肯定会在此处失败并推荐使用def代替:

def not_same(t):
    return t[0] != t[1]

两者都将创建一个名为not_same()的函数,接受一个元组并返回两个元素是否相同:

>>> not_same(('A', 'A'))
False
>>> not_same(('A', 'T'))
True

但是,如果我编写函数来接受两个位置参数,那么之前看到的相同错误将会出现:

>>> not_same = lambda a, b: a != b
>>> list(map(not_same, zip_longest(seq1, seq2)))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: <lambda>() missing 1 required positional argument: 'b'

我需要的是map()的一个版本,它可以展开传入的元组(正如我在第一章中首次展示的那样),通过在元组前添加*(星号、星号、星形)来将其展开为其元素,这正是itertools.starmap()函数所做的(参见图 6-7):

>>> from itertools import zip_longest, starmap
>>> seq1, seq2 = 'AC', 'ACGT'
>>> list(starmap(not_same, zip_longest(seq1, seq2)))
[False, False, True, True]

mpfb 0607

图 6-7. starmap()函数对传入的元组应用星号,将其转换为lambda所期望的两个值

但等等,还有更多!我甚至不需要编写自己的not_same()函数,因为我已经有operator.ne()(不等于),通常使用!=操作符来编写:

>>> import operator
>>> operator.ne('A', 'A')
False
>>> operator.ne('A', 'T')
True

运算符是一种特殊的二元函数(接受两个参数),函数名称通常是一些符号,如+,它位于参数之间。对于+,Python 必须决定这是否意味着operator.add()

>>> 1 + 2
3
>>> operator.add(1, 2)
3

或者operator.concat()

>>> 'AC' + 'GT'
'ACGT'
>>> operator.concat('AC', 'GT')
'ACGT'

关键在于我已经有一个现有的函数,期望两个参数并返回它们是否相等,并且我可以使用starmap()来正确地将元组扩展为所需的参数:

>>> seq1, seq2 = 'AC', 'ACGT'
>>> list(starmap(operator.ne, zip_longest(seq1, seq2)))
[False, False, True, True]

与之前一样,汉明距离是不匹配对的总和:

>>> seq1, seq2, = 'GAGCCTACTAACGGGAT', 'CATCGTAATGACGGCCT'
>>> sum(starmap(operator.ne, zip_longest(seq1, seq2)))
7

看它如何运作:

def hamming(seq1: str, seq2: str) -> int:
    """ Calculate Hamming distance """

    return sum(starmap(operator.ne, zip_longest(seq1, seq2))) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

将序列压缩,将元组转换为布尔比较,并求和。

此最终解决方案完全依赖于适合的四个我没有编写的函数。我相信最好的代码是你不编写(或测试或文档化)的代码。虽然我更喜欢这个纯函数解决方案,但你可能认为这段代码过于巧妙。你应该使用一年后你能理解的版本。

更进一步

  • 不查看源代码,编写zip_longest()的一个版本。确保从测试开始,然后编写满足测试的函数。

  • 扩展你的程序以处理超过两个输入序列。使你的程序打印每对序列之间的汉明距离。这意味着程序将打印n选择k个数,即n!/(k!(n - k)!)。对于三个序列,你的程序将打印 3!/(2!(3 - 2)!)= 6 / 2 = 3 距离对。

  • 尝试编写一个序列对齐算法,该算法将显示例如序列AAACCCGGGTTTAACCCGGGTTTA之间仅有一个差异。

回顾

这是一个相当深的兔子洞,只为了找到汉明距离,但它突出了关于 Python 函数的许多有趣细节:

  • 内置的zip()函数将两个或更多个列表合并为元组列表,将相同位置的元素分组。它会停在最短的序列处,因此如果要处理最长的序列,请使用itertools.zip_longest()函数。

  • map()filter() 都会将函数应用于某些可迭代的值。map() 函数将由函数转换的新序列返回,而 filter() 仅在应用函数时返回那些返回真值的元素。

  • 传递给 map()filter() 的函数可以是由 lambda 创建的匿名函数,也可以是现有函数。

  • operator 模块包含许多像 ne()(不等于)这样的函数,可以与 map()filter() 一起使用。

  • functools.starmap() 函数的工作方式类似于 map(),但会将函数的传入值展开成值列表。

第七章:mRNA 翻译成蛋白质:更多的函数式编程

根据分子生物学的中心法则,DNA 生成 mRNA,mRNA 生成蛋白质。在第二章中,我展示了如何将 DNA 转录成 mRNA,现在是时候将 mRNA 翻译成蛋白质序列了。正如在Rosalind PROT 页面上描述的那样,现在我需要编写一个接受 mRNA 字符串并生成氨基酸序列的程序。我将展示几种解决方案,包括列表、for循环、列表推导、字典和高阶函数,但最后我会用 Biopython 函数结束。不过,这将会非常有趣。

大部分时间我会专注于如何编写、测试和组合小函数来创建解决方案。你将学到:

  • 如何使用字符串切片从序列中提取密码子/K-mers

  • 如何使用字典作为查找表

  • 如何将for循环转换为列表推导和map()表达式

  • 如何使用takewhile()partial()函数

  • 如何使用Bio.Seq模块将 mRNA 翻译成蛋白质

入门指南

你需要在07_prot目录中工作。我建议你从将第一个解决方案复制到prot.py并要求用法开始:

$ cp solution1_for.py prot.py
$ ./prot.py -h
usage: prot.py [-h] RNA

Translate RNA to proteins

positional arguments:
  RNA         RNA sequence

optional arguments:
  -h, --help  show this help message and exit

该程序需要一个 RNA 序列作为单个位置参数。从现在开始,我会使用术语RNA,但要知道我指的是mRNA。以下是使用 Rosalind 页面示例字符串的结果:

$ ./prot.py AUGGCCAUGGCGCCCAGAACUGAGAUCAAUAGUACCCGUAUUAACGGGUGA
MAMAPRTEINSTRING

运行make test确保程序正常工作。当你觉得你对程序的工作原理有了相当好的理解时,从头开始:

$ new.py -fp 'Translate RNA to proteins' prot.py
Done, see new script "prot.py".

这是我定义参数的方式:

class Args(NamedTuple):
    """ Command-line arguments """
    rna: str ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

def get_args() -> Args:
    """Get command-line arguments"""

    parser = argparse.ArgumentParser(
        description='Translate RNA to proteins',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('rna', type=str, metavar='RNA', help='RNA sequence') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    args = parser.parse_args()

    return Args(args.rna)

1

唯一的参数是一串 mRNA。

2

定义rna作为一个位置字符串。

修改你的参数,直到程序能够产生正确的用法,然后修改你的main()函数以打印输入的 RNA 字符串:

def main() -> None:
    args = get_args()
    print(args.rna)

验证它是否有效:

$ ./prot.py AUGGCCAUGGCGCCCAGAACUGAGAUCAAUAGUACCCGUAUUAACGGGUGA
AUGGCCAUGGCGCCCAGAACUGAGAUCAAUAGUACCCGUAUUAACGGGUGA

运行pytestmake test来查看你的表现。你的程序应该通过前两个测试,并且在第三个测试中失败,输出应该是蛋白质翻译。如果你认为你能解决这个问题,继续执行你的解决方案。挣扎是完全可以接受的。没有什么可着急的,所以如果需要的话可以多花几天时间。确保除了专注编码时间外,还包括小睡和散步(扩散思维时间)。如果需要帮助,请继续阅读。

K-mers 和密码子

到目前为止,你已经看到了许多关于如何迭代字符串字符的例子,比如 DNA 的碱基。在这里,我需要将 RNA 的碱基分组成三个一组,以便读取每个密码子,即三个核苷酸序列,对应一个氨基酸。共有 64 个密码子,如表 7-1 所示。

表格 7-1. RNA 密码子表描述了 RNA 的三联体如何编码 22 种氨基酸

AAA K AAC N AAG K AAU N ACA T
ACC T ACG T ACU T AGA R AGC S
AGG R AGU S AUA I AUC I AUG M
AUU I CAA Q CAC H CAG Q CAU H
CCA P CCC P CCG P CCU P CGA R
CGC R CGG R CGU R CUA L CUC L
CUG L CUU L GAA E GAC D GAG E
GAU D GCA A GCC A GCG A GCU A
GGA G GGC G GGG G GGU G GUA V
GUC V GUG V GUU V UAC Y UAU Y
UCA S UCC S UCG S UCU S UGC C
UGG W UGU C UUA L UUC F UUG L
UUU F UAA 终止 UAG 终止 UGA 终止

给定一些 RNA 字符串:

>>> rna = 'AUGGCCAUGGCGCCCAGAACUGAGAUCAAUAGUACCCGUAUUAACGGGUGA'

我想读取前三个碱基,AUG。如图 7-1 所示,我可以使用字符串切片从索引 0 到 3 手动抓取字符(请记住上界不包括在内):

>>> rna[0:3]
'AUG'

mpfb 0701

图 7-1. 使用字符串切片从 RNA 中提取密码子

下一个密码子可通过将起始和停止位置加 3 来找到:

>>> rna[3:6]
'GCC'

你能看出出现了什么模式吗?对于第一个数字,我需要从 0 开始加 3。对于第二个数字,我需要在第一个数字上加 3(见图 7-2)。

mpfb 0702

图 7-2. 每个切片是密码子起始位置的函数,可以使用range()函数找到

我可以使用range()函数处理第一部分,它可以接受一、两或三个参数。给定一个参数,它将生成从 0 到给定值但不包括给定值的所有数字。请注意这是一个惰性函数,我将使用list()来强制它:

>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

给定两个参数,range()将假定第一个是起始位置,第二个是停止位置:

>>> list(range(5, 10))
[5, 6, 7, 8, 9]

第三个参数将被解释为步长。在第三章中,我使用range()没有起始或停止位置,步长为-1来反转字符串。在这种情况下,我想从 0 开始计数直到 RNA 的长度,步长为 3。这些是密码子的起始位置:

>>> list(range(0, len(rna), 3))
[0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48]

我可以使用列表推导生成起始和停止值作为元组。停止位置比起始位置多 3。这里仅展示前五个:

>>> [(n, n + 3) for n in range(0, len(rna), 3)][:5]
[(0, 3), (3, 6), (6, 9), (9, 12), (12, 15)]

我可以使用这些值来对 RNA 进行切片:

>>> [rna[n:n + 3] for n in range(0, len(rna), 3)][:5]
['AUG', 'GCC', 'AUG', 'GCG', 'CCC']

密码子是 RNA 的子序列,类似于k-mers。这里的k是大小,为 3,mer类似于聚合物中的共享。通常用 k-mers 的大小来指代它们,所以这里我可能称之为3-mer。k-mers 重叠一个字符,所以窗口向右移动一个碱基。图 7-3 展示了输入 RNA 的前九个碱基中找到的前七个 3-mer。

mpfb 0703

图 7-3. RNA 序列前九个碱基中的所有 3-mer

任何序列s中 k-mers 的数量n为:

n = l e n ( s ) - k + 1

该 RNA 序列的长度为 51,因此包含 49 个 3-mer:

>>> len(rna) - k + 1
49

除了考虑多帧翻译(我将在第十四章中展示),密码子不重叠,因此每次移动 3 个位置(参见图 7-4),留下 17 个密码子:

>>> len([rna[n:n + 3] for n in range(0, len(rna), 3)])
17

mpfb 0704

图 7-4. 密码子是非重叠的 3-mers

翻译密码子

现在你知道如何从 RNA 中提取密码子了,让我们考虑如何将密码子翻译成蛋白质。Rosalind 页面提供了以下翻译表:

UUU F      CUU L      AUU I      GUU V
UUC F      CUC L      AUC I      GUC V
UUA L      CUA L      AUA I      GUA V
UUG L      CUG L      AUG M      GUG V
UCU S      CCU P      ACU T      GCU A
UCC S      CCC P      ACC T      GCC A
UCA S      CCA P      ACA T      GCA A
UCG S      CCG P      ACG T      GCG A
UAU Y      CAU H      AAU N      GAU D
UAC Y      CAC H      AAC N      GAC D
UAA Stop   CAA Q      AAA K      GAA E
UAG Stop   CAG Q      AAG K      GAG E
UGU C      CGU R      AGU S      GGU G
UGC C      CGC R      AGC S      GGC G
UGA Stop   CGA R      AGA R      GGA G
UGG W      CGG R      AGG R      GGG G

使用字典来查找像 AUG 这样的字符串自然是一个合适的数据结构,以找到它翻译为蛋白质 M,这也正是表示蛋白质序列起始的密码子。我把字典中的 Stop 改为 *,用于表示终止密码子,标志着蛋白质序列的结束。我把我的字典命名为 codon_to_aa,可以这样使用它:

>>> rna = 'AUGGCCAUGGCGCCCAGAACUGAGAUCAAUAGUACCCGUAUUAACGGGUGA'
>>> aa = []
>>> for codon in [rna[n:n + 3] for n in range(0, len(rna), 3)]:
...     aa.append(codon_to_aa[codon])
...
>>> aa
['M', 'A', 'M', 'A', 'P', 'R', 'T', 'E', 'I', 'N', 'S', 'T', 'R', 'I',
 'N', 'G', '*']

* 密码子表示翻译结束的位置,通常会显示以便你知道找到了终止并且蛋白质已完成。为了通过 Rosalind 的测试,输出中不应包含终止密码子。注意,终止密码子可能出现在 RNA 字符串的末尾之前。这些提示应足够让你创建一个能通过测试的解决方案。确保运行 pytestmake test 以确保你的程序在逻辑上和风格上都是正确的。

解决方案

在本节中,我将展示将 RNA 翻译成蛋白质的五种变体,从完全手动解决方案(其中我使用字典对 RNA 密码子表进行编码)到使用 Biopython 函数的单行代码。所有解决方案都使用之前展示的相同 get_args()

解决方案 1:使用 for 循环

这是我第一个解决方案的完整内容,它使用 for 循环来迭代密码子,并通过字典将其翻译为蛋白质:

def main() -> None:
    args = get_args()
    rna = args.rna.upper() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    codon_to_aa = { ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        'AAA': 'K', 'AAC': 'N', 'AAG': 'K', 'AAU': 'N', 'ACA': 'T',
        'ACC': 'T', 'ACG': 'T', 'ACU': 'T', 'AGA': 'R', 'AGC': 'S',
        'AGG': 'R', 'AGU': 'S', 'AUA': 'I', 'AUC': 'I', 'AUG': 'M',
        'AUU': 'I', 'CAA': 'Q', 'CAC': 'H', 'CAG': 'Q', 'CAU': 'H',
        'CCA': 'P', 'CCC': 'P', 'CCG': 'P', 'CCU': 'P', 'CGA': 'R',
        'CGC': 'R', 'CGG': 'R', 'CGU': 'R', 'CUA': 'L', 'CUC': 'L',
        'CUG': 'L', 'CUU': 'L', 'GAA': 'E', 'GAC': 'D', 'GAG': 'E',
        'GAU': 'D', 'GCA': 'A', 'GCC': 'A', 'GCG': 'A', 'GCU': 'A',
        'GGA': 'G', 'GGC': 'G', 'GGG': 'G', 'GGU': 'G', 'GUA': 'V',
        'GUC': 'V', 'GUG': 'V', 'GUU': 'V', 'UAC': 'Y', 'UAU': 'Y',
        'UCA': 'S', 'UCC': 'S', 'UCG': 'S', 'UCU': 'S', 'UGC': 'C',
        'UGG': 'W', 'UGU': 'C', 'UUA': 'L', 'UUC': 'F', 'UUG': 'L',
        'UUU': 'F', 'UAA': '*', 'UAG': '*', 'UGA': '*',
    }

    k = 3 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    protein = '' ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    for codon in [rna[i:i + k] for i in range(0, len(rna), k)]: ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        aa = codon_to_aa.get(codon, '-') ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
        if aa == '*': ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
            break ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
        protein += aa ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)

    print(protein) ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)

1

复制传入的 RNA,并强制转换为大写。

2

使用字典创建密码子/氨基酸查找表。

3

确定用于查找 k-mer 的 k 的大小。

4

初始化蛋白质序列为空字符串。

5

遍历 RNA 的密码子。

6

使用 dict.get() 查找这个密码子对应的氨基酸,并在找不到时返回短横线。

7

检查这是否为终止密码子。

8

退出for循环。

9

将氨基酸追加到蛋白质序列中。

10

打印蛋白质序列。

解决方案 2:添加单元测试

第一个解决方案工作得相当好,对于如此简短的程序,它的组织也相当不错。问题在于,短程序通常会变成长程序。函数变得越来越长是很常见的,因此我想展示如何将main()中的代码拆分为几个更小的函数,并附带测试。一般来说,我喜欢看到一个函数在 50 行或更少的情况下适合,至于一个函数可以有多短,我不反对只有一行代码。

我的第一直觉是提取找到密码子的代码,并将其变成一个带有单元测试的函数。我可以先定义一个函数的占位符,类型签名帮助我思考函数接受什么参数并返回什么结果:

def codons(seq: str) -> List[str]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Extract codons from a sequence """

    return [] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

函数将接受一个字符串,并返回一个字符串列表。

2

现在,只返回一个空列表。

接下来,我定义一个test_codons()函数来设想它可能如何工作。每当我有一个字符串作为函数参数时,我尝试传递空字符串。(每当我有一个整数作为函数参数时,我尝试传递0。)然后我尝试其他可能的值,并设想函数应该做什么。如你所见,我在这里做了一些判断调用,通过返回长度小于三个碱基的字符串。我只期望函数将一个字符串分解成至少三个碱基的子字符串。在这里,没有理由让完美成为好的敌人:

def test_codons() -> None:
    """ Test codons """

    assert codons('') == []
    assert codons('A') == ['A']
    assert codons('ABC') == ['ABC']
    assert codons('ABCDE') == ['ABC', 'DE']
    assert codons('ABCDEF') == ['ABC', 'DEF']

现在编写满足这些测试的函数。如果我将相关代码从main()移入codons()函数中,结果如下:

def codons(seq: str) -> List[str]:
    """ Extract codons from a sequence """

    k = 3
    ret = []
    for codon in [seq[i:i + k] for i in range(0, len(seq), k)]:
        ret.append(codon)

    return ret

如果我尝试在此程序上运行pytest,我看到它通过了。万岁!由于for循环用于构建返回列表,使用列表推导式在风格上更好:

def codons(seq: str) -> List[str]:
    """ Extract codons from a sequence """

    k = 3
    return [seq[i:i + k] for i in range(0, len(seq), k)]

这是一个精心编写和测试过的小函数,可以使代码其余部分更易读:

def main() -> None:
    args = get_args()
    rna = args.rna.upper()
    codon_to_aa = {
        'AAA': 'K', 'AAC': 'N', 'AAG': 'K', 'AAU': 'N', 'ACA': 'T',
        'ACC': 'T', 'ACG': 'T', 'ACU': 'T', 'AGA': 'R', 'AGC': 'S',
        'AGG': 'R', 'AGU': 'S', 'AUA': 'I', 'AUC': 'I', 'AUG': 'M',
        'AUU': 'I', 'CAA': 'Q', 'CAC': 'H', 'CAG': 'Q', 'CAU': 'H',
        'CCA': 'P', 'CCC': 'P', 'CCG': 'P', 'CCU': 'P', 'CGA': 'R',
        'CGC': 'R', 'CGG': 'R', 'CGU': 'R', 'CUA': 'L', 'CUC': 'L',
        'CUG': 'L', 'CUU': 'L', 'GAA': 'E', 'GAC': 'D', 'GAG': 'E',
        'GAU': 'D', 'GCA': 'A', 'GCC': 'A', 'GCG': 'A', 'GCU': 'A',
        'GGA': 'G', 'GGC': 'G', 'GGG': 'G', 'GGU': 'G', 'GUA': 'V',
        'GUC': 'V', 'GUG': 'V', 'GUU': 'V', 'UAC': 'Y', 'UAU': 'Y',
        'UCA': 'S', 'UCC': 'S', 'UCG': 'S', 'UCU': 'S', 'UGC': 'C',
        'UGG': 'W', 'UGU': 'C', 'UUA': 'L', 'UUC': 'F', 'UUG': 'L',
        'UUU': 'F', 'UAA': '*', 'UAG': '*', 'UGA': '*',
    }

    protein = ''
    for codon in codons(rna): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        aa = codon_to_aa.get(codon, '-')
        if aa == '*':
            break
        protein += aa

    print(protein)

1

寻找密码子的复杂性隐藏在一个函数中。

此外,这个函数(及其测试)现在更容易集成到另一个程序中。最简单的情况是复制粘贴这些行,但更好的解决方案是共享函数。让我演示一下如何使用 REPL。如果你的 prot.py 程序中有 codons() 函数,那么导入这个函数:

>>> from prot import codons

现在你可以执行 codons() 函数:

>>> codons('AAACCCGGGTTT')
['AAA', 'CCC', 'GGG', 'TTT']

或者你可以导入整个 prot 模块并像这样调用函数:

>>> import prot
>>> prot.codons('AAACCCGGGTTT')
['AAA', 'CCC', 'GGG', 'TTT']

Python 程序 也是可重复使用代码的 模块。有时你执行一个源代码文件,它就变成了一个程序,但在 Python 中程序和模块之间并没有很大区别。这就是所有程序末尾对联的含义:

if __name__ == '__main__': ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    main() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

当一个 Python 程序作为程序 执行 时,__name__ 的值是 __main__

2

调用 main() 函数启动程序。

当一个 Python 模块被另一段代码 导入 时,__name__ 是模块的名称;例如,在 prot.py 中是 prot。如果你在程序末尾简单地调用 main() 而没有检查 __name__,那么它将在你的模块被导入时执行,这不好。

随着你编写越来越多的 Python 代码,你可能会发现自己重复解决一些相同的问题。通过编写函数并在项目之间共享,而不是复制粘贴代码片段,来分享常见解决方案会更好。Python 很容易将可重用函数放入模块并在其他程序中导入它们。

解决方案 3:另一个函数和一个列表推导式

codons() 函数整洁实用,使 main() 函数更易于理解;然而,所有留在 main() 中的代码都涉及蛋白质的翻译。我想把这部分隐藏在一个 translate() 函数中,并且这是我想使用的测试:

def test_translate() -> None:
    """ Test translate """

    assert translate('') == '' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert translate('AUG') == 'M' ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert translate('AUGCCGUAAUCU') == 'MP' ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert translate('AUGGCCAUGGCGCCCAGAACUGAGAU' ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
                     'CAAUAGUACCCGUAUUAACGGGUGA') == 'MAMAPRTEINSTRING' ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

我通常用空字符串来测试字符串参数。

2

测试单个氨基酸。

3

在序列结束之前使用终止密码测试。

4

注意相邻的字符串文字被合并为一个字符串。这是在源代码中换行的一个有用方法。

5

使用来自 Rosalind 的示例进行测试。

我将所有代码从main()移到这里,将for循环改为列表推导,并使用列表切片来截断蛋白质在终止密码子处:

def translate(rna: str) -> str:
    """ Translate codon sequence """

    codon_to_aa = {
        'AAA': 'K', 'AAC': 'N', 'AAG': 'K', 'AAU': 'N', 'ACA': 'T',
        'ACC': 'T', 'ACG': 'T', 'ACU': 'T', 'AGA': 'R', 'AGC': 'S',
        'AGG': 'R', 'AGU': 'S', 'AUA': 'I', 'AUC': 'I', 'AUG': 'M',
        'AUU': 'I', 'CAA': 'Q', 'CAC': 'H', 'CAG': 'Q', 'CAU': 'H',
        'CCA': 'P', 'CCC': 'P', 'CCG': 'P', 'CCU': 'P', 'CGA': 'R',
        'CGC': 'R', 'CGG': 'R', 'CGU': 'R', 'CUA': 'L', 'CUC': 'L',
        'CUG': 'L', 'CUU': 'L', 'GAA': 'E', 'GAC': 'D', 'GAG': 'E',
        'GAU': 'D', 'GCA': 'A', 'GCC': 'A', 'GCG': 'A', 'GCU': 'A',
        'GGA': 'G', 'GGC': 'G', 'GGG': 'G', 'GGU': 'G', 'GUA': 'V',
        'GUC': 'V', 'GUG': 'V', 'GUU': 'V', 'UAC': 'Y', 'UAU': 'Y',
        'UCA': 'S', 'UCC': 'S', 'UCG': 'S', 'UCU': 'S', 'UGC': 'C',
        'UGG': 'W', 'UGU': 'C', 'UUA': 'L', 'UUC': 'F', 'UUG': 'L',
        'UUU': 'F', 'UAA': '*', 'UAG': '*', 'UGA': '*',
    }

    aa = [codon_to_aa.get(codon, '-') for codon in codons(rna)] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    if '*' in aa: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        aa = aa[:aa.index('*')] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    return ''.join(aa) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

使用列表推导将密码子列表转换为氨基酸列表。

2

查看列表中是否存在终止(*)密码子。

3

使用列表切片覆盖氨基酸直到终止密码子的索引处。

4

将氨基酸连接在空字符串上并返回新的蛋白质序列。

要理解这一点,请考虑以下 RNA 序列:

>>> rna = 'AUGCCGUAAUCU'

我可以使用 codons() 函数获取密码子:

>>> from solution3_list_comp_slice import codons, translate
>>> codons(rna)
['AUG', 'CCG', 'UAA', 'UCU']

并使用列表推导将其转换为氨基酸:

>>> codon_to_aa = {
...     'AAA': 'K', 'AAC': 'N', 'AAG': 'K', 'AAU': 'N', 'ACA': 'T',
...     'ACC': 'T', 'ACG': 'T', 'ACU': 'T', 'AGA': 'R', 'AGC': 'S',
...     'AGG': 'R', 'AGU': 'S', 'AUA': 'I', 'AUC': 'I', 'AUG': 'M',
...     'AUU': 'I', 'CAA': 'Q', 'CAC': 'H', 'CAG': 'Q', 'CAU': 'H',
...     'CCA': 'P', 'CCC': 'P', 'CCG': 'P', 'CCU': 'P', 'CGA': 'R',
...     'CGC': 'R', 'CGG': 'R', 'CGU': 'R', 'CUA': 'L', 'CUC': 'L',
...     'CUG': 'L', 'CUU': 'L', 'GAA': 'E', 'GAC': 'D', 'GAG': 'E',
...     'GAU': 'D', 'GCA': 'A', 'GCC': 'A', 'GCG': 'A', 'GCU': 'A',
...     'GGA': 'G', 'GGC': 'G', 'GGG': 'G', 'GGU': 'G', 'GUA': 'V',
...     'GUC': 'V', 'GUG': 'V', 'GUU': 'V', 'UAC': 'Y', 'UAU': 'Y',
...     'UCA': 'S', 'UCC': 'S', 'UCG': 'S', 'UCU': 'S', 'UGC': 'C',
...     'UGG': 'W', 'UGU': 'C', 'UUA': 'L', 'UUC': 'F', 'UUG': 'L',
...     'UUU': 'F', 'UAA': '*', 'UAG': '*', 'UGA': '*',
... }
>>> aa = [codon_to_aa.get(c, '-') for c in codons(rna)]
>>> aa
['M', 'P', '*', 'S']

我可以看到终止密码子的存在:

>>> '*' in aa
True

所以序列需要在索引 2 处截断:

>>> aa.index('*')
2

我可以使用列表切片选择到终止密码子的位置。如果没有提供起始位置,则 Python 假定索引为 0:

>>> aa = aa[:aa.index('*')]
>>> aa
['M', 'P']

最后,需要将这些列表连接为空字符串:

>>> ''.join(aa)
'MP'

main() 函数包含了新函数,使得程序非常易读:

def main() -> None:
    args = get_args()
    print(translate(args.rna.upper()))

这是另一个单位测试几乎重复集成测试的实例,后者仍然很重要,因为它确保程序能够工作,生成文档,处理参数等等。尽管这种解决方案可能显得过于工程化,但我希望你专注于如何将程序分解为更小的函数,以便理解、测试、组合和共享。

解决方案 4:使用 map()partial()takewhile() 函数进行函数式编程

对于下一个解决方案,我想展示如何使用三个高阶函数 map()partial()takewhile() 重写一些逻辑。图 7-5 显示列表推导如何被重写为 map()

mpfb 0705

图 7-5. 列表推导可以被重写为 map()

我可以使用 map() 获取氨基酸序列。你可能认为这比列表推导更容易阅读,也可能不认为;关键是理解它们在功能上是等效的,都将一个列表转换为一个新列表:

>>> aa = list(map(lambda codon: codon_to_aa.get(codon, '-'), codons(rna)))
>>> aa
['M', 'P', '*', 'S']

找到终止密码子并切片列表的代码可以使用 itertools.takewhile() 函数重写:

>>> from itertools import takewhile

如其名,此函数会在谓词满足时从序列中 元素。一旦谓词失败,函数就停止产生值。这里的条件是残留物不是 *(停止):

>>> list(takewhile(lambda residue: residue != '*', aa))
['M', 'P']

如果你喜欢使用这些高阶函数,可以通过使用我在 第四章 中展示的 functools.partial() 函数更进一步。这里我想部分应用 operator.ne()(不等于)函数:

>>> from functools import partial
>>> import operator
>>> not_stop = partial(operator.ne, '*')

函数 not_stop() 在返回值之前需要再加一个字符串值:

>>> not_stop('F')
True
>>> not_stop('*')
False

当我组合这些函数时,它们几乎读起来像是一句英语句子:

>>> list(takewhile(not_stop, aa))
['M', 'P']

这里是我如何使用纯函数思想编写 translate() 函数的方式:

def translate(rna: str) -> str:
    """ Translate codon sequence """

    codon_to_aa = {
        'AAA': 'K', 'AAC': 'N', 'AAG': 'K', 'AAU': 'N', 'ACA': 'T',
        'ACC': 'T', 'ACG': 'T', 'ACU': 'T', 'AGA': 'R', 'AGC': 'S',
        'AGG': 'R', 'AGU': 'S', 'AUA': 'I', 'AUC': 'I', 'AUG': 'M',
        'AUU': 'I', 'CAA': 'Q', 'CAC': 'H', 'CAG': 'Q', 'CAU': 'H',
        'CCA': 'P', 'CCC': 'P', 'CCG': 'P', 'CCU': 'P', 'CGA': 'R',
        'CGC': 'R', 'CGG': 'R', 'CGU': 'R', 'CUA': 'L', 'CUC': 'L',
        'CUG': 'L', 'CUU': 'L', 'GAA': 'E', 'GAC': 'D', 'GAG': 'E',
        'GAU': 'D', 'GCA': 'A', 'GCC': 'A', 'GCG': 'A', 'GCU': 'A',
        'GGA': 'G', 'GGC': 'G', 'GGG': 'G', 'GGU': 'G', 'GUA': 'V',
        'GUC': 'V', 'GUG': 'V', 'GUU': 'V', 'UAC': 'Y', 'UAU': 'Y',
        'UCA': 'S', 'UCC': 'S', 'UCG': 'S', 'UCU': 'S', 'UGC': 'C',
        'UGG': 'W', 'UGU': 'C', 'UUA': 'L', 'UUC': 'F', 'UUG': 'L',
        'UUU': 'F', 'UAA': '*', 'UAG': '*', 'UGA': '*',
    }

    aa = map(lambda codon: codon_to_aa.get(codon, '-'), codons(rna))
    return ''.join(takewhile(partial(operator.ne, '*'), aa))

解决方案 5:使用 Bio.Seq.translate()

如约,最后的解决方案使用了 Biopython。在 第三章 中,我使用了 Bio.Seq.reverse_complement() 函数,这里可以使用 Bio.Seq.translate()。首先,导入 Bio.Seq 类:

>>> from Bio import Seq

然后调用 translate() 函数。请注意,终止密码子用 * 表示:

>>> rna = 'AUGGCCAUGGCGCCCAGAACUGAGAUCAAUAGUACCCGUAUUAACGGGUGA'
>>> Seq.translate(rna)
'MAMAPRTEINSTRING*'

默认情况下,此函数不会在终止密码子处停止翻译:

>>> Seq.translate('AUGCCGUAAUCU')
'MP*S'

如果在 REPL 中阅读 help(Seq.translate),你会发现 to_stop 选项可以将其更改为 Rosalind 挑战所期望的版本:

>>> Seq.translate('AUGCCGUAAUCU', to_stop=True)
'MP'

这是我将所有内容整合在一起的方式:

def main() -> None:
    args = get_args()
    print(Seq.translate(args.rna, to_stop=True))

这是我推荐的解决方案,因为它依赖广泛使用的 Biopython 模块。虽然手动编写解决方案探索如何编码是有趣且启发性的,但更好的实践是使用已由专门开发团队编写和测试过的代码。

基准测试

哪种方法最快?我可以使用我在 第四章 中介绍的 hyperfine 基准测试程序来比较程序的运行时间。因为这是一个非常简短的程序,我决定至少运行每个程序 1,000 次,正如在仓库中的 bench.sh 程序中所记录的那样。

尽管第二种解决方案运行速度最快,可能比 Biopython 版本快多达 1.5 倍,但我仍然建议使用后者,因为这是一个在社区广泛使用并且有详细文档和测试的模块。

更进一步

添加一个 --frame-shift 参数,默认为 0,允许值为 0-2(包括)。使用帧移来从备用位置开始读取 RNA。

复习

本章的重点实际上是如何编写、测试和组合函数来解决手头的问题。我编写了用于查找序列中密码子和翻译 RNA 的函数。然后展示了如何使用高阶函数来组合其他函数,最后使用了 Biopython 的现成函数。

  • K-mers 是序列的 k 长度子序列。

  • 密码子是在特定框架中不重叠的 3-mer。

  • 字典作为查找表格非常有用,比如将密码子翻译为氨基酸。

  • for 循环、列表推导式和 map() 都是将一个序列转换为另一个的方法。

  • takewhile() 函数类似于 filter() 函数,根据值的谓词或测试从序列中接受值。

  • partial() 函数允许将参数部分应用于一个函数。

  • Bio.Seq.translate() 函数将 RNA 序列翻译成蛋白质序列。

第八章:在 DNA 中查找基序:探索序列相似性

Rosalind SUBS 挑战 中,我将搜索另一个序列内任何出现的序列。共享的子序列可能代表诸如标记、基因或调控序列等保守元素。两个生物之间的保守序列可能暗示一些遗传或收敛特征。我将探讨如何使用 Python 中的 str(字符串)类编写解决方案,并将字符串与列表进行比较。然后,我将探讨如何使用高阶函数表达这些想法,并将继续讨论我在 第七章 中开始的 k-mer。最后,我将展示如何使用正则表达式找到模式,并指出重叠匹配的问题。

在本章中,我将演示:

  • 如何使用 str.find()str.index() 和字符串切片

  • 如何使用集合创建唯一的元素集合

  • 如何组合高阶函数

  • 如何使用 k-mer 查找子序列

  • 如何使用正则表达式查找可能重叠的序列

入门

本章的代码和测试位于 08_subs。我建议您从将第一个解决方案复制到程序 subs.py 并请求帮助开始:

$ cd 08_subs/
$ cp solution1_str_find.py subs.py
$ ./subs.py -h
usage: subs.py [-h] seq subseq

Find subsequences

positional arguments:
  seq         Sequence
  subseq      subsequence

optional arguments:
  -h, --help  show this help message and exit

该程序应报告子序列在序列中的起始位置。如 图 8-1 所示,子序列 ATAT 可以在序列 GATATATGCATATACTT 的位置 2、4 和 10 处找到:

$ ./subs.py GATATATGCATATACTT ATAT
2 4 10

mpfb 0801

图 8-1。子序列 ATAT 可以在位置 2、4 和 10 找到

运行测试以查看您是否了解将要预期的内容,然后从头开始编写您的程序:

$ new.py -fp 'Find subsequences' subs.py
Done, see new script "subs.py".

这是我定义程序参数的方法:

class Args(NamedTuple): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Command-line arguments """
    seq: str
    subseq: str

def get_args() -> Args: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Find subsequences',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('seq', metavar='seq', help='Sequence')

    parser.add_argument('subseq', metavar='subseq', help='subsequence')

    args = parser.parse_args()

    return Args(args.seq, args.subseq) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

Args 类将有两个字符串字段,seqsubseq

2

函数返回一个 Args 对象。

3

使用 Args 打包并返回参数。

让您的 main() 打印序列和子序列:

def main() -> None:
    args = get_args()
    print(f'sequence = {args.seq}')
    print(f'subsequence = {args.subseq}')

使用预期的输入运行程序,并验证它是否正确打印了参数:

$ ./subs.py GATATATGCATATACTT ATAT
sequence = GATATATGCATATACTT
subsequence = ATAT

现在您有一个应该通过前两个测试的程序。如果您认为自己能够独立完成,请继续;否则,我将向您展示一种在一个字符串中找到另一个字符串的位置的方法。

查找子序列

为了演示如何找到子序列,我将首先在 REPL 中定义以下序列和子序列:

>>> seq = 'GATATATGCATATACTT'
>>> subseq = 'ATAT'

我可以使用 in 来确定一个序列是否是另一个序列的子集。这也适用于列表、集合或字典的键的成员资格:

>>> subseq in seq
True

这是很好的信息,但它没有告诉我字符串可以在哪里找到。幸运的是有str.find()函数,它说subseq可以从索引 1(即第二个字符)开始找到:

>>> seq.find(subseq)
1

我从 Rosalind 的描述中知道答案应该是 2、4 和 10。我刚刚找到了 2,那么下一个如何找到?我不能再次调用相同的函数,因为我会得到相同的答案。我需要进一步查看序列。也许help(str.find)可能有一些用处?

>>> help(str.find)
find(...)
    S.find(sub[, start[, end]]) -> int

    Return the lowest index in S where substring sub is found,
    such that sub is contained within S[start:end].  Optional
    arguments start and end are interpreted as in slice notation.

    Return -1 on failure.

看起来我可以指定一个起始位置。我将使用比第一个子序列被发现的位置大 1 的位置,这个位置是 1,所以从 2 开始:

>>> seq.find(subseq, 2)
3

太好了。这是下一个答案——好吧,4 是下一个答案,但你知道我是什么意思的。我再试一次,这次从 4 开始:

>>> seq.find(subseq, 4)
9

那是我预料之外的最后一个值。如果我尝试使用起始值为 10 会发生什么?正如文档所示,这将返回-1来指示无法找到子序列:

>>> seq.find(subseq, 10)
-1

你能想到一种方法来遍历序列,并记住子序列被找到的最后位置直到找不到为止吗?

另一个选项是使用str.index(),但仅当子序列存在时:

>>> if subseq in seq:
...     seq.index(subseq)
...
1

要找到下一个出现,你可以使用最后已知位置对序列进行切片。你将不得不将这个位置添加到起始位置,但你本质上是在做同样的操作,即向序列深入以查找子序列是否存在以及其位置在哪里:

>>> if subseq in seq[2:]:
...     seq.index(subseq[2:])
...
1

如果你阅读help(str.index),你会发现,像str.find()一样,这个函数接受第二个可选的索引起始位置来开始查找:

>>> if subseq in seq[2:]:
...     seq.index(subseq, 2)
...
3

第三种方法是使用 k-mer。如果子序列存在,那么它就是一个 k-mer,其中k是子序列的长度定义。使用你从第七章中提取序列中所有 k-mer 及其位置的代码,并注意与子序列匹配的 k-mer 的位置。

最后,由于我正在寻找一种文本模式,我可以使用正则表达式。在第五章中,我使用re.findall()函数来查找 DNA 中所有GC。我可以类似地使用这种方法来找到序列中所有的子序列:

>>> import re
>>> re.findall(subseq, seq)
['ATAT', 'ATAT']

这似乎有一些问题。一个问题是它只返回了两个子序列,而我知道有三个。另一个问题是这并不提供关于匹配位置的任何信息。不用担心,re.finditer()函数解决了这个第二个问题:

>>> list(re.finditer(subseq, seq))
[<re.Match object; span=(1, 5), match='ATAT'>,
 <re.Match object; span=(9, 13), match='ATAT'>]

现在显而易见它找到了第一个和最后一个子序列。为什么它不会找到第二个实例?原来正则表达式对重叠模式处理得不太好,但是对搜索模式的一些添加可以解决这个问题。我将把这个问题留给你和一些互联网搜索,看看你是否能找到解决方案。

我介绍了四种不同的解决方案。看看你能否使用每种方法编写解决方案。关键在于探索 Python 的各个角落,存储可能在未来某个程序中起决定性作用的有趣片段和技巧。花上几个小时或几天也没关系。坚持下去,直到你有解决方案能够通过pytestmake test

解决方案

所有解决方案都共享相同的get_args(),如前所示。

解决方案 1:使用str.find()方法

这是我使用str.find()方法的第一个解决方案:

def main() -> None:
    args = get_args()
    last = 0 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    found = [] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    while True: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        pos = args.seq.find(args.subseq, last) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        if pos == -1: ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
            break
        found.append(pos + 1) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
        last = pos + 1 ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

    print(*found) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

1

last位置初始化为0,序列的起始位置。

2

初始化一个列表以保存发现子序列的所有位置。

3

创建一个使用while的无限循环。

4

使用str.find()查找子序列,使用上次已知位置。

5

如果返回-1,则未找到子序列,因此退出循环。

6

将大于索引的值附加到已发现位置的列表中。

7

更新最后已知位置,其值比找到的索引大一。

8

使用*打印已找到的位置,将列表扩展为其元素。函数将使用空格分隔多个值。

此解决方案开启了跟踪最后发现子序列的位置。我将其初始化为0

>>> last = 0

我使用str.find()查找子序列,从上次已知位置开始:

>>> seq = 'GATATATGCATATACTT'
>>> subseq = 'ATAT'
>>> pos = seq.find(subseq, last)
>>> pos
1

只要seq.find()返回除-1以外的值,我将更新最后位置为大于该值的一个以便从下一个字符开始搜索:

>>> last = pos + 1
>>> pos = seq.find(subseq, last)
>>> pos
3

另一个函数调用找到了最后一个实例:

>>> last = pos + 1
>>> pos = seq.find(subseq, last)
>>> pos
9

最后,seq.find()返回-1表示无法再找到该模式:

>>> last = pos + 1
>>> pos = seq.find(subseq, last)
>>> pos
-1

对于具有 C 语言背景的人来说,这种解决方案将立即变得易于理解。这是一种非常 命令式 的方法,具有大量用于更新算法状态的详细逻辑。状态 是程序中数据随时间变化的方式。例如,正确更新和使用上一个已知位置对于使这种方法工作至关重要。后续方法使用的显式编码要少得多。

解决方案 2:使用 str.index() 方法

下一个解决方案是使用上一个已知位置对序列进行切片的变体:

def main() -> None:
    args = get_args()
    seq, subseq = args.seq, args.subseq ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    found = []
    last = 0
    while subseq in seq[last:]: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        last = seq.index(subseq, last) + 1 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        found.append(last) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    print(' '.join(map(str, found))) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

解包序列和子序列。

2

询问子序列是否出现在从上次找到的位置开始的序列片段中。只要此条件为真,while 循环就会执行。

3

使用 str.index() 获取子序列的起始位置。last 变量通过将子序列索引加 1 来更新,以创建下一个起始位置。

4

将此位置追加到找到位置列表中。

5

使用 map() 将所有找到的整数位置强制转换为字符串,然后在空格上进行连接以打印。

再次,我依赖于跟踪子序列被找到的上一个位置。我从位置 0 或字符串的开头开始:

>>> last = 0
>>> if subseq in seq[last:]:
...     last = seq.index(subseq, last) + 1
...
>>> last
2

第一个解决方案中的 while True 循环是开始一个无限循环的常见方法。在这里,只要子序列在序列的片段中被找到,while 循环就会执行,这意味着我不必手动决定何时 break 出循环:

>>> last = 0
>>> found = []
>>> while subseq in seq[last:]:
...     last = seq.index(subseq, last) + 1
...     found.append(last)
...
>>> found
[2, 4, 10]

在这种情况下,找到的位置是一个整数值列表。在第一个解决方案中,我使用 *found 来展开列表,并依赖于 print() 将值强制转换为字符串并在空格上进行连接。如果我尝试使用 str.join()found 创建新字符串,我会遇到问题。str.join() 函数将许多 字符串 连接成单个字符串,因此当你给它非字符串值时会引发异常:

>>> ' '.join(found)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: sequence item 0: expected str instance, int found

我可以使用列表推导式,使用 str() 函数将每个数字 n 转换为字符串:

>>> ' '.join([str(n) for n in found])
'2 4 10'

这也可以使用 map() 来写:

>>> ' '.join(map(lambda n: str(n), found))
'2 4 10'

我完全可以省略 lambda,因为 str() 函数期望单个参数,并且 map() 自然会将每个 found 的值作为参数传递给 str()。这是我首选的方法,将整数列表转换为字符串列表:

>>> ' '.join(map(str, found))
'2 4 10'

解决方案 3:一种纯函数式方法

下一个解决方案结合了许多先前的想法,使用纯函数式方法。首先,考虑第一和第二个解决方案中使用的while循环,用于将非负值附加到found列表中。这听起来像是列表理解可以做的事情吗?迭代的值范围包括从 0 到序列末尾减去子序列长度的所有位置n

>>> r = range(len(seq) - len(subseq))
>>> [n for n in r]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

列表理解可以使用这些值和str.find()来搜索子序列在序列中从每个位置n开始。从位置 0 和 1 开始,子序列可以在索引 1 处找到。从位置 2 和 3 开始,子序列可以在索引 3 处找到。这一直持续到-1指示子序列在那些位置n上不存在:

>>> [seq.find(subseq, n) for n in r]
[1, 1, 3, 3, 9, 9, 9, 9, 9, 9, -1, -1, -1]

我只想要非负值,所以我使用filter()将它们移除:

>>> list(filter(lambda n: n >= 0, [seq.find(subseq, n) for n in r]))
[1, 1, 3, 3, 9, 9, 9, 9, 9, 9]

这也可以通过反转lambda中的比较来编写:

>>> list(filter(lambda n: 0 <= n, [seq.find(subseq, n) for n in r]))
[1, 1, 3, 3, 9, 9, 9, 9, 9, 9]

我向您展示这个,因为我想使用partial()operator.le()(小于或等于)函数,因为我不喜欢lambda表达式:

>>> from functools import partial
>>> import operator
>>> ok = partial(operator.le, 0)
>>> list(filter(ok, [seq.find(subseq, n) for n in r]))
[1, 1, 3, 3, 9, 9, 9, 9, 9, 9]

我想将列表理解改为map()

>>> list(filter(ok, map(lambda n: seq.find(subseq, n), r)))
[1, 1, 3, 3, 9, 9, 9, 9, 9, 9]

但我再次想要摆脱lambda,所以使用partial()

>>> find = partial(seq.find, subseq)
>>> list(filter(ok, map(find, r)))
[1, 1, 3, 3, 9, 9, 9, 9, 9, 9]

我可以使用set()来获取一个不同的列表:

>>> set(filter(ok, map(find, r)))
{1, 3, 9}

这些几乎是正确的值,但它们是索引位置,零基础的。我需要值加一,所以我可以创建一个函数来加 1,并使用map()应用它:

>>> add1 = partial(operator.add, 1)
>>> list(map(add1, set(filter(ok, map(find, r)))))
[2, 4, 10]

在这些有限的例子中,结果已经正确排序;但是,人们永远不能依赖于集合中的值的顺序。我必须使用sorted()函数确保它们在数值上正确排序:

>>> sorted(map(add1, set(filter(ok, map(find, r)))))
[2, 4, 10]

最后,我需要打印这些仍然存在的整数列表的值:

>>> print(sorted(map(add1, set(filter(ok, map(find, r))))))
[2, 4, 10]

那几乎是正确的。与第一个解决方案一样,我需要展开结果以使print()看到单独的元素:

>>> print(*sorted(map(add1, set(filter(ok, map(find, r))))))
2 4 10

这是很多右括号。这段代码开始看起来有点像 Lisp。如果将所有这些想法结合起来,你最终得到与命令式解决方案相同的答案,但现在只结合函数:

def main() -> None:
    args = get_args()
    seq, subseq = args.seq, args.subseq ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    r = list(range(len(seq) - len(subseq))) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    ok = partial(operator.le, 0) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    find = partial(seq.find, subseq) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    add1 = partial(operator.add, 1) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    print(*sorted(map(add1, set(filter(ok, map(find, r)))))) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

1

展开序列和子序列。

2

生成一个数字范围,直到序列的长度减去子序列的长度。

3

创建一个部分ok()函数,如果给定的数字大于或等于0,则返回True

4

创建一个部分find()函数,当提供起始参数时,将查找序列中的子序列。

5

创建一个部分函数add1(),它将返回比参数大一的值。

6

将范围内的所有数字应用于find()函数,过滤掉负值,使用set()函数使结果唯一,对值加 1 并排序后再打印。

这个解决方案只使用函数,对于背景在 Haskell 编程语言中的人来说应该很容易理解。如果对你来说这似乎一团糟,请花些时间在 REPL 中逐个理解每个部分如何完美地配合在一起。

解决方案 4:使用 K-mers

我提到过你可以尝试使用 k-mers 找到答案,这在第七章中已经展示过了。如果子序列存在于序列中,那么它必须是一个 k-mer,其中k等于子序列的长度:

>>> seq = 'GATATATGCATATACTT'
>>> subseq = 'ATAT'
>>> k = len(subseq)
>>> k
4

这里是序列中的所有 4-mers:

>>> kmers = [seq[i:i + k] for i in range(len(seq) - k + 1)]
>>> kmers
['GATA', 'ATAT', 'TATA', 'ATAT', 'TATG', 'ATGC', 'TGCA', 'GCAT', 'CATA',
 'ATAT', 'TATA', 'ATAC', 'TACT', 'ACTT']

这里是与子序列相同的 4-mers:

>>> list(filter(lambda s: s == subseq, kmers))
['ATAT', 'ATAT', 'ATAT']

我需要知道位置以及 k-mers。enumerate()函数将返回序列中所有元素的索引和值。以下是前四个:

>>> kmers = list(enumerate([seq[i:i + k] for i in range(len(seq) - k + 1)]))
>>> kmers[:4]
[(0, 'GATA'), (1, 'ATAT'), (2, 'TATA'), (3, 'ATAT')]

我可以与filter()一起使用,但现在lambda接收的是索引和值的元组,所以我需要查看第二个字段(即索引 1):

>>> list(filter(lambda t: t[1] == subseq, kmers))
[(1, 'ATAT'), (3, 'ATAT'), (9, 'ATAT')]

我真正关心的只是获取匹配 k-mers 的索引。我可以使用带有if表达式的map()来重写这个,当匹配时返回索引位置,否则返回None

>>> list(map(lambda t: t[0] if t[1] == subseq else None, kmers))
[None, 1, None, 3, None, None, None, None, None, 9, None, None, None, None]

我对标准的map()函数只能传递单个值给lambda感到沮丧。我需要的是一种可以打散元组的方法,就像*t,将其转换为两个值。幸运的是,我研究了itertools模块的文档,并找到了starmap()函数,因为它会给lambda参数添加一个星号来打散它。这使我能够将类似(0, 'GATA')的元组值解包为变量i(索引值为0)和kmer(值为'GATA')。有了这些,我可以比较kmer与子序列,并将索引(i)加 1:

>>> from itertools import starmap
>>> list(starmap(lambda i, kmer: i + 1 if kmer == subseq else None, kmers))
[None, 2, None, 4, None, None, None, None, None, 10, None, None, None, None]

这可能看起来是一个奇怪的选择,直到我告诉你,如果在lambda中传递Nonefilter()将使用每个值的真值性,因此将排除None值。因为这行代码变得相当长,我将在单独的一行上写函数f()来进行map()

>>> f = lambda i, kmer: i + 1 if kmer == subseq else None
>>> list(filter(None, starmap(f, kmers)))
[2, 4, 10]

我可以使用命令式技术表达一个 k-mer 解决方案:

def main() -> None:
    args = get_args()
    seq, subseq = args.seq, args.subseq
    k = len(subseq) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    kmers = [seq[i:i + k] for i in range(len(seq) - k + 1)] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    found = [i + 1 for i, kmer in enumerate(kmers) if kmer == subseq] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    print(*found) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

在寻找 k-mers 时,k是子序列的长度。

2

使用列表推导式从序列生成所有 k-mer。

3

迭代遍历所有 k-mer 的索引和值,其中 k-mer 等于子序列。返回比索引位置大一的值。

4

打印找到的位置。

我还可以使用纯函数技术表达这些想法。请注意,mypy 坚持对 found 变量进行类型注释:

def main() -> None:
    args = get_args()
    seq, subseq = args.seq, args.subseq
    k = len(subseq)
    kmers = enumerate(seq[i:i + k] for i in range(len(seq) - k + 1)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    found: Iterator[int] = filter( ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        None, starmap(lambda i, kmer: i + 1 if kmer == subseq else None, kmers))
    print(*found) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

生成一个 k-mer 的枚举列表。

2

选择与子序列相等的那些 k-mer 的位置。

3

打印结果。

我发现命令式版本更易于阅读,但建议您使用您认为最直观的版本。无论您更喜欢哪个解决方案,有趣的是 k-mer 在许多情况下都可以证明极其有用,例如用于部分序列比较。

解决方案 5:使用正则表达式查找重叠模式

到目前为止,我一直在编写相当复杂的解决方案来查找字符串中的字符模式。这恰好是正则表达式的领域,因此手动编写解决方案有点愚蠢。在本章的早些时候,我展示了 re.finditer() 函数不会找到重叠的匹配项,因此当我知道有三个匹配项时,它只返回两个:

>>> import re
>>> list(re.finditer(subseq, seq))
[<re.Match object; span=(1, 5), match='ATAT'>,
 <re.Match object; span=(9, 13), match='ATAT'>]

我将向您展示解决方案非常简单,但我想强调的是,直到我在互联网上搜索之前,我并不知道解决方案。找到答案的关键是知道要使用什么搜索词——诸如正则表达式重叠模式之类的内容会提供几个有用的结果。这一旁白的重点在于,没有人知道所有的答案,您将不断搜索解决问题的方法,甚至是您以前从未意识到的问题。重要的不是您知道什么,而是您能学到什么。

问题实际上是正则表达式引擎消耗匹配的字符串。也就是说,一旦引擎匹配了第一个ATAT,它就从匹配的末尾重新开始搜索。解决方案是使用语法 ?=(<*pattern*>) 将搜索模式包装在正向查找断言中,这样引擎就不会消耗匹配的字符串。请注意,这是正向查找断言;还有负向查找断言以及正向和负向查找断言。

因此,如果子序列是 ATAT,那么我想要模式为 ?=(ATAT)。现在的问题是正则表达式引擎不会 保存 匹配结果 —— 我只是告诉它寻找这个模式,但没有告诉它对找到的文本做任何处理。我需要进一步将断言包裹在括号中以创建一个 捕获组

>>> list(re.finditer('(?=(ATAT))', 'GATATATGCATATACTT'))
[<re.Match object; span=(1, 1), match=''>,
 <re.Match object; span=(3, 3), match=''>,
 <re.Match object; span=(9, 9), match=''>]

我可以使用列表推导式遍历此迭代器,在每个 re.Match 对象上调用 match.start() 函数,并加 1 以校正位置:

>>> [match.start() + 1 for match in re.finditer(f'(?=({subseq}))', seq)]
[2, 4, 10]

这是我建议作为解决这个问题的最佳方式的最终解决方案:

def main() -> None:
    args = get_args()
    seq, subseq = args.seq, args.subseq
    print(*[m.start() + 1 for m in re.finditer(f'(?=({subseq}))', seq)])

基准测试

对我来说,看到哪种解决方案运行速度最快总是很有趣。我将再次使用 hyperfine 运行每个版本 1,000 次:

$ hyperfine -m 1000 -L prg ./solution1_str_find.py,./solution2_str_index.py,\
./solution3_functional.py,./solution4_kmers_functional.py,\
./solution4_kmers_imperative.py,./solution5_re.py \
'{prg} GATATATGCATATACTT ATAT' --prepare 'rm -rf __pycache__'
...
Summary
  './solution2_str_index.py GATATATGCATATACTT ATAT' ran
    1.01 ± 0.11 times faster than
        './solution4_kmers_imperative.py GATATATGCATATACTT ATAT'
    1.02 ± 0.14 times faster than
        './solution5_re.py GATATATGCATATACTT ATAT'
    1.02 ± 0.14 times faster than
        './solution3_functional.py GATATATGCATATACTT ATAT'
    1.03 ± 0.13 times faster than
        './solution4_kmers_functional.py GATATATGCATATACTT ATAT'
    1.09 ± 0.18 times faster than
        './solution1_str_find.py GATATATGCATATACTT ATAT'

在我看来,这些差异并不显著,不能仅凭性能来决定我的选择。我更倾向于使用正则表达式,因为它们专门设计用于查找文本模式。

进一步探讨

扩展程序以查找子序列 模式。例如,您可以搜索简单的序列重复(也称为 SSR 或微卫星),如 GA(26),它表示“GA 重复 26 次”。或者像 (GA)15GT(GA)2 这样的重复,它表示“GA 重复 15 次,然后是 GT,然后是 GA,重复 2 次。” 还要考虑如何找到使用第一章提到的 IUPAC 代码表示的子序列。例如,R 表示 AG,因此 ARC 可以匹配序列 AACAGC

复习

本章的关键点:

  • str.find()str.index() 方法可以确定给定字符串中是否存在子序列。

  • 集合可以用来创建独特的元素集合。

  • 按照定义,k-mers 是子序列,相对快速地提取和比较。

  • 正则表达式可以通过使用前瞻断言结合捕获组来找到重叠的序列。

第九章:重叠图:使用共享的 K-mers 进行序列组装

是用于表示对象间两两关系的结构。如罗莎琳德 GRPH 挑战所述,本练习的目标是找到可以通过从一个序列末端到另一个序列开端的重叠来连接的序列对。这在实践中可以应用于将短的 DNA 读取序列连接成更长的连续序列(contigs)甚至整个基因组。起初,我只关注连接两个序列,但程序的第二个版本将使用可以连接任意数量序列的图结构来近似完成组装。在此实现中,用于连接序列的重叠区域必须是精确匹配的。真实世界的组装器必须允许重叠序列的大小和组成的变化。

你将学到:

  • 如何使用 k-mers 创建重叠图

  • 如何将运行时消息记录到文件中

  • 如何使用collections.defaultdict()

  • 如何使用集合交集来查找集合之间的共同元素

  • 如何使用itertools.product()来创建列表的笛卡尔积

  • 如何使用iteration_utilities.starfilter()函数

  • 如何使用 Graphviz 来建模和可视化图结构

入门指南

此练习的代码和测试位于09_grph目录中。首先复制程序grph.py的解决方案,并请求用法:

$ cd 09_grph/
$ cp solution1.py grph.py
$ ./grph.py -h
usage: grph.py [-h] [-k size] [-d] FILE

Overlap Graphs

positional arguments:
  FILE                  FASTA file ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

optional arguments:
  -h, --help            show this help message and exit
  -k size, --overlap size
                        Size of overlap (default: 3) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
  -d, --debug           Debug (default: False) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

位置参数是一个必需的 FASTA 格式序列文件。

2

-k选项控制重叠字符串的长度,默认为3

3

这是一个标志或布尔参数。当参数存在时,其值为True,否则为False

Rosalind 页面上显示的示例输入也是第一个示例输入文件的内容:

$ cat tests/inputs/1.fa
>Rosalind_0498
AAATAAA
>Rosalind_2391
AAATTTT
>Rosalind_2323
TTTTCCC
>Rosalind_0442
AAATCCC
>Rosalind_5013
GGGTGGG

罗莎琳德问题始终假设一个三个碱基的重叠窗口。我认为没有理由将此参数硬编码,因此我的版本包含一个k参数,用于指示重叠窗口的大小。例如,默认值为3时,可以连接三对序列:

$ ./grph.py tests/inputs/1.fa
Rosalind_2391 Rosalind_2323
Rosalind_0498 Rosalind_2391
Rosalind_0498 Rosalind_0442

图 9-1 显示了这些序列如何通过三个共同碱基重叠。

mpfb 0901

图 9-1. 当连接 3-mers 时,三对序列形成重叠图

如图 9-2 所示,当重叠窗口增加到四个碱基时,只有这些序列对中的一对可以连接。

$ ./grph.py -k 4 tests/inputs/1.fa
Rosalind_2391 Rosalind_2323

mpfb 0902

图 9-2. 当连接 4-mers 时,只有一对序列形成重叠图

最后,--debug 选项是一个标志,当参数存在时具有 True 值,否则为 False。当存在时,此选项指示程序将运行时日志消息打印到当前工作目录中名为 .log 的文件中。这不是 Rosalind 挑战的要求,但我认为你了解如何记录消息很重要。要看它如何运作,运行带有该选项的程序:

$ ./grph.py tests/inputs/1.fa --debug
Rosalind_2391 Rosalind_2323
Rosalind_0498 Rosalind_2391
Rosalind_0498 Rosalind_0442

--debug 标志可以放在位置参数之前或之后,argparse 将正确解释其含义。其他参数解析器要求所有选项和标志在位置参数之前。Vive la différence.

现在应该有一个名为 .log 的文件,其内容如下,其含义将在稍后变得更加明显:

$ cat .log
DEBUG:root:STARTS
defaultdict(<class 'list'>,
            {'AAA': ['Rosalind_0498', 'Rosalind_2391', 'Rosalind_0442'],
             'GGG': ['Rosalind_5013'],
             'TTT': ['Rosalind_2323']})
DEBUG:root:ENDS
defaultdict(<class 'list'>,
            {'AAA': ['Rosalind_0498'],
             'CCC': ['Rosalind_2323', 'Rosalind_0442'],
             'GGG': ['Rosalind_5013'],
             'TTT': ['Rosalind_2391']})

一旦你理解了你的程序应该如何工作,从一个新的 grph.py 程序重新开始:

$ new.py -fp 'Overlap Graphs' grph.py
Done, see new script "grph.py".

这是我定义和验证参数的方法:

from typing import List, NamedTuple, TextIO

class Args(NamedTuple): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Command-line arguments """
    file: TextIO
    k: int
    debug: bool

# --------------------------------------------------
def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Overlap Graphs',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('file', ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                        metavar='FILE',
                        type=argparse.FileType('rt'),
                        help='FASTA file')

    parser.add_argument('-k', ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
                        '--overlap',
                        help='Size of overlap',
                        metavar='size',
                        type=int,
                        default=3)

    parser.add_argument('-d', '--debug', help='Debug', action='store_true') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    args = parser.parse_args()

    if args.overlap < 1: ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        parser.error(f'-k "{args.overlap}" must be > 0') ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

    return Args(args.file, args.overlap, args.debug) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

1

Args 类包含三个字段:一个是文件句柄 file;一个是应为正整数的 k;还有一个是布尔值 debug

2

使用 argparse.FileType 确保这是一个可读的文本文件。

3

定义一个默认为 3 的整数参数。

4

定义一个布尔标志,当存在时将存储一个 True 值。

5

检查 k(重叠)值是否为负数。

6

使用 parser.error() 终止程序并生成一个有用的错误消息。

7

返回经过验证的参数。

我想强调这些行中发生了多少事情,以确保程序的参数是正确的。参数值应该在程序启动后尽快验证。我遇到过太多的程序,例如从不验证文件参数,然后在程序深处尝试打开一个不存在的文件,结果抛出一个晦涩难解的异常,没有人能够调试。如果你想要可重现的程序,第一要务是记录和验证所有的参数。

修改你的 main() 如下:

def main() -> None:
    args = get_args()
    print(args.file.name)

运行你的程序与第一个测试输入文件,并验证你是否看到了这个:

$ ./grph.py tests/inputs/1.fa
tests/inputs/1.fa

尝试用无效的k值和文件输入运行你的程序,然后运行**pytest**来验证你的程序是否通过了前四个测试。失败的测试期望有三对可以连接的序列 ID,但程序输出了输入文件的名称。在我讲解如何创建重叠图之前,我想先介绍一下日志记录,因为这对于调试程序可能会很有用。

使用 STDOUT、STDERR 和日志管理运行时消息

我已经展示了如何将字符串和数据结构打印到控制台。你刚刚通过打印输入文件名来验证程序是否正常工作。在编写和调试程序时打印这样的消息可能会被戏称为基于日志的开发。这是几十年来调试程序的一种简单有效的方法。^(1)

默认情况下,print()会将消息发送到STDOUT标准输出),Python 使用sys.stdout来表示。我可以使用print()函数的file选项将其更改为STDERR标准错误),方法是指定sys.stderr。考虑以下 Python 程序:

$ cat log.py
#!/usr/bin/env python3

import sys

print('This is STDOUT.') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
print('This is also STDOUT.', file=sys.stdout) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
print('This is STDERR.', file=sys.stderr) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

默认的file值是STDOUT

2

我可以使用file选项来指定标准输出。

3

这将会将消息打印到标准错误。

当我运行时,所有输出似乎都打印到标准输出:

$ ./log.py
This is STDOUT.
This is also STDOUT.
This is STDERR.

bash shell 中,我可以使用文件重定向和>来分隔和捕获这两个流。标准输出可以使用文件句柄 1 捕获,标准错误可以使用 2 捕获。如果你运行以下命令,你应该在控制台上看不到任何输出:

$ ./log.py 1>out 2>err

现在应该有两个新文件,一个名为out,其中包含打印到标准输出的两行:

$ cat out
This is STDOUT.
This is also STDOUT.

另一个称为err的文件,其中只有一行打印到标准错误输出:

$ cat err
This is STDERR.

仅了解如何打印和捕获这两个文件句柄可能足以满足你的调试需求。然而,有时候你可能需要比两个更多级别的打印,并且你可能希望通过代码控制这些消息被写入的位置,而不是使用 shell 重定向。这就是日志记录的作用,一种控制运行时消息何时、如何、何地打印的方法。Python 的logging模块处理所有这些,所以首先导入该模块:

import logging

对于这个程序,如果存在--debug标志,我将调试消息打印到一个名为.log的文件中(在当前工作目录中)。将你的main()修改为以下内容:

def main() -> None:
    args = get_args()

    logging.basicConfig( ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        filename='.log', ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        filemode='w', ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        level=logging.DEBUG if args.debug else logging.CRITICAL) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    logging.debug('input file = "%s"', args.file.name) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

这将全局影响所有后续对logging模块函数的调用。

2

所有输出将写入当前工作目录中的.log文件。我选择了以点开头的文件名,因此通常不会显示在视图中。

3

输出文件将使用w写入)选项打开,意味着每次运行时都将覆盖它。使用a模式来追加,但要注意每次运行文件都会增长,除非您手动截断或删除它。

4

这设置了最低的日志记录级别(参见表 9-1)。低于设置级别的消息将被忽略。

5

使用logging.debug()函数在日志级别设置为DEBUG或更高时将消息打印到日志文件中。

在上一个示例中,我使用了旧式的printf()格式化风格来调用logging.debug()。占位符用符号如%s表示字符串,需要替换的值作为参数传递。您也可以使用str.format()和 f-strings 进行日志消息格式化,但pylint可能建议您使用printf()风格。

日志记录的一个关键概念是日志级别的概念。如表 9-1 所示,严重级别是最高的,调试级别是最低的(未设置级别具有特定特性)。要了解更多信息,建议您在 REPL 中阅读help(logging)或访问模块的在线文档。对于此程序,我只会使用最低的(调试)设置。当存在--debug标志时,日志级别设置为logging.DEBUG,所有通过logging.debug()记录的消息都会打印在日志文件中。当标志不存在时,日志级别设置为logging.CRITICAL,只有通过logging.critical()记录的消息才会通过。您可能认为我应该使用logging.NOTSET值,但请注意,这比logging.DEBUG低,因此所有调试消息都会通过。

表 9-1. Python 的logging模块中可用的日志级别

等级 数值
严重 50
错误 40
警告 30
信息 20
调试 10
未设置 0

要查看实际操作,请按以下步骤运行您的程序:

$ ./grph.py --debug tests/inputs/1.fa

似乎程序什么都没做,但现在应该有一个.log文件,内容如下:

$ cat .log
DEBUG:root:input file = "tests/inputs/1.fa"

再次运行程序,但不带--debug标志,并注意.log文件为空,因为它在打开时被覆盖,但从未记录任何内容。如果您使用典型的基于打印的调试技术,则必须找到并删除(或注释掉)程序中的所有print()语句以关闭调试。相反,如果您使用logging.debug(),则可以在调试级别记录并部署程序以仅记录关键消息。此外,您可以根据环境将日志消息写入不同的位置,所有这些操作都是程序化地在您的代码中完成,而不是依赖于 shell 重定向将日志消息放入正确的位置。

没有测试来确保您的程序创建日志文件。这只是向您展示如何使用日志记录。请注意,对logging.critical()logging.debug()等函数的调用由logging模块的全局范围控制。我通常不喜欢程序受全局设置控制,但这是我唯一的例外,主要是因为我别无选择。我鼓励您在代码中大量使用logging.debug()调用,以查看您可以生成的各种输出。考虑在您在笔记本电脑上编写程序和在远程计算集群上部署它以无人值守运行时如何使用日志记录。

寻找重叠部分

接下来的任务是读取输入的 FASTA 文件。我首先在第五章展示了如何做到这一点。再次,我将使用Bio.SeqIO模块通过添加以下导入来完成:

from Bio import SeqIO

我可以将main()修改为以下内容(省略任何日志调用):

def main() -> None:
    args = get_args()

    for rec in SeqIO.parse(args.file, 'fasta'):
        print(rec.id, rec.seq)

然后在第一个输入文件上运行以确保程序正常工作:

$ ./grph.py tests/inputs/1.fa
Rosalind_0498 AAATAAA
Rosalind_2391 AAATTTT
Rosalind_2323 TTTTCCC
Rosalind_0442 AAATCCC
Rosalind_5013 GGGTGGG

在每个练习中,我尝试逐步逻辑地展示如何编写程序。我希望您学会进行非常小的更改以达到某个最终目标,然后运行您的程序以查看输出。您应该经常运行测试来查看需要修复的问题,并根据需要添加自己的测试。此外,请考虑在程序运行良好时频繁提交程序,以便在破坏程序时可以还原。采取小步骤并经常运行您的程序是学习编程的关键要素。

现在考虑如何从每个序列中获取第一个和最后一个k个碱基。您可以使用我在第七章中首次展示的提取 k-mer 的代码吗?例如,尝试让您的程序打印出这个:

$ ./grph.py tests/inputs/1.fa
Rosalind_0498 AAATAAA first AAA last AAA
Rosalind_2391 AAATTTT first AAA last TTT
Rosalind_2323 TTTTCCC first TTT last CCC
Rosalind_0442 AAATCCC first AAA last CCC
Rosalind_5013 GGGTGGG first GGG last GGG

想想哪些起始字符串匹配哪些结束字符串。例如,序列 0498 以AAA结尾,而序列 0442 以AAA开头。这些序列可以连接成重叠图。

k的值更改为4

$ ./grph.py tests/inputs/1.fa -k 4
Rosalind_0498 AAATAAA first AAAT last TAAA
Rosalind_2391 AAATTTT first AAAT last TTTT
Rosalind_2323 TTTTCCC first TTTT last TCCC
Rosalind_0442 AAATCCC first AAAT last TCCC
Rosalind_5013 GGGTGGG first GGGT last TGGG

现在你可以看到,只有两个序列,2391 和 2323,可以通过它们的重叠序列TTTT连接起来。从110变化k并检查第一个和最后一个区域。你有足够的信息来编写解决方案吗?如果没有,让我们继续思考这个问题。

通过重叠来分组序列

for循环逐个读取序列。在读取任何一个序列以查找起始和结束重叠区域时,我必须没有足够的信息来说哪些其他序列可以连接起来。我将不得不创建一些数据结构来保存所有序列的重叠区域。只有这样我才能回过头来弄清楚哪些序列可以连接起来。这涉及到序列组装器的关键元素——大多数需要大量内存来收集来自所有输入序列的所有所需信息,这可能会有数百万到数十亿。

我选择使用两个字典,一个用于起始区域,一个用于结束区域。我决定键将是k长度的序列,例如在k3时为AAA,而值将是共享此区域的序列 ID 列表。我可以使用值k的字符串切片来提取这些前导和尾随序列:

>>> k = 3
>>> seq = 'AAATTTT'
>>> seq[:k] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
'AAA'
>>> seq[-k:] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
'TTT'

1

取前k个碱基片段。

2

取最后k个碱基片段,使用负索引从序列末尾开始。

这些是 k-mer,在上一章中我用过。它们一直出现,因此编写一个find_kmers()函数来从序列中提取 k-mer 是合理的。我将从定义函数的签名开始:

def find_kmers(seq: str, k: int) -> List[str]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Find k-mers in string """

    return [] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

此函数将接受一个字符串(序列)和一个整数值k,并返回一个字符串列表。

2

目前返回空列表。

现在我写一个测试来想象如何使用这个函数:

def test_find_kmers() -> None:
    """Test find_kmers"""

    assert find_kmers('', 1) == [] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert find_kmers('ACTG', 1) == ['A', 'C', 'T', 'G'] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert find_kmers('ACTG', 2) == ['AC', 'CT', 'TG']
    assert find_kmers('ACTG', 3) == ['ACT', 'CTG']
    assert find_kmers('ACTG', 4) == ['ACTG']
    assert find_kmers('ACTG', 5) == [] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

将空字符串作为序列传递,以确保函数返回空列表。

2

检查所有使用短序列的k值。

3

一个长度为 4 的字符串没有 5-mers。

在继续阅读之前尝试编写你自己的版本。这是我写的函数:

def find_kmers(seq: str, k: int) -> List[str]:
    """Find k-mers in string"""

    n = len(seq) - k + 1 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    return [] if n < 1 else [seq[i:i + k] for i in range(n)] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

找到字符串 seqk 长度子字符串的数量 n

2

如果 n 是负数,返回空列表;否则,使用列表推导返回 k-mer。

现在我有了从序列中获取前导和尾随 k-mer 的便利方法:

>>> from grph import find_kmers
>>> kmers = find_kmers('AAATTTT', 3)
>>> kmers
['AAA', 'AAT', 'ATT', 'TTT', 'TTT']
>>> kmers[0] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
'AAA'
>>> kmers[-1] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
'TTT'

1

第一个元素是前导 k-mer。

2

最后一个元素是尾随 k-mer。

第一个和最后一个 k-mer 给我所需的键的重叠序列。我希望字典的值是共享这些 k-mer 的序列 ID 列表。我在 第一章 中介绍的 collections.defaultdict() 函数很适合用于此,因为它允许我轻松地用空列表实例化每个字典条目。为了日志记录目的,我需要导入它和 pprint.pformat() 函数,所以我添加如下内容:

from collections import defaultdict
from pprint import pformat

这里是我如何应用这些想法:

def main() -> None:
    args = get_args()

    logging.basicConfig(
        filename='.log',
        filemode='w',
        level=logging.DEBUG if args.debug else logging.CRITICAL)

    start, end = defaultdict(list), defaultdict(list) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    for rec in SeqIO.parse(args.file, 'fasta'): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        if kmers := find_kmers(str(rec.seq), args.k): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            start[kmers[0]].append(rec.id) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            end[kmers[-1]].append(rec.id) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

    logging.debug(f'STARTS\n{pformat(start)}') ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
    logging.debug(f'ENDS\n{pformat(end)}')

1

创建起始和结束区域的字典,其默认值为列表。

2

迭代 FASTA 记录。

3

强制 Seq 对象转换为字符串并找到 k-mer。:= 语法将返回值分配给 kmers,然后 if 评估 kmers 是否为真值。如果函数没有返回 kmers,则以下块不会执行。

4

使用第一个 k-mer 作为 start 字典的键,并将此序列 ID 添加到列表中。

5

类似地,使用最后一个 k-mer 对 end 字典进行操作。

6

使用 pprint.pformat() 函数为日志格式化字典。

我之前在较早的章节中使用了 pprint.pprint() 函数来打印复杂的数据结构,以比默认的 print() 函数更漂亮的格式输出。我不能在这里使用 pprint(),因为它会打印到 STDOUT(或 STDERR)。相反,我需要为 logging.debug() 函数格式化数据结构以进行日志记录。

现在再次运行程序,使用第一个输入和 --debug 标志,然后检查日志文件:

$ ./grph.py tests/inputs/1.fa -d
$ cat .log
DEBUG:root:STARTS ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
defaultdict(<class 'list'>,
            {'AAA': ['Rosalind_0498', 'Rosalind_2391', 'Rosalind_0442'], ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
             'GGG': ['Rosalind_5013'],
             'TTT': ['Rosalind_2323']})
DEBUG:root:ENDS ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
defaultdict(<class 'list'>,
            {'AAA': ['Rosalind_0498'], ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
             'CCC': ['Rosalind_2323', 'Rosalind_0442'],
             'GGG': ['Rosalind_5013'],
             'TTT': ['Rosalind_2391']})

1

各种起始序列和其 ID 的字典。

2

三个序列以 AAA 开始:0498、2391 和 0442。

3

各种结束序列及其 ID 的字典。

4

仅有一个以 AAA 结尾的序列,即 0498。

对于这个输入文件和重叠的 3-mers,正确的序对如下:

  • Rosalind_0498, Rosalind_2391: AAA

  • Rosalind_0498, Rosalind_0442: AAA

  • Rosalind_2391, Rosalind_2323: TTT

例如,当你将以 AAA 结尾的序列(0498)与以该序列开头的序列(0498、2391、0442)结合时,你会得到以下配对:

  • Rosalind_0498, Rosalind_0498

  • Rosalind_0498, Rosalind_2391

  • Rosalind_0498, Rosalind_0442

由于我无法将一个序列与自身连接,所以第一对被取消资格。找到下一个共同的 endstart 序列,然后迭代所有序列对。我留给你完成这个练习,找出所有共同的起始和结束键,然后组合所有序列 ID 以打印可以连接的序对。序对可以以任何顺序排列仍然通过测试。我只想祝你好运。我们都在依赖你。

解决方案

我有两种变体与你分享。第一种解决了 Rosalind 问题,展示了如何组合任意两个序列。第二种扩展了图形以创建所有序列的完整组装。

解决方案 1:使用集合交集查找重叠部分

在以下解决方案中,我介绍如何使用集合交集来找到起始和结束字典之间共享的 k-mer:

def main() -> None:
    args = get_args()

    logging.basicConfig(
        filename='.log',
        filemode='w',
        level=logging.DEBUG if args.debug else logging.CRITICAL)

    start, end = defaultdict(list), defaultdict(list)
    for rec in SeqIO.parse(args.file, 'fasta'):
        if kmers := find_kmers(str(rec.seq), args.k):
            start[kmers[0]].append(rec.id)
            end[kmers[-1]].append(rec.id)

    logging.debug('STARTS\n{}'.format(pformat(start)))
    logging.debug('ENDS\n{}'.format(pformat(end)))

    for kmer in set(start).intersection(set(end)): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        for pair in starfilter(op.ne, product(end[kmer], start[kmer])): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
            print(*pair) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

找到 startend 字典之间的公共键。

2

迭代不相等的结束和起始序列对。

3

打印序列对。

最后三行让我尝试了几次才写出来,所以让我解释一下我是如何做到的。鉴于这些字典:

>>> from pprint import pprint
>>> from Bio import SeqIO
>>> from collections import defaultdict
>>> from grph import find_kmers
>>> k = 3
>>> start, end = defaultdict(list), defaultdict(list)
>>> for rec in SeqIO.parse('tests/inputs/1.fa', 'fasta'):
...     if kmers := find_kmers(str(rec.seq), k):
...         start[kmers[0]].append(rec.id)
...         end[kmers[-1]].append(rec.id)
...
>>> pprint(start)
{'AAA': ['Rosalind_0498', 'Rosalind_2391', 'Rosalind_0442'],
 'GGG': ['Rosalind_5013'],
 'TTT': ['Rosalind_2323']}
>>> pprint(end)
{'AAA': ['Rosalind_0498'],
 'CCC': ['Rosalind_2323', 'Rosalind_0442'],
 'GGG': ['Rosalind_5013'],

我从这个想法开始:

>>> for kmer in end: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
...     if kmer in start: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
...         for seq_id in end[kmer]: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
...             for other in start[kmer]: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
...                 if seq_id != other: ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
...                     print(seq_id, other) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
...
Rosalind_0498 Rosalind_2391
Rosalind_0498 Rosalind_0442
Rosalind_2391 Rosalind_2323

1

迭代 end 字典的 k-mer(即 keys)。

2

检查这个 k-mer 是否在start字典中。

3

遍历每个 k-mer 的结束序列 ID。

4

遍历每个 k-mer 的起始序列 ID。

5

确保序列不相同。

6

打印序列 ID。

虽然这很有效,但我让它静置一段时间,然后回来,问自己我究竟想要做什么。前两行试图找到两个字典之间共同的键。使用集合交集可以更轻松地实现这一点。如果我在字典上使用set()函数,它将使用字典的键创建一个集合:

>>> set(start)
{'TTT', 'GGG', 'AAA'}
>>> set(end)
{'TTT', 'CCC', 'AAA', 'GGG'}

然后,我可以调用set.intersection()函数来查找共同的键:

>>> set(start).intersection(set(end))
{'TTT', 'GGG', 'AAA'}

在上面的代码中,下一行找到所有结束和起始序列 ID 的组合。使用itertools.product()函数可以更容易地完成这个任务,它将创建任意数量列表的笛卡尔积。例如,考虑在 k-mer AAA 上重叠的序列:

>>> from itertools import product
>>> kmer = 'AAA'
>>> pairs = list(product(end[kmer], start[kmer]))
>>> pprint(pairs)
[('Rosalind_0498', 'Rosalind_0498'),
 ('Rosalind_0498', 'Rosalind_2391'),
 ('Rosalind_0498', 'Rosalind_0442')]

我想排除任何两个值相同的对。我可以为此编写一个filter()

>>> list(filter(lambda p: p[0] != p[1], pairs)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
[('Rosalind_0498', 'Rosalind_2391'), ('Rosalind_0498', 'Rosalind_0442')]

1

lambda接收对p并检查零 th 和第一个元素不相等。

这个方法可以,但我对这段代码不满意。我真的很讨厌不能在lambda中解包元组值。我立即开始思考在第 6 和第八章中使用的itertools.starmap()函数可以做到这一点,所以我搜索了互联网找到了Python starfilter函数iteration_utilities.starfilter()。我安装了这个模块并导入了该函数:

>>> from iteration_utilities import starfilter
>>> list(starfilter(lambda a, b: a != b, pairs))
[('Rosalind_0498', 'Rosalind_2391'), ('Rosalind_0498', 'Rosalind_0442')]

这是一个改进,但我可以通过使用operator.ne()(不相等)函数来使它更清晰,这将消除lambda

>>> import operator as op
>>> list(starfilter(op.ne, pairs))
[('Rosalind_0498', 'Rosalind_2391'), ('Rosalind_0498', 'Rosalind_0442')]

最后,我展开每个对,使print()看到单独的字符串而不是列表容器:

>>> for pair in starfilter(op.ne, pairs):
...     print(*pair)
...
Rosalind_0498 Rosalind_2391
Rosalind_0498 Rosalind_0442

我本可以进一步缩短这段代码,但我担心这会变得太密集:

>>> print('\n'.join(map(' '.join, starfilter(op.ne, pairs))))
Rosalind_0498 Rosalind_2391
Rosalind_0498 Rosalind_0442

最后,在main()函数中有相当多的代码,在一个更大的程序中,我可能会将其移到一个带有单元测试的函数中。在这种情况下,集成测试覆盖了所有功能,所以这样做可能过于复杂。

解决方案 2:使用图形查找所有路径

下一个解决方案通过图形近似地装配所有序列,以链接所有重叠的序列。虽然不是原始挑战的一部分,但思考起来非常有趣,而且实现起来甚至很简单。由于GRPH是挑战名称,探索如何在 Python 代码中表示图形是有意义的。

我可以手动对齐所有序列,如图 9-3 所示。这揭示了一个图结构,其中序列 Rosalind_0498 可以连接到 Rosalind_2391 或 Rosalind_0442,然后从 Rosalind_0498 到 Rosalind_2391 到 Rosalind_2323 形成链。

mpfb 0903

图 9-3。第一个输入文件中的所有序列都可以使用 3-mer 连接。

为了编码这一点,我使用Graphviz 工具来表示和可视化图形结构。请注意,您需要在计算机上安装 Graphviz 才能使用此功能。例如,在 macOS 上,您可以使用 Homebrew 软件包管理器(brew install graphviz),而在 Ubuntu Linux 上,您可以使用apt install graphviz

Graphviz 的输出将是一个文本文件,格式为Dot 语言,可以通过 Graphviz 的dot工具转换为图形图。仓库中的第二个解决方案具有控制输出文件名和是否打开图像的选项:

$ ./solution2_graph.py -h
usage: solution2_graph.py [-h] [-k size] [-o FILE] [-v] [-d] FILE

Overlap Graphs

positional arguments:
  FILE                  FASTA file

optional arguments:
  -h, --help            show this help message and exit
  -k size, --overlap size
                        Size of overlap (default: 3)
  -o FILE, --outfile FILE
                        Output filename (default: graph.txt) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
  -v, --view            View outfile (default: False) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
  -d, --debug           Debug (default: False)

1

默认的输出文件名是graph.txt。还会自动生成一个.pdf文件,这是图形的可视化呈现。

2

此选项控制程序完成后是否自动打开 PDF。

如果您在第一个测试输入上运行此程序,您将看到与之前相同的输出,以便通过测试套件:

$ ./solution2_graph.py tests/inputs/1.fa -o 1.txt
Rosalind_2391 Rosalind_2323
Rosalind_0498 Rosalind_2391
Rosalind_0498 Rosalind_0442

现在应该还有一个新的输出文件叫做1.txt,其中包含使用 Dot 语言编码的图结构:

$ cat 1.txt
digraph {
	Rosalind_0498
	Rosalind_2391
	Rosalind_0498 -> Rosalind_2391
	Rosalind_0498
	Rosalind_0442
	Rosalind_0498 -> Rosalind_0442
	Rosalind_2391
	Rosalind_2323
	Rosalind_2391 -> Rosalind_2323
}

您可以使用dot程序将其转换为可视化。以下是一个将图形保存为 PNG 文件的命令:

$ dot -O -Tpng 1.txt

图 9-4 展示了连接第一个 FASTA 文件中所有序列的图形的结果可视化,重新概括了从图 9-3 的手动对齐。

mpfb 0904

图 9-4。dot程序的输出,显示了在连接 3-mer 时第一个输入文件中序列的装配。

如果您使用-v|--view标志运行程序,此图像应该会自动显示。在图的术语中,每个序列是一个节点,两个序列之间的关系是一条

图可能具有方向性,也可能没有。图 9-4 包括箭头,表明存在从一个节点到另一个节点的关系;因此,这是一个有向图。以下代码显示了我如何创建和可视化此图。请注意,我导入 graphiz.Digraph 来创建有向图,而此代码省略了实际解决方案中的日志记录代码:

def main() -> None:
    args = get_args()
    start, end = defaultdict(list), defaultdict(list)
    for rec in SeqIO.parse(args.file, 'fasta'):
        if kmers := find_kmers(str(rec.seq), args.k):
            start[kmers[0]].append(rec.id)
            end[kmers[-1]].append(rec.id)

    dot = Digraph() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    for kmer in set(start).intersection(set(end)): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        for s1, s2 in starfilter(op.ne, product(end[kmer], start[kmer])): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            print(s1, s2) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            dot.node(s1) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
            dot.node(s2)
            dot.edge(s1, s2) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

    args.outfile.close() ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
    dot.render(args.outfile.name, view=args.view) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

1

创建一个有向图。

2

迭代共享的 k-mer。

3

查找共享 k-mer 的序列对,并将两个序列 ID 解包为 s1s2

4

打印测试的输出。

5

为每个序列添加节点。

6

添加连接节点的边缘。

7

关闭输出文件句柄,以便图形可以写入文件名。

8

将图结构写入输出文件名。根据 args.view 选项,使用 view 选项打开图像。

这几行代码对程序的输出有很大影响。例如,图 9-5 显示,该程序基本上可以创建第二个输入文件中 100 个序列的完整组装。

mpfb 0905

图 9-5. 第二个测试输入文件的图形

这张图像(虽然已缩小以适合页面)充分展示了数据的复杂性和完整性;例如,右上角的序列对——Rosalind_1144 和 Rosalind_2208——无法与任何其他序列连接。我鼓励您尝试将 k 增加到 4 并检查生成的图形,看看会有非常不同的结果。

图是真正强大的数据结构。正如本章介绍中指出的,图编码了成对关系。通过如此少量的代码看到 图 9-5 中 100 个序列的组装是令人惊讶的。虽然可能滥用 Python 列表和字典来表示图,但 Graphviz 工具使这一切变得更简单。

我在这个练习中使用了有向图,但这不一定是必需的。这也可以是无向图,但我喜欢箭头。我要注意的是,你可能会遇到术语 有向无环图 (DAG),表示没有循环的有向图,当一个节点指向自身时则形成循环。在线性基因组的情况下,循环可能指向不正确的组装,但在细菌的环形基因组中可能是必需的。如果你对这些想法感兴趣,应该调查一下 De Bruijn 图,它们通常由重叠的 k-mer 构建。

进一步探索

添加一个汉明距离选项,允许重叠序列具有指定的编辑距离。也就是说,距离为 1 允许具有单个碱基差异的序列重叠。

复习

本章要点:

  • 要找到重叠区域,我使用了 k-mer 来找到每个序列的前 k 个和最后 k 个碱基。

  • logging 模块使得可以轻松地控制运行时消息的记录与关闭。

  • 我使用 defaultdict(list) 创建了一个字典,当键不存在时会自动创建一个空列表作为默认值。

  • 集合交集可以找到两个字典之间共享的键等公共元素。

  • itertools.product() 函数找到了所有可能的序列对。

  • iteration_utilities.starfilter() 函数将会解构 filter() 的参数,就像 itertools.starmap() 函数对 map() 一样。

  • Graphviz 工具能高效地表示和可视化复杂的图结构。

  • 图可以用 Dot 语言进行文本表示,而 dot 程序可以生成各种格式的图形化显示。

  • 重叠图可用于创建两个或多个序列的完整组装。

^(1) 想象一下在没有控制台的情况下调试程序。在 1950 年代,克劳德·香农访问了艾伦·图灵在英国实验室的实验室。在他们的对话期间,一只喇叭开始定期发声。图灵说这表明他的代码陷入了循环。在没有控制台的情况下,这是他监视程序进展的方式。

第十章:找到最长共享子序列:找到 K-mers,编写函数和使用二分搜索

Rosalind LCSM 挑战中所述,此练习的目标是在给定的 FASTA 文件中找到所有序列共享的最长子字符串。在第八章中,我正在搜索一些序列中的给定基序。在这个挑战中,我不知道先验地存在任何共享的基序——更不用说它的大小或组成——所以我将只是寻找每个序列中存在的任何长度的序列。这是一个具有挑战性的练习,汇集了我在早期章节中展示的许多想法。我将使用解决方案来探索算法设计、函数、测试和代码组织。

您将学习:

  • 如何使用 K-mer 查找共享子序列

  • 如何使用itertools.chain()连接列表的列表

  • 如何以及为什么使用二分搜索

  • 最大化函数的一种方式

  • 如何在min()max()中使用key选项

入门

所有与此挑战相关的代码和测试都在10_lcsm目录中。首先将第一个解决方案复制到lcsm.py程序,并请求帮助:

$ cp solution1_kmers_imperative.py lcsm.py
$ ./lcsm.py -h
usage: lcsm.py [-h] FILE

Longest Common Substring

positional arguments:
  FILE        Input FASTA

optional arguments:
  -h, --help  show this help message and exit

唯一必需的参数是一个 FASTA 格式的 DNA 序列单文件。与接受文件的其他程序一样,程序将拒绝无效或不可读的输入。以下是我将要使用的第一个输入。这些序列中的最长共同子序列是CATAAC,最后一个在输出中以粗体显示:

$ cat tests/inputs/1.fa
>Rosalind_1
GATTACA
>Rosalind_2
TAGACCA
>Rosalind_3
ATACA

这些答案中的任何一个都是可以接受的。使用第一个测试输入运行程序,看看它随机选择了一个可接受的 2-mer:

$ ./lcsm.py tests/inputs/1.fa
CA

第二个测试输入要大得多,您会注意到程序花费的时间明显更长。在我的笔记本电脑上,它几乎花了 40 秒。在解决方案中,我将展示一种使用二分搜索显著减少运行时间的方法:

$ time ./lcsm.py tests/inputs/2.fa
GCCTTTTGATTTTAACGTTTATCGGGTGTAGTAAGATTGCGCGCTAATTCCAATAAACGTATGGAGGACATTCCCCGT

real	0m39.244s
user	0m33.708s
sys	0m6.202s

虽然这不是挑战的要求,但我已经包含了一个输入文件,其中不包含任何程序应该创建合适响应的共享子序列:

$ ./lcsm.py tests/inputs/none.fa
No common subsequence.

从头开始运行lcsm.py程序:

$ new.py -fp 'Longest Common Substring' lcsm.py
Done, see new script "lcsm.py".

定义参数如下:

class Args(NamedTuple): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Command-line arguments """
    file: TextIO

def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Longest Common Substring',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('file', ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                        help='Input FASTA',
                        metavar='FILE',
                        type=argparse.FileType('rt'))

    args = parser.parse_args()

    return Args(args.file) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

此程序的唯一输入是一个 FASTA 格式的文件。

2

定义一个单一的file参数。

3

返回包含打开文件句柄的Args对象。

然后更新main()函数以打印传入的文件名:

def main() -> None:
    args = get_args()
    print(args.file.name)

确保您看到正确的用法,并且程序正确地打印了文件名:

$ ./lcsm.py tests/inputs/1.fa
tests/inputs/1.fa

此时,您的程序应该通过前三个测试。如果您认为自己知道如何完成程序,请继续。如果您想要一些正确方向的提示,请继续阅读。

在 FASTA 文件中找到最短的序列

现在应该已经熟悉了读取 FASTA 文件的方法。我将像之前一样使用Bio.SeqIO.parse()。我在解决这个问题时的第一个想法是找到共享的 k-mer,同时最大化k。最长的子序列不能比文件中最短的序列更长,因此我决定从k等于最短的那个开始。找到最短的序列需要我首先扫描所有记录。要回顾如何做到这一点,Bio.SeqIO.parse()函数返回一个迭代器,让我可以访问每个 FASTA 记录:

>>> from Bio import SeqIO
>>> fh = open('./tests/inputs/1.fa')
>>> recs = SeqIO.parse(fh, 'fasta')
>>> type(recs)
<class 'Bio.SeqIO.FastaIO.FastaIterator'>

我可以使用在第四章中首次展示的next()函数来强制迭代器生成下一个值,其类型为SeqRecord

>>> rec = next(recs)
>>> type(rec)
<class 'Bio.SeqRecord.SeqRecord'>

除了序列本身,FASTA 记录还包含元数据,如序列 ID、名称等:

>>> rec
SeqRecord(seq=Seq('GATTACA'),
          id='Rosalind_1',
          name='Rosalind_1',
          description='Rosalind_1',
          dbxrefs=[])

读取信息包装在Seq对象中,该对象具有许多有趣和有用的方法,您可以在 REPL 中使用help(rec.seq)来探索。我只对原始序列感兴趣,因此可以使用str()函数将其强制转换为字符串:

>>> str(rec.seq)
'GATTACA'

我需要将所有序列都存入列表中,以便可以找到最短序列的长度。由于我将多次使用它们,我可以使用列表推导式将整个文件读入列表:

>>> fh = open('./tests/inputs/1.fa') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
>>> seqs = [str(rec.seq) for rec in SeqIO.parse(fh, 'fasta')] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
>>> seqs
['GATTACA', 'TAGACCA', 'ATACA']

1

重新打开文件句柄,否则现有文件句柄将从第二次读取开始。

2

创建一个列表,强制将每个记录的序列转换为字符串。

序列文件可能包含数百万个读取,并将它们存储在列表中可能会超出可用内存并导致机器崩溃。(问我为什么知道。)问题在于,我需要在下一步中找到所有这些序列中共同的子序列。我有几个Makefile目标,将使用10_lcsm目录中的genseq.py程序生成带有共同模体的大型 FASTA 输入,供您测试。该程序对 Rosalind 提供的数据集运行得很好。

可以使用map()函数表达相同的想法:

>>> fh = open('./tests/inputs/1.fa')
>>> seqs = list(map(lambda rec: str(rec.seq), SeqIO.parse(fh, 'fasta')))
>>> seqs
['GATTACA', 'TAGACCA', 'ATACA']

要找到最短序列的长度,我需要找到所有序列的长度,可以使用列表推导式来完成:

>>> [len(seq) for seq in seqs]
[7, 7, 5]

我更喜欢使用map()来写这个更短的方法:

>>> list(map(len, seqs))
[7, 7, 5]

Python 内置了min()max()函数,可以从列表中返回最小值或最大值:

>>> min(map(len, seqs))
5
>>> max(map(len, seqs))
7

因此,最短序列等于长度的最小值:

>>> shortest = min(map(len, seqs))
>>> shortest
5

从序列中提取 K-mer

最长的共享子序列不可能长于最短序列,并且必须被所有读取共享。 因此,我的下一步是找出所有序列中的所有k-mer,从最短序列的长度(5)开始。 在第九章中,我编写了一个find_kmers()函数和测试,所以我会把那段代码复制到这个程序中。 记得为此导入typing.List

def find_kmers(seq: str, k: int) -> List[str]:
    """ Find k-mers in string """

    n = len(seq) - k + 1
    return [] if n < 1 else [seq[i:i + k] for i in range(n)]

def test_find_kmers() -> None:
    """ Test find_kmers """

    assert find_kmers('', 1) == []
    assert find_kmers('ACTG', 1) == ['A', 'C', 'T', 'G']
    assert find_kmers('ACTG', 2) == ['AC', 'CT', 'TG']
    assert find_kmers('ACTG', 3) == ['ACT', 'CTG']
    assert find_kmers('ACTG', 4) == ['ACTG']
    assert find_kmers('ACTG', 5) == []

一个合乎逻辑的方法是从k的最大可能值开始,递减计数,直到找到所有序列共享的k-mer。 到目前为止,我只使用range()函数递增计数。 我可以反转起始和停止值来逆向计数吗? 显然不行。 如果起始值大于停止值,则range()将生成一个空列表:

>>> list(range(shortest, 0))
[]

当阅读第七章中的密码子时,我提到range()函数接受最多三个参数,最后一个是步长,我在那里用来每次跳三个碱基。 这里我需要使用步长-1来倒数。 记住停止值不包括在内:

>>> list(range(shortest, 0, -1))
[5, 4, 3, 2, 1]

另一种逆向计数的方法是从头开始计数并反转结果:

>>> list(reversed(range(1, shortest + 1)))
[5, 4, 3, 2, 1]

无论如何,我都想反复迭代k的减少值,直到找到所有序列共享的k-mer。 一个序列可能包含多个相同的k-mer,因此使用set()函数将结果唯一化非常重要:

>>> from lcsm import find_kmers
>>> from pprint import pprint
>>> for k in range(shortest, 0, -1):
...     print(f'==> {k} <==')
...     pprint([set(find_kmers(s, k)) for s in seqs])
...
==> 5 <==
[{'TTACA', 'GATTA', 'ATTAC'}, {'TAGAC', 'AGACC', 'GACCA'}, {'ATACA'}]
==> 4 <==
[{'ATTA', 'TTAC', 'TACA', 'GATT'},
 {'GACC', 'AGAC', 'TAGA', 'ACCA'},
 {'TACA', 'ATAC'}]
==> 3 <==
[{'ACA', 'TAC', 'GAT', 'ATT', 'TTA'},
 {'AGA', 'TAG', 'CCA', 'ACC', 'GAC'},
 {'ACA', 'ATA', 'TAC'}]
==> 2 <==
[{'AC', 'AT', 'CA', 'TA', 'TT', 'GA'},
 {'AC', 'CA', 'CC', 'TA', 'AG', 'GA'},
 {'AC', 'AT', 'CA', 'TA'}]
==> 1 <==
[{'G', 'C', 'T', 'A'}, {'G', 'C', 'T', 'A'}, {'C', 'T', 'A'}]

你能看到如何使用这个想法来计算每个k值的所有k-mer吗? 寻找频率与序列数匹配的k-mer。 如果找到多个,打印任意一个。

解决方案

该程序的两个变体使用相同的基本逻辑来查找最长的共享子序列。 第一个版本在输入规模增加时表现不佳,因为它使用逐步线性方法迭代每个可能的k长度的序列。 第二个版本引入了二分搜索来找到一个好的k的起始值,然后启动一个爬坡搜索来发现k的最大值。

解决方案 1:计算 K-mer 的频率

在前一节中,我已经找到了所有序列中值为kk-mer,从最短序列开始,然后降到1。 这里我将从k等于5开始,这是第一个 FASTA 文件中最短序列的长度:

>>> fh = open('./tests/inputs/1.fa')
>>> seqs = [str(rec.seq) for rec in SeqIO.parse(fh, 'fasta')]
>>> shortest = min(map(len, seqs))
>>> kmers = [set(find_kmers(seq, shortest)) for seq in seqs]
>>> kmers
[{'TTACA', 'GATTA', 'ATTAC'}, {'TAGAC', 'AGACC', 'GACCA'}, {'ATACA'}]

我需要一种方法来计算每个k-mer在所有序列中出现的次数。 一种方法是使用collections.Counter(),我在第一章中首次展示了它:

>>> from collections import Counter
>>> counts = Counter()

我可以遍历每个序列的k-mer集合,并使用Counter.update()方法将它们添加起来:

>>> for group in kmers:
...     counts.update(group)
...
>>> pprint(counts)
Counter({'TTACA': 1,
         'GATTA': 1,
         'ATTAC': 1,
         'TAGAC': 1,
         'AGACC': 1,
         'GACCA': 1,
         'ATACA': 1})

或者我可以使用itertools.chain()将许多k-mer列表连接成单个列表:

>>> from itertools import chain
>>> list(chain.from_iterable(kmers))
['TTACA', 'GATTA', 'ATTAC', 'TAGAC', 'AGACC', 'GACCA', 'ATACA']

将此作为Counter()的输入会产生相同的集合,显示每个 5-mer 都是唯一的,每次出现一次:

>>> counts = Counter(chain.from_iterable(kmers))
>>> pprint(counts)
Counter({'TTACA': 1,
         'GATTA': 1,
         'ATTAC': 1,
         'TAGAC': 1,
         'AGACC': 1,
         'GACCA': 1,
         'ATACA': 1})

Counter()在底层是一个常规字典,这意味着我可以访问所有字典方法。我想通过dict.items()方法迭代键和值作为一对,使用计数的 k-mers 等于序列数量:

>>> n = len(seqs)
>>> candidates = []
>>> for kmer, count in counts.items():
...     if count == n:
...         candidates.append(kmer)
...
>>> candidates
[]

k5时,没有候选序列,因此我需要尝试较小的值。由于我知道正确的答案是2,所以我将使用k=2重新运行此代码以生成此字典:

>>> k = 2
>>> kmers = [set(find_kmers(seq, k)) for seq in seqs]
>>> counts = Counter(chain.from_iterable(kmers))
>>> pprint(counts)
Counter({'CA': 3,
         'AC': 3,
         'TA': 3,
         'GA': 2,
         'AT': 2,
         'TT': 1,
         'AG': 1,
         'CC': 1})

从这里,我找到了三个候选的 2-mer,它们的频率为 3,这等于序列的数量:

>>> candidates = []
>>> for kmer, count in counts.items():
...     if count == n:
...         candidates.append(kmer)
...
>>> candidates
['CA', 'AC', 'TA']

不管我选择哪个候选者,我都会使用random.choice()函数,该函数从一个选择列表中返回一个值:

>>> import random
>>> random.choice(candidates)
'AC'

我喜欢这个方向,所以我想把它放到一个函数中,这样我就可以测试它:

def common_kmers(seqs: List[str], k: int) -> List[str]:
    """ Find k-mers common to all sequences """

    kmers = [set(find_kmers(seq, k)) for seq in seqs]
    counts = Counter(chain.from_iterable(kmers))
    n = len(seqs) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    return [kmer for kmer, freq in counts.items() if freq == n] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

找到序列的数量。

2

返回频率等于序列数量的 k-mers。

这使得main()非常易读:

import random
import sys

def main() -> None:
    args = get_args()
    seqs = [str(rec.seq) for rec in SeqIO.parse(args.file, 'fasta')] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    shortest = min(map(len, seqs)) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    for k in range(shortest, 0, -1): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        if kmers := common_kmers(seqs, k): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            print(random.choice(kmers)) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
            sys.exit(0) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

    print('No common subsequence.') ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

1

将所有序列读入列表。

2

找到最短序列的长度。

3

从最短序列倒数。

4

使用这个k值找到所有共同的 k-mers。

5

如果找到任何 k-mers,则打印一个随机选择。

6

使用退出值为0(无错误)退出程序。

7

如果我到达这一点,请通知用户没有共享的序列。

在上述代码中,我再次使用了我在第五章介绍的海象运算符(:=)来首先将调用common_kmers()的结果赋给变量kmers,然后评估kmers的真实性。如果kmers是真实的,即意味着找到了这个k值的共同 k-mers,Python 将只在下一个块中进入。在引入这种语言特性之前,我必须写两行赋值和评估,如下所示:

kmers = common_kmers(seqs, k)
if kmers:
    print(random.choice(kmers))

解决方案 2:使用二分搜索加快速度

正如本章开头所述,随着输入大小的增加,该解决方案的增长速度会变得更慢。跟踪程序进度的一种方法是在for循环的开头放置一个print(k)语句。用第二个输入文件运行此命令,你会看到它从 1,000 开始倒数,直到 k 达到 78 才达到正确的值。

逆向计数 1 太慢了。如果你的朋友让你猜一个介于 1 和 1,000 之间的数字,你不会从 1,000 开始,每次朋友说“太高”时就猜少 1。选择 500 会更快(也更有利于你们的友谊)。如果你的朋友选择了 453,他们会说“太高”,所以你聪明地选择了 250。他们会回答“太低”,然后你继续在你最后一次高低猜测的中间值之间分割差值,直到找到正确答案。这就是二分查找,它是快速从排序值列表中查找所需值位置的绝佳方法。

为了更好地理解这一点,我在 10_lcsm 目录中包含了一个名为binsearch.py的程序:

$ ./binsearch.py -h
usage: binsearch.py [-h] -n int -m int

Binary Search

optional arguments:
  -h, --help         show this help message and exit
  -n int, --num int  The number to guess (default: None)
  -m int, --max int  The maximum range (default: None)

下面是程序的相关部分。如果你愿意,可以阅读参数定义的源代码。binary_search()函数是递归的,就像第四章中对斐波那契数列问题的一种解决方案一样。请注意,为了使二分查找起作用,搜索值必须是排序的,range()函数提供了这个功能:

def main() -> None:
    args = get_args()
    nums = list(range(args.maximum + 1))
    pos = binary_search(args.num, nums, 0, args.maximum)
    print(f'Found {args.num}!' if pos > 0 else f'{args.num} not present.')

def binary_search(x: int, xs: List[int], low: int, high: int) -> int:
    print(f'{low:4} {high:4}', file=sys.stderr)

    if high >= low: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        mid = (high + low) // 2 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

        if xs[mid] == x: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            return mid

        if xs[mid] > x: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            return binary_search(x, xs, low, mid - 1) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

        return binary_search(x, xs, mid + 1, high) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

    return -1 ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

1

退出递归的基本情况是当此条件为假时。

2

中点是highlow之间的中间值,使用地板除法。

3

如果元素在中间,则返回中点。

4

看看中点的值是否大于所需值。

5

搜索较低的值。

6

搜索较高的值。

7

未找到该值。

binary_search()函数中的xxs名称意为单数和复数。在我的脑海中,我将它们发音为exexes。这种符号在纯函数式编程中很常见,因为我不试图描述x是什么类型的值。它可以是字符串、数字或其他任何类型的值。重要的是xs是某种类型的全部可比较值的集合。

我加入了一些print()语句,这样,使用前面的数字运行,您可以看到lowhigh在 10 步中最终收敛到目标数字:

$ ./binsearch.py -n 453 -m 1000
   0 1000
   0  499
 250  499
 375  499
 438  499
 438  467
 453  467
 453  459
 453  455
 453  453
Found 453!

只需八次迭代即可确定数字不存在:

$ ./binsearch.py -n 453 -m 100
   0  100
  51  100
  76  100
  89  100
  95  100
  98  100
 100  100
 101  100
453 not present.

二分搜索可以告诉我值是否存在于值列表中,但这并不是我的问题。虽然我相当确定大多数数据集中至少会有一个 2-mer 或 1-mer 的共同点,但我包含了一个没有这种共同点的文件:

$ cat tests/inputs/none.fa
>Rosalind_1
GGGGGGG
>Rosalind_2
AAAAAAAA
>Rosalind_3
CCCC
>Rosalind_4
TTTTTTTT

如果有一个可接受的k值,那么我需要找到最大值。我决定使用二分搜索来找到一个起始点,以进行寻找最大值的爬坡搜索。首先我会展示main(),然后我会分解其他函数:

def main() -> None:
    args = get_args()
    seqs = [str(rec.seq) for rec in SeqIO.parse(args.file, 'fasta')] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    shortest = min(map(len, seqs)) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    common = partial(common_kmers, seqs) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    start = binary_search(common, 1, shortest) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    if start >= 0: ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        candidates = [] ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
        for k in range(start, shortest + 1): ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
            if kmers := common(k): ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
                candidates.append(random.choice(kmers)) ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
            else:
                break ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)

        print(max(candidates, key=len)) ![11](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/11.png)
    else:
        print('No common subsequence.') ![12](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/12.png)

1

将序列列表作为字符串获取。

2

找到最短序列的长度。

3

部分应用common_kmers()函数与seqs输入。

4

使用二分搜索找到给定函数的起始点,使用1作为k的最小值,使用最短序列长度为最大值。

5

检查二分搜索是否找到了有用的东西。

6

初始化候选值列表。

7

以二分搜索结果开始爬坡搜索。

8

检查是否存在共同的 k-mer。

9

如果是这样,随机向候选列表添加一个。

10

如果没有共同的 k-mer,则退出循环。

11

选择具有最长长度的候选序列。

12

告诉用户没有答案。

虽然在上述代码中有许多要解释的事情,但我想强调对max()的调用。我之前展示了该函数将从列表中返回最大值。通常您可能会考虑在数字列表上使用它:

>>> max([4, 2, 8, 1])
8

在上述代码中,我想在列表中找到最长的字符串。我可以使用map()函数映射len()函数来找到它们的长度:

>>> seqs = ['A', 'CC', 'GGGG', 'TTT']
>>> list(map(len, seqs))
[1, 2, 4, 3]

这表明第三个序列GGGG是最长的。max()函数接受一个可选的key参数,该参数是在比较之前应用于每个元素的函数。如果我使用len()函数,那么max()可以正确地识别出最长的序列:

>>> max(seqs, key=len)
'GGGG'

让我们看看我如何修改binary_search()函数以适应我的需求:

def binary_search(f: Callable, low: int, high: int) -> int: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Binary search """

    hi, lo = f(high), f(low) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    mid = (high + low) // 2 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    if hi and lo: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        return high

    if lo and not hi: ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        return binary_search(f, low, mid)

    if hi and not lo: ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
        return binary_search(f, mid, high)

    return -1 ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

1

该函数接受另一个函数f()以及lowhigh值作为参数。在这个例子中,函数f()将返回共同的 k-mer,但该函数可以执行任何您想要的计算。

2

使用最高和最低的k值调用函数f()

3

找到k的中点值。

4

如果函数f()对高k和低k值都找到了共同的 k-mer,则返回最高的 k。

5

如果高k找不到 k-mer,但低值找到了,则递归调用函数,在更低的k值中搜索。

6

如果低k找不到 k-mer,但高值找到了,则递归调用函数,在更高的k值中搜索。

7

返回-1以指示使用highlow参数调用f()时未找到 k-mer。

这是我为此编写的测试:

def test_binary_search() -> None:
    """ Test binary_search """

    seqs1 = ['GATTACA', 'TAGACCA', 'ATACA'] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    f1 = partial(common_kmers, seqs1) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert binary_search(f1, 1, 5) == 2 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    seqs2 = ['GATTACTA', 'TAGACTCA', 'ATACTA'] ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    f2 = partial(common_kmers, seqs2)
    assert binary_search(f2, 1, 6) == 3 ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

这些是我一直在使用的具有三个共享 2-mer 的序列。

2

定义一个函数来找到第一组序列中的 k-mers。

3

搜索找到k2是正确答案。

4

与以前相同的序列,但现在共享 3-mer。

5

搜索找到k3

与前面的二分搜索不同,我的版本不一定会返回精确答案,只是一个不错的起点。如果没有任何大小为k的共享序列,则我会告知用户:

$ ./solution2_binary_search.py tests/inputs/none.fa
No common subsequence.

如果有共享子序列,则此版本运行速度显著提高,可能快 28 倍:

$ hyperfine -L prg ./solution1_kmers_functional.py,./solution2_binary_search.py\
  '{prg} tests/inputs/2.fa'
Benchmark #1: ./solution1_kmers_functional.py tests/inputs/2.fa
  Time (mean ± σ):     40.686 s ±  0.443 s    [User: 35.208 s, System: 6.042 s]
  Range (min … max):   40.165 s … 41.349 s    10 runs

Benchmark #2: ./solution2_binary_search.py tests/inputs/2.fa
  Time (mean ± σ):      1.441 s ±  0.037 s    [User: 1.903 s, System: 0.255 s]
  Range (min … max):    1.378 s …  1.492 s    10 runs

Summary
  './solution2_binary_search.py tests/inputs/2.fa' ran
   28.24 ± 0.79 times faster than './solution1_kmers_functional.py
   tests/inputs/2.fa'

当我从最大的k值开始搜索并向下迭代时,我正在执行对所有可能值的线性搜索。这意味着搜索时间与值的数量n成比例增长(线性增长)。相比之下,二分搜索的增长率是对数log n。通常用大 O表示法来讨论算法的运行时增长,因此您可能会看到二分搜索描述为 O(log n),而线性搜索是 O(n),这要糟糕得多。

更进一步

与第九章中的建议一样,添加一个汉明距离选项,可以在决定共享 k-mer 时允许指定数量的差异。

复习

本章的关键点:

  • K-mer 可以用来找到序列的保守区域。

  • 列表的列表可以使用itertools.chain()组合成单个列表。

  • 可以在排序值上使用二分搜索来比线性搜索更快地找到值。

  • 爬山法是最大化函数输入的一种方式。

  • min()max()key选项是在比较之前应用于值的函数。

第十一章:查找蛋白质基序:获取数据和使用正则表达式

现在我们花了相当多的时间寻找序列基序。如罗莎琳德 MPRT 挑战所述,蛋白质中的共享或保守序列意味着共享功能。在这个练习中,我需要识别包含 N-糖基化基序的蛋白质序列。程序的输入是一个蛋白质 ID 列表,将用于从UniProt 网站下载序列。在演示了如何手动和编程方式下载数据之后,我将展示如何使用正则表达式和编写手动解决方案来查找这一基序。

你将学到:

  • 如何从互联网上获取数据

  • 如何编写正则表达式来查找 N-糖基化基序

  • 如何手动查找 N-糖基化基序

入门

所有这个程序的代码和测试都位于11_mprt目录中。要开始,请将第一个解决方案复制到程序mprt.py中:

$ cd 11_mprt
$ cp solution1_regex.py mprt.py

检查用法:

$ ./mprt.py -h
usage: mprt.py [-h] [-d DIR] FILE

Find locations of N-glycosylation motif

positional arguments:
  FILE                  Input text file of UniProt IDs ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

optional arguments:
  -h, --help            show this help message and exit
  -d DIR, --download_dir DIR ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                        Directory for downloads (default: fasta)

1

所需的位置参数是一个蛋白质 ID 文件。

2

可选的下载目录名默认为fasta

输入文件将列出蛋白质 ID,每行一个。在罗莎琳德示例中提供的蛋白质 ID 组成了第一个测试输入文件:

$ cat tests/inputs/1.txt
A2Z669
B5ZC00
P07204_TRBM_HUMAN
P20840_SAG1_YEAST

使用此参数运行程序。程序的输出列出包含 N-糖基化基序及其位置的每个蛋白质 ID:

$ ./mprt.py tests/inputs/1.txt
B5ZC00
85 118 142 306 395
P07204_TRBM_HUMAN
47 115 116 382 409
P20840_SAG1_YEAST
79 109 135 248 306 348 364 402 485 501 614

运行上述命令后,你应该看到默认的fasta目录已创建。在其中你应该找到四个 FASTA 文件。除非你删除下载目录(例如运行make clean),否则使用这些蛋白质 ID 的所有后续运行都会更快,因为将使用缓存数据。

使用命令head -2查看每个文件的前两行。某些 FASTA 记录的标题相当长,所以我在这里分行显示它们以防止换行,但实际的标题必须在一行内:

$ head -2 fasta/*
==> fasta/A2Z669.fasta <==
>sp|A2Z669|CSPLT_ORYSI CASP-like protein 5A2 OS=Oryza sativa subsp.
 indica OX=39946 GN=OsI_33147 PE=3 SV=1
MRASRPVVHPVEAPPPAALAVAAAAVAVEAGVGAGGGAAAHGGENAQPRGVRMKDPPGAP

==> fasta/B5ZC00.fasta <==
>sp|B5ZC00|SYG_UREU1 Glycine--tRNA ligase OS=Ureaplasma urealyticum
 serovar 10 (strain ATCC 33699 / Western) OX=565575 GN=glyQS PE=3 SV=1
MKNKFKTQEELVNHLKTVGFVFANSEIYNGLANAWDYGPLGVLLKNNLKNLWWKEFVTKQ

==> fasta/P07204_TRBM_HUMAN.fasta <==
>sp|P07204|TRBM_HUMAN Thrombomodulin OS=Homo sapiens OX=9606 GN=THBD PE=1 SV=2
MLGVLVLGALALAGLGFPAPAEPQPGGSQCVEHDCFALYPGPATFLNASQICDGLRGHLM

==> fasta/P20840_SAG1_YEAST.fasta <==
>sp|P20840|SAG1_YEAST Alpha-agglutinin OS=Saccharomyces cerevisiae
 (strain ATCC 204508 / S288c) OX=559292 GN=SAG1 PE=1 SV=2
MFTFLKIILWLFSLALASAININDITFSNLEITPLTANKQPDQGWTATFDFSIADASSIR

运行make test查看程序应通过的测试类型。当你准备好时,从头开始运行程序:

$ new.py -fp 'Find locations of N-glycosylation motif' mprt.py
Done, see new script "mprt.py".

你应该定义一个位置文件参数和一个可选的下载目录作为程序的参数:

class Args(NamedTuple):
    """ Command-line arguments """
    file: TextIO ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    download_dir: str ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

def get_args() -> Args:
    """Get command-line arguments"""

    parser = argparse.ArgumentParser(
        description='Find location of N-glycosylation motif',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('file',
                        help='Input text file of UniProt IDs',
                        metavar='FILE',
                        type=argparse.FileType('rt')) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    parser.add_argument('-d',
                        '--download_dir',
                        help='Directory for downloads',
                        metavar='DIR',
                        type=str,
                        default='fasta') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    args = parser.parse_args()

    return Args(args.file, args.download_dir)

1

file将是一个文件句柄。

2

download_dir将是一个字符串。

3

确保file参数是一个可读的文本文件。

4

download_dir是一个可选的字符串,有一个合理的默认值。

确保你的程序可以创建用法,然后开始打印文件中的蛋白质 ID。每个 ID 以换行符结尾,所以我将使用str.rstrip()右侧去除)方法从右侧去除任何空白:

def main() -> None:
    args = get_args()
    for prot_id in map(str.rstrip, args.file):
        print(prot_id)

运行程序,确保你看到蛋白质 ID:

$ ./mprt.py tests/inputs/1.txt
A2Z669
B5ZC00
P07204_TRBM_HUMAN
P20840_SAG1_YEAST

如果你运行pytest,你应该通过前三个测试并失败第四个。

下载命令行上的序列文件

下一个工作是获取蛋白质序列。每个蛋白质的 UniProt 信息可以通过将蛋白质 ID 替换到 URL http://www.uniprot.org/uniprot/{uniprot_id} 中找到。我将修改程序以打印这个字符串:

def main() -> None:
    args = get_args()
    for prot_id in map(str.rstrip, args.file):
        print(f'http://www.uniprot.org/uniprot/{prot_id}')

现在你应该看到这个输出:

$ ./mprt.py tests/inputs/1.txt
http://www.uniprot.org/uniprot/A2Z669
http://www.uniprot.org/uniprot/B5ZC00
http://www.uniprot.org/uniprot/P07204_TRBM_HUMAN
http://www.uniprot.org/uniprot/P20840_SAG1_YEAST

将第一个 URL 粘贴到你的 Web 浏览器中并检查页面。这里有大量的数据,全部以人类可读的格式展示。向下滚动到序列,你应该看到 203 个氨基酸。如果必须解析此页面以提取序列,那将是件糟糕的事情。幸运的是,我可以在 URL 末尾添加.fasta并获得序列的 FASTA 文件。

在我向你展示如何使用 Python 下载序列之前,我认为你应该知道如何使用命令行工具来做这件事。从命令行可以使用curl(你可能需要安装)来下载序列。默认情况下,这会将文件内容打印到STDOUT

$ curl https://www.uniprot.org/uniprot/A2Z669.fasta
>sp|A2Z669|CSPLT_ORYSI CASP-like protein 5A2 OS=Oryza sativa subsp.
 indica OX=39946 GN=OsI_33147 PE=3 SV=1
MRASRPVVHPVEAPPPAALAVAAAAVAVEAGVGAGGGAAAHGGENAQPRGVRMKDPPGAP
GTPGGLGLRLVQAFFAAAALAVMASTDDFPSVSAFCYLVAAAILQCLWSLSLAVVDIYAL
LVKRSLRNPQAVCIFTIGDGITGTLTLGAACASAGITVLIGNDLNICANNHCASFETATA
MAFISWFALAPSCVLNFWSMASR

你可以将其重定向到文件:

$ curl https://www.uniprot.org/uniprot/A2Z669.fasta > A2Z669.fasta

或者使用-o|--output选项来命名输出文件:

$ curl -o A2Z669.fasta https://www.uniprot.org/uniprot/A2Z669.fasta

你也可以使用wget(网络获取,可能需要安装)来下载序列文件,就像这样:

$ wget https://www.uniprot.org/uniprot/A2Z669.fasta

无论你使用哪种工具,现在你应该有一个名为A2Z669.fasta的包含序列数据的文件:

$ cat A2Z669.fasta
>sp|A2Z669|CSPLT_ORYSI CASP-like protein 5A2 OS=Oryza sativa subsp.
 indica OX=39946 GN=OsI_33147 PE=3 SV=1
MRASRPVVHPVEAPPPAALAVAAAAVAVEAGVGAGGGAAAHGGENAQPRGVRMKDPPGAP
GTPGGLGLRLVQAFFAAAALAVMASTDDFPSVSAFCYLVAAAILQCLWSLSLAVVDIYAL
LVKRSLRNPQAVCIFTIGDGITGTLTLGAACASAGITVLIGNDLNICANNHCASFETATA
MAFISWFALAPSCVLNFWSMASR

我知道这是一本关于 Python 的书,但学习如何编写基本的bash程序也是值得的。就像有些故事可以用俳句来讲述,而另一些则是庞大的小说,有些任务可以用几个 shell 命令轻松表达,而其他则需要更复杂语言的数千行代码。有时我可以用 10 行bash来做我需要的事情。当我达到大约 30 行bash时,我通常会转向 Python 或 Rust。

这是我如何使用bash脚本自动下载蛋白质的方式:

#!/usr/bin/env bash ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

if [[ $# -ne 1 ]]; then ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    printf "usage: %s FILE\n" $(basename "$0") ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    exit 1 ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
fi

OUT_DIR="fasta" ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
[[ ! -d "$OUT_DIR" ]] && mkdir -p "$OUT_DIR" ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

while read -r PROT_ID; do ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
    echo "$PROT_ID"   ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
    URL="https://www.uniprot.org/uniprot/${PROT_ID}" ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
    OUT_FILE="$OUT_DIR/${PROT_ID}.fasta" ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)
    wget -q -o "$OUT_FILE" "$URL" ![11](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/11.png)
done < $1 ![12](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/12.png)

echo "Done, see output in \"$OUT_DIR\"." ![13](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/13.png)

1

shebang(#!)应该使用env(环境)来找到bash

2

检查参数的数量($#)是否为1

3

使用程序的基本名称($0)打印使用语句。

4

用非零值退出。

5

定义输出目录为fasta。请注意,在bash中,您可以在变量分配周围没有空格。

6

如果输出目录不存在,则创建它。

7

将文件中的每一行读入PROT_ID变量中。

8

打印当前蛋白质 ID,以便用户知道正在发生某事。

9

使用双引号内的变量插值构建 URL。

10

通过结合输出目录和蛋白质 ID 构建输出文件名。

11

使用-q(静默)标志调用wget,将 URL 获取到输出文件中。

12

这从第一个位置参数($1)读取每一行,即输入文件名。

13

让用户知道程序已经完成,并告知输出文件的位置。

我可以像这样运行它:

$ ./fetch_fasta.sh tests/inputs/1.txt
A2Z669
B5ZC00
P07204_TRBM_HUMAN
P20840_SAG1_YEAST
Done, see output in "fasta".

现在应该有一个fasta目录,其中包含四个 FASTA 文件。编写mprt.py程序的一种方法是首先使用类似于这样的东西获取所有输入文件,然后将 FASTA 文件作为参数提供。这在生物信息学中是非常常见的模式,编写这样的 shell 脚本是记录确切地如何检索数据以进行分析的绝佳方式。确保像这样的程序始终提交到您的源代码库,并考虑添加一个名称为fastaMakefile目标,其左侧紧随着一个冒号,后面是一个单个制表符缩进的命令:

fasta:
	./fetch_fasta.sh tests/inputs/1.txt

现在你应该能够运行make fasta来自动化获取数据的过程。通过编写程序以接受输入文件作为参数而不是硬编码它,我可以使用此程序和多个Makefile目标来自动化下载许多不同的数据集的过程。复现性为胜利。

使用 Python 下载序列文件

我现在将把bash实用程序翻译成 Python。正如你从前面的程序中看到的那样,获取每个序列文件涉及到几个步骤。我不希望这成为main()的一部分,因为它会使程序变得凌乱,所以我会为此编写一个函数:

def fetch_fasta(fh: TextIO, fasta_dir: str) -> List[str]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Fetch the FASTA files into the download directory """

    return [] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

该函数将接受蛋白质 ID 的文件句柄和下载目录名称,并返回已下载或已存在的文件列表。务必将 typing.List 添加到您的导入中。

2

暂时返回一个空列表。

我想这样调用它:

def main() -> None:
    args = get_args()
    files = fetch_fasta(args.file, args.download_dir)
    print('\n'.join(files))

运行您的程序,并确保它编译并且不打印任何内容。现在添加以下 Python 代码来获取序列。您需要导入 ossysrequests ,这是用于进行网络请求的库:

def fetch_fasta(fh: TextIO, fasta_dir: str) -> List[str]:
    """ Fetch the FASTA files into the download directory """

    if not os.path.isdir(fasta_dir): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        os.makedirs(fasta_dir) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    files = [] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    for prot_id in map(str.rstrip, fh): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        fasta = os.path.join(fasta_dir, prot_id + '.fasta') ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        if not os.path.isfile(fasta): ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
            url = f'http://www.uniprot.org/uniprot/{prot_id}.fasta' ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
            response = requests.get(url) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
            if response.status_code == 200: ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
                print(response.text, file=open(fasta, 'wt')) ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)
            else:
                print(f'Error fetching "{url}": "{response.status_code}"',
                      file=sys.stderr) ![11](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/11.png)
                continue ![12](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/12.png)

        files.append(fasta) ![13](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/13.png)

    return files ![14](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/14.png)

1

如果输出目录不存在,则创建它。

2

如果不存在,则创建目录及其所需的父目录。

3

初始化返回文件名列表。

4

从文件中读取每个蛋白质 ID。

5

构造输出文件名,将输出目录与蛋白质 ID 结合起来。

6

检查文件是否已存在。

7

构造到 FASTA 文件的 URL。

8

发送 GET 请求获取文件。

9

200 的响应代码表示成功。

10

将响应的文本写入输出文件。

11

将一条警告打印到 STDERR ,指出文件无法获取。

12

跳转到下一次迭代。

13

将文件追加到返回列表中。

14

返回现在本地存在的文件。

os.makedirs()是一个示例函数,如果创建失败会抛出异常。这可能是由于用户权限不足以创建目录,或者由于磁盘错误。我捕获和处理这种错误的目的是什么?如果我的程序无法解决问题,我觉得让它大声崩溃,输出错误代码和堆栈跟踪,比捕获和处理异常要好得多。人们必须在程序能够工作之前解决潜在的问题。捕获和错误处理异常比让程序崩溃更糟糕。

这种逻辑几乎与bash程序完全相似。如果重新运行程序,应该会有一个fasta目录,其中包含四个文件,并且程序应该会打印出下载文件的名称:

$ ./mprt.py tests/inputs/1.txt
fasta/A2Z669.fasta
fasta/B5ZC00.fasta
fasta/P07204_TRBM_HUMAN.fasta
fasta/P20840_SAG1_YEAST.fasta

编写正则表达式以查找基序

Rosalind 页面指出:

为了允许其变化形式的存在,蛋白质基序以以下缩写表示:[XY]表示要么是 X 或 Y{X}表示除了 X 以外的任何氨基酸。例如,N-糖基化基序写作N{P}[ST]{P}

Prosite 网站是一个蛋白质域、家族和功能位点的数据库。N-糖基化基序的详细信息显示了类似的共识模式 N-{P}-[ST]-{P}。这两种模式与图 11-1 中显示的正则表达式非常接近。

mpfb 1101

图 11-1. N-糖基化蛋白质基序的正则表达式

在这个正则表达式中,N表示字面字符N[ST]是一个字符类,表示字符ST。它与我在第五章中写的[GC]正则表达式找到GC的方式相同。[^P]是一个否定字符类,表示匹配任何不是P的字符。

有些人(好吧,主要是我)喜欢使用有限状态机(FSMs)的符号来表示正则表达式,例如图 11-2 中显示的这种表示方式。想象模式从左侧输入。它首先需要找到字母N,才能进行下一步。接下来可以是任何不是字母P的字符。然后,图表有两条经过字母ST的替代路径,之后必须再次跟随一个非P字符。如果模式到达双圆圈,表示匹配成功。

mpfb 1102

图 11-2. 识别 N-糖基化基序的 FSM 的图形表示

在第八章中,我指出使用正则表达式查找重叠文本时存在问题。第一个测试文件中没有这种情况,但我用来解决问题的另一个数据集确实有两个重叠的基序。让我在 REPL 中演示:

>>> import re
>>> regex = re.compile('N[^P][ST][^P]')

在这里我使用re.compile()函数来强制正则表达式引擎解析模式并创建必要的内部代码来进行匹配。这类似于编译语言如 C 使用人类可编辑和读取的源代码转换为计算机可以直接执行的机器代码。这种转换在使用re.compile()时只发生一次,而像re.search()这样的函数必须在每次调用时重新编译正则表达式。

下面是P07204_TRBM_HUMAN蛋白序列的相关部分,该部分具有在第一和第二位置开始的模式(见图 11-3)。re.findall()函数显示仅找到第一个位置开始的模式:

>>> seq = 'NNTSYS'
>>> regex.findall(seq)
['NNTS']

mpfb 1103

图 11-3. 此序列包含两个重叠的模体副本

如同第 8 章中一样,解决方案是将正则表达式包装在一个使用?=(<*pattern*>的前瞻断言中,这本身需要包装在捕获括号中:

>>> regex = re.compile('(?=(N[^P][ST][^P]))')
>>> regex.findall(seq)
['NNTS', 'NTSY']

我需要知道匹配的位置,这可以从re.finditer()获取。这将返回一个re.Match对象列表,每个对象都有一个match.start()函数,该函数将返回匹配起始位置的零偏移索引。我需要加 1 以使用基于 1 的计数报告位置:

>>> [match.start() + 1 for match in regex.finditer(seq)]
[1, 2]

这应该足以帮助您解决剩下的问题。继续进行修改,直到通过所有测试为止。确保从 Rosalind 站点下载数据集,并验证您的解决方案是否能够通过该测试。看看是否还可以编写一个不使用正则表达式的版本。回顾一下 FSM 模型,并思考如何在 Python 代码中实现这些想法。

解决方案

我将提出两种解决此问题的变体。两者都使用先前显示的相同的get_args()fetch_fasta()函数。第一种使用正则表达式找到模体,第二种则设想在一个可怕的、荒凉的智力荒原中解决问题,在那里正则表达式不存在。

解决方案 1:使用正则表达式

以下是我使用正则表达式的最终解决方案。确保为此导入reBio.SeqIO

def main():
    args = get_args()
    files = fetch_fasta(args.file, args.download_dir) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    regex = re.compile('(?=(N[^P][ST][^P]))') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    for file in files: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        prot_id, _ = os.path.splitext(os.path.basename(file)) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        recs = SeqIO.parse(file, 'fasta') ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        if rec := next(recs): ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
            if matches := list(regex.finditer(str(rec.seq))): ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
                print(prot_id) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
                print(*[match.start() + 1 for match in matches]) ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)

1

为给定文件中的蛋白质 ID 获取序列文件。将文件放入指定的下载目录中。

2

编译 N-糖基化模体的正则表达式。

3

遍历文件。

4

从文件的基本名称减去文件扩展名获取蛋白质 ID。

5

创建一个懒惰迭代器以从文件中获取 FASTA 序列。

6

尝试从迭代器中检索第一个序列记录。

7

强制将序列转换为 str,然后尝试查找文本中基序的所有匹配项。

8

打印蛋白质 ID。

9

打印所有匹配项,修正为基于 1 的计数。

在这个解决方案中,我使用了 os.path.basename()os.path.splitext() 函数。我经常使用这些函数,所以我希望确保你准确理解它们的作用。我首次在 第二章 中介绍了 os.path.basename() 函数。此函数将从可能包含目录的路径中返回文件名:

>>> import os
>>> basename = os.path.basename('fasta/B5ZC00.fasta')
>>> basename
'B5ZC00.fasta'

函数 os.path.splitext() 将文件名分解为文件扩展名前部分和扩展名部分:

>>> os.path.splitext(basename)
('B5ZC00', '.fasta')

文件扩展名可以提供有关文件的有用元数据。例如,您的操作系统可能知道要使用 Microsoft Excel 打开以 .xls.xlsx 结尾的文件。有许多用于 FASTA 扩展名的约定,包括 .fasta.fa.fna(用于核苷酸)和 .faa(用于氨基酸)。您可以为 FASTA 文件添加任何扩展名或不添加扩展名,但请记住,FASTA 文件始终是纯文本,无需特殊应用程序即可查看。此外,仅因为文件具有类似 FASTA 的扩展名并不一定意味着它是 FASTA 文件。买方自负

在前面的代码中,我不需要扩展名,所以我将其赋值给变量 _(下划线),这是一种表明我不打算使用该值的约定。我还可以使用列表切片从函数中获取第一个元素:

>>> os.path.splitext(basename)[0]
'B5ZC00'

解决方案 2:编写手动解决方案

如果我为了生产使用而编写这样的程序,我将使用正则表达式来查找基序。然而,在这种情况下,我想挑战自己找到一个手动解决方案。像往常一样,我想写一个函数来封装这个想法,所以我先草拟出来:

def find_motif(text: str) -> List[int]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Find a pattern in some text """

    return [] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

函数将获取一些文本,并返回在文本中可以找到基序的整数列表。

2

现在,返回空列表。

有函数的最大原因是写一个测试,我会编码我期望匹配和失败的示例:

def test_find_motif() -> None:
    """ Test find_pattern """

    assert find_motif('') == [] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert find_motif('NPTX') == [] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert find_motif('NXTP') == [] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert find_motif('NXSX') == [0] ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    assert find_motif('ANXTX') == [1] ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    assert find_motif('NNTSYS') == [0, 1] ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
    assert find_motif('XNNTSYS') == [1, 2] ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
    assert find_motif('XNNTSYSXNNTSYS') == [1, 2, 8, 9] ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

1

确保函数在给定空字符串时不会像抛出异常这样愚蠢地做某事。

2

这应该失败,因为第二个位置有一个 P

3

这应该失败,因为第四个位置有一个 P

4

这应该在字符串开头找到基序。

5

这应该找到不在字符串开头的基序。

6

这应该在字符串开头找到重叠的基序。

7

这应该找到不在字符串开头的重叠基序。

8

这是一个稍微复杂的模式,包含四个基序的副本。

我可以将这些函数添加到我的 mprt.py 程序中,并可以在该源代码上运行 pytest 来确保测试如预期般失败。现在我需要编写 find_motif() 代码来通过这些测试。我决定再次使用 k-mer,所以我将从第九章和第十章带入 find_kmers() 函数(当然,我会测试它,但我会在这里省略):

def find_kmers(seq: str, k: int) -> List[str]:
    """ Find k-mers in string """

    n = len(seq) - k + 1
    return [] if n < 1 else [seq[i:i + k] for i in range(n)]

由于基序长度为四个字符,我可以用它来找到序列中所有的 4-mer:

>>> from solution2_manual import find_kmers
>>> seq = 'NNTSYS'
>>> find_kmers(seq, 4)
['NNTS', 'NTSY', 'TSYS']

我还需要它们的位置。我在第八章介绍的 enumerate() 函数将为序列中的项提供索引和值:

>>> list(enumerate(find_kmers(seq, 4)))
[(0, 'NNTS'), (1, 'NTSY'), (2, 'TSYS')]

我可以在迭代时展开每个位置和 k-mer,像这样:

>>> for i, kmer in enumerate(find_kmers(seq, 4)):
...     print(i, kmer)
...
0 NNTS
1 NTSY
2 TSYS

取第一个 k-mer,NNTS。一种测试这种模式的方法是手动检查每个索引:

>>> kmer = 'NNTS'
>>> kmer[0] == 'N' and kmer[1] != 'P' and kmer[2] in 'ST' and kmer[3] != 'P'
True

我知道前两个 k-mer 应该匹配,事实证明如此:

>>> for i, kmer in enumerate(find_kmers(seq, 4)):
...   kmer[0] == 'N' and kmer[1] != 'P' and kmer[2] in 'ST' and kmer[3] != 'P'
...
True
True
False

尽管有效,但这太繁琐了。我想把这段代码隐藏在一个函数中:

def is_match(seq: str) -> bool:
    """ Find the N-glycosylation """

    return len(seq) == 4 and (seq[0] == 'N' and seq[1] != 'P'
                              and seq[2] in 'ST' and seq[3] != 'P')

这是我为该函数编写的一个测试:

def test_is_match() -> None:
    """ Test is_match """

    assert not is_match('') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert is_match('NASA') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert is_match('NATA')
    assert not is_match('NATAN') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert not is_match('NPTA') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    assert not is_match('NASP') ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

如果函数接受字符串参数,我总是用空字符串进行测试。

2

接下来的两个序列应该匹配。

3

此序列过长,应予以拒绝。

4

此序列第二个位置有一个 P,应予以拒绝。

5

这个序列在第四个位置有一个P,应该被拒绝。

这使得代码更加可读:

>>> for i, kmer in enumerate(find_kmers(seq, 4)):
...     print(i, kmer, is_match(kmer))
...
0 NNTS True
1 NTSY True
2 TSYS False

我只想要匹配的 k-mer。我可以使用带有保护的if表达式来编写这段代码,我在第五章和第六章中展示过:

>>> kmers = list(enumerate(find_kmers(seq, 4)))
>>> [i for i, kmer in kmers if is_match(kmer)]
[0, 1]

或者使用我在第九章中展示的starfilter()函数:

>>> from iteration_utilities import starfilter
>>> list(starfilter(lambda i, s: is_match(s), kmers))
[(0, 'NNTS'), (1, 'NTSY')]

我只想要每个元组的第一个元素,所以我可以使用map()来选择它们:

>>> matches = starfilter(lambda i, s: is_match(s), kmers)
>>> list(map(lambda t: t[0], matches))
[0, 1]

就其价值而言,Haskell 广泛使用元组,并在预设中包含两个方便的函数:fst()用于从 2 元组获取第一个元素,snd()用于获取第二个元素。确保为此代码导入typing.Tuple

def fst(t: Tuple[Any, Any]) -> Any:
    return t[0]

def snd(t: Tuple[Any, Any]) -> Any:
    return t[1]

使用这些函数,我可以像这样消除starfilter()

>>> list(map(fst, filter(lambda t: is_match(snd(t)), kmers)))
[0, 1]

但是请注意,如果我尝试使用filter()/starmap()技术,我之前展示过几次的话,会有一个非常微妙的 bug:

>>> from itertools import starmap
>>> list(filter(None, starmap(lambda i, s: i if is_match(s) else None, kmers)))
[1]

它只返回第二个匹配项。为什么?因为在filter()中使用None作为谓词。根据help(filter),“如果函数为None,返回为True的项。”在第一章中,我介绍了真值和假值的概念。布尔值TrueFalse分别由整数值10表示;因此,实际数字零(无论是int还是float)在技术上是False,这意味着任何非零数字都不是False,或者说是真值。Python 会在布尔上下文中评估许多数据类型,以决定它们是真还是假。

在这种情况下,将None用作filter()的谓词会导致它移除数字0

>>> list(filter(None, [1, 0, 2]))
[1, 2]

我从 Perl 和 JavaScript 转到 Python,这两种语言在不同的上下文中也会悄悄地强制转换值,所以对这种行为并不感到惊讶。如果你来自像 Java、C 或 Haskell 这样有更严格类型的语言,这可能会令人非常困扰。我经常觉得 Python 是一种非常强大的语言,如果你在任何时候都确切地知道自己在做什么。这是一个很高的门槛,所以在编写 Python 代码时,大量使用类型和测试至关重要。

最后,我觉得列表推导式是最易读的。这是我如何编写我的函数手动识别蛋白质模体的方式:

def find_motif(text: str) -> List[int]:
    """ Find a pattern in some text """

    kmers = list(enumerate(find_kmers(text, 4))) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    return [i for i, kmer in kmers if is_match(kmer)] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

从文本中获取 4-mer 的位置和值。

2

选择与模体匹配的 k-mer 的位置。

使用这个函数几乎和我使用正则表达式的方式完全相同,这正是将复杂性隐藏在函数背后的关键所在:

def main() -> None:
    args = get_args()
    files = fetch_fasta(args.file, args.download_dir)

    for file in files:
        prot_id, _ = os.path.splitext(os.path.basename(file))
        recs = SeqIO.parse(file, 'fasta')
        if rec := next(recs):
            if matches := find_motif(str(rec.seq)): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                pos = map(lambda p: p + 1, matches) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                print('\n'.join([prot_id, ' '.join(map(str, pos))])) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

尝试查找与模体匹配的任何匹配项。

2

匹配结果是一个从 0 开始的索引列表,因此对每个索引加 1。

3

将整数值转换为字符串,并在空格上连接它们以打印。

尽管这样做很有效且有趣(结果可能因人而异),但我不希望使用或维护这段代码。希望它能让您感受到正则表达式为我们做了多少工作。正则表达式允许我描述我想要什么,而不是如何获得它

深入了解

真核线性基序数据库示例提供了用于在蛋白质中定义功能位点的基序查找表达式。编写一个程序,在给定的一组 FASTA 文件中搜索任何模式的出现。

回顾

本章的要点:

  • 您可以使用命令行实用程序如curlwget从互联网获取数据。有时编写一个 Shell 脚本执行此类任务是有意义的,有时使用像 Python 这样的语言来编码更好。

  • 正则表达式可以找到 N-糖基化基序,但必须将其包装在前瞻断言和捕获括号中以找到重叠匹配。

  • 手动查找 N-糖基化基序是可能的,但并不容易。

  • 当您需要从扩展名中分离文件名时,os.path.splitext()函数非常有用。

  • 文件扩展名是约定俗成的,可能不可靠。

第十二章:从蛋白质推断 mRNA:列表的乘积和减少

罗莎琳的 mRNA 挑战中所述,该程序的目标是找出可以产生给定蛋白质序列的 mRNA 字符串数量。你会发现这个数字可能非常大,因此最终答案将是除以给定值后的余数。我希望展示我能通过尝试生成可以匹配特定模式的所有字符串来扭转正则表达式的局面。我还将展示如何创建数字和列表的乘积,以及如何将任何值列表减少到单个值,并在此过程中谈论可能导致问题的一些内存问题。

你将学到:

  • 如何使用 functools.reduce() 函数创建一个数学 product() 函数来相乘数字

  • 如何使用 Python 的取模(%)运算符

  • 关于缓冲区溢出问题

  • 什么是单子群

  • 如何通过交换键和值来反转字典

入门指南

你应该在存储库的 12_mrna 目录中工作。首先将第一个解决方案复制到程序 mrna.py 中:

$ cd 12_mrna/
$ cp solution1_dict.py mrna.py

像往常一样,首先检查用法:

$ ./mrna.py -h
usage: mrna.py [-h] [-m int] protein

Inferring mRNA from Protein

positional arguments:
  protein               Input protein or file ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

optional arguments:
  -h, --help            show this help message and exit
  -m int, --modulo int  Modulo value (default: 1000000) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

所需的位置参数是一个蛋白质序列或包含蛋白质序列的文件。

2

--modulo 选项默认为 1,000,000。

用罗莎琳示例 MA 运行程序,并验证它是否打印 12,可能编码该蛋白质序列的 1,000,000 取模后的 mRNA 序列数量:

$ ./mrna.py MA
12

该程序还将读取序列的输入文件。第一个输入文件的序列长度为 998,结果应为 448832

$ ./mrna.py tests/inputs/1.txt
448832

用其他输入运行程序,还要执行make test测试。当你确信理解程序应该如何工作时,重新开始:

$ new.py -fp 'Infer mRNA from Protein' mrna.py
Done, see new script "mrna.py".

按照用法描述定义参数。蛋白质可能是一个字符串或一个文件名,但我选择将参数建模为一个字符串。如果用户提供了一个文件,我将读取内容并将其传递给程序,就像我在第三章中首次展示的那样:

class Args(NamedTuple):
    """ Command-line arguments """
    protein: str ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    modulo: int ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Infer mRNA from Protein',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('protein', ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                        metavar='protein',
                        type=str,
                        help='Input protein or file')

    parser.add_argument('-m', ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                        '--modulo',
                        metavar='int',
                        type=int,
                        default=1000000,
                        help='Modulo value')

    args = parser.parse_args()

    if os.path.isfile(args.protein): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        args.protein = open(args.protein).read().rstrip()

    return Args(args.protein, args.modulo)

1

所需的 protein 参数应为一个字符串,可能是一个文件名。

2

modulo 选项是一个整数,默认为 1000000

3

如果 protein 参数命名一个现有文件,则从文件中读取蛋白质序列。

将您的 main() 更改为打印蛋白质序列:

def main() -> None:
    args = get_args()
    print(args.protein)

验证您的程序从命令行和文件中打印出蛋白质:

$ ./mrna.py MA
MA
$ ./mrna.py tests/inputs/1.txt | wc -c ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
  998

1

-c 选项到 wc 表示我只想要输入中的字符数的计数。

您的程序应通过前两个测试并失败第三个。

创建列表的乘积

当输入为 MA 时,程序应打印出响应 12,这是能够产生该蛋白质序列的可能 mRNA 字符串数量,如 图 12-1 所示。使用来自 第七章 的同一 RNA 编码表,我发现氨基甲酸 (M) 由 mRNA 密码序列 AUG 编码,^(1) 丙氨酸 (A) 有四种可能的密码子 (GCA, GCC, GCG, GCU),而终止密码子有三种 (UAA, UAG, UGA)。这三组的乘积为 1 × 4 × 3 = 12。

mpfb 1201

图 12-1. 编码蛋白质序列 MA 的所有密码子的笛卡尔乘积结果为 12 个 mRNA 序列

在 第九章 中,我介绍了 itertools.product() 函数,它将从值列表生成笛卡尔乘积。我可以像这样在 REPL 中生成这 12 个密码子的所有可能组合:

>>> from itertools import product
>>> from pprint import pprint
>>> combos = product(*codons)

如果您尝试打印 combos 查看内容,您会看到它不是一个值列表,而是一个 product object。也就是说,这是另一个延迟生成值的对象:

>>> pprint(combos)
<itertools.product object at 0x7fbdd822dac0>

我可以使用 list() 函数强制转换值:

>>> pprint(list(combos))
[('AUG', 'GCA', 'UAA'),
 ('AUG', 'GCA', 'UAG'),
 ('AUG', 'GCA', 'UGA'),
 ('AUG', 'GCC', 'UAA'),
 ('AUG', 'GCC', 'UAG'),
 ('AUG', 'GCC', 'UGA'),
 ('AUG', 'GCG', 'UAA'),
 ('AUG', 'GCG', 'UAG'),
 ('AUG', 'GCG', 'UGA'),
 ('AUG', 'GCU', 'UAA'),
 ('AUG', 'GCU', 'UAG'),
 ('AUG', 'GCU', 'UGA')]

我想向您展示一个等待您的小错误。再试打印组合:

>>> pprint(list(combos))
[]

此乘积对象像生成器一样,仅会产生值一次,然后将被用尽。所有后续调用将生成空列表。为了保存结果,我需要将强制转换后的列表保存到一个变量中:

>>> combos = list(product(*codons))

该产品的长度为 12,表示有 12 种方式将这些氨基酸组合成序列 MA

>>> len(combos)
12

使用模块化乘法避免溢出

随着输入蛋白质序列长度的增加,可能组合的数量会变得极其庞大。例如,第二个测试使用具有 998 个残基的蛋白质文件,导致大约 8.98 × 10²⁹ 个假设的 mRNA 序列。Rosalind 挑战说明:

由于内存限制,大多数内置语言数据格式对整数的大小有上限:在某些 Python 版本中,int 变量可能要求不大于 2³¹−1,即 2,147,483,647. 因此,为了处理 Rosalind 中的大数,我们需要设计一种系统,允许我们在不实际存储大数的情况下操作它们。

非常大的数字可能会超过整数大小的内存限制,特别是在旧的 32 位系统上。为了避免这种情况,最终答案应该是组合数对 1,000,000 取模的结果。模运算返回一个数除以另一个数的余数。例如,5 模 2 = 1,因为 5 除以 2 是 2,余数为 1。Python 有%运算符来计算模运算:

>>> 5 % 2
1

对于 998 个残基的蛋白质来说,答案是 448,832,这是在将 8.98 × 10²⁹除以 1,000,000 后的余数:

$ ./mrna.py tests/inputs/1.txt
448832

在第五章中,我介绍了用于数学运算的 NumPy 模块。正如你所预料的那样,有一个numpy.prod()函数可以计算一组数字的乘积。不幸的是,当我尝试计算像 1000 的阶乘这样的大数时,它可能会悄无声息地失败并返回0

>>> import numpy as np
>>> np.prod(range(1, 1001))
0

这里的问题是 NumPy 是用 C 实现的,比 Python 更快,C 代码尝试存储比整数可用内存更大的数字。不幸的结果是0。这种类型的错误通常被称为缓冲区溢出,在这里缓冲区是一个整数变量,但也可以是字符串、浮点数、列表或任何其他容器。一般来说,Python 程序员不必像其他语言的程序员那样担心内存分配,但我必须意识到底层库的限制。因为int的最大大小可能因机器而异,numpy.prod()是一个不可靠的解决方案,应该避免使用。

自 Python 3.8 以来,存在一个math.prod()函数,可以计算像 1000 的阶乘这样的极大乘积。这是因为所有计算都发生在 Python 内部,而 Python 中的整数几乎是无限大的,这意味着它们仅受到计算机可用内存的限制。请在您的计算机上运行这个试试:

>>> import math
>>> math.prod(range(1, 1001))

然而,请注意,当我应用模运算时,结果是0

>>> math.prod(range(1, 1001)) % 1000000
0

再次,我遇到了由于 Python 在除法操作中使用了有界类型float而导致的溢出问题,这是一个不太可能遇到的问题,如果您使用math.prod()和对结果进行模运算。在解决方案中,我将展示一种计算任意大数字集合乘积的方法,使用模运算以避免整数溢出。这应该足以帮助您解决问题。继续努力,直到您的程序通过所有测试。

解决方案

我提出了三种解决方案,它们主要在用于表示 RNA 翻译信息的字典结构以及如何计算数字列表的数学乘积方面有所不同。

解决方案 1:使用 RNA 密码子表的字典

对于我的第一个解决方案,我使用了来自第七章的 RNA 密码子表来查找每个残基的密码子数量:

>>> c2aa = {
...     'AAA': 'K', 'AAC': 'N', 'AAG': 'K', 'AAU': 'N', 'ACA': 'T',
...     'ACC': 'T', 'ACG': 'T', 'ACU': 'T', 'AGA': 'R', 'AGC': 'S',
...     'AGG': 'R', 'AGU': 'S', 'AUA': 'I', 'AUC': 'I', 'AUG': 'M',
...     'AUU': 'I', 'CAA': 'Q', 'CAC': 'H', 'CAG': 'Q', 'CAU': 'H',
...     'CCA': 'P', 'CCC': 'P', 'CCG': 'P', 'CCU': 'P', 'CGA': 'R',
...     'CGC': 'R', 'CGG': 'R', 'CGU': 'R', 'CUA': 'L', 'CUC': 'L',
...     'CUG': 'L', 'CUU': 'L', 'GAA': 'E', 'GAC': 'D', 'GAG': 'E',
...     'GAU': 'D', 'GCA': 'A', 'GCC': 'A', 'GCG': 'A', 'GCU': 'A',
...     'GGA': 'G', 'GGC': 'G', 'GGG': 'G', 'GGU': 'G', 'GUA': 'V',
...     'GUC': 'V', 'GUG': 'V', 'GUU': 'V', 'UAC': 'Y', 'UAU': 'Y',
...     'UCA': 'S', 'UCC': 'S', 'UCG': 'S', 'UCU': 'S', 'UGC': 'C',
...     'UGG': 'W', 'UGU': 'C', 'UUA': 'L', 'UUC': 'F', 'UUG': 'L',
...     'UUU': 'F', 'UAA': '*', 'UAG': '*', 'UGA': '*',
... }

我想要遍历蛋白质序列MA加上终止密码子,以找到所有编码密码子。请注意,来自罗萨琳德的序列不以终止密码子结束,所以我必须添加*。我可以使用列表推导式和守卫来表达这一点:

>>> protein = 'MA'
>>> for aa in protein + '*':
...     print(aa, [c for c, res in c2aa.items() if res == aa])
...
M ['AUG']
A ['GCA', 'GCC', 'GCG', 'GCU']
* ['UAA', 'UAG', 'UGA']

我不需要编码给定残基的密码子实际列表,只需要我可以使用len()函数找到的数字:

>>> possible = [
...     len([c for c, res in c2aa.items() if res == aa])
...     for aa in protein + '*'
... ]
>>>
>>> possible
[1, 4, 3]

答案在于将这些值相乘。在前一节中,我建议您可以使用math.prod()函数:

>>> import math
>>> math.prod(possible)
12

虽然这个方法可以完美地工作,但我想借此机会谈谈一系列值减少为单个值。在第五章中,我介绍了sum()函数,它将数字 1、4 和 3 相加得到结果 8:

>>> sum(possible)
8

它这样做是成对的,首先将 1 + 4 相加得到 5,然后将 5 + 3 相加得到 8。如果我将+运算符改为*,那么我得到一个乘积,结果是 12,如图 12-2 所示。

mpfb 1202

图 12-2. 使用加法和乘法减少数字列表

这就是减少值列表背后的思想,也正是functools.reduce()函数帮助我们做到的。这是另一个高阶函数,类似于filter()map()以及本书中我使用过的其他函数,但有一个重要的区别:lambda函数将接收两个参数而不是一个。文档显示如何编写sum()

reduce(...)
    reduce(function, sequence[, initial]) -> value

    Apply a function of two arguments cumulatively to the items of a sequence,
    from left to right, so as to reduce the sequence to a single value.
    For example, reduce(lambda x, y: x+y, [1, 2, 3, 4, 5]) calculates
    ((((1+2)+3)+4)+5).  If initial is present, it is placed before the items
    of the sequence in the calculation, and serves as a default when the
    sequence is empty.

这是我如何使用这个来编写自己版本的sum()

>>> from functools import reduce
>>> reduce(lambda x, y: x + y, possible)
8

要创建一个乘积,我可以将加法改为乘法:

>>> reduce(lambda x, y: x * y, possible)
12

我可以使用functools.reduce()来编写自己的product()函数:

def product(xs: List[int]) -> int: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Return the product """

    return reduce(lambda x, y: x * y, xs, 1) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

返回整数列表的乘积。

2

使用functools.reduce()函数逐步将值相乘。使用1作为初始结果确保空列表返回1

为什么要这样做?一部分是出于好奇心,但我还想展示如何编写一个可以在不依赖 Python 无界整数的情况下工作的函数。为了避免在减少的任何步骤中溢出,我需要将模操作合并到函数本身而不是应用到最终结果上。考虑到我不是数学专家,我不知道如何编写这样一个函数。我在互联网上搜索并找到了一些代码,我修改成了这个:

def mulmod(a: int, b: int, mod: int) -> int: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Multiplication with modulo """

    def maybemod(x): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        ret = (x % mod) if mod > 1 and x > mod else x
        return ret or x ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    res = 0 ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    a = maybemod(a) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    while b > 0: ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
        if b % 2 == 1: ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
            res = maybemod(res + a) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

        a = maybemod(a * 2) ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
        b //= 2 ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)

    return res

1

mulmod()函数接受两个整数ab以及一个整数模值mod来进行乘法。

2

这是一个封闭的函数,用于可能返回模 mod 的值。

3

如果结果为 0,则返回原始值;否则,返回计算得到的值。

4

初始化结果。

5

可能减少 a 的大小。

6

b 大于 0 的同时循环。

7

检查 b 是否为奇数。

8

a 加到结果中,并可能对结果取模。

9

a 加倍,并可能对值取模。

10

使用地板除法将 b 减半,最终结果为 0 并终止循环。

下面是我写的测试:

def test_mulmod() -> None:
    """ Text mulmod """

    assert mulmod(2, 4, 3) == 2
    assert mulmod(9223372036854775807, 9223372036854775807, 1000000) == 501249

我选择这些大数,因为它们是我机器上的 sys.maxsize

>>> import sys
>>> sys.maxsize
9223372036854775807

注意,这与我可以从 math.prod() 得到的答案相同,但我的版本不依赖于 Python 的动态整数大小,并且不像(更多地)绑定于我的机器上的可用内存:

>>> import math
>>> math.prod([9223372036854775807, 9223372036854775807]) % 1000000
501249

为了集成这一点,我编写了一个 modprod() 函数,并添加了一个如下的测试:

def modprod(xs: List[int], modulo: int) -> int:
    """ Return the product modulo a value """

    return reduce(lambda x, y: mulmod(x, y, modulo), xs, 1)

def test_modprod() -> None:
    """ Test modprod """

    assert modprod([], 3) == 1
    assert modprod([1, 4, 3], 1000000) == 12
    n = 9223372036854775807
    assert modprod([n, n], 1000000) == 501249

请注意,它可以处理前述的 1000 阶乘的示例。这个答案仍然太大而无法打印,但重点是答案不是 0

>>> modprod(range(1, 1001), 1000000)

最终答案是这些数字的乘积模给定的参数。以下是我如何将所有这些内容整合到一起的:

def main() -> None:
    args = get_args()
    codon_to_aa = { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        'AAA': 'K', 'AAC': 'N', 'AAG': 'K', 'AAU': 'N', 'ACA': 'T',
        'ACC': 'T', 'ACG': 'T', 'ACU': 'T', 'AGA': 'R', 'AGC': 'S',
        'AGG': 'R', 'AGU': 'S', 'AUA': 'I', 'AUC': 'I', 'AUG': 'M',
        'AUU': 'I', 'CAA': 'Q', 'CAC': 'H', 'CAG': 'Q', 'CAU': 'H',
        'CCA': 'P', 'CCC': 'P', 'CCG': 'P', 'CCU': 'P', 'CGA': 'R',
        'CGC': 'R', 'CGG': 'R', 'CGU': 'R', 'CUA': 'L', 'CUC': 'L',
        'CUG': 'L', 'CUU': 'L', 'GAA': 'E', 'GAC': 'D', 'GAG': 'E',
        'GAU': 'D', 'GCA': 'A', 'GCC': 'A', 'GCG': 'A', 'GCU': 'A',
        'GGA': 'G', 'GGC': 'G', 'GGG': 'G', 'GGU': 'G', 'GUA': 'V',
        'GUC': 'V', 'GUG': 'V', 'GUU': 'V', 'UAC': 'Y', 'UAU': 'Y',
        'UCA': 'S', 'UCC': 'S', 'UCG': 'S', 'UCU': 'S', 'UGC': 'C',
        'UGG': 'W', 'UGU': 'C', 'UUA': 'L', 'UUC': 'F', 'UUG': 'L',
        'UUU': 'F', 'UAA': '*', 'UAG': '*', 'UGA': '*',
    }

    possible =  ![2
        len([c for c, res in codon_to_aa.items() if res == aa])
        for aa in args.protein + '*'
    ]
    print(modprod(possible, args.modulo)) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

一个将 RNA 密码子编码为氨基酸的字典。

2

迭代蛋白质残基加上停止密码子,然后找到与给定氨基酸匹配的密码子数量。

3

打印可能性的乘积模给定的值。

解决方案 2:扭转节拍

对于下一个解决方案,我决定颠倒 RNA 密码子字典的键和值,使得唯一的氨基酸形成键,而值则是密码子的列表。知道如何像这样翻转字典非常方便,但只有当值是唯一的时候才能起作用。例如,我可以创建一个查找表,将 DNA 碱基如AT映射到它们的名称:

>>> base_to_name = dict(A='adenine', G='guanine', C='cytosine', T='thymine')
>>> base_to_name['A']
'adenine'

要反过来,从名称转到碱基,我可以使用dict.items()获取键/值对:

>>> list(base_to_name.items())
[('A', 'adenine'), ('G', 'guanine'), ('C', 'cytosine'), ('T', 'thymine')]

然后我将它们通过reversed()映射,最后将结果传递给dict()函数以创建字典:

>>> dict(map(reversed, base_to_name.items()))
{'adenine': 'A', 'guanine': 'G', 'cytosine': 'C', 'thymine': 'T'}

如果我尝试在第一个解决方案中使用的 RNA 密码子表上尝试,我会得到这个:

>>> pprint(dict(map(reversed, c2aa.items())))
{'*': 'UGA',
 'A': 'GCU',
 'C': 'UGU',
 'D': 'GAU',
 'E': 'GAG',
 'F': 'UUU',
 'G': 'GGU',
 'H': 'CAU',
 'I': 'AUU',
 'K': 'AAG',
 'L': 'UUG',
 'M': 'AUG',
 'N': 'AAU',
 'P': 'CCU',
 'Q': 'CAG',
 'R': 'CGU',
 'S': 'UCU',
 'T': 'ACU',
 'V': 'GUU',
 'W': 'UGG',
 'Y': 'UAU'}

您可以看到我缺少大部分密码子。只有MW有一个密码子。其余的都去哪了?当创建字典时,Python 会用最新的值覆盖键的任何现有值。例如,在原始表中,UUG是最后指定给L的值,因此这是保留的值。只需记住这个反转字典键/值的技巧,并确保值是唯一的。值得一提的是,如果我需要这样做,我会使用collections.defaultdict()函数:

>>> from collections import defaultdict
>>> aa2codon = defaultdict(list)
>>> for k, v in c2aa.items():
...     aa2codon[v].append(k)
...
>>> pprint(aa2codon)
defaultdict(<class 'list'>,
            {'*': ['UAA', 'UAG', 'UGA'],
             'A': ['GCA', 'GCC', 'GCG', 'GCU'],
             'C': ['UGC', 'UGU'],
             'D': ['GAC', 'GAU'],
             'E': ['GAA', 'GAG'],
             'F': ['UUC', 'UUU'],
             'G': ['GGA', 'GGC', 'GGG', 'GGU'],
             'H': ['CAC', 'CAU'],
             'I': ['AUA', 'AUC', 'AUU'],
             'K': ['AAA', 'AAG'],
             'L': ['CUA', 'CUC', 'CUG', 'CUU', 'UUA', 'UUG'],
             'M': ['AUG'],
             'N': ['AAC', 'AAU'],
             'P': ['CCA', 'CCC', 'CCG', 'CCU'],
             'Q': ['CAA', 'CAG'],
             'R': ['AGA', 'AGG', 'CGA', 'CGC', 'CGG', 'CGU'],
             'S': ['AGC', 'AGU', 'UCA', 'UCC', 'UCG', 'UCU'],
             'T': ['ACA', 'ACC', 'ACG', 'ACU'],
             'V': ['GUA', 'GUC', 'GUG', 'GUU'],
             'W': ['UGG'],
             'Y': ['UAC', 'UAU']})

这是我在以下解决方案中使用的数据结构。我还展示了如何使用math.prod()函数而不是自己编写代码:

def main():
    args = get_args()
    aa_to_codon = { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        'A': ['GCA', 'GCC', 'GCG', 'GCU'],
        'C': ['UGC', 'UGU'],
        'D': ['GAC', 'GAU'],
        'E': ['GAA', 'GAG'],
        'F': ['UUC', 'UUU']
        'G': ['GGA', 'GGC', 'GGG', 'GGU'],
        'H': ['CAC', 'CAU'],
        'I': ['AUA', 'AUC', 'AUU'],
        'K': ['AAA', 'AAG'],
        'L': ['CUA', 'CUC', 'CUG', 'CUU', 'UUA', 'UUG'],
        'M': ['AUG'],
        'N': ['AAC', 'AAU'],
        'P': ['CCA', 'CCC', 'CCG', 'CCU'],
        'Q': ['CAA', 'CAG'],
        'R': ['AGA', 'AGG', 'CGA', 'CGC', 'CGG', 'CGU'],
        'S': ['AGC', 'AGU', 'UCA', 'UCC', 'UCG', 'UCU'],
        'T': ['ACA', 'ACC', 'ACG', 'ACU'],
        'V': ['GUA', 'GUC', 'GUG', 'GUU'],
        'W': ['UGG'],
        'Y': ['UAC', 'UAU'],
        '*': ['UAA', 'UAG', 'UGA'],
    }

    possible = [len(aa_to_codon[aa]) for aa in args.protein + '*'] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    print(math.prod(possible) % args.modulo) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

用残基作为键,密码子作为值来表示字典。

2

找到编码蛋白质序列中每种氨基酸的密码子数量以及终止密码子。

3

使用math.prod()计算乘积,然后应用模运算符。

这个版本要短得多,并假设机器有足够的内存来计算乘积。(Python 将处理表示天文数字所需的内存要求。)对我罗莎琳德提供的所有数据集而言,这是真的,但您可能有一天会遇到需要在旅途中使用类似mulmod()函数的情况。

解决方案 3: 编码最少信息

前一个解决方案编码了比找到解决方案所需更多的信息。由于我只需要编码给定氨基酸的密码子数量,而不是实际列表,我可以创建这个查找表:

>>> codons = {
...     'A': 4, 'C': 2, 'D': 2, 'E': 2, 'F': 2, 'G': 4, 'H': 2, 'I': 3,
...     'K': 2, 'L': 6, 'M': 1, 'N': 2, 'P': 4, 'Q': 2, 'R': 6, 'S': 6,
...     'T': 4, 'V': 4, 'W': 1, 'Y': 2, '*': 3,
... }

列表推导式将返回所需的乘积数字。我会在这里使用1作为dict.get()的默认参数,以防我找到字典中不存在的残基:

>>> [codons.get(aa, 1) for aa in 'MA*']
[1, 4, 3]

导致这段代码:

def main():
    args = get_args()
    codons = { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        'A': 4, 'C': 2, 'D': 2, 'E': 2, 'F': 2, 'G': 4, 'H': 2, 'I': 3,
        'K': 2, 'L': 6, 'M': 1, 'N': 2, 'P': 4, 'Q': 2, 'R': 6, 'S': 6,
        'T': 4, 'V': 4, 'W': 1, 'Y': 2, '*': 3,
    }
    nums = [codons.get(aa, 1) for aa in args.protein + '*'] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    print(math.prod(nums) % args.modulo) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

编码每种氨基酸的密码子数量。

2

查找每种氨基酸及终止子的密码子数量。

3

输出给定值取模后的组合乘积。

进一步探讨

在某种意义上,我颠倒了正则表达式匹配的思路,通过创建所有可能的匹配字符串。也就是说,能产生蛋白质 MA 的 12 种模式如下:

$ ./show_patterns.py MA
    1: AUGGCAUAA
    2: AUGGCAUAG
    3: AUGGCAUGA
    4: AUGGCCUAA
    5: AUGGCCUAG
    6: AUGGCCUGA
    7: AUGGCGUAA
    8: AUGGCGUAG
    9: AUGGCGUGA
   10: AUGGCUUAA
   11: AUGGCUUAG
   12: AUGGCUUGA

本质上,我可以尝试使用这些信息创建一个统一的正则表达式。这可能并不容易,甚至可能不可能,但这个想法可能帮助我找到蛋白质的基因组来源。例如,前两个序列的区别在于它们的最后一个碱基。在 AG 之间的交替可以用字符类 [AG] 表示:

  AUGGCAUAA
+ AUGGCAUAG
  ---------
  AUGGCAUA[AG]

你能写一个工具,将许多正则表达式模式组合成一个单一模式吗?

复习

本章的关键点:

  • itertools.product() 函数将创建列表可迭代对象的笛卡尔积。

  • functools.reduce() 是一个高阶函数,它提供了一种从可迭代对象中逐渐组合成对元素的方式。

  • Python 的 %(取模)运算符将返回除法后的余数。

  • 数字和字符串的同类列表可以通过加法、乘法和连接等幺半群操作减少为单一值。

  • 一个具有唯一值的字典可以通过交换键和值来反转。

  • Python 中整数值的大小仅受可用内存限制。

^(1) 虽然存在其他可能的起始密码子,但罗莎琳问题只考虑了这一个。

第十三章:位置限制位点:使用、测试和共享代码

DNA 中的回文序列是指其 5'到 3'碱基序列在两条链上是相同的。例如,图 13-1 显示 DNA 序列GCATGC的反向互补序列是该序列本身。

mpfb 1301

图 13-1. 反向回文等于其反向互补序列

我可以通过代码验证这一点:

>>> from Bio import Seq
>>> seq = 'GCATGC'
>>> Seq.reverse_complement(seq) == seq
True

正如在Rosalind REVP 挑战中描述的那样,限制酶识别和切割 DNA 中特定的回文序列,称为限制位点。它们通常具有 4 到 12 个核苷酸的长度。这个练习的目标是找到每个可能的限制酶在 DNA 序列中的位置。解决这个问题的代码可能非常复杂,但对一些函数式编程技术的清晰理解有助于创建一个简短而优雅的解决方案。我将探索map()zip()enumerate()以及许多小的、经过测试的函数。

你将学到:

  • 如何找到反向回文

  • 如何创建模块以共享常用函数

  • 关于PYTHONPATH环境变量

入门指南

这个练习的代码和测试位于13_revp目录下。首先将一个解决方案复制到程序revp.py中开始:

$ cd 13_revp
$ cp solution1_zip_enumerate.py revp.py

检查用法:

$ ./revp.py -h
usage: revp.py [-h] FILE

Locating Restriction Sites

positional arguments:
  FILE        Input FASTA file ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

optional arguments:
  -h, --help  show this help message and exit

1

唯一必需的参数是一个 FASTA 格式的 DNA 序列的单个位置文件。

先看一下第一个测试输入文件。其内容与 Rosalind 页面上的示例相同:

$ cat tests/inputs/1.fa
>Rosalind_24
TCAATGCATGCGGGTCTATATGCAT

运行程序,使用此输入,并验证你是否看到每个字符串中长度介于 4 到 12 之间的每个反向回文的位置(使用基于 1 的计数),如图 13-2 所示。注意,结果的顺序无关紧要:

$ ./revp.py tests/inputs/1.fa
5 4
7 4
17 4
18 4
21 4
4 6
6 6
20 6

mpfb 1302

图 13-2. 在序列TCAATGCATGCGGGTCTATATGCAT中找到的八个反向回文的位置。

运行测试以验证程序是否通过,然后重新开始:

$ new.py -fp 'Locating Restriction Sites' revp.py
Done, see new script "revp.py".

这是定义程序参数的一种方式:

class Args(NamedTuple):
    """ Command-line arguments """
    file: TextIO ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Locating Restriction Sites',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('file', ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                        help='Input FASTA file',
                        metavar='FILE',
                        type=argparse.FileType('rt'))

    args = parser.parse_args()

    return Args(args.file)

1

唯一的参数是一个文件。

2

定义一个必须是可读文本文件的参数。

暂时让main()函数打印输入文件名:

def main() -> None:
    args = get_args()
    print(args.file.name)

手动验证程序是否能生成正确的用法,是否会拒绝伪文件,并打印一个有效输入的名称:

$ ./revp.py tests/inputs/1.fa
tests/inputs/1.fa

运行make test,你应该发现通过了一些测试。现在你可以编写程序的基础部分了。

使用 K-mer 查找所有子序列

第一步是从 FASTA 输入文件中读取序列。我可以使用 SeqIO.parse() 创建一个惰性迭代器,然后使用 next() 获取第一个序列:

>>> from Bio import SeqIO
>>> recs = SeqIO.parse(open('tests/inputs/1.fa'), 'fasta')
>>> rec = next(recs)
>>> seq = str(rec.seq)
>>> seq
'TCAATGCATGCGGGTCTATATGCAT'

如果文件为空,比如 tests/inputs/empty.fa,则上述代码不安全。如果尝试以同样的方式打开此文件并调用 next(),Python 将会引发 StopIteration 异常。在你的代码中,我建议你使用一个 for 循环来检测迭代器的耗尽并优雅地退出。

>>> empty = SeqIO.parse(open('tests/inputs/empty.fa'), 'fasta')
>>> next(empty)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/
  site-packages/Bio/SeqIO/Interfaces.py", line 73, in __next__
    return next(self.records)
StopIteration

我需要找到所有长度在 4 到 12 之间的序列。这听起来又是 k-mer 的工作,所以我将从 第九章 中引入 find_kmers() 函数:

>>> def find_kmers(seq, k):
...     n = len(seq) - k + 1
...     return [] if n < 1 else [seq[i:i + k] for i in range(n)]
...

我可以使用 range() 生成从 4 到 12 之间的所有数字,记住结束位置不包括在内,所以我必须取到 13。因为每个 k 有很多 k-mer,我将打印出 k 的值以及找到的 k-mer 的数量:

>>> for k in range(4, 13):
...     print(k, len(find_kmers(seq, k)))
...
4 22
5 21
6 20
7 19
8 18
9 17
10 16
11 15
12 14

查找所有反向互补序列

在 第三章 中,我展示了许多找到反向互补的方法,结论是 Bio.Seq.reverse_complement() 可能是最简单的方法。首先找到所有的 12-mer:

>>> kmers = find_kmers(seq, 12)
>>> kmers
['TCAATGCATGCG', 'CAATGCATGCGG', 'AATGCATGCGGG', 'ATGCATGCGGGT',
 'TGCATGCGGGTC', 'GCATGCGGGTCT', 'CATGCGGGTCTA', 'ATGCGGGTCTAT',
 'TGCGGGTCTATA', 'GCGGGTCTATAT', 'CGGGTCTATATG', 'GGGTCTATATGC',
 'GGTCTATATGCA', 'GTCTATATGCAT']

要创建反向互补序列的列表,你可以使用列表推导式:

>>> from Bio import Seq
>>> revc = [Seq.reverse_complement(kmer) for kmer in kmers]

或者使用 map()

>>> revc = list(map(Seq.reverse_complement, kmers))

无论哪种方式,你应该有 12 个反向互补序列:

>>> revc
['CGCATGCATTGA', 'CCGCATGCATTG', 'CCCGCATGCATT', 'ACCCGCATGCAT',
 'GACCCGCATGCA', 'AGACCCGCATGC', 'TAGACCCGCATG', 'ATAGACCCGCAT',
 'TATAGACCCGCA', 'ATATAGACCCGC', 'CATATAGACCCG', 'GCATATAGACCC',
 'TGCATATAGACC', 'ATGCATATAGAC']

将所有内容整合在一起

你应该已经具备完成这个挑战所需的一切。首先,将所有的 k-mer 与它们的反向互补配对,找出相同的那些,并打印它们的位置。你可以使用 for 循环来遍历它们,或者考虑使用我们在 第六章 中首次介绍的 zip() 函数来创建这些配对。这是一个有趣的挑战,我相信在阅读我的解决方案之前,你能找到一个可行的解决方案。

解决方案

我将展示三种方法来查找限制位点,它们越来越依赖函数来隐藏程序的复杂性。

解决方案 1:使用 zip()enumerate() 函数

在我的第一个解决方案中,我首先使用 zip() 将 k-mer 和它们的反向互补配对。假设 k=4

>>> seq = 'TCAATGCATGCGGGTCTATATGCAT'
>>> kmers = find_kmers(seq, 4)
>>> revc = list(map(Seq.reverse_complement, kmers))
>>> pairs = list(zip(kmers, revc))

我还需要知道这些配对的位置,这可以通过 enumerate() 得到。如果检查这些配对,我发现其中一些(4、6、16、17 和 20)是相同的:

>>> pprint(list(enumerate(pairs)))
[(0, ("TCAA", "TTGA")),
 (1, ("CAAT", "ATTG")),
 (2, ("AATG", "CATT")),
 (3, ("ATGC", "GCAT")),
 (4, ("TGCA", "TGCA")),
 (5, ("GCAT", "ATGC")),
 (6, ("CATG", "CATG")),
 (7, ("ATGC", "GCAT")),
 (8, ("TGCG", "CGCA")),
 (9, ("GCGG", "CCGC")),
 (10, ("CGGG", "CCCG")),
 (11, ("GGGT", "ACCC")),
 (12, ("GGTC", "GACC")),
 (13, ("GTCT", "AGAC")),
 (14, ("TCTA", "TAGA")),
 (15, ("CTAT", "ATAG")),
 (16, ("TATA", "TATA")),
 (17, ("ATAT", "ATAT")),
 (18, ("TATG", "CATA")),
 (19, ("ATGC", "GCAT")),
 (20, ("TGCA", "TGCA")),
 (21, ("GCAT", "ATGC"))]

我可以使用带有保护条件的列表推导式来找到所有配对相同的位置。注意,我将索引值加 1 以得到基于 1 的位置:

>>> [pos + 1 for pos, pair in enumerate(pairs) if pair[0] == pair[1]]
[5, 7, 17, 18, 21]

在 第十一章 中,我介绍了用于获取二元组中第一个或第二个元素的函数 fst()snd()。我想在这里使用它们,这样就不必再用元组索引了。我还继续使用之前章节中的 find_kmers() 函数。现在看起来是时候将这些函数放入一个单独的模块中,这样我就可以根据需要导入它们,而不是复制它们了。

如果检查 common.py 模块,你会看到这些函数及其测试。我可以运行 pytest 确保它们全部通过:

$ pytest -v common.py
============================= test session starts ==============================
...

common.py::test_fst PASSED                                               [ 33%]
common.py::test_snd PASSED                                               [ 66%]
common.py::test_find_kmers PASSED                                        [100%]

============================== 3 passed in 0.01s ===============================

因为 common.py 在当前目录中,所以我可以从中导入任何我喜欢的函数:

>>> from common import fst, snd
>>> [pos + 1 for pos, pair in enumerate(pairs) if fst(pair) == snd(pair)]
[5, 7, 17, 18, 21]

这里是我如何在第一个解决方案中结合这些想法的方式:

def main() -> None:
    args = get_args()
    for rec in SeqIO.parse(args.file, 'fasta'): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        for k in range(4, 13): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
            kmers = find_kmers(str(rec.seq), k) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            revc = list(map(Seq.reverse_complement, kmers)) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

            for pos, pair in enumerate(zip(kmers, revc)): ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
                if fst(pair) == snd(pair): ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
                    print(pos + 1, k) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

1

遍历 FASTA 文件中的记录。

2

遍历所有 k 的值。

3

找到这个 k 的 k-mer。

4

找到 k-mer 的反向互补。

5

遍历位置和 k-mer/反向互补对。

6

检查对的第一个元素是否与第二个元素相同。

7

打印位置加 1(以修正基于 0 的索引)和序列 k 的大小。

解决方案 2:使用 operator.eq() 函数

虽然我喜欢 fst()snd() 函数,并想强调如何共享模块和函数,但我却重复了 operator.eq() 函数。我在第六章首次引入了这个模块,用于使用 operator.ne()(不等于)函数,并且在其他地方也使用了 operator.le()(小于或等于)和 operator.add() 函数。

我可以像这样重写前面解决方案的一部分:

for pos, pair in enumerate(zip(kmers, revc)):
    if operator.eq(*pair): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        print(pos + 1, k)

1

使用函数版本的 == 操作符来比较对的元素。请注意需要展开对以将元组扩展为其两个值。

我更喜欢使用守卫的列表推导来压缩这段代码:

def main() -> None:
    args = get_args()
    for rec in SeqIO.parse(args.file, 'fasta'):
        for k in range(4, 13):
            kmers = find_kmers(str(rec.seq), k)
            revc = map(Seq.reverse_complement, kmers)
            pairs = enumerate(zip(kmers, revc))

            for pos in [pos + 1 for pos, pair in pairs if operator.eq(*pair)]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                print(pos, k)

1

使用守卫进行相等比较,并在列表推导中修正位置。

解决方案 3:编写一个 revp() 函数

在最终解决方案中,我喜欢编写一个 revp() 函数并创建一个测试。这将使程序更易读,并且也会更容易将此函数移入像 common.py 这样的模块中,以便在其他项目中共享。

如往常一样,我设想函数的签名:

def revp(seq: str, k: int) -> List[int]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Return positions of reverse palindromes """

    return [] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

我想传入一个序列和一个k值,以获取反向回文字符串的位置列表。

2

目前,返回空列表。

这是我编写的测试。请注意,我决定该函数应该校正索引以进行基于 1 的计数:

def test_revp() -> None:
    """ Test revp """

    assert revp('CGCATGCATTGA', 4) == [3, 5]
    assert revp('CGCATGCATTGA', 5) == []
    assert revp('CGCATGCATTGA', 6) == [2, 4]
    assert revp('CGCATGCATTGA', 7) == []
    assert revp('CCCGCATGCATT', 4) == [5, 7]
    assert revp('CCCGCATGCATT', 5) == []
    assert revp('CCCGCATGCATT', 6) == [4, 6]

如果我将这些添加到我的revp.py程序中并运行pytest revp.py,我会看到测试失败,这正是应该的。现在我可以填写代码:

def revp(seq: str, k: int) -> List[int]:
    """ Return positions of reverse palindromes """

    kmers = find_kmers(seq, k)
    revc = map(Seq.reverse_complement, kmers)
    pairs = enumerate(zip(kmers, revc))
    return [pos + 1 for pos, pair in pairs if operator.eq(*pair)]

如果我再次运行 pytest,我应该会得到通过的测试。main() 函数现在更易读了:

def main() -> None:
    args = get_args()
    for rec in SeqIO.parse(args.file, 'fasta'):
        for k in range(4, 13): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
            for pos in revp(str(rec.seq), k): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                print(pos, k) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

遍历每个k值。

2

遍历在序列中找到的每个大小为k的反向回文字符串。

3

打印反向回文的位置和大小。

请注意,可以在列表推导式中使用多个迭代器。我可以将两个for循环合并成一个,如下所示:

for k, pos in [(k, pos) for k in range(4, 13) for pos in revp(seq, k)]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    print(pos, k)

1

首先迭代k值,然后使用这些值迭代revp()值,并将两者作为元组返回。

我可能不会使用这种结构。它让我想起了我的老同事乔,他会开玩笑说:“如果写起来很难,那么阅读起来也应该很难!”

测试程序

我想花一点时间查看 tests/revp_test.py 中的集成测试。前两个测试总是一样的,检查预期程序的存在性以及在请求时程序将生成一些用法说明。对于接受文件作为输入的程序(比如这个程序),我包括一个测试,即程序拒绝无效文件。我通常也会挑战其他输入,比如在预期整数时传递字符串,以确保参数被拒绝。

在确认程序的参数全部验证通过后,我开始传递良好的输入值,以确保程序按预期工作。这需要我使用有效的、已知的输入,并验证程序产生正确的预期输出。在这种情况下,我使用 tests/inputs 目录中的文件编码输入和输出。例如,文件 1.fa 的预期输出可以在 1.fa.out 中找到:

$ ls tests/inputs/
1.fa          2.fa          empty.fa
1.fa.out      2.fa.out      empty.fa.out

以下是第一个输入:

$ cat tests/inputs/1.fa
>Rosalind_24
TCAATGCATGCGGGTCTATATGCAT

期望的输出是:

$ cat tests/inputs/1.fa.out
5 4
7 4
17 4
18 4
21 4
4 6
6 6
20 6

第二个输入文件比第一个大得多。这在 Rosalind 问题中很常见,因此试图在测试程序中包含输入和输出值作为字面字符串是不合适的。第二个文件的预期输出长度为 70 行。最后一个测试是针对空文件,预期输出为空字符串。虽然这似乎是显而易见的,但重点是检查程序在空输入文件时是否会抛出异常。

tests/revp_test.py 中,我编写了一个 run() 辅助函数,该函数接受输入文件的名称,读取预期输出文件名,并运行程序以检查输出:

def run(file: str) -> None: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Run the test """

    expected_file = file + '.out' ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert os.path.isfile(expected_file) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    rv, out = getstatusoutput(f'{PRG} {file}') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    assert rv == 0 ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

    expected = set(open(expected_file).read().splitlines()) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
    assert set(out.splitlines()) == expected ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

1

函数接受输入文件的名称。

2

输出文件是输入文件名加上 .out

3

确保输出文件存在。

4

使用输入文件运行程序并捕获返回值和输出。

5

确保程序报告了成功运行。

6

读取预期输出文件,将内容按行分割并创建结果字符串的集合。

7

将程序的输出按行分割并创建一个集合,以便与预期结果进行比较。集合使我能够忽略行的顺序。

这简化了测试。请注意,INPUT*EMPTY 变量在模块顶部声明:

def test_ok1() -> None:
    run(INPUT1)

def test_ok2() -> None:
    run(INPUT2)

def test_mepty() -> None:
    run(EMPTY)

我鼓励你花一些时间阅读每个程序的 *_test.py 文件。我希望你能将测试整合到你的开发工作流中,我相信你可以从我的测试中找到大量可供复制的代码,这将节省你的时间。

深入了解

程序中硬编码了长度为最小(4)和最大(12)的位点值。添加命令行参数以将这些作为整数选项传递,并使用这些默认值更改代码。添加测试以确保找到不同范围内正确的位点。

编写一个程序,可以识别英语回文,例如“A man, a plan, a canal—Panama!” 开始创建一个新的存储库。找几个有趣的回文来用于测试。确保提供不是回文的短语,并验证你的算法也能拒绝这些短语。将你的代码发布到互联网上,并收获编写开源软件的名声、荣誉和利润。

复习

本章的关键点:

  • 可以通过将它们放入模块并根据需要导入来重用函数。

  • PYTHONPATH 环境变量指示 Python 在查找代码模块时应搜索的目录。

第十四章:寻找开放阅读框架

ORF 挑战是本书中我将处理的最后一个 Rosalind 问题。其目标是在 DNA 序列中找到所有可能的开放阅读框架(ORFs)。一个 ORF 是从起始密码子到终止密码子之间的核苷酸区域。解决方案将考虑前向和反向互补以及移码。虽然有像 TransDecoder 这样的现有工具来找到编码区域,但编写一个专门的解决方案将集成许多前几章的技能,包括读取 FASTA 文件、创建序列的反向互补、使用字符串切片、找到 k-mer、使用多个for循环/迭代、翻译 DNA 和使用正则表达式。

你将学到:

  • 如何将序列截断到与密码子大小整除的长度

  • 如何使用str.find()str.partition()函数

  • 如何使用代码格式化、注释和 Python 的隐式字符串连接来记录正则表达式

入门指南

这个挑战的代码、测试和解决方案位于14_orf目录中。首先复制第一个解决方案到orf.py程序中:

$ cd 14_orf/
$ cp solution1_iterate_set.py orf.py

如果你请求使用方法,你将看到程序需要一个 FASTA 格式文件的单个位置参数:

$ ./orf.py -h
usage: orf.py [-h] FILE

Open Reading Frames

positional arguments:
  FILE        Input FASTA file

optional arguments:
  -h, --help  show this help message and exit

第一个测试输入文件与 Rosalind 页面上的示例内容相同。请注意,我在这里断开了序列文件,但在输入文件中是单行:

$ cat tests/inputs/1.fa
>Rosalind_99
AGCCATGTAGCTAACTCAGGTTACATGGGGATGACCCCGCGACTTGGATTAGAGTCTCTTTTGGAATAAG\
CCTGAATGATCCGAGTAGCATCTCAG

运行程序并注意输出。ORF 的顺序并不重要:

$ ./orf.py tests/inputs/1.fa
M
MGMTPRLGLESLLE
MLLGSFRLIPKETLIQVAGSSPCNLS
MTPRLGLESLLE

运行测试套件以确保程序通过测试。当你对程序的工作方式感到满意时,请重新开始:

$ new.py -fp 'Open Reading Frames' orf.py
Done, see new script "orf.py".

到这一步,你可能不需要帮助来定义单个位置参数文件,但这是你可以使用的代码:

class Args(NamedTuple):
    """ Command-line arguments """
    file: TextIO

def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Open Reading Frames',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('file', ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                        help='Input FASTA file',
                        metavar='FILE',
                        type=argparse.FileType('rt'))

    args = parser.parse_args()

    return Args(args.file)

1

定义一个必须是可读文本文件的位置参数。

修改main()函数以打印传入的文件名:

def main() -> None:
    args = get_args()
    print(args.file.name)

验证程序是否打印使用说明,拒绝坏文件,并打印有效参数的文件名:

$ ./orf.py tests/inputs/1.fa
tests/inputs/1.fa

到这一步,你的程序应该通过前三个测试。接下来,我将讨论如何让程序找到开放阅读框架(ORFs)。

在每一帧内翻译蛋白质

可以写一些伪代码来帮助勾勒出需要发生的事情:

def main() -> None:
    args = get_args()

    # Iterate through each DNA sequence in the file:
        # Transcribe the sequence from DNA to mRNA
        # Iterate using the forward and reverse complement of the mRNA:
            # Iterate through 0, 1, 2 for frames in this sequence:
                # Translate the mRNA frame into a protein sequence
                # Try to find the ORFs in this protein sequence

你可以使用for循环来迭代通过Bio.SeqIO读取的输入序列:

def main() -> None:
    args = get_args()

    for rec in SeqIO.parse(args.file, 'fasta'):
        print(str(rec.seq))

运行程序以验证其工作情况:

$ ./orf.py tests/inputs/1.fa
AGCCATGTAGCTAACTCAGGTTACATGGGGATGACCCCGCGACTTGGATTAGAGTCTCTTTTGGA\
ATAAGCCTGAATGATCCGAGTAGCATCTCAG

我需要将这段转录成 mRNA,这意味着将所有的T改成U。你可以使用第第二章中任何你喜欢的解决方案,只要你的程序现在能打印出这个:

$ ./orf.py tests/inputs/1.fa
AGCCAUGUAGCUAACUCAGGUUACAUGGGGAUGACCCCGCGACUUGGAUUAGAGUCUCUUUUGGA\
AUAAGCCUGAAUGAUCCGAGUAGCAUCUCAG

接下来,参考第三章,让你的程序打印出这个序列的前向和反向互补:

$ ./orf.py tests/inputs/1.fa
AGCCAUGUAGCUAACUCAGGUUACAUGGGGAUGACCCCGCGACUUGGAUUAGAGUCUCUUUUGGA\
AUAAGCCUGAAUGAUCCGAGUAGCAUCUCAG
CUGAGAUGCUACUCGGAUCAUUCAGGCUUAUUCCAAAAGAGACUCUAAUCCAAGUCGCGGGGUCA\
UCCCCAUGUAACCUGAGUUAGCUACAUGGCU

参考第七章来将前向和反向互补转化为蛋白质:

$ ./orf.py tests/inputs/1.fa
SHVANSGYMGMTPRLGLESLLE*A*MIRVASQ
LRCYSDHSGLFQKRL*SKSRGHPHVT*VSYMA

现在,不再从每个 mRNA 序列的开头读取,而是通过从零点、第一个和第二个字符开始读取来实现移位,可以使用字符串切片。如果使用 Biopython 翻译 mRNA 切片,可能会遇到以下警告:

部分密码子,长度(sequence)不是三的倍数。在翻译之前明确修剪序列或添加尾随的 N。这在未来可能会成为错误。

为了修复这个问题,我创建了一个函数来截断序列,使其最接近被一个值偶数分割:

def truncate(seq: str, k: int) -> str:
    """ Truncate a sequence to even division by k """

    return ''

图 14-1 显示了通过字符串0123456789进行位移并将每个结果截断到可以被 3 整除的长度的结果。

mpfb 1401

图 14-1. 将各种移位截断到密码子大小 3 可以整除的长度

这是一个你可以使用的测试:

def test_truncate() -> None:
    """ Test truncate """

    seq = '0123456789'
    assert truncate(seq, 3) == '012345678'
    assert truncate(seq[1:], 3) == '123456789'
    assert truncate(seq[2:], 3) == '234567'

修改你的程序,以打印 mRNA 的前向和反向互补物的三种移位的蛋白质翻译。确保打印完整的翻译,包括所有的终止(*)密码子,如下所示:

$ ./orf.py tests/inputs/1.fa
SHVANSGYMGMTPRLGLESLLE*A*MIRVASQ
AM*LTQVTWG*PRDLD*SLFWNKPE*SE*HL
PCS*LRLHGDDPATWIRVSFGISLNDPSSIS
LRCYSDHSGLFQKRL*SKSRGHPHVT*VSYMA
*DATRIIQAYSKRDSNPSRGVIPM*PELATW
EMLLGSFRLIPKETLIQVAGSSPCNLS*LHG

在蛋白质序列中找到 ORF

现在,程序可以从每个 mRNA 的移位框中找到所有蛋白质序列,是时候在蛋白质中寻找开放阅读框了。你的代码需要考虑从每个起始密码子到第一个随后的终止密码子的每个区间。密码子AUG是最常见的起始密码子,编码氨基酸甲硫氨酸(M)。有三种可能的终止密码子,用星号(*)表示。例如,图 14-2 显示,氨基酸序列MAMAPR**包含两个起始密码子和一个终止密码子,因此有MAMAPRMAPR*两种可能的蛋白质。尽管通常工具只报告较长的序列,但 Rosalind 挑战期望所有可能的序列。

mpfb 1402

图 14-2. 蛋白质序列 MAMAPR*有两个重叠的开放阅读框

我决定编写一个名为find_orfs()的函数,它将接受一个氨基酸字符串并返回一个 ORF 列表:

def find_orfs(aa: str) -> List[str]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Find ORFs in AA sequence """

    return [] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

该函数接受一个氨基酸字符串,并返回可能的蛋白质字符串列表。

2

目前,返回空列表。

这是这个函数的一个测试。如果你能实现通过这个测试的find_orfs(),那么你应该能够通过集成测试:

def test_find_orfs() -> None:
    """ Test find_orfs """

    assert find_orfs('') == [] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    assert find_orfs('M') == [] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert find_orfs('*') == [] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert find_orfs('M*') == ['M'] ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    assert find_orfs('MAMAPR*') == ['MAMAPR', 'MAPR'] ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    assert find_orfs('MAMAPR*M') == ['MAMAPR', 'MAPR'] ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
    assert find_orfs('MAMAPR*MP*') == ['MAMAPR', 'MAPR', 'MP'] ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

1

空字符串应该不产生 ORF。

2

单个起始密码子而没有终止密码子应该不产生 ORF。

3

单个终止密码子而没有前置的起始密码子应该不产生 ORF。

4

即使在停止密码子之前没有中间碱基,函数也应该返回起始密码子。

5

此序列包含两个 ORF。

6

此序列同样仅包含两个 ORF。

7

此序列在两个不同部分中包含三个推测的 ORF。

一旦你能在每个 mRNA 序列中找到所有 ORFs,你应该将它们收集到一个独特的列表中。我建议你使用set()来完成这个任务。虽然我的解决方案按排序顺序打印 ORFs,但这不是测试的要求。解决问题的关键是将你已经学到的各种技能组合起来。编写越来越长的程序的技艺在于组合你理解和测试过的较小的片段。继续努力直到你通过所有的测试。

解决方案

我将提出三种解决方案来使用两个字符串函数和正则表达式来查找 ORFs。

解决方案 1:使用str.index()函数

首先,这里是我编写的truncate()函数的方法,该函数将在尝试转换各种移码 mRNA 序列时缓解Bio.Seq.translate()函数的影响:

def truncate(seq: str, k: int) -> str:
    """ Truncate a sequence to even division by k """

    length = len(seq) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    end = length - (length % k) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    return seq[:end] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

找到序列的长度。

2

所需子序列的末尾是长度减去长度模k

3

返回子序列。

接下来,这里是编写find_orfs()的一种方法,它使用str.index()函数来查找每个起始密码子 M 后面跟着一个*停止密码子:

def find_orfs(aa: str) -> List[str]:
    orfs = [] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    while 'M' in aa: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        start = aa.index('M') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        if '*' in aa[start + 1:]: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            stop = aa.index('*', start + 1) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
            orfs.append(''.join(aa[start:stop])) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
            aa = aa[start + 1:] ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
        else:
            break ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

    return orfs

1

初始化一个列表来保存 ORFs。

2

创建一个循环来在存在起始密码子时迭代。

3

使用str.index()查找起始密码子的位置。

4

查看起始密码子位置后是否存在停止密码子。

5

获取起始密码子之后停止密码子的索引。

6

使用字符串切片来获取蛋白质。

7

将氨基酸字符串设置为起始密码子位置之后的索引,以查找下一个起始密码子。

8

如果没有停止密码子,退出while循环。

这里是我如何将这些想法整合到程序中的方法:

def main() -> None:
    args = get_args()
    for rec in SeqIO.parse(args.file, 'fasta'): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        rna = str(rec.seq).replace('T', 'U') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        orfs = set() ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

        for seq in [rna, Seq.reverse_complement(rna)]: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            for i in range(3): ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
                if prot := Seq.translate(truncate(seq[i:], 3), to_stop=False): ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
                    for orf in find_orfs(prot): ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
                        orfs.add(orf) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

        print('\n'.join(sorted(orfs))) ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)

1

遍历输入序列。

2

将 DNA 序列转录为 mRNA。

3

创建一个空集合来保存所有的 ORF。

4

遍历 mRNA 的正向和反向互补。

5

遍历移码。

6

尝试将截断的、移码的 mRNA 翻译成蛋白质序列。

7

遍历蛋白质序列中找到的每一个 ORF。

8

将 ORF 添加到集合中以保持唯一列表。

9

打印排序后的 ORF。

解决方案 2:使用 str.partition()函数

这里是编写find_orfs()函数的另一种方法,使用了str.partition()。此函数将字符串分成某个子字符串前的部分、子字符串本身和后面的部分。例如,字符串MAMAPRMP**可以在终止密码子(*)上进行分区:

>>> 'MAMAPR*MP*'.partition('*')
('MAMAPR', '*', 'MP*')

如果蛋白质序列不包含终止密码子,则函数将在第一个位置返回整个序列和其他位置返回空字符串:

>>> 'M'.partition('*')
('M', '', '')

在这个版本中,我使用了两个无限循环。第一个尝试在终止密码子上对给定的氨基酸序列进行分区。如果这不成功,我退出循环。图 14-3 显示蛋白质序列MAMAPRMP**包含两个有起始和终止密码子的部分。

mpfb 1403

图 14-3。蛋白质序列MAMAPRMP*包含两个有起始和终止密码子的部分

第二个循环检查第一个分区,以找到所有以M起始密码子的子序列。因此,在分区MAMAPR中,它找到两个序列MAMAPRMAPR。然后,代码截断氨基酸序列到最后分区*MP**,重复操作直到找到所有 ORF:

def find_orfs(aa: str) -> List[str]:
    """ Find ORFs in AA sequence """

    orfs = [] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    while True: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        first, middle, rest = aa.partition('*') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        if middle == '': ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            break

        last = 0 ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        while True: ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
            start = first.find('M', last) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
            if start == -1: ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
                break
            orfs.append(first[start:]) ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
            last = start + 1 ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)
        aa = rest ![11](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/11.png)

    return orfs ![12](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/12.png)

1

初始化一个用于返回 ORF 的列表。

2

创建第一个无限循环。

3

在终止密码子上对氨基酸序列进行分区。

4

如果终止密码子不存在,则中间为空,因此退出外循环。

5

设置一个变量以记住起始密码子的最后位置。

6

创建第二个无限循环。

7

使用str.find()方法找到起始密码子的索引。

8

值为-1 表示起始密码子不存在,因此退出内循环。

9

将起始索引到 ORF 列表的子字符串添加。

10

将最后已知位置移动到当前起始位置之后。

11

将蛋白质序列截断到初始分区的最后部分。

12

将 ORFs 返回给调用者。

解决方案 3:使用正则表达式

在这个最终解决方案中,我再次指出,正则表达式可能是找到文本模式的最合适解决方案。这个模式总是以M开头,我可以使用re.findall()函数来找到这个蛋白质序列中的四个M

>>> import re
>>> re.findall('M', 'MAMAPR*MP*M')
['M', 'M', 'M', 'M']

Rosalind 挑战不考虑非规范起始密码子,因此 ORF 始终以M开头,并延伸到第一个停止密码子。在这些之间,可以有零个或多个非停止密码子,我可以用[^*]的否定字符类来表示,该类排除停止密码子,后跟一个*以指示可以有零个或多个之前的模式:

>>> re.findall('M[^*]*', 'MAMAPR*MP*M')
['MAMAPR', 'MP', 'M']

我需要将停止密码子*添加到这个模式中。因为文字星号是一个元字符,我必须使用反斜杠进行转义:

>>> re.findall('M[^*]*\*', 'MAMAPR*MP*M')
['MAMAPR*', 'MP*']

我也可以将星号放在字符类内,这样它就没有元字符的含义:

>>> re.findall('M[^*]*[*]', 'MAMAPR*MP*M')
['MAMAPR*', 'MP*']

图 14-4 显示了使用有限状态机图的正则表达式来找到开放阅读框的模式。

mpfb 1404

图 14-4. 正则表达式找到开放阅读框的有限状态机图示

我可以看到这个模式接近工作,但它只找到三个 ORFs 中的两个,因为第一个与第二个重叠。就像第八章和 11 章中一样,我可以在正向先行断言中包装模式。此外,我将使用括号创建一个围绕 ORF 到停止密码子的捕获组:

>>> re.findall('(?=(M[^*]*)[*])', 'MAMAPR*MP*M')
['MAMAPR', 'MAPR', 'MP']

这是一个使用这个模式的find_orfs()的版本:

def find_orfs(aa: str) -> List[str]:
    """ Find ORFs in AA sequence """

    return re.findall('(?=(M[^*]*)[*])', aa)

尽管这通过了test_find_orfs(),但这是一个复杂的正则表达式,我每次回头都需要重新学习它。另一种编写方式是将正则表达式的每个功能部分放在单独的行上,然后跟随一条行尾注释,并依赖 Python 的隐式字符串连接(首次出现在第二章)将它们连接成一个字符串。这是我首选的找到开放阅读框(ORFs)的方法:

def find_orfs(aa: str) -> List[str]:
    """ Find ORFs in AA sequence """

    pattern = ( ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        '(?='    # start positive look-ahead to handle overlaps
        '('      # start a capture group
        'M'      # a literal M
        '[^*]*'  # zero or more of anything not the asterisk
        ')'      # end the capture group
        '[*]'    # a literal asterisk
        ')')     # end the look-ahead group

    return re.findall(pattern, aa) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

括号将下面的行分组,以便 Python 会自动将字符串连接成单个字符串。确保没有逗号,否则 Python 会创建一个元组。

2

使用re.findall()函数与模式。

这是一个更长的函数,但下次看到它时会更容易理解。唯一的缺点是我用来格式化代码的yapf会移除注释的垂直对齐,因此我必须手动格式化这部分。不过,我认为值得这样做,以获得更具自我描述性的代码。

更进一步

扩展程序以处理多个输入文件,并将所有唯一的 ORFs 写入指定的输出文件。

复习

本章的关键点:

  • 如果输入序列不能被三整除,Bio.Seq.translate() 函数会打印警告,因此我编写了一个 truncate() 函数来修剪蛋白质。

  • str.find()str.partition() 函数各自提供了在字符串中查找子序列的方法。

  • 正则表达式仍然是我在文本中查找模式的首选方法。

  • 复杂的正则表达式可以分行编写,并附有注释,这样 Python 会隐式地将它们连接成单个字符串。

第二部分:其他程序

在本部分的章节中,我将向您展示我编写的几个程序,这些程序捕捉了我在生物信息学中反复使用的模式。首先,我将向您展示如何编写一个程序来从序列文件中查找基本统计信息并格式化输出表格。接下来,我将演示如何使用头信息上的模式匹配来选择序列,然后是一个将从训练文件中学习到的数据创建人工 DNA 序列的程序。然后,我将展示一个探索随机性以对序列进行子采样的程序,并最后使用 Python 来解析带有和不带有头信息的分隔文本文件。我希望您能在这些程序中找到一些模式,以便在编写您自己的程序时使用。

第十五章:Seqmagique:创建和格式化报告

在生物信息学项目中,你经常会发现自己盯着一个目录,里面充满了序列文件,通常是 FASTA 或 FASTQ 格式。你可能希望首先了解文件中序列的分布情况,例如每个文件中有多少序列以及序列的平均长度、最小长度和最大长度。你需要知道是否有任何文件损坏了,也许它们从测序中心传输时没有完全完成,或者是否有任何样本读数远低,可能表明需要重新进行糟糕的测序运行。在本章中,我将介绍使用哈希检查序列文件的一些技术,并编写一个小型实用程序来模拟 Seqmagick 的部分功能,以说明如何创建格式化的文本表格。这个程序可以作为处理给定文件集中所有记录并生成汇总统计表格的任何程序的模板。

你将学到:

  • 如何安装 seqmagick 工具

  • 如何使用 MD5 哈希

  • 如何在 argparse 中使用 choices 限制参数

  • 如何使用 numpy 模块

  • 如何模拟文件句柄

  • 如何使用 tabulaterich 模块来格式化输出表格

使用 Seqmagick 分析序列文件

seqmagick 是一个处理序列文件的实用命令行工具。如果按照前言中的设置说明进行安装,它应该已经和其他 Python 模块一起安装好了。如果没有安装,你可以使用 pip 安装它:

$ python3 -m pip install seqmagick

如果你运行 seqmagick --help,你会看到这个工具提供了许多选项。我只想关注 info 子命令。我可以在 15_seqmagique 目录中的测试输入 FASTA 文件上运行它,如下所示:

$ cd 15_seqmagique
$ seqmagick info tests/inputs/*.fa
name                  alignment    min_len   max_len   avg_len  num_seqs
tests/inputs/1.fa     FALSE             50        50     50.00         1
tests/inputs/2.fa     FALSE             49        79     64.00         5
tests/inputs/empty.fa FALSE              0         0      0.00         0

在这个练习中,你将创建一个名为 seqmagique.py 的程序(应该用夸张的法语口音发音),它将模拟这个输出。该程序的目的是提供给定文件集中序列的基本概述,以便你可以发现例如截断或损坏的文件。

首先将解决方案复制到 seqmagique.py 并请求使用说明:

$ cp solution1.py seqmagique.py
$ ./seqmagique.py -h
usage: seqmagique.py [-h] [-t table] FILE [FILE ...]

Mimic seqmagick

positional arguments:
  FILE                  Input FASTA file(s) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

optional arguments:
  -h, --help            show this help message and exit
  -t table, --tablefmt table ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                        Tabulate table style (default: plain)

1

程序接受一个或多个应该是 FASTA 格式的输入文件。

2

这个选项控制输出表格的格式。

在相同的文件上运行这个程序,并注意输出几乎相同,只是省略了 alignment 列:

$ ./seqmagique.py tests/inputs/*.fa
name                     min_len    max_len    avg_len    num_seqs
tests/inputs/1.fa             50         50      50.00           1
tests/inputs/2.fa             49         79      64.00           5
tests/inputs/empty.fa          0          0       0.00           0

--tablefmt 选项控制输出表格的格式。这是你将编写的第一个程序,它限制了给定列表中的值。要看看它如何运行,请使用一个像 blargh 这样的虚假值:

$ ./seqmagique.py -t blargh tests/inputs/1.fa
usage: seqmagique.py [-h] [-t table] FILE [FILE ...]
seqmagique.py: error: argument -t/--tablefmt: invalid choice: 'blargh'
(choose from 'plain', 'simple', 'grid', 'pipe', 'orgtbl', 'rst',
 'mediawiki', 'latex', 'latex_raw', 'latex_booktabs')

然后尝试不同的表格格式,例如 simple

$ ./seqmagique.py -t simple tests/inputs/*.fa
name                     min_len    max_len    avg_len    num_seqs
---------------------  ---------  ---------  ---------  ----------
tests/inputs/1.fa             50         50      50.00           1
tests/inputs/2.fa             49         79      64.00           5
tests/inputs/empty.fa          0          0       0.00           0

运行程序与其他表格样式,然后尝试测试套件。接下来,我将讨论如何获取我们程序分析的数据。

使用 MD5 哈希检查文件

大多数基因组学项目的第一步将是将序列文件传输到某个可以分析它们的位置,并且防止数据损坏的第一道防线是确保文件完整复制。文件的来源可能是测序中心或像GenBankSequence Read Archive (SRA)这样的公共存储库。文件可能会通过存储卡送达,或者你可以从互联网下载它们。如果是后者,你可能会发现你的连接中断,导致一些文件被截断或损坏。如何找到这些类型的错误?

一个检查文件完整性的方法是在本地与服务器上的文件大小进行比较。例如,你可以使用ls -l命令查看文件的列表,其中显示以字节为单位的文件大小。对于大型序列文件,这将是一个非常大的数字,你将不得不手动比较源和目的地的文件大小,这很繁琐且容易出错。

另一种技术涉及使用文件的哈希消息摘要,这是由一种单向加密算法生成的文件内容签名,为每个可能的输入创建唯一的输出。尽管有许多工具可以用来创建哈希,我将专注于使用 MD5 算法的工具。这种算法最初是在密码学和安全领域开发的,但研究人员后来发现了许多缺陷,现在只适合用于验证数据完整性等目的。

在 macOS 上,我可以使用md5来从第一个测试输入文件的内容生成一个 128 位的哈希值,如下所示:

$ md5 -r tests/inputs/1.fa
c383c386a44d83c37ae287f0aa5ae11d tests/inputs/1.fa

我还可以使用openssl

$ openssl md5 tests/inputs/1.fa
MD5(tests/inputs/1.fa)= c383c386a44d83c37ae287f0aa5ae11d

在 Linux 上,我使用md5sum

$ md5sum tests/inputs/1.fa
c383c386a44d83c37ae287f0aa5ae11d  tests/inputs/1.fa

正如你所看到的,无论是什么工具或平台,对于相同的输入文件,哈希值都是相同的。如果我改变输入文件的任何一个比特,将会生成一个不同的哈希值。相反地,如果我找到另一个生成相同哈希值的文件,那么这两个文件的内容是相同的。例如,empty.fa 文件是我为测试创建的一个零长度文件,它具有以下哈希值:

$ md5 -r tests/inputs/empty.fa
d41d8cd98f00b204e9800998ecf8427e tests/inputs/empty.fa

如果我使用touch foo命令创建另一个空文件,我会发现它具有相同的签名:

$ touch foo
$ md5 -r foo
d41d8cd98f00b204e9800998ecf8427e foo

数据提供者通常会创建一个校验和文件,以便你可以验证数据的完整性。我创建了一个tests/inputs/checksums.md5如下所示:

$ cd tests/inputs
$ md5 -r *.fa > checksums.md5

它具有以下内容:

$ cat checksums.md5
c383c386a44d83c37ae287f0aa5ae11d 1.fa
863ebc53e28fdfe6689278e40992db9d 2.fa
d41d8cd98f00b204e9800998ecf8427e empty.fa

md5sum工具有一个--check选项,我可以使用它来自动验证文件与给定文件中的校验和是否匹配。macOS 的md5工具没有这个选项,但你可以使用brew install md5sha1sum来安装一个等效的md5sum工具来实现这个功能。

$ md5sum --check checksums.md5
1.fa: OK
2.fa: OK
empty.fa: OK

MD5 校验和提供了比手动检查文件大小更完整和更简便的数据完整性验证方式。虽然文件摘要不直接属于本练习的一部分,但在开始任何分析之前了解如何验证数据的完整性和未损坏性非常重要。

入门指南

你应该在15_seqmagique目录下进行此练习。我将像往常一样启动程序:

$ new.py -fp 'Mimic seqmagick' seqmagique.py
Done, see new script "seqmagique.py".

首先,我需要使程序接受一个或多个文本文件作为位置参数。我还想创建一个选项来控制输出表格的格式。以下是相应的代码:

import argparse
from typing import NamedTuple, TextIO, List

class Args(NamedTuple):
    """ Command-line arguments """
    files: List[TextIO]
    tablefmt: str

def get_args() -> Args:
    """Get command-line arguments"""

    parser = argparse.ArgumentParser(
        description='Argparse Python script',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('file', ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                        metavar='FILE',
                        type=argparse.FileType('rt'),
                        nargs='+',
                        help='Input FASTA file(s)')

    parser.add_argument('-t',
                        '--tablefmt',
                        metavar='table',
                        type=str,
                        choices= ![2
                            'plain', 'simple', 'grid', 'pipe', 'orgtbl', 'rst',
                            'mediawiki', 'latex', 'latex_raw', 'latex_booktabs'
                        ],
                        default='plain',
                        help='Tabulate table style')

    args = parser.parse_args()

    return Args(args.file, args.tablefmt)

1

为一个或多个可读文本文件定义一个位置参数。

2

定义一个选项,使用choices来限制参数为列表中的值,确保定义一个合理的default值。

使用choices选项来控制--tablefmt参数确实可以大大减少验证用户输入的工作量。如在“使用 Seqmagick 分析序列文件”,对于表格格式选项的错误值会触发有用的错误消息。

修改main()函数以打印输入文件名:

def main() -> None:
    args = get_args()
    for fh in args.files:
        print(fh.name)

并验证这是否有效:

$ ./seqmagique.py tests/inputs/*.fa
tests/inputs/1.fa
tests/inputs/2.fa
tests/inputs/empty.fa

目标是遍历每个文件并打印以下内容:

name

文件名

min_len

最短序列的长度

max_len

最长序列的长度

avg_len

所有序列的平均/均值长度

num_seqs

序列的数量

如果你想为程序准备一些真实的输入文件,可以使用fastq-dump工具从 NCBI 下载来自研究“北太平洋亚热带环流中的浮游微生物群落”的序列:

$ fastq-dump --split-3 SAMN00000013 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

--split-3选项将确保成对端读取正确分割为正向/反向/未配对的读取。SAMN00000013字符串是实验中一个样本的访问号。

使用 tabulate()格式化文本表格

程序的输出将是使用该模块的tabulate()函数格式化的文本表格。确保阅读文档:

>>> from tabulate import tabulate
>>> help(tabulate)

我需要为表格定义标题,并决定使用与 Seqmagick 相同的标题(减去alignment列):

>>> hdr = ['name', 'min_len', 'max_len', 'avg_len', 'num_seqs']

第一个测试文件tests/inputs/1.fa只有一个长度为 50 的序列,因此其列如下:

>>> f1 = ['tests/inputs/1.fa', 50, 50, 50.00, 1]

第二个测试文件tests/inputs/2.fa有五个序列,长度从 49 个碱基到 79 个碱基,平均长度为 64 个碱基:

>>> f2 = ['tests/inputs/2.fa', 49, 79, 64.00, 5]

tabulate()函数期望将表格数据作为一个列表的列表按位置传递,并且可以指定headers作为关键字参数:

>>> print(tabulate([f1, f2], headers=hdr))
name                 min_len    max_len    avg_len    num_seqs
-----------------  ---------  ---------  ---------  ----------
tests/inputs/1.fa         50         50         50           1
tests/inputs/2.fa         49         79         64           5

或者,我可以将标题放在数据的第一行,并指示这是标题的位置:

>>> print(tabulate([hdr, f1, f2], headers='firstrow'))
name                 min_len    max_len    avg_len    num_seqs
-----------------  ---------  ---------  ---------  ----------
tests/inputs/1.fa         50         50         50           1
tests/inputs/2.fa         49         79         64           5

注意,tabulate() 函数的默认表格样式是 simple,但我需要匹配 Seqmagick 的输出格式,所以我可以使用 tablefmt 选项设置为 plain

>>> print(tabulate([f1, f2], headers=hdr, tablefmt='plain'))
name                 min_len    max_len    avg_len    num_seqs
tests/inputs/1.fa         50         50         50           1
tests/inputs/2.fa         49         79         64           5

还有一点需要注意的是,avg_len 列中的值显示为整数,但应格式化为两位小数的浮点数。floatfmt 选项控制这一点,使用类似于我之前展示过的 f-string 数字格式化的语法:

>>> print(tabulate([f1, f2], headers=hdr, tablefmt='plain', floatfmt='.2f'))
name                 min_len    max_len    avg_len    num_seqs
tests/inputs/1.fa         50         50      50.00           1
tests/inputs/2.fa         49         79      64.00           5

你的任务是处理每个文件中的所有序列,找到统计数据并打印最终表格。这应该足以帮助你解决问题。在能够通过所有测试之前,请不要提前阅读。

解决方案

我将展示两种解决方案,它们都显示文件统计信息,但输出格式不同。第一个解决方案使用 tabulate() 函数创建 ASCII 文本表格,第二个使用 rich 模块创建更精美的表格,可以让你的实验室同事和主要研究员(PI)印象深刻。

解决方案 1:使用 tabulate() 进行格式化

对于我的解决方案,我首先决定编写一个 process() 函数来处理每个输入文件。每当我面对需要处理某些列表项的问题时,我更喜欢专注于如何处理其中的一个项。也就是说,我不是试图找出所有文件的所有统计信息,而是首先想弄清楚如何找到一个文件的信息。

我的函数需要返回文件名和四个指标:最小值、最大值、平均序列长度以及序列数。就像 Args 类一样,我喜欢为此创建一个基于 NamedTuple 的类型,这样我就可以拥有一个静态类型的数据结构,可以由 mypy 进行验证:

class FastaInfo(NamedTuple):
    """ FASTA file information """
    filename: str
    min_len: int
    max_len: int
    avg_len: float
    num_seqs: int

现在我可以定义一个函数来返回这个数据结构。请注意,我使用 numpy.mean() 函数来获取平均长度。numpy 模块提供了许多强大的数学操作来处理数值数据,特别适用于多维数组和线性代数函数。在导入依赖项时,通常使用 np 别名导入 numpy 模块:

import numpy as np
from tabulate import tabulate
from Bio import SeqIO

在 REPL 中运行 help(np) 可以查看文档。这是我编写此函数的方式:

def process(fh: TextIO) -> FastaInfo: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Process a file """

    if lengths := [len(rec.seq) for rec in SeqIO.parse(fh, 'fasta')]: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        return FastaInfo(filename=fh.name, ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
                         min_len=min(lengths), ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
                         max_len=max(lengths), ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
                         avg_len=round(float(np.mean(lengths)), 2), ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
                         num_seqs=len(lengths)) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

    return FastaInfo(filename=fh.name, ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
                     min_len=0,
                     max_len=0,
                     avg_len=0,
                     num_seqs=0)

1

该函数接受一个文件句柄,并返回一个 FastaInfo 对象。

2

使用列表推导式从文件句柄中读取所有序列。使用 len() 函数返回每个序列的长度。

3

文件的名称可以通过 fh.name 属性获得。

4

min()函数将返回最小值。

5

max()函数将返回最大值。

6

np.mean()函数将返回值列表的平均值。round()函数用于将此浮点值四舍五入为两位有效数字。

7

序列的数量是列表的长度。

8

如果没有序列,对所有值返回零。

无论何时,我都想为此编写一个单元测试。尽管我编写的集成测试覆盖了程序的这部分,但我想展示如何编写一个读取文件的函数的单元测试。而不是依赖实际文件,我将创建一个模拟或假文件句柄。

第一个测试文件如下所示:

$ cat tests/inputs/1.fa
>SEQ0
GGATAAAGCGAGAGGCTGGATCATGCACCAACTGCGTGCAACGAAGGAAT

我可以使用io.StringIO()函数创建一个类似文件句柄的对象:

>>> import io
>>> f1 = '>SEQ0\nGGATAAAGCGAGAGGCTGGATCATGCACCAACTGCGTGCAACGAAGGAAT\n' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
>>> fh = io.StringIO(f1) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
>>> for line in fh: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
...     print(line, end='') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
...
>SEQ0
GGATAAAGCGAGAGGCTGGATCATGCACCAACTGCGTGCAACGAAGGAAT

1

这是来自第一个输入文件的数据。

2

创建一个模拟文件句柄。

3

遍历模拟文件句柄的各行。

4

打印具有换行符(\n)的行,因此使用end=''以避免额外的换行符。

不过,有一个小问题,因为process()函数调用fh.name属性以获取输入文件名,这将引发异常:

>>> fh.name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: '_io.StringIO' object has no attribute 'name'

幸运的是,还有另一种方法可以使用 Python 的标准unittest模块创建模拟文件句柄。虽然我几乎在所有写作中更喜欢使用pytest模块,但unittest模块已存在很长时间,是另一个能够编写和运行测试的功能强大的框架。在这种情况下,我需要导入uni⁠t​test.mock.mock_open()函数。这是我如何使用来自第一个测试文件的数据创建模拟文件句柄。我使用read_data来定义将由fh.read()方法返回的数据:

>>> from unittest.mock import mock_open
>>> fh = mock_open(read_data=f1)()
>>> fh.read()
'>SEQ0\nGGATAAAGCGAGAGGCTGGATCATGCACCAACTGCGTGCAACGAAGGAAT\n'

在测试的上下文中,我不关心文件名,只要它返回一个字符串且不抛出异常即可:

>>> fh.name
<MagicMock name='open().name' id='140349116126880'>

虽然我经常将单元测试放在与它们测试的函数相同的模块中,但在这种情况下,我更愿意将其放在一个单独的unit.py模块中,以使主程序更短。我编写了测试来处理一个空文件,一个带有一个序列的文件和一个带有多个序列的文件(这也反映在三个输入测试文件中)。假设如果函数对这三种情况有效,则对于所有其他情况都应有效:

from unittest.mock import mock_open ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
from seqmagique import process ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

def test_process() -> None:
    """ Test process """

    empty = process(mock_open(read_data='')()) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert empty.min_len == 0
    assert empty.max_len == 0
    assert empty.avg_len == 0
    assert empty.num_seqs == 0

    one = process(mock_open(read_data='>SEQ0\nAAA')()) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    assert one.min_len == 3
    assert one.max_len == 3
    assert one.avg_len == 3
    assert one.num_seqs == 1

    two = process(mock_open(read_data='>SEQ0\nAAA\n>SEQ1\nCCCC')()) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    assert two.min_len == 3
    assert two.max_len == 4
    assert two.avg_len == 3.5
    assert two.num_seqs == 2

1

导入mock_open()函数。

2

导入我正在测试的process()函数。

3

一个模拟空文件句柄,所有值应该为零。

4

一个具有三个碱基的单一序列。

5

一个带有两个序列的文件句柄,一个有三个碱基,一个有四个碱基。

使用pytest运行测试:

$ pytest -xv unit.py
============================= test session starts ==============================
...

unit.py::test_process PASSED                                             [100%]

============================== 1 passed in 2.55s ===============================

这是我如何在main()中使用我的process()函数的方法:

def main() -> None:
    args = get_args()
    data = [process(fh) for fh in args.files] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    hdr = ['name', 'min_len', 'max_len', 'avg_len', 'num_seqs'] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    print(tabulate(data, tablefmt=args.tablefmt, headers=hdr, floatfmt='.2f')) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

将所有输入文件处理成一个FastaInfo对象(元组)列表。

2

定义表头。

3

使用tabulate()函数打印格式化的输出表格。

为了测试这个程序,我使用以下输入来运行它:

  • 空文件

  • 带有一个序列的文件

  • 具有两个序列的文件

  • 所有输入文件

要开始,我使用默认表格样式运行所有这些。然后我需要验证所有 10 个表格样式是否正确创建。将所有可能的测试输入与所有表格样式结合起来会产生很高的圈复杂度——参数可以组合的不同方式的数量。

要测试这个,我首先需要手动验证我的程序是否正确运行。然后我需要为我打算测试的每个组合生成样本输出。我编写了以下bash脚本来为给定的输入文件和可能的表格样式创建一个out文件:

$ cat mk-outs.sh
#!/usr/bin/env bash

PRG="./seqmagique.py" ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
DIR="./tests/inputs" ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
INPUT1="${DIR}/1.fa" ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
INPUT2="${DIR}/2.fa"
EMPTY="${DIR}/empty.fa"

$PRG $INPUT1 > "${INPUT1}.out" ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
$PRG $INPUT2 > "${INPUT2}.out"
$PRG $EMPTY > "${EMPTY}.out"
$PRG $INPUT1 $INPUT2 $EMPTY > "$DIR/all.fa.out"

STYLES="plain simple grid pipe orgtbl rst mediawiki latex latex_raw
 latex_booktabs"

for FILE in $INPUT1 $INPUT2; do ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    for STYLE in $STYLES; do
        $PRG -t $STYLE $FILE > "$FILE.${STYLE}.out"
    done
done

echo Done.

1

正在测试的程序。

2

输入文件的目录。

3

输入文件。

4

使用三个输入文件和默认的表格样式运行程序。

5

使用两个输入文件和所有表格样式运行程序。

tests/seqmagique_test.py中的测试将使用给定文件运行程序,并将输出与tests/inputs目录中的out文件之一进行比较。在此模块的顶部,我定义了输入和输出文件,如下所示:

TEST1 = ('./tests/inputs/1.fa', './tests/inputs/1.fa.out')

我在模块中定义了一个run()函数,用于使用输入文件运行程序,并将实际输出与预期输出进行比较。这是一个基本模式,你可以用来测试任何程序的输出:

def run(input_file: str, expected_file: str) -> None:
    """ Runs on command-line input """

    expected = open(expected_file).read().rstrip() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    rv, out = getstatusoutput(f'{RUN} {input_file}') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert rv == 0 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    assert out == expected ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

从文件中读取预期的输出。

2

使用默认的表格样式运行给定的输入文件。

3

检查返回值是否为0

4

检查输出是否符合预期值。

我是这样使用它的:

def test_input1() -> None:
    """ Runs on command-line input """

    run(*TEST1) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

将元组展开以将两个值按位置传递给run()函数。

测试套件还检查表格样式:

def test_styles() -> None:
    """ Test table styles """

    styles =  ![1
        'plain', 'simple', 'grid', 'pipe', 'orgtbl', 'rst', 'mediawiki',
        'latex', 'latex_raw', 'latex_booktabs'
    ]

    for file in [TEST1[0], TEST2[0]]: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        for style in styles: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            expected_file = file + '.' + style + '.out' ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            assert os.path.isfile(expected_file) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
            expected = open(expected_file).read().rstrip() ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
            flag = '--tablefmt' if random.choice([0, 1]) else '-t' ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
            rv, out = getstatusoutput(f'{RUN} {flag} {style} {file}') ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
            assert rv == 0 ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
            assert out == expected

1

定义一个包含所有可能样式的列表。

2

使用两个非空文件。

3

遍历每种样式。

4

输出文件是输入文件名加上样式和扩展名.out

5

检查文件是否存在。

6

从文件中读取预期值。

7

随机选择短或长标志进行测试。

8

使用标志选项、样式和文件运行程序。

9

确保程序无错误运行,并生成正确的输出。

如果我做了改动以致于程序不再创建与之前相同的输出,这些测试应该能够捕捉到。这是一种回归测试,我正在比较程序现在的工作方式与之前的工作方式。也就是说,无法生成相同输出将被视为一种回归。虽然我的测试套件并非完全详尽,但它涵盖了足够多的组合,我对程序的正确性感到有信心。

解决方案 2:使用 rich 进行格式化

在这第二个解决方案中,我想展示另一种创建输出表格的方法,使用 rich 模块来跟踪输入文件的处理并创建一个更漂亮的输出表格。图 15-1 展示了输出的外观。

mpfb 1501

图 15-1. 使用 rich 模块的进度指示器和输出表格更加华丽

我仍然以相同的方式处理文件,所不同的只是创建输出的方式。我首先需要导入所需的函数:

from rich.console import Console
from rich.progress import track
from rich.table import Table, Column

这是我使用它们的方式:

def main() -> None:
    args = get_args()

    table = Table('Name', ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                  Column(header='Min. Len', justify='right'),
                  Column(header='Max. Len', justify='right'),
                  Column(header='Avg. Len', justify='right'),
                  Column(header='Num. Seqs', justify='right'),
                  header_style="bold black")

    for fh in track(args.file): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        file = process(fh) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        table.add_row(file.filename, str(file.min_len), str(file.max_len), ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
                      str(file.avg_len), str(file.num_seqs))

    console = Console() ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    console.print(table)

1

创建表格来保存数据。名称列是一个标准的左对齐字符串字段。所有其他列都需要右对齐,并需要一个自定义的 Column 对象。

2

使用 track() 函数迭代每个文件句柄,为用户创建进度条。

3

处理文件以获取统计信息。

4

将文件的统计信息添加到表格中。注意,所有值必须是字符串。

5

创建一个 Console 对象,并用它来打印输出。

更进一步

seqmagick 工具还有许多其他有用的选项。尽量实现您能实现的所有版本。

复习

本章要点:

  • seqmagick 工具提供了许多检查序列文件的方法。

  • 有许多方法可以验证您的输入文件是否完整且未损坏,从检查文件大小到使用 MD5 哈希等消息摘要。

  • argparse 参数的 choices 选项将强制用户从给定列表中选择一个值。

  • tabulaterich 模块可以创建数据的文本表格。

  • numpy 模块对许多数学操作都很有用。

  • io.StringIO()unittest.mock.mock_open() 函数提供了两种模拟文件句柄进行测试的方法。

  • 回归测试验证程序继续像之前一样工作。

第十六章:FASTX grep:创建一个选择序列的实用程序

有一次,一个同事让我找出 FASTQ 文件中所有具有包含字符串LSU(表示长亚单位RNA)的描述或名称的 RNA 序列。虽然可以通过使用grep程序在 FASTQ 文件中找到与某些模式匹配的所有行来解决此问题^(1),但是使用 Python 编写解决方案可以创建一个可以扩展到处理其他格式(如 FASTA)的程序,并且可以根据其他标准(如长度或 GC 含量)选择记录。此外,您还可以添加选项以更改输出序列格式,并为用户提供方便,例如根据文件扩展名猜测输入文件的格式。

在本章中,你将学到:

  • 关于 FASTQ 文件的结构

  • 如何执行不区分大小写的正则表达式匹配

  • 有关在代码中实现 DWIM(做我想要的)和 DRY(不要重复自己)思想

  • 如何使用andor操作来减少布尔值和位

使用 grep 在文件中查找行

grep程序可以找到文件中与给定模式匹配的所有行。如果我在 FASTQ 文件中搜索LSU,它会找到包含此模式的两个标题行:

$ grep LSU tests/inputs/lsu.fq
@ITSLSUmock2p.ITS_M01380:138:000000000-C9GKM:1:1101:14440:2042 2:N:0
@ITSLSUmock2p.ITS_M01384:138:000000000-C9GKM:1:1101:14440:2043 2:N:0

如果目标只是查找多少序列包含此字符串,我可以将其管道传递到wc(单词计数)以使用-l选项计算行数:

$ grep LSU tests/inputs/lsu.fq | wc -l
       2

由于我的目标是提取头部包含子字符串LSU的序列记录,我必须做更多的工作。只要输入文件是 FASTQ 格式,我仍然可以使用grep,但这需要更好地理解格式。

FASTQ 记录的结构

FASTQ 序列格式是从测序仪接收序列数据的常见方式,因为它包括每个碱基的碱基调用和质量分数。也就是说,测序仪通常报告一个碱基及其正确的测量确定性。例如,一些测序技术可能在同源聚合物串中遇到麻烦,比如许多A的聚(A)串,其中测序仪可能无法正确计数。随着读数变长,许多测序仪也会对碱基调用失去信心。质量分数是拒绝或截断低质量读数的重要手段。

根据测序仪的不同,有些碱基可能很难区分,模糊性可能会使用我在第一章中描述的 IUPAC 代码来报告,例如用R表示AG,或者用N表示任意碱基。

FASTQ 格式在许多 Rosalind 挑战中使用的 FASTA 格式有些相似。作为提醒,FASTA 记录以>符号开头,后跟标识序列并可能包含元数据的标题行。然后是序列本身,可能是一行(可能很长)文本,也可能是分成多行。相比之下,FASTQ 记录必须始终是确切的四行,如图 16-1 所示。

mpfb 1601

图 16-1. FASTQ 记录的元素 —— 尽管此显示中的长行已换行,但实际记录包含四行。

让我们仔细看一下这个图的内容:

  1. 第一行以 @ 符号开头,并包含头部信息。

  2. 第二行包含没有换行的序列。

  3. 第三行以 + 符号开头。通常只有这个符号,但有时头部信息可能会重复。

  4. 第四行包含序列中每个碱基的质量分数,且没有换行。

  5. 序列 ID 是直到第一个空格的所有字符。

  6. 附加元数据可能跟随 ID 并包含在描述中。

  7. 序列中的每个碱基在质量行中都有一个对应的伙伴,表示此碱基正确的置信度。

FASTQ 头部具有与 FASTA 记录中头部相同的结构,唯一的区别是它以 @ 符号而不是 > 符号开头。序列标识符通常是从 @ 到第一个空格之间的所有字符。包含序列的第二行不能包含任何换行,序列中的每个碱基都有对应的质量值在第四行中。第四行上的质量分数使用字符的 ASCII 值来编码基本调用的确定性。这些分数首先使用 ASCII 表中的可打印字符,最初是在 第三章 中介绍的。

ASCII 表中的前 32 个值是不可打印的控制字符和空格。可打印字符从第 33 个开始,标点符号后跟数字。第一个字母 A 直到 65 才出现,大写字符在小写字符之前。以下是存储库中包含的 asciitbl.py 程序输出的 ASCII 表中 128 个值的序号值:

$ ./asciitbl.py
  0 NA      26 NA      52 4       78 N      104 h
  1 NA      27 NA      53 5       79 O      105 i
  2 NA      28 NA      54 6       80 P      106 j
  3 NA      29 NA      55 7       81 Q      107 k
  4 NA      30 NA      56 8       82 R      108 l
  5 NA      31 NA      57 9       83 S      109 m
  6 NA      32 SPACE   58 :       84 T      110 n
  7 NA      33 !       59 ;       85 U      111 o
  8 NA      34 "       60 <       86 V      112 p
  9 NA      35 #       61 =       87 W      113 q
 10 NA      36 $       62 >       88 X      114 r
 11 NA      37 %       63 ?       89 Y      115 s
 12 NA      38 &       64 @       90 Z      116 t
 13 NA      39 '       65 A       91 [      117 u
 14 NA      40 (       66 B       92 \      118 v
 15 NA      41 )       67 C       93 ]      119 w
 16 NA      42 *       68 D       94 ^      120 x
 17 NA      43 +       69 E       95 _      121 y
 18 NA      44 ,       70 F       96 `      122 z
 19 NA      45 -       71 G       97 a      123 {
 20 NA      46 .       72 H       98 b      124 |
 21 NA      47 /       73 I       99 c      125 }
 22 NA      48 0       74 J      100 d      126 ~
 23 NA      49 1       75 K      101 e      127 DEL
 24 NA      50 2       76 L      102 f
 25 NA      51 3       77 M      103 g

查看 图 16-1 中 FASTQ 记录的质量行,看看字符是如何从开始的大写字母到末尾的标点符号和数字进行变化的。请注意,第四行上的 @+ 符号表示可能的质量值,因此它们不是表示记录开头或分隔线的元字符。因此,FASTQ 记录不能使用换行符来断开序列(如 FASTA 记录)或质量行:符号 @+ 可能会成为行中的第一个字符,使得无法找到记录的起始位置。结合这一点与通常由一个单独的 + 符号组成的完全无用的第三行,有时无端地重述了所有的头部信息,你就会明白为什么不应该让生物学家定义文件格式。

有多种使用不同范围表示质量分数的编码标准。

因为 FASTQ 记录必须是四行长,我可以使用 -A|--after-context 选项来指定每个匹配后面的行数:

$ grep -A 4 LSU tests/inputs/lsu.fq | head -4
@ITSLSUmock2p.ITS_M01380:138:000000000-C9GKM:1:1101:14440:2042 2:N:0
CAAGTTACTTCCTCTAAATGACCAAGCCTAGTGTAGAACCATGTCGTCAGTGTCAGTCTGAGTGTAGATCT\
CGGTGGTCGCCGTATCATTAAAAAAAAAAATGTAATACTACTAGTAATTATTAATATTATAATTTTGTCTA\
TTAGCATCTTATTATAGATAGAAGATATTATTCATATTTCACTATCTTATACTGATATCAGCTTTATCAGA\
TCACACTCTAGTGAAGATTGTTCTTAACTGAAATTTCCTTCTTCATACAGACACATTAATCTTACCTA
+
EFGGGGGGGGGCGGGGGFCFFFGGGGGFGGGGGGGGGGGFGGGGGGGFGFFFCFGGFFGGGGGGGGGFGGG\
GFGGGDG<FD@4@CFFGGGGCFFAFEFEG+,9,,,,99,,,5,,49,4,8,4,444,4,4,,,,,,,,,,,\
,,,8,,,,63,,,,,,,,376,3,,,,,,,8,,,,,,,,,+++++++++++++3++25+++0+*+0+*0+*\
**))*0))1/+++**************.****.*******0*********/(,(/).)))1)).).).

只要感兴趣的子字符串仅出现在头部(记录的第一行),这将有效。如果 grep 成功在记录的任何其他行中找到匹配项,它将打印该行及其接下来的三行,导致无法使用的垃圾。考虑到我想精确控制要搜索的记录部分以及输入文件可能是 FASTQ、FASTA 或任意其他格式,很快就会显而易见,grep 不能带我走得很远。

入门指南

首先,我会展示我的解决方案是如何工作的,然后挑战你来实现你自己的版本。此练习的所有代码和测试都在仓库的 16_fastx_grep 目录中。首先进入此目录,并将解决方案复制到 fastx_grep.py

$ cd 16_fastx_grep
$ cp solution.py fastx_grep.py

grep 的使用显示它接受两个位置参数,一个模式和一个或多个文件:

$ grep -h
usage: grep [-abcDEFGHhIiJLlmnOoqRSsUVvwxZ] [-A num] [-B num] [-C[num]]
	[-e pattern] [-f file] [--binary-files=value] [--color=when]
	[--context[=num]] [--directories=action] [--label] [--line-buffered]
	[--null] [pattern] [file ...]

请求 fastx_grep.py 程序的帮助,并查看它具有类似的接口,需要一个模式和一个或多个输入文件。此外,此程序可以解析不同的输入文件格式,生成各种输出格式,将输出写入文件,并执行不区分大小写的匹配:

$ ./fastx_grep.py -h
usage: fastx_grep.py [-h] [-f str] [-O str] [-o FILE] [-i]
                     PATTERN FILE [FILE ...]

Grep through FASTX files

positional arguments:
  PATTERN               Search pattern ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
  FILE                  Input file(s) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

optional arguments:
  -h, --help            show this help message and exit
  -f str, --format str  Input file format (default: ) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
  -O str, --outfmt str  Output file format (default: ) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
  -o FILE, --outfile FILE
                        Output file (default: <_io.TextIOWrapper ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
                        name='<stdout>' mode='w' encoding='utf-8'>)
  -i, --insensitive     Case-insensitive search (default: False) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

1

正则表达式(模式)是第一个位置参数。

2

需要第二个的一个或多个位置文件参数。

3

序列的输入文件格式,可以是 fastafastq。默认情况下会从文件扩展名猜测。

4

输出文件格式,可以是 fastafastqfasta-2line 中的一种。默认情况下与输入文件相同。

5

输出文件名;默认为 STDOUT

6

是否执行不区分大小写的匹配;默认为 False

此程序具有比第一部分的许多程序更复杂的参数集合。像往常一样,我喜欢使用 NamedTuple 来模拟选项:

from typing import List, NamedTuple, TextIO

class Args(NamedTuple):
    """ Command-line arguments """
    pattern: str ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    files: List[TextIO] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    input_format: str ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    output_format: str ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    outfile: TextIO ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    insensitive: bool ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

1

要使用的正则表达式。

2

一个或多个输入文件。

3

输入文件的格式,例如 FASTA 或 FASTQ。

4

输出文件的格式。

5

输出文件的名称。

6

是否执行不区分大小写的搜索。

这是我定义程序参数的方法:

def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Grep through FASTX files',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('pattern', ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                        metavar='PATTERN',
                        type=str,
                        help='Search pattern')

    parser.add_argument('file',
                        metavar='FILE',
                        nargs='+',
                        type=argparse.FileType('rt'), ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                        help='Input file(s)')

    parser.add_argument('-f',
                        '--format',
                        help='Input file format',
                        metavar='str',
                        choices=['fasta', 'fastq'], ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
                        default='')

    parser.add_argument('-O',
                        '--outfmt',
                        help='Output file format',
                        metavar='str',
                        choices=['fasta', 'fastq', 'fasta-2line'], ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
                        default='')

    parser.add_argument('-o',
                        '--outfile',
                        help='Output file',
                        type=argparse.FileType('wt'), ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
                        metavar='FILE',
                        default=sys.stdout)

    parser.add_argument('-i', ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
                        '--insensitive',
                        help='Case-insensitive search',
                        action='store_true')

    args = parser.parse_args()

    return Args(pattern=args.pattern,
                files=args.file,
                input_format=args.format,
                output_format=args.outfmt,
                outfile=args.outfile,
                insensitive=args.insensitive)

1

模式将是一个字符串。

2

输入必须是可读的文本文件。

3

使用choices来约束输入值。默认值将从输入文件扩展名中猜测。

4

使用choices来约束值;默认使用输入格式。fasta-2line选项不会将长序列分割成多行,因此每条记录只使用两行。

5

输出文件将是可写的文本文件。默认为STDOUT

6

一个标志来指示不区分大小写的搜索。默认值为False

如果您运行以下命令在lsu.fq测试文件中搜索LSU,您应该看到八行输出,表示两个 FASTQ 记录:

$ ./fastx_grep.py LSU tests/inputs/lsu.fq | wc -l
       8

但是,如果您搜索小写的lsu,您应该看不到输出:

$ ./fastx_grep.py lsu tests/inputs/lsu.fq | wc -l
       0

使用-i|--insensitive标志执行不区分大小写的搜索:

$ ./fastx_grep.py -i lsu tests/inputs/lsu.fq  | wc -l
       8

您可以使用-o|--outfile选项将结果写入文件,而不是STDOUT

$ ./fastx_grep.py -o out.fq -i lsu tests/inputs/lsu.fq
$ wc -l out.fq
       8 out.fq

如果您查看out.fq文件,您会看到它的格式与原始输入一样是 FASTQ 格式。您可以使用-O|--outfmt选项将其更改为类似 FASTA 的格式,并查看输出文件以验证格式:

$ ./fastx_grep.py -O fasta -o out.fa -i lsu tests/inputs/lsu.fq
$ head -3 out.fa
>ITSLSUmock2p.ITS_M01380:138:000000000-C9GKM:1:1101:14440:2042 2:N:0
CAAGTTACTTCCTCTAAATGACCAAGCCTAGTGTAGAACCATGTCGTCAGTGTCAGTCTG
AGTGTAGATCTCGGTGGTCGCCGTATCATTAAAAAAAAAAATGTAATACTACTAGTAATT

尝试使用fasta-2line输出格式,看看长序列如何不会被分割成多行。请注意,该程序也可以处理 FASTA 输入,无需我指示文件格式,因为它是从.fa文件扩展名中猜测出来的:

$ ./fastx_grep.py -o out.fa -i lsu tests/inputs/lsu.fa
$ ../15_seqmagique/seqmagique.py out.fa
name      min_len    max_len    avg_len    num_seqs
out.fa        281        301     291.00           2

运行pytest -v以查看程序的所有测试,这些测试包括猜测文件格式、处理空文件、搜索小写和大写输入,同时包括大小写敏感和不敏感的情况、写入输出文件以及写入不同的输出格式。当您认为自己了解了程序必须处理的所有选项时,请重新开始:

$ new.py -fp 'Grep through FASTX files' fastx_grep.py
Done, see new script "fastx_grep.py".

猜测文件格式

如果您查看前一节中创建的out.fa,您会发现它是以 FASTA 格式保存的,与输入格式匹配,但我从未指定过输入文件格式。程序智能地检查输入文件的文件扩展名,并根据表 16-1 中的假设猜测格式。类似地,如果没有指定输出格式,则假定输入文件格式为所需的输出格式。这是软件开发中DWIM原则的一个例子:做我所想要的。

表 16-1. FASTA/Q 文件的常见文件扩展名

Extension Format
.fasta FASTA
.fa FASTA
.fna FASTA(核苷酸)
.faa FASTA(氨基酸)
.fq FASTQ
.fastq FASTQ

您的程序同样需要猜测输入文件的格式。我创建了一个guess_format()函数,它接受文件名并返回一个字符串,要么是fasta,要么是fastq。这是函数的一个桩代码:

def guess_format(filename: str) -> str:
    """ Guess format from extension """

    return ''

这是我写的测试。在定义了参数之后,我建议您从这个函数开始。在您的代码通过这个测试之前,请不要继续:

def test_guess_format() -> None:
    """ Test guess_format """

    assert guess_format('/foo/bar.fa') == 'fasta'
    assert guess_format('/foo/bar.fna') == 'fasta'
    assert guess_format('/foo/bar.faa') == 'fasta'
    assert guess_format('/foo/bar.fasta') == 'fasta'
    assert guess_format('/foo/bar.fq') == 'fastq'
    assert guess_format('/foo/bar.fastq') == 'fastq'
    assert guess_format('/foo/bar.fx') == ''

这可能有助于勾勒出程序应该如何工作:

def main():
    get the program arguments

    for each input file:
        guess the input format or complain that it can't be guessed
        figure out the output format from the args or use the input format

        for each record in the input file:
            if the sequence ID or description matches the pattern:
                write the sequence to the output file in the output format

例如,我可以通过使用 shell glob *.f[aq]在三个输入文件上运行程序,以指示所有以字母f开头并以字母aq结尾的文件:

$ ls tests/inputs/*.f[aq]
tests/inputs/empty.fa  tests/inputs/lsu.fa    tests/inputs/lsu.fq

这应该向文件out.fa写入四个序列:

$ ./fastx_grep.py -O fasta -o out.fa -i lsu tests/inputs/*.f[aq]
$ ../15_seqmagique/seqmagique.py out.fa
name      min_len    max_len    avg_len    num_seqs
out.fa        281        301     291.00           4

这是一个复杂的程序,可能需要您相当长的时间才能完成。在你的挣扎中有价值,所以只需继续编写并运行测试,你还应该阅读以了解如何挑战你的程序。

解决方案

根据我的经验,这是一个真实复杂的程序,捕捉了我经常编写的许多模式。它开始验证和处理一些输入文件。我是一个真正懒惰的程序员^(2),总是希望给我的程序尽可能少的信息,所以我很高兴写一点代码来猜测文件格式。

从文件扩展名猜测文件格式

我将从猜测文件格式的函数开始:

def guess_format(filename: str) -> str:
    """ Guess format from extension """

    ext = re.sub('^[.]', '', os.path.splitext(filename)[1]) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    return 'fasta' if re.match('f(ast|a|n)?a$', ext) else 'fastq' if re.match( ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        'f(ast)?q$', ext) else ''

1

使用os.path.splitext()函数获取文件扩展名并去除前导点。

2

如果扩展名与 Table 16-1 中的 FASTA 文件模式之一匹配,则返回字符串 fasta;如果匹配 FASTQ 模式,则返回 fastq;否则返回空字符串。

os.path.splitext() 函数将文件名的根和扩展名作为一个 2 元组返回:

>>> import os
>>> os.path.splitext('/foo/bar.fna')
('/foo/bar', '.fna')

由于我只关心第二部分,我可以使用 _ 将元组的第一个成员赋值给一个丢弃变量:

>>> _, ext = os.path.splitext('/foo/bar.fna')
>>> ext
'.fna'

相反,我选择索引元组以仅选择扩展名:

>>> ext = os.path.splitext('/foo/bar.fna')[1]
>>> ext
'.fna'

因为我不想要前导点,我可以使用字符串切片来移除它,但这看起来对我来说既神秘又难以理解:

>>> ext = os.path.splitext('/foo/bar.fna')[1][1:]
>>> ext
'fna'

相反,我更喜欢使用我在 Chapter 2 中首次介绍的 re.sub() 函数。我要查找的模式是字符串开头的字面点。插入符号 ^ 表示字符串的开头,. 是一个元字符,表示任何字符。为了显示我想要一个字面点,我必须在其前面加上反斜杠,如 ^\.,或者将其放在字符类中,如 ^[.]

>>> import re
>>> ext = re.sub('^[.]', '', os.path.splitext('/foo/bar.fna')[1]) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
>>> ext
'fna'

1

使用 re.sub() 函数去除文件扩展名开头的字面点。

如 Table 16-1 所示,有四个常见的 FASTA 文件扩展名,可以用一个紧凑的正则表达式表示。回想一下,re 模块中有两个用于搜索的函数:

  • re.match() 函数用于从字符串开头找到匹配项。

  • re.search() 函数可以在字符串的任意位置找到匹配项。

在这个例子中,我使用 re.match() 函数来确保模式(第一个参数)在扩展名(第二个参数)的开头找到匹配项:

>>> re.match('f(ast|a|n)?a$', ext)
<re.Match object; span=(0, 3), match='fna'>

要从 re.search() 中获得相同的结果,我需要在模式的开始处使用插入符号来锚定字符串的开始:

>>> re.search('^f(ast|a|n)?a$', ext)
<re.Match object; span=(0, 3), match='fna'>

Figure 16-2 描述了正则表达式的每个部分。

mpfb 1602

图 16-2. 用于匹配四种 FASTA 模式的正则表达式

作为有限状态机图的绘制,可能会有所帮助,如 Figure 16-3 所示。

mpfb 1603

图 16-3. 用于匹配四种 FASTA 模式的有限状态机图

由于 FASTQ 文件只有两种模式,因此模式相对较简单:

>>> re.search('^f(ast)?q$', 'fq')
<re.Match object; span=(0, 2), match='fq'>
>>> re.search('^f(ast)?q$', 'fastq')
<re.Match object; span=(0, 5), match='fastq'>

Figure 16-4 解释了这个正则表达式。

mpfb 1604

图 16-4. 用于匹配两种 FASTQ 模式的正则表达式

Figure 16-5 展示了相同的想法作为有限状态机的表示。

mpfb 1605

图 16-5. 用于匹配两种 FASTQ 模式的有限状态机图

当计划顺利进行时,我非常喜欢它

下面是我如何在 main() 中使用我在本章第一部分介绍的结构编写的:

def main() -> None:
    args = get_args()
    regex = re.compile(args.pattern, re.IGNORECASE if args.insensitive else 0) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    for fh in args.files: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        input_format = args.input_format or guess_format(fh.name) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

        if not input_format: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            sys.exit(f'Please specify file format for "{fh.name}"')

        output_format = args.output_format or input_format ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

        for rec in SeqIO.parse(fh, input_format): ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
            if any(map(regex.search, [rec.id, rec.description])): ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
                SeqIO.write(rec, args.outfile, output_format) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

1

编译正则表达式以找到给定的模式。

2

遍历输入文件。

3

使用输入格式或从文件名猜测它。

4

如果没有输入文件格式,则报错退出。

5

使用输出格式或使用输入格式。

6

遍历文件中的每个序列。

7

查看序列 ID 或描述是否与模式匹配。

8

如果是这样,请将序列写入输出文件。

有几件事我想要强调,我将从使用sys.exit()来中断程序处理文件的过程中,如果我无法决定输出文件格式。这是一个我不一定期望用户提供的值,希望在程序运行时能够弄清楚。如果不能,那么我需要向用户返回一个错误消息,并向操作系统返回一个退出值,指示失败。我需要用户重新开始并纠正缺失的信息,然后才能继续。

我还要指出我使用any()函数的地方,该函数在all()函数中有一个类似物。这两个函数将一系列真值值减少为单个布尔值。all()函数将返回True,如果所有值为真,否则为False

>>> all([True, True, True])
True
>>> all([True, False, True])
False

any()函数返回True时,任何值为真,否则为False

>>> any([True, False, True])
True
>>> any([False, False, False])
False

我将此与编译的正则表达式一起使用,搜索记录的 ID 和描述字段。该正则表达式还使用re.IGNORECASE标志来开启不区分大小写的匹配。为了解释这一点,我想离题讨论一下 Python 如何使用andor结合布尔值,以及使用相应的位运算符&|

合并正则表达式搜索标志

默认情况下,正则表达式区分大小写,但该程序需要处理大小写敏感和不敏感的搜索。例如,如果我搜索小写的lsu但记录标头只有大写的LSU,我期望这次搜索失败:

>>> import re
>>> type(re.search('lsu', 'This contains LSU'))
<class 'NoneType'>

一种忽略大小写的方法是强制搜索模式和字符串都转换为大写或小写:

>>> re.search('lsu'.upper(), 'This contains LSU'.upper())
<re.Match object; span=(14, 17), match='LSU'>

另一种方法是向re.search()函数提供一个可选标志:

>>> re.search('lsu', 'This contains LSU', re.IGNORECASE)
<re.Match object; span=(14, 17), match='LSU'>

可以缩短为re.I

>>> re.search('lsu', 'This contains LSU', re.I)
<re.Match object; span=(14, 17), match='LSU'>

在程序中,当我编译正则表达式时使用这个:

regex = re.compile(args.pattern, re.IGNORECASE if args.insensitive else 0) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

1

如果args.insensitiveTrue,则在编译模式时使用re.IGNORECASE选项;否则,使用0,表示没有选项。

我首先展示了如何在第十一章中编译正则表达式。优点是 Python 只需解析模式一次,通常可以使代码运行更快。这里我需要决定是否使用可选标志进行不区分大小写匹配。我可以使用其他标志改变正则表达式匹配的许多方面,这些标志可以使用位或|运算符组合。我认为最好从help(re)的文档开始:

Each function other than purge and escape can take an optional 'flags' argument
consisting of one or more of the following module constants, joined by "|".
A, L, and U are mutually exclusive.
    A  ASCII       For string patterns, make \w, \W, \b, \B, \d, \D
                   match the corresponding ASCII character categories
                   (rather than the whole Unicode categories, which is the
                   default).
                   For bytes patterns, this flag is the only available
                   behaviour and needn't be specified.
    I  IGNORECASE  Perform case-insensitive matching.
    L  LOCALE      Make \w, \W, \b, \B, dependent on the current locale.
    M  MULTILINE   "^" matches the beginning of lines (after a newline)
                   as well as the string.
                   "$" matches the end of lines (before a newline) as well
                   as the end of the string.
    S  DOTALL      "." matches any character at all, including the newline.
    X  VERBOSE     Ignore whitespace and comments for nicer looking RE's.
    U  UNICODE     For compatibility only. Ignored for string patterns (it
                   is the default), and forbidden for bytes patterns.

仔细观察,我发现re.IGNORECASE是一个enum枚举,具有可能值:

>>> type(re.IGNORECASE)
<enum 'RegexFlag'>

根据文档,这是“enum.IntFlag的子类”描述如下:(https://oreil.ly/l1dyG)

创建枚举常量的基类,可以使用位运算符组合它们而不失去它们的IntFlag成员资格。IntFlag成员也是int的子类。

这意味着re.IGNORECASE深层次上是一个int,就像False实际上是0True实际上是1一样。我进行了一些侦探工作,通过添加0来确定标志的整数值:

>>> for flag in sorted([re.A, re.I, re.L, re.M, re.S, re.X, re.U]):
...     print(f'{flag:15} {flag + 0:5} {0 + flag:#011b}')
...
re.IGNORECASE       2 0b000000010
re.LOCALE           4 0b000000100
re.MULTILINE        8 0b000001000
re.DOTALL          16 0b000010000
re.UNICODE         32 0b000100000
re.VERBOSE         64 0b001000000
re.ASCII          256 0b100000000

注意每个值都是 2 的幂,因此每个标志可以由单个唯一位表示。这使得可以使用文档中提到的|运算符组合标志。为了演示,我可以使用前缀0b表示原始字节字符串。以下是数字 1 和 2 的二进制表示。请注意,每个值仅使用一个设置为 1 的位:

>>> one = 0b001
>>> two = 0b010

如果我使用|来对位进行操作,每个三位数都将使用表 16-2 中显示的真值表进行组合。

表 16-2. 或运算(|)的真值表

第一 第二 结果
T T T
T F T
F T T
F F F

如图 16-6 所示,Python 将查看每个位,并且如果任一位为 1,则选择 1,仅当两个位都为 0 时,结果为 0,导致0b011,这是数字 3 的二进制表示,因为位置 1 和 2 的位均已设置:

>>> one | two
3

mpfb 1606

图 16-6. 当对每列位进行或运算时,任何位置为 1 的情况都会得到 1;如果所有位都为 0,则结果为 0。

当使用&运算符时,Python 仅当两个位都为 1 时才会返回 1;否则,会返回 0,如表 16-3 所示。

表 16-3. 与运算(&)的真值表

第一 第二 结果
T T T
T F F
F T F
F F F

因此,使用&来组合onetwo将导致值为0b000,这是 0 的二进制表示:

>>> one & two
0

我可以使用|运算符来连接多个正则表达式标志位。例如,re.IGNORECASE是 2,表示为0b010re.LOCALE是 4,表示为0b100。按位或将它们组合为0b110,这是数字 6:

>>> 0b010 | 0b100
6

我可以验证这一点:

>>> (re.IGNORECASE | re.LOCALE) == 6
True

要返回到re.compile()函数,默认情况下是区分大小写的:

>>> regex = re.compile('lsu')
>>> type(regex.search('This contains LSU'))
<class 'NoneType'>

如果用户想要执行不区分大小写的搜索,那么我想要执行类似这样的操作:

>>> regex = re.compile('lsu', re.IGNORECASE)
>>> regex.search('This contains LSU')
<re.Match object; span=(14, 17), match='LSU'>

避免这种情况的一种方法是使用一个if语句:

regex = None
if args.insensitive:
    regex = re.compile(args.pattern, re.IGNORECASE)
else:
    regex = re.compile(args.pattern)

我不喜欢这个解决方案,因为它违反了DRY原则:不要重复自己。我可以编写一个if表达式来选择re.IGNORECASE标志或一些表示无标志的默认值,这个值恰好是数字 0:

regex = re.compile(args.pattern, re.IGNORECASE if args.insensitive else 0)

如果我想要扩展这个程序以包括文档中的任何其他搜索标志,我可以使用|来组合它们。第六章和第十二章讨论了将多个值减少为单个值的思想。例如,我可以使用加法将数字列表减少到它们的和,或者使用乘法创建乘积,并使用str.join()函数将字符串列表减少到单个值。我可以类似地使用按位|来减少所有的正则表达式标志:

>>> (re.A | re.I | re.L | re.M | re.S | re.X | re.U) + 0
382

因为这些标志使用唯一的位,所以可以通过使用&运算符来确定特定位是否处于打开状态,从而确切地找出生成特定值时使用了哪些标志。例如,前面我展示了如何使用|组合标志re.IGNORECASEre.LOCALE

>>> flags = re.IGNORECASE | re.LOCALE

要查看flags变量中是否存在特定标志,我使用&。当我进行操作时,只有两个值中都存在的 1 位才会返回:

>>> flags & re.IGNORECASE
re.IGNORECASE

如果我一个不在组合值中的标志,结果将为 0:

>>> (flags & re.VERBOSE) + 0
0

关于组合位的信息太多了。所以,如果你不知道,现在你知道了。

减少布尔值

我想把这个带回我在这个程序中使用的any()函数。与整数值的按位组合类似,我可以类似地减少多个布尔值。也就是说,这里是与表 16-2 中相同的信息,使用or运算符来组合布尔值:

>>> True or True
True
>>> True or False
True
>>> False or True
True
>>> False or False
False

这与使用any()和布尔值列表是一样的。如果任何值为真,则整个表达式为True

>>> any([True, True])
True
>>> any([True, False])
True
>>> any([False, True])
True
>>> any([False, False])
False

这里是与表 16-3 中相同的数据,使用and来组合布尔值:

>>> True and True
True
>>> True and False
False
>>> False and True
False
>>> False and False
False

这与使用all()是一样的。只有当所有值都为真时,整个表达式才为True

>>> all([True, True])
True
>>> all([True, False])
False
>>> all([False, True])
False
>>> all([False, False])
False

这是我使用这个想法的代码行:

if any(map(regex.search, [rec.id, rec.description])):

map() 函数将每个 rec.idrec.description 的值传递给 regex.search() 函数,返回一个可以解释其真实性的值列表。如果其中任何一个是真的——意味着至少在一个字段中找到了匹配——则 any() 将返回 True,并且应将序列写入输出文件。

进一步探索

有时序列头部包含键/值元数据,如“Organism=Oryza sativa”。添加一个选项来搜索这些值。确保将输入文件示例添加到 tests/inputs 目录,并将相应的测试添加到 tests/fastx_grep_test.py 中。

扩展程序以处理额外的输入序列格式,如 GenBank、EMBL 和 SwissProt。同样,请确保添加示例文件和测试以确保程序正常工作。

修改程序以选择具有一些最小长度和质量分数的序列。

复习

本章的重点:

  • FASTQ 文件格式要求每个记录由四行表示:一个头部、序列、一个分隔符和质量分数。

  • 正则表达式匹配可以接受控制标志,例如是否执行大小写不敏感匹配。默认情况下,正则表达式是区分大小写的。

  • 要指定多个正则表达式标志,请使用 |)按位运算符来组合标志的整数值。

  • 布尔值可以使用 andor 操作符以及 any()all() 函数进行简化。

  • DWIM(做我想要的)美学意味着你尝试预测用户自然和智能地希望程序做什么。

  • DRY(不要重复自己)原则意味着你永远不要在代码中重复相同的想法,而是将其隔离到一个位置或函数中。

^(1) 有人说这是 全局正则表达式打印 的缩写。

^(2) 根据Perl 编程(Tom Christiansen 等著,O’Reilly,2012)中的描述,程序员的三大美德是懒惰、急躁和傲慢。

第十七章:DNA 合成器:使用马尔可夫链创建合成数据

马尔可夫链是用于表示给定数据集中可能性序列的模型。它是一种机器学习(ML)算法,因为它能从输入数据中发现或学习模式。在这个练习中,我将展示如何使用训练在一组 DNA 序列上的马尔可夫链来生成新的 DNA 序列。

在这个练习中,你将:

  • 读取一些输入序列文件以找出给定k的所有唯一 k-mer。

  • 使用这些 k-mer 创建马尔可夫链来生成长度受最小和最大限制的一些新序列。

  • 学习生成器。

  • 使用随机种子复制随机选择。

理解马尔可夫链

在克劳德·香农的《通信的数学理论》(1948 年)中,作者描述了一种意外类似于我一直用来说明正则表达式的图和有限状态图的马尔可夫过程。香农将这一过程描述为“系统的有限数量可能的状态”和“一组转移概率”,其中一个状态将导致另一个状态。

例如,对于马尔可夫过程的一个例子,香农描述了一个系统,通过从英语字母表的 26 个字母和一个空格中随机选择来生成文本字符串。在“零阶近似”中,每个字符被选择的概率相等。这个过程生成的字符串中,像bzqr这样的字母组合可能与stqu一样频繁出现。然而,实际的英语单词表明,后两者比前两者常见得多:

$ for LETTERS in bz qr st qu
> do echo -n $LETTERS && grep $LETTERS /usr/share/dict/words | wc -l; done
bz       4
qr       1
st   21433
qu    3553

为了更准确地模拟从一个字母到另一个字母的可能过渡,香农引入了一个“一阶近似…通过选择连续的字母独立进行,但每个字母的选择概率与其在自然语言中的使用概率相同”。对于这个模型,我需要在代表性的英语文本上训练选择过程。香农指出,字母e的使用概率为 0.12,反映了它在英语单词中的使用频率,而使用频率较低的w的概率为 0.02,如图 17-1 所示。

mpfb 1701

图 17-1. 一个有限状态图,包括从英语中任意字符移动到字母“e”或“w”的概率

香农继续描述了一个“二阶近似”,其中后续字母“根据各字母跟随第一个字母的频率选择”。这与我在第一部分中多次使用的 k-mer 相关。在语言学中,这些被称为N-gram。例如,给定 2-mer th,可能出现的 3-mer 是字母er,而z是不可能的,因为没有英语单词包含序列thz

我可以粗略估算我能找到这些模式的频率。我使用 wc -l 命令来计算系统字典中约有 236K 个英语单词的行数:

$ wc -l /usr/share/dict/words
  235886 /usr/share/dict/words

要找出子字符串的频率,我需要考虑到某些单词可能具有两次模式的情况。例如,以下是一些包含多个 the 模式的单词:

$ grep -E '.*the.*the.*' /usr/share/dict/words | head -3
diathermotherapy
enthelminthes
hyperthermesthesia

我可以使用 grep -io 命令以不区分大小写 (-i) 的方式搜索 thrthe 字符串,同时 -o 标志告诉 grep 仅返回匹配的字符串,这将显示每个单词中的所有匹配项。我发现 thr 出现了 1,270 次,而 the 出现了 3,593 次:

$ grep -io thr /usr/share/dict/words | wc -l
    1270
$ grep -io the /usr/share/dict/words | wc -l
    3593

将这些数字除以总单词数会得出 thr 的频率为 0.005,the 的频率为 0.015,如图 17-2 所示。

mpfb 1702

图 17-2. 显示从“th”到“r”或“e”的有限状态图中的移动概率

我可以应用这些思想通过阅读一些样本序列并注意到一些 k-mer(如 10 个碱基对)的碱基排序来生成新的 DNA 序列。重要的是要注意,不同的训练文本将会影响模型。例如,英语单词和拼写随时间的推移而改变,因此在旧英语文本(如 BeowulfCanterbury Tales)上进行训练将产生与现代报纸文章不同的结果。这是机器学习中的 学习 部分。许多机器学习算法旨在从某些数据集中找到模式并应用于另一个数据集。在这个程序的情况下,生成的序列在组成上将与输入序列有些相似。使用人类基因组作为训练数据将产生与使用来自海洋热液喷口的病毒元基因组不同的结果。

入门指南

你应该在包含此程序输入和测试的 17_synth 目录中工作。首先将解决方案复制到 synth.py 程序:

$ cd 17_synth
$ cp solution.py synth.py

这个程序有大量参数。运行帮助命令以查看它们:

$ ./synth.py -h
usage: synth.py [-h] [-o FILE] [-f format] [-n number] [-x max] [-m min]
                [-k kmer] [-s seed]
                FILE [FILE ...]

Create synthetic DNA using Markov chain

positional arguments:
  FILE                  Training file(s) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

optional arguments:
  -h, --help            show this help message and exit
  -o FILE, --outfile FILE
                        Output filename (default: out.fa) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
  -f format, --format format
                        Input file format (default: fasta) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
  -n number, --num number
                        Number of sequences to create (default: 100) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
  -x max, --max_len max
                        Maximum sequence length (default: 75) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
  -m min, --min_len min
                        Minimum sequence length (default: 50) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
  -k kmer, --kmer kmer  Size of kmers (default: 10) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
  -s seed, --seed seed  Random seed value (default: None) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

1

唯一需要的参数是一个或多个输入文件。

2

输出文件名将默认为 out.fa

3

输入格式应为 fastafastq,默认为第一个。

4

默认生成的序列数将为 100。

5

默认的最大序列长度为 75 bp。

6

默认的最小序列长度为 50 bp。

7

默认的 k-mer 长度是10 bp

8

默认的随机种子是值为None

像往常一样,我创建一个Args类来表示这些参数。我使用以下typing导入。注意后面程序中使用了Dict

from typing import NamedTuple, List, TextIO, Dict, Optional

class Args(NamedTuple):
    """ Command-line arguments """
    files: List[TextIO] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    outfile: TextIO ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    file_format: str ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    num: int ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    min_len: int ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    max_len: int ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
    k: int ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
    seed: Optional[int] ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

1

输入的files 将是一个打开文件句柄的列表。

2

outfile 将是一个打开的文件句柄。

3

输入文件的file_format 是一个字符串。

4

要生成的序列数(num)是一个整数。

5

min_len 是一个整数。

6

max_len 是一个整数。

7

k 表示 k-mer 长度为整数。

8

随机种子可以是值为None或整数。

这是我定义程序参数的方式:

def get_args() -> Args:
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Create synthetic DNA using Markov chain',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('file',
                        help='Training file(s)',
                        metavar='FILE',
                        nargs='+',
                        type=argparse.FileType('rt')) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    parser.add_argument('-o',
                        '--outfile',
                        help='Output filename',
                        metavar='FILE',
                        type=argparse.FileType('wt'), ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                        default='out.fa')

    parser.add_argument('-f',
                        '--format',
                        help='Input file format',
                        metavar='format',
                        type=str,
                        choices=['fasta', 'fastq'], ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
                        default='fasta')

    parser.add_argument('-n',
                        '--num',
                        help='Number of sequences to create',
                        metavar='number',
                        type=int,
                        default=100) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    parser.add_argument('-x',
                        '--max_len',
                        help='Maximum sequence length',
                        metavar='max',
                        type=int,
                        default=75) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

    parser.add_argument('-m',
                        '--min_len',
                        help='Minimum sequence length',
                        metavar='min',
                        type=int,
                        default=50) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

    parser.add_argument('-k',
                        '--kmer',
                        help='Size of kmers',
                        metavar='kmer',
                        type=int,
                        default=10) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

    parser.add_argument('-s',
                        '--seed',
                        help='Random seed value',
                        metavar='seed',
                        type=int,
                        default=None) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

    args = parser.parse_args()

    return Args(files=args.file,
                outfile=args.outfile,
                file_format=args.format,
                num=args.num,
                min_len=args.min_len,
                max_len=args.max_len,
                k=args.kmer,
                seed=args.seed)

1

type 限制为可读的文本文件,nargs 要求一个或多个值。

2

type 限制为可写的文本文件,文件名默认为out.fa

3

choices 限制为fastafastq,默认为fasta

4

type 限制为有效的整数值,默认为100

5

type 限制为有效的整数值,默认为75

6

type 限制为有效的整数值,默认为50

7

type 限制为有效的整数值,默认为10

8

type 限制为有效的整数值,默认为None

seedtype=int但默认为None可能看起来有点奇怪,因为None不是整数。我想说的是,如果用户提供种子的任何值,它必须是有效的整数;否则,值将为None。这也反映在Args.seed的定义中,作为Optional[int],这意味着该值可以是intNone。请注意,这相当于typing.Union[int, None],即int类型和None值的联合。

理解随机种子

这个程序有一定的随机性,因为你生成序列。我可以从 Shannon 的零阶实现开始,选择每个碱基的基础独立随机。我可以使用random.choice()函数选择一个碱基:

>>> bases = list('ACGT')
>>> import random
>>> random.choice(bases)
'G'

如果我想生成一个 10-bp 序列,我可以使用带有range()函数的列表推导,就像这样:

>>> [random.choice(bases) for _ in range(10)]
['G', 'T', 'A', 'A', 'C', 'T', 'C', 'T', 'C', 'T']

我可以进一步使用random.randint()函数在一定范围内选择随机序列长度:

>>> [random.choice(bases) for _ in range(random.randint(10, 20))]
['G', 'T', 'C', 'A', 'C', 'C', 'A', 'G', 'C', 'A', 'G']

如果你在你的计算机上执行上述代码,你几乎不可能看到与所示相同的输出。幸运的是,这些选择只是伪随机的,因为它们是由随机数生成器(RNG)确定性地产生的。真正的随机、不可重现的选择会使得测试这个程序变得不可能。

我可以使用seed或初始值来强制伪随机选择可预测。如果你阅读help(random.seed),你会看到“支持的种子类型包括Noneintfloatstrbytesbytearray。”例如,我可以使用整数作为种子:

>>> random.seed(1)
>>> [random.choice(bases) for _ in range(random.randint(10, 20))]
['A', 'G', 'A', 'T', 'T', 'T', 'T', 'C', 'A', 'T', 'A', 'T']

我也可以使用字符串:

>>> random.seed('markov')
>>> [random.choice(bases) for _ in range(random.randint(10, 20))]
['G', 'A', 'G', 'C', 'T', 'A', 'A', 'C', 'G', 'T', 'C', 'C', 'C', 'G', 'G']

如果你执行上述代码,你应该得到与所示完全相同的输出。默认情况下,随机种子为None,你会注意到这是程序的默认值。这与不设置种子相同,因此当程序使用默认值运行时,它将以伪随机方式运行。在测试时,我可以提供一个值,以产生已知结果,以验证程序是否正常工作。

注意,我已经强制用户提供一个整数值。虽然使用整数很方便,但在编写自己的程序时,你可以使用字符串、数字或字节进行种子化。只需记住整数4和字符串'4'是两个不同的值,会产生不同的结果:

>>> random.seed(4) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
>>> [random.choice(bases) for _ in range(random.randint(10, 20))]
['G', 'A', 'T', 'T', 'C', 'A', 'A', 'A', 'T', 'G', 'A', 'C', 'G']
>>> random.seed('4') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
>>> [random.choice(bases) for _ in range(random.randint(10, 20))]
['G', 'A', 'T', 'C', 'G', 'G', 'A', 'G', 'A', 'C', 'C', 'A']

1

使用整数值4作为种子。

2

使用字符串值'4'作为种子。

随机种子影响从那一点开始的每次对random函数的调用。这会对你的程序造成全局变化,因此应该极度谨慎地看待。通常情况下,在验证参数后我会立即在我的程序中设置随机种子:

def main() -> None:
    args = get_args()
    random.seed(args.seed)

如果种子是默认值None,这不会影响random函数。如果用户提供了一个种子值,那么所有随后的random调用都会受到影响。

读取训练文件

我程序的第一步是读取训练文件。由于我用argparse定义了这个参数,处理验证输入文件的过程已经完成,我知道我将得到一个List[TextIO],即打开文件句柄的列表。我将使用Bio.SeqIO.parse(),与前几章类似,来读取序列。

从训练文件中,我希望生成一个描述每个 k-mer 后续可能基的加权概率的字典。我认为使用类型别名来定义几种新类型来描述这一点很有帮助。首先,我想要一个字典,将像T这样的碱基映射到一个介于 0 和 1 之间的浮点值,以描述选择该碱基的概率。我将其称为WeightedChoice

WeightedChoice = Dict[str, float]

例如,在序列ACGTACGC中,3-mer ACG后跟等可能的TC。我将其表示如下:

>>> choices = {'T': 0.5, 'C': 0.5}

接下来,我想要一个类型,将 k-mer ACG映射到选择。我将其称为Chain,因为它表示马尔可夫链:

Chain = Dict[str, WeightedChoice]

它看起来像这样:

>>> weighted = {'ACG': {'T': 0.5, 'C': 0.5}}

输入文件中的每个 k-mer 将有一个加权选项的字典,用于选择下一个碱基。这是我用来定义读取训练文件函数的方式:

def read_training(fhs: List[TextIO], file_format: str, k: int) -> Chain: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Read training files, return dict of chains """

    pass ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

函数接受文件句柄列表、文件的格式以及要读取的 k-mer 大小。它返回类型Chain

2

使用pass来什么都不做,并暂时返回None

由于 k-mers 在这个解决方案中非常重要,您可能希望使用来自第 I 部分的find_kmers()函数。作为提醒,对于具有此签名的函数:

def find_kmers(seq: str, k: int) -> List[str]:
    """ Find k-mers in string """

我将使用以下测试:

def test_find_kmers() -> None:
    """ Test find_kmers """

    assert find_kmers('ACTG', 2) == ['AC', 'CT', 'TG']
    assert find_kmers('ACTG', 3) == ['ACT', 'CTG']
    assert find_kmers('ACTG', 4) == ['ACTG']

我认为看到这个函数的具体内容和我的期望返回很有帮助。在tests/unit_test.py文件中,你会找到这个程序的所有单元测试。这是该函数的测试:

def test_read_training() -> None: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Test read_training """

    f1 = io.StringIO('>1\nACGTACGC\n') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    assert read_training([f1], 'fasta', 4) == { ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        'ACG': { 'T': 0.5, 'C': 0.5 },
        'CGT': { 'A': 1.0 },
        'GTA': { 'C': 1.0 },
        'TAC': { 'G': 1.0 }
    }

    f2 = io.StringIO('@1\nACGTACGC\n+\n!!!!!!!!') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    assert read_training([f2], 'fastq', 5) == {  ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        'ACGT': { 'A': 1.0 },
        'CGTA': { 'C': 1.0 },
        'GTAC': { 'G': 1.0 },
        'TACG': { 'C': 1.0 }
    }

1

函数不接受任何参数并返回None

2

定义一个包含单个序列的模拟文件句柄,其格式为 FASTA。

3

以 FASTA 格式读取数据并返回 4-mers 的马尔可夫链。

4

定义一个包含单个序列的模拟文件句柄,其格式为 FASTQ。

5

读取 FASTQ 格式的数据并返回 5-mer 的马尔可夫链。

为了帮助你更好地理解 k-mer,我包含了一个叫做kmer_tiler.py的程序,它将展示给定序列中的重叠 k-mer。前面函数中的第一个测试检查 3-mer ACG后面是否跟着等概率的TC,以创建 4-mer ACGTACGC。通过查看kmer_tiler.py的输出,我可以看到这两种可能性:

$ ./kmer_tiler.py ACGTACGC -k 4
There are 5 4-mers in "ACGTACGC."
ACGTACGC
ACGT ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
 CGTA
  GTAC
   TACG
    ACGC ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

ACG后面跟着T

2

ACG后面跟着C

利用这些信息,我可以创建香农的二阶近似。例如,如果我随机选择 3-mer ACG来开始生成新的序列,我可以等概率地添加TC。根据这些训练数据,我永远不会添加AG,因为这些模式从未出现过。

这是一个难以编写的函数,所以让我给你一些提示。首先,你需要在所有文件的所有序列中找到所有的 k-mer。对于每个 k-mer,你需要找到长度为k - 1的序列中所有可能的末端。也就是说,如果k4,你首先找到所有的 4-mer,然后注意如何用最后一个碱基完成前面的 3-mer。

我使用了collections.Counter(),得到了一个类似这样的中间数据结构:

{
    'ACG': Counter({'T': 1, 'C': 1}),
    'CGT': Counter({'A': 1}),
    'GTA': Counter({'C': 1}),
    'TAC': Counter({'G': 1})
}

由于输入文件都是 DNA 序列,每个 k-mer 最多可能有四种选择。马尔可夫链的关键在于给这些值赋权重,因此接下来我需要将每个选项除以总选项数。例如在ACG的情况下,有两个可能的值,每个值出现一次,因此它们的权重为 1/2 或 0.5。从这个函数返回的数据结构如下所示:

{
    'ACG': {'T': 0.5, 'C': 0.5},
    'CGT': {'A': 1.0},
    'GTA': {'C': 1.0},
    'TAC': {'G': 1.0}
}

我建议你首先专注于编写通过这个测试的函数。

生成序列

接下来,我建议你专注于使用Chain来生成新的序列。以下是你函数的桩代码:

def gen_seq(chain: Chain, k: int, min_len: int, max_len: int) -> Optional[str]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Generate a sequence """

    return '' ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

该函数接受Chain、k-mer 的大小以及序列的最小和最大长度。它可能会或可能不会返回一个新的序列字符串,原因我稍后会解释。

2

现在,暂时返回空字符串。

当写桩代码时,我会用pass替换成返回一些虚拟值。在这里,我使用空字符串,因为函数返回一个str。重点是创建一个 Python 可以解析的函数,我可以用来测试。此时,我期望函数会失败。

这是我为此编写的测试:

def test_gen_seq() -> None: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    """ Test gen_seq """

    chain = { ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        'ACG': { 'T': 0.5, 'C': 0.5 },
        'CGT': { 'A': 1.0 },
        'GTA': { 'C': 1.0 },
        'TAC': { 'G': 1.0 }
    }

    state = random.getstate() ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    random.seed(1) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    assert gen_seq(chain, k=4, min_len=6, max_len=12) == 'CGTACGTACG' ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    random.seed(2) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
    assert gen_seq(chain, k=4, min_len=5, max_len=10) == 'ACGTA' ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
    random.setstate(state) ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

1

该函数不接受任何参数,并返回None

2

这是read_training()函数返回的数据结构。

3

保存当前全局状态的random模块。

4

将种子设置为已知值1

5

验证生成适当的序列。

6

将种子设置为已知值2

7

验证生成适当的序列。

8

恢复random模块到任意之前的状态。

如前所述,全局调用random.seed()会修改random模块的状态。我使用random.getstate()保存修改前的当前状态,并在测试完成后恢复该状态。

编写这个函数很棘手,所以我会给你一些指导。你首先会随机选择要生成的序列长度,random.randint()函数正好能做到这一点。请注意上下限都是包含的:

>>> min_len, max_len = 5, 10
>>> import random
>>> seq_len = random.randint(min_len, max_len)
>>> seq_len
9

接下来,你应该使用马尔可夫Chain结构的一个键来初始化序列。请注意需要强制转换list(chain.keys())以避免出现错误“dict_keys object is not subscriptable”:

>>> chain = {
...     'ACG': { 'T': 0.5, 'C': 0.5 },
...     'CGT': { 'A': 1.0 },
...     'GTA': { 'C': 1.0 },
...     'TAC': { 'G': 1.0 }
... }
>>> seq = random.choice(list(chain.keys()))
>>> seq
'ACG'

我决定设置一个循环,条件是序列长度小于所选序列长度。在循环内部,我将不断追加碱基。要选择每个新的碱基,我需要获取不断增长的序列的最后k - 1个碱基,可以使用列表切片和负索引来实现。以下是循环的一个执行过程:

>>> k = 4
>>> while len(seq) < seq_len:
...     prev = seq[-1 * (k - 1):]
...     print(prev)
...     break
...
ACG

如果前一个值在给定的链中出现,那么可以使用random.choices()函数选择下一个碱基。如果你阅读help(random.choices),你会发现该函数接受一个population来选择,考虑weights以进行选择,并且k用于返回选择的数量。对于给定 k-mer 的链的键是 population:

>>> opts = chain['ACG']
>>> pop = opts.keys()
>>> pop
dict_keys(['T', 'C'])

链的值是权重:

>>> weights = opts.values()
>>> weights
dict_values([0.5, 0.5])

注意需要使用list()强制转换键和值,并且random.choices()在请求一个值时始终返回一个列表,因此你需要选择第一个值:

>>> from random import choices
>>> next = choices(population=list(pop), weights=list(weights), k=1)
>>> next
['T']

我可以将其追加到序列中:

>>> seq += next[0]
>>> seq
'ACGT'

循环重复,直到序列达到正确的长度或选择了链中不存在的先前值。下一次循环时,prev 3-mer 将是 CGT,因为这些是seq中的最后三个碱基。碰巧 CGT 是链中的一个键,但有时您可能会发现由于链中不存在下一个 k-mer,无法继续序列。在这种情况下,您可以退出循环,并从函数返回None。这就是为什么gen_seq()函数签名返回Optional[str]的原因;我不希望我的函数返回过短的序列。建议您在此函数通过单元测试之前不要继续进行。

程序结构

一旦能够读取训练文件并使用马尔可夫链算法生成新序列,即可将新序列打印到输出文件中。以下是我的程序的一般概述:

def main() -> None:
    args = get_args()
    random.seed(args.seed)
    chains = read_training(...)
    seqs = calls to gen_seq(...)
    print each sequence to the output file
    print the final status

请注意,程序仅会生成 FASTA 输出,每个序列的 ID 应从 1 开始编号。也就是说,您的输出文件应如下所示:

>1
GGATTAGATA
>2
AGTCAACG

由于要检查的选项很多,测试套件相当大。建议您运行 make test 或查阅 Makefile 以查看更长的命令,确保正确运行所有单元测试和集成测试。

解决方案

对于这个复杂的程序,我只有一个解决方案。我将从读取训练文件的函数开始,这需要您从 collections 模块导入 defaultdict()Counter()

def read_training(fhs: List[TextIO], file_format: str, k: int) -> Chain:
    """ Read training files, return dict of chains """

    counts: Dict[str, Dict[str, int]] = defaultdict(Counter) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    for fh in fhs: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        for rec in SeqIO.parse(fh, file_format): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            for kmer in find_kmers(str(rec.seq), k): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
                counts[kmer[:k - 1]][kmer[-1]] += 1 ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

    def weight(freqs: Dict[str, int]) -> Dict[str, float]: ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
        total = sum(freqs.values()) ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
        return {base: freq / total for base, freq in freqs.items()} ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

    return {kmer: weight(freqs) for kmer, freqs in counts.items()} ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)

1

初始化一个字典以保存马尔可夫链。

2

遍历每个文件句柄。

3

遍历文件句柄中的每个序列。

4

遍历序列中的每个 k-mer。

5

将 k-mer 的前缀用作马尔可夫链中的键,并将最终碱基的计数加一。

6

定义一个将计数转换为加权值的函数。

7

查找碱基的总数。

8

将每个碱基的频率除以总数。

9

使用字典推导将原始计数转换为权重。

这使用了第一部分中的 find_kmers() 函数,其定义如下:

def find_kmers(seq: str, k: int) -> List[str]:
    """ Find k-mers in string """

    n = len(seq) - k + 1 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    return [] if n < 1 else [seq[i:i + k] for i in range(n)] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

k-mer 的数量是序列长度减去k加 1。

2

使用列表推导从序列中选择所有 k-mer。

这是我编写的gen_seq()函数来生成单个序列的方式:

def gen_seq(chain: Chain, k: int, min_len: int, max_len: int) -> Optional[str]:
    """ Generate a sequence """

    seq = random.choice(list(chain.keys())) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    seq_len = random.randint(min_len, max_len) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    while len(seq) < seq_len: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        prev = seq[-1 * (k - 1):] ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        if choices := chain.get(prev): ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
            seq += random.choices(population=list(choices.keys()), ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
                                  weights=list(choices.values()),
                                  k=1)[0]
        else:
            break ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)

    return seq if len(seq) >= min_len else None ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

1

将序列初始化为链中键的随机选择。

2

选择序列的长度。

3

在序列长度小于期望长度时执行循环。

4

选择最后k - 1个碱基。

5

尝试获取此 k-mer 的选择列表。

6

使用加权选择随机选择下一个碱基。

7

如果在链中找不到这个 k-mer,退出循环。

8

如果新序列足够长,则返回新序列;否则返回None

要集成所有这些,这是我的main()函数:

def main() -> None:
    args = get_args()
    random.seed(args.seed) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    if chain := read_training(args.files, args.file_format, args.k): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        seqs = (gen_seq(chain, args.k, args.min_len, args.max_len) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
                for _ in count())

        for i, seq in enumerate(filter(None, seqs), start=1): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
            print(f'>{i}\n{seq}', file=args.outfile) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
            if i == args.num: ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
                break

        print(f'Done, see output in "{args.outfile.name}".') ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
    else:
        sys.exit(f'No {args.k}-mers in input sequences.') ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)

1

设置随机种子。

2

使用给定大小k读取以给定格式的训练文件。如果序列比k短,可能会失败。

3

创建生成器以生成序列。

4

使用filter()和谓词Noneseqs生成器中移除虚假元素。使用enumerate()从索引位置 1 开始而不是 0 迭代位置和序列。

5

使用索引位置打印 FASTA 格式的序列。

6

如果已生成足够的序列,则退出循环。

7

打印最终状态。

8

让用户知道为什么无法生成序列。

我想花点时间解释上述代码中的生成器。我使用 range() 函数生成所需数量的序列。我本可以像这样使用列表推导式:

>>> from solution import gen_seq, read_training
>>> import io
>>> f1 = io.StringIO('>1\nACGTACGC\n')
>>> chain = read_training([f1], 'fasta', k=4)
>>> [gen_seq(chain, k=4, min_len=3, max_len=5) for _ in range(3)]
['CGTACG', 'CGTACG', 'TACGTA']

列表推导式会在继续下一行之前强制创建所有序列。如果我要创建数百万个序列,程序会在这里阻塞,并且可能会使用大量内存来存储所有序列。如果我用圆括号 () 替换列表推导式中的方括号 [],那么它就变成了一个惰性生成器:

>>> seqs = (gen_seq(chain, k=4, min_len=3, max_len=5) for _ in range(3))
>>> type(seqs)
<class 'generator'>

我仍然可以像遍历值列表一样处理它,但这些值只在需要时产生。这意味着创建生成器的代码行几乎立即执行并继续到 for 循环。此外,程序仅使用生成下一个序列所需的内存。

使用 range() 和序列的数量存在一个小问题,即我知道 gen_seq() 函数有时可能返回 None 来指示随机选择导致未能生成足够长的序列。我需要生成器无上限地运行,并在生成足够多的序列后停止请求序列。我可以使用 itertools.count() 创建无限序列,并使用带有 None 谓词的 filter() 来移除假值元素:

>>> seqs = ['ACGT', None, 'CCCGT']
>>> list(filter(None, seqs))
['ACGT', 'CCCGT']

我可以运行最终程序来使用默认设置创建输出文件:

$ ./synth.py tests/inputs/*
Done, see output in "out.fa".

然后,我可以使用来自第十五章的 seqmagique.py 验证它是否在预期范围内生成了正确数量的序列:

$ ../15_seqmagique/seqmagique.py out.fa
name      min_len    max_len    avg_len    num_seqs
out.fa         50         75      63.56         100

真的很棒。

深入探讨

添加一个 --type 选项来生成 DNA 或 RNA 序列。

扩展程序以处理前端和反向读取分别存储在两个文件中的成对端序列。

现在你了解了马尔可夫链,你可能会对看到它们在生物信息学中的其他用途感兴趣。例如,HMMER 工具使用隐藏的马尔可夫模型在序列数据库中找到同源物并创建序列比对。

复习

本章的关键点:

  • 使用随机种子来复制伪随机选择。

  • 马尔可夫链可用于编码图中一个节点移动到另一个节点或状态的概率。

  • 通过用圆括号替换方括号,可以将列表推导式转换为惰性生成器。

第十八章:FASTX Sampler:随机子抽样序列文件

在基因组学和宏基因组学中,序列数据集可能会变得非常庞大,需要大量时间和计算资源来进行分析。许多测序仪每个样本可以生成数千万个读取,许多实验涉及数十到数百个样本,每个样本具有多个技术重复,导致数据达到几个 GB 到 TB 的量级。通过随机子抽样序列减少输入文件的大小,可以更快地探索数据。在本章中,我将展示如何使用 Python 的random模块从 FASTA/FASTQ 序列文件中选择部分读取。

您将学习到:

  • 非确定性抽样

入门

此练习的代码和测试位于18_fastx_sampler目录中。首先复制名为sampler.py的程序解决方案:

$ cd 18_fastx_sampler/
$ cp solution.py sampler.py

用于测试该程序的 FASTA 输入文件将由您在第十七章中编写的synth.py程序生成。如果您没有完成编写该程序,请务必在执行make fasta以创建包含 1K、10K 和 100K 读取数的三个 75 到 200 bp 长度文件(分别命名为n1k.fan10k.fan100k.fa)之前将解决方案复制到该文件名中。使用seqmagique.py来验证文件的正确性:

$ ../15_seqmagique/seqmagique.py tests/inputs/n1*
name                     min_len    max_len    avg_len    num_seqs
tests/inputs/n100k.fa         75        200     136.08      100000
tests/inputs/n10k.fa          75        200     136.13       10000
tests/inputs/n1k.fa           75        200     135.16        1000

运行sampler.py以选择最小文件中默认的 10%序列。如果使用随机种子1,您应该得到 95 个读取:

$ ./sampler.py -s 1 tests/inputs/n1k.fa
Wrote 95 sequences from 1 file to directory "out"

结果可以在名为out的输出目录中的n1k.fa文件中找到。验证方法之一是使用grep -c来计算每个记录开头的符号>出现的次数:

$ grep -c '>' out/n1k.fa
95

注意,如果您忘记在>周围加上引号,则会等待您一个顽固的错误:

$ grep -c > out/n1k.fa
usage: grep [-abcDEFGHhIiJLlmnOoqRSsUVvwxZ] [-A num] [-B num] [-C[num]]
	[-e pattern] [-f file] [--binary-files=value] [--color=when]
	[--context[=num]] [--directories=action] [--label] [--line-buffered]
	[--null] [pattern] [file ...]

等等,发生了什么?请记住>bash中将STDOUT从一个程序重定向到文件的运算符。在前面的命令中,我没有足够的参数运行grep并将输出重定向到out/n1k.fa。您看到的输出是打印到STDERR的用法。什么也没有打印到STDOUT,所以这个空输出覆盖了out/n1k.fa文件,现在它是空的:

$ wc out/n1k.fa
       0       0       0 out/n1k.fa

我特别指出这一点是因为我曾经由于这个问题丢失了几个序列文件。数据已经永久丢失,所以我必须重新运行之前的命令来重新生成文件。在这之后,我建议您使用seqmagique.py来验证内容:

$ ../15_seqmagique/seqmagique.py out/n1k.fa
name          min_len    max_len    avg_len    num_seqs
out/n1k.fa         75        200     128.42          95

回顾程序参数

这是一个非常复杂的程序,具有许多选项。运行sampler.py程序请求帮助。请注意,唯一需要的参数是输入文件,因为所有选项都设置为合理的默认值:

$ ./sampler.py -h
usage: sampler.py [-h] [-f format] [-p reads] [-m max] [-s seed] [-o DIR]
                  FILE [FILE ...]

Probabilistically subset FASTA files

positional arguments:
  FILE                  Input FASTA/Q file(s) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

optional arguments:
  -h, --help            show this help message and exit
  -f format, --format format
                        Input file format (default: fasta) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
  -p reads, --percent reads
                        Percent of reads (default: 0.1) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
  -m max, --max max     Maximum number of reads (default: 0) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
  -s seed, --seed seed  Random seed value (default: None) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
  -o DIR, --outdir DIR  Output directory (default: out) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

1

需要一个或多个FASTAFASTQ文件。

2

输入文件的默认序列格式是FASTA

3

默认情况下,程序将选择 10%的读取。

4

这个选项会在达到指定的最大值时停止抽样。

5

此选项将随机种子设置为重现选择。

6

输出文件的默认目录是out

与先前的程序一样,程序将拒绝无效或不可读的输入文件,随机种子参数必须是一个整数值。-p|--percent选项应该是介于 0 和 1 之间(不包括 0 和 1)的浮点值,程序将拒绝超出此范围的任何内容。我手动验证此参数并使用parser.error(),就像第四章和第九章一样:

$ ./sampler.py -p 3 tests/inputs/n1k.fa
usage: sampler.py [-h] [-f format] [-p reads] [-m max] [-s seed] [-o DIR]
                  FILE [FILE ...]
sampler.py: error: --percent "3.0" must be between 0 and 1

-f|--format选项只接受值fastafastq,默认为第一个。我使用argparsechoices选项,就像第十五章和第十六章一样,自动拒绝不需要的值。例如,程序将拒绝fastb的值:

$ ./sampler.py -f fastb tests/inputs/n1k.fa
usage: sampler.py [-h] [-f format] [-p reads] [-m max] [-s seed] [-o DIR]
                  FILE [FILE ...]
sampler.py: error: argument -f/--format: invalid choice:
'fastb' (choose from 'fasta', 'fastq')

最后,-m|--max选项默认为0,意味着程序会无上限地抽样约--percent的读取。实际上,你可能有数千万个读取的输入文件,但你只希望每个文件最多有 10 万个。使用这个选项在达到所需数量时停止抽样。例如,我可以使用-m 30在 30 个读取时停止抽样:

$ ./sampler.py -m 30 -s 1 tests/inputs/n1k.fa
  1: n1k.fa
Wrote 30 sequences from 1 file to directory "out"

当您认为理解程序应该如何工作时,请重新开始使用您的版本:

$ new.py -fp 'Probabilistically subset FASTA files' sampler.py
Done, see new script "sampler.py".

定义参数

程序的参数包含许多不同的数据类型,我用以下类表示:

class Args(NamedTuple):
    """ Command-line arguments """
    files: List[TextIO] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    file_format: str ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    percent: float ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    max_reads: int ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    seed: Optional[int] ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    outdir: str ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

1

2

文件是一组打开的文件句柄列表。

输入文件格式是一个字符串。

3

读取的百分比是一个浮点值。

4

最大读取数是一个整数。

5

随机种子值是一个可选的整数。

6

输出目录名称是一个字符串。

这是我如何使用argparse定义参数的方式:

def get_args() -> Args:
    parser = argparse.ArgumentParser(
        description='Probabilistically subset FASTA files',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('file',
                        metavar='FILE',
                        type=argparse.FileType('r'), ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                        nargs='+',
                        help='Input FASTA/Q file(s)')

    parser.add_argument('-f',
                        '--format',
                        help='Input file format',
                        metavar='format',
                        type=str,
                        choices=['fasta', 'fastq'], ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                        default='fasta')

    parser.add_argument('-p',
                        '--percent',
                        help='Percent of reads',
                        metavar='reads',
                        type=float, ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
                        default=.1)

    parser.add_argument('-m',
                        '--max',
                        help='Maximum number of reads',
                        metavar='max',
                        type=int,
                        default=0) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    parser.add_argument('-s',
                        '--seed',
                        help='Random seed value',
                        metavar='seed',
                        type=int,
                        default=None) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

    parser.add_argument('-o',
                        '--outdir',
                        help='Output directory',
                        metavar='DIR',
                        type=str,
                        default='out') ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

    args = parser.parse_args()

    if not 0 < args.percent < 1: ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
        parser.error(f'--percent "{args.percent}" must be between 0 and 1')

    if not os.path.isdir(args.outdir): ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
        os.makedirs(args.outdir)

    return Args(files=args.file, ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
                file_format=args.format,
                percent=args.percent,
                max_reads=args.max,
                seed=args.seed,
                outdir=args.outdir)

1

将文件输入定义为一个或多个可读文本文件。

2

使用choices来限制文件格式,默认为fasta

3

百分比参数是浮点数,默认为 10%。

4

最大读取次数应为整数,默认为0

5

随机种子值是可选的,但如果存在则应为有效整数。

6

输出目录是一个字符串,默认值为out

7

验证百分比应在 0 到 1 之间。

8

如果输出目录不存在,则创建该输出目录。

9

返回Args对象。

注意,虽然程序接受 FASTA 和 FASTQ 输入,但应仅编写 FASTA 格式的输出文件。

非确定性采样

由于序列没有固有的排序,人们可能会试图获取用户指定的前几个序列。例如,我可以使用head从每个文件中选择一些行。这对 FASTQ 文件有效,只要该数字是四的倍数,但这种方法可能会为大多数其他序列格式(如多行 FASTA 或 Swiss-Prot)创建无效文件。

我展示了几个从文件中读取并选择序列的程序,因此我可以重新使用其中一个来选择记录,直到达到所需的数量。当老板首次要求我编写这个程序时,我确实这样做了。然而,输出是无用的,因为我没有意识到输入是同事创建的合成数据集,用于模拟宏基因组,即由未知生物组成的环境样本。输入文件是通过连接来自已知基因组的各种读取而创建的,因此,例如,前 10K 读取来自细菌,接下来的 10K 来自另一种细菌,接下来的 10K 来自代表性古细菌,接下来的 10K 来自病毒,依此类推。仅获取前N条记录未能包含输入的多样性。编写这个程序不仅无聊,而且更糟糕的是,它总是生成相同的输出,因此无法用于生成不同的子样本。这是一个确定性程序的示例,因为给定相同的输入,输出始终相同。

因为我需要找到一种方法来随机选择一定百分比的读取,我的第一个想法是计算读取的数量,以便我可以弄清楚有多少是,例如,10%。为此,我将所有序列存储在一个列表中,使用len()来确定存在多少个,然后随机选择该范围内的 10%的数字。虽然这种方法对于非常小的输入文件可能有效,但我希望您能看出它在任何有意义的方式上都无法扩展。遇到包含数千万读取的输入文件并不罕见。在类似 Python 列表的数据结构中保持所有这些数据可能需要比机器上可用内存更多的内存。

最终,我选择了一种方案,每次只读取一个序列,然后随机决定是选择还是拒绝它。也就是说,对于每个序列,我从一个连续均匀分布中随机选择一个数字,该分布在 0 到 1 之间,意味着这个范围内的所有值被选中的可能性是相等的。如果该数字小于或等于给定的百分比,我选择这个读取。这种方法一次只在内存中保持一个序列记录,因此应该至少是线性或O(n)可扩展的。

为了演示选择过程,我将导入random模块,并使用random.random()函数选择 0 到 1 之间的一个数字:

>>> import random
>>> random.random()
0.465289867914331

很难让你得到和我一样的数字。我们必须就种子达成一致,才能产生相同的值。使用整数1,你应该得到这个数字:

>>> random.seed(1)
>>> random.random()
0.13436424411240122

random.random()函数使用均匀分布。random模块还可以从其他分布中进行采样,如正态或高斯分布。查阅help(random)以了解这些其他函数及其使用方法。

当我遍历每个文件中的序列时,我使用此函数来选择一个数字。如果该数字小于或等于所选百分比,我希望将该序列写入输出文件。也就是说,random.random()函数应该大约有 10%的时间产生小于或等于.10的数字。通过这种方式,我使用了一种非确定性的采样方法,因为每次运行程序时(假设我没有设置随机种子),所选的读取将会有所不同。这使我能够从同一输入文件中生成许多不同的子样本,这在生成技术重复用于分析时可能会很有用。

程序的结构化

您可能会对这个程序的复杂性感到有些不知所措,因此我将提供您可能会发现有帮助的伪代码:

set the random seed
iterate through each input file
    set the output filename to output directory plus the input file's basename
    open the output filehandle
    initialize a counter for how many records have been taken

    iterate through each record of the input file
        select a random number between 0 and 1
        if this number is less than or equal to the percent
            write the sequence in FASTA format to the output filehandle
            increment the counter for taking records

        if there is a max number of records and the number taken is equal
            leave the loop

    close the output filehandle

print how many sequences were taken from how many files and the output location

我鼓励您在一个文件上运行solution.py程序,然后在多个文件上运行,并尝试逆向工程输出。继续运行测试套件以确保您的程序正常运行。

希望您能看到此程序在结构上与许多先前处理某些输入文件并创建某些输出的程序非常相似。例如,在第二章中,您处理了 DNA 序列文件以在输出目录中生成 RNA 序列文件。在第十五章中,您处理了序列文件以生成统计摘要表。在第十六章中,您处理了序列文件以选择符合某一模式的记录,并将选定的序列写入输出文件。第二章和第十六章中的程序最接近您需要在这里做的事情,因此我建议您借鉴这些解决方案。

解决方案

我想分享两个解决方案的版本。第一个解决方案致力于解决所描述的确切问题。第二个解决方案超出了原始要求,因为我想向您展示如何解决处理大型生物信息数据集时可能面临的两个常见问题,即打开过多文件句柄和读取压缩文件。

解决方案 1:读取常规文件

如果您处理的是少量未压缩的输入文件,则以下解决方案是合适的:

def main() -> None:
    args = get_args()
    random.seed(args.seed) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

    total_num = 0 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    for i, fh in enumerate(args.files, start=1): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        basename = os.path.basename(fh.name)
        out_file = os.path.join(args.outdir, basename)
        print(f'{i:3}: {basename}') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

        out_fh = open(out_file, 'wt') ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        num_taken = 0 ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)

        for rec in SeqIO.parse(fh, args.file_format): ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
            if random.random() <= args.percent: ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
                num_taken += 1
                SeqIO.write(rec, out_fh, 'fasta')

            if args.max_reads and num_taken == args.max_reads: ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
                break

        out_fh.close() ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)
        total_num += num_taken

    num_files = len(args.files) ![11](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/11.png)
    print(f'Wrote {total_num:,} sequence{"" if total_num == 1 else "s"} '
          f'from {num_files:,} file{"" if num_files == 1 else "s"} '
          f'to directory "{args.outdir}"')

1

设置随机种子(如果存在)。默认的None值与不设置种子相同。

2

初始化一个变量以记录选择的总序列数。

3

遍历每个输入文件句柄。

4

通过将输出目录与文件的基本名称连接来构造输出文件名。

5

打开输出文件句柄以写入文本。

6

初始化一个变量以记录从此文件中获取的序列数。

7

遍历输入文件中的每个序列记录。

8

如果记录是随机选择的,则递增计数器并将序列写入输出文件。

9

如果定义了最大限制并且选择的记录数等于此限制,则退出内部for循环。

10

关闭输出文件句柄并递增总记录数。

11

注意处理的文件数并告知用户最终状态。

解决方案 2:读取大量压缩文件

最初的问题不涉及读取压缩文件,但通常会发现数据以这种方式存储,以节省数据传输的带宽和存储数据的磁盘空间。Python 可以直接读取使用诸如zipgzip等工具压缩的文件,因此在处理之前不需要解压缩输入文件。

另外,如果您正在处理数百到数千个输入文件,您会发现使用type=argparse.FileType()将导致程序失败,因为您可能会超出操作系统允许的最大打开文件数。在这种情况下,您应将Args.files声明为List[str]并像这样创建参数:

parser.add_argument('file',
                    metavar='FILE',
                    type=str, ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                    nargs='+',
                    help='Input FASTA/Q file(s)')

1

将参数类型设置为一个或多个字符串值。

这意味着您需要自行验证输入文件,可以在get_args()函数中执行,如下所示:

if bad_files := [file for file in args.file if not os.path.isfile(file)]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    parser.error(f'Invalid file: {", ".join(bad_files)}') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

查找所有不是有效文件的参数。

2

使用parser.error()报告不良输入。

main()处理需要稍作更改,因为现在args.files将是一个字符串列表。您需要自行使用open()打开文件句柄,这是处理压缩文件所需的关键更改。我使用了一个简单的启发式方法来检查文件扩展名是否为.gz,以确定文件是否被压缩,并将使用gzip.open()函数来打开它:

def main() -> None:
    args = get_args()
    random.seed(args.seed)

    total_num = 0
    for i, file in enumerate(args.files, start=1): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        basename = os.path.basename(file)
        out_file = os.path.join(args.outdir, basename)
        print(f'{i:3}: {basename}')

        ext = os.path.splitext(basename)[1] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        fh = gzip.open(file, 'rt') if ext == '.gz' else open(file, 'rt') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
        out_fh = open(out_file, 'wt')
        num_taken = 0

        for rec in SeqIO.parse(fh, args.file_format):
            if random.random() <= args.percent:
                num_taken += 1
                SeqIO.write(rec, out_fh, 'fasta')

            if args.max_reads and num_taken == args.max_reads:
                break

        out_fh.close()
        total_num += num_taken

    num_files = len(args.files)
    print(f'Wrote {total_num:,} sequence{"" if total_num == 1 else "s"} '
          f'from {num_files:,} file{"" if num_files == 1 else "s"} '
          f'to directory "{args.outdir}".')

1

args.files现在是一个字符串列表,而不是文件句柄。

2

获取文件扩展名。

3

如果文件扩展名是.gz,则使用gzip.open()打开文件;否则,使用普通的open()函数。

最后,有时nargs='+'也不起作用。对于一个项目,我不得不下载超过 35 万个 XML 文件。将所有这些作为参数传递将导致命令行本身出现“Argument list too long”的错误。我的解决方法是将目录名称作为参数接受:

parser.add_argument('-d',
                    '--dir',
                    metavar='DIR',
                    type=str,
                    nargs='+',
                    help='Input directories of FASTA/Q file(s)')

然后我使用 Python 递归搜索目录中的文件。对于这段代码,我添加了from pathlib import Path,以便我可以使用Path.rglob()函数:

files = []
for dirname in args.dir:
    if os.path.isdir(dirname):
        files.extend(list(Path(dirname).rglob('*')))

if not files:
    parser.error('Found no files')

return Args(files=files,
            file_format=args.format,
            percent=args.percent,
            max_reads=args.max,
            seed=args.seed,
            outdir=args.outdir)

程序可以像以前一样继续运行,因为 Python 在列表中存储几十万项没有问题。

进一步探索

该程序始终产生 FASTA 格式的输出。添加一个--outfmt输出格式选项,以便您可以指定输出格式。考虑检测输入文件格式,并像您在第十六章中所做的那样以相同的方式编写输出格式。务必添加适当的测试以验证程序的功能。

复习

  • FASTA 文件中的>记录标记也是bash中的重定向操作符,因此在命令行中必须谨慎引用此值。

  • 确定性方法始终为给定输入产生相同的输出。非确定性方法对相同的输入产生不同的输出。

  • random模块具有从各种分布(如均匀分布和正态分布)中选择数字的函数。

第十九章:Blastomatic:解析分隔文本文件

分隔文本文件是编码列数据的一种标准方式。你可能熟悉类似于 Microsoft Excel 或 Google Sheets 的电子表格,其中每个工作表可能包含具有跨顶部的列和向下运行的记录的数据集。您可以将这些数据导出为文本文件,其中数据的列是分隔的,或者由一个字符分隔。很多时候,分隔符是逗号,文件的扩展名为.csv。这种格式称为CSV,代表逗号分隔的值。当分隔符是制表符时,扩展名可能为.tab.txt.tsv,代表制表符分隔的值。文件的第一行通常包含列的名称。值得注意的是,这不适用于来自 BLAST(基本局部比对搜索工具)的表格输出,BLAST 是生物信息学中最流行的工具之一,用于比较序列。在本章中,我将向您展示如何解析此输出,并使用csvpandas模块将 BLAST 结果与另一个分隔文本文件中的元数据合并。

在这个练习中,你将学到:

  • 如何使用csvkitcsvchk查看分隔文本文件

  • 如何使用csvpandas模块解析分隔文本文件

BLAST 简介

BLAST 程序是生物信息学中用于确定序列相似性的最普遍工具之一。在第六章中,我展示了两个序列之间的 Hamming 距离是相似性的一种度量,并将其与对齐概念进行了比较。而 Hamming 距离从开头比较两个序列,BLAST 的对齐则从两个序列开始重叠的地方开始,并允许插入、删除和不匹配以找到最长可能的相似区域。

我将向您展示国家生物技术中心(NCBI)的 BLAST 网络界面,但如果您在本地安装了 BLAST,也可以使用blastn。我将比较来自全球海洋采样探险(GOS)的 100 个序列与 NCBI 的序列数据库。GOS 是最早的宏基因组研究之一,始于 2000 年代初,当时克雷格·文特博士资助了一项为期两年的远征,收集和分析来自全球各地海洋样本。这是一个宏基因组项目,因为遗传物质直接来自环境样本。使用 BLAST 的目的是将未知的 GOS 序列与 NCBI 中已知的序列进行比较,以确定其可能的分类。

我使用了来自第十八章的 FASTX 采样器,随机选择了tests/inputs/gos.fa中的 100 个输入序列:

$ ../15_seqmagique/seqmagique.py tests/inputs/gos.fa
name                   min_len    max_len    avg_len    num_seqs
tests/inputs/gos.fa        216       1212    1051.48         100

我使用NCBI BLAST 工具将这些序列与nr/nt(非冗余核苷酸)数据库进行比较,使用blastn程序比较核苷酸。结果页面允许我选择每个 100 个序列的详细结果。如 Figure 19-1 所示,第一个序列有四个匹配到已知序列的hits。第一个和最佳匹配在其长度的 99%上与Candidatus Pelagibacter的某部分基因组大约相似度达到 93%。考虑到 GOS 查询序列来自海洋,这看起来是一个合理的匹配。

mpfb 1901

Figure 19-1. 第一个 GOS 序列在 nr/nt 中有四个可能的匹配

Figure 19-2 展示了查询序列与Candidatus Pelagibacter基因组区域的相似程度。请注意,对齐允许单核苷酸变异(SNVs)以及由序列之间的删除或插入引起的间隙。如果你想挑战自己,请尝试编写一个序列比对工具。你可以在 Figure 19-2 中看到一个例子。

mpfb 1902

Figure 19-2. 最佳 BLAST 匹配的比对结果

尽管逐个探索每个匹配很有趣,但我想下载所有匹配的表格。有一个下载所有菜单,提供 11 种下载格式。我选择了“Hit table(csv)”格式,并在tests/inputs目录下拆分为hits1.csvhits2.csv

$ wc -l tests/inputs/hits*.csv
     500 tests/inputs/hits1.csv
     255 tests/inputs/hits2.csv
     755 total

如果你用文本编辑器打开这些文件,你会看到它们包含逗号分隔的值。你也可以用类似 Excel 的电子表格程序打开文件,以列格式查看数据,并且你可能会注意到这些列没有名称。如果你在像群集节点这样的远程机器上,可能无法访问像 Excel 这样的图形程序来检查结果。此外,Excel 仅限于大约 100 万行和 16000 列。在真实的生物信息学中,很容易超过这两个值,因此我将向你展示一些命令行工具,可以用来查看分隔文本文件。

使用 csvkit 和 csvchk

首先,我想介绍一下csvkit模块,“用于转换和处理 CSV 的命令行工具套件”。存储库的requirements.txt文件列出了这个依赖项,所以它可能已经安装。如果没有安装,你可以使用这个命令来安装它:

$ python3 -m pip install csvkit

这将安装几个有用的工具,我鼓励你阅读文档以了解它们。我想强调csvlook,它“在控制台中将 CSV 文件渲染为 Markdown 兼容的固定宽度表格”。运行csvlook --help以查看用法,并注意有一个-H|--no-header-row选项,可以查看没有标题行的文件。以下命令将显示前三行匹配表格。根据你的屏幕大小,这可能无法阅读:

$ csvlook -H --max-rows 3 tests/inputs/hits1.csv

csvchk程序将一个宽记录转置为一个纵向以列名在左侧而不是在顶部的竖向记录。这也应该已经安装了其他模块依赖项,但如果需要,你可以使用pip来安装它:

$ python3 -m pip install csvchk

如果你阅读了使用说明,你会发现这个工具还有一个-N|--noheaders选项。使用csvchk检查相同的 hits 文件中的第一条记录:

$ csvchk -N tests/inputs/hits1.csv
// ****** Record 1 ****** //
Field1  : CAM_READ_0234442157
Field2  : CP031125.1
Field3  : 92.941
Field4  : 340
Field5  : 21
Field6  : 3
Field7  : 3
Field8  : 340
Field9  : 801595
Field10 : 801257
Field11 : 6.81e-135
Field12 : 492

你可以从 NCBI BLAST 下载的输出文件与 BLAST 程序的命令行版本匹配,比如用于比较核苷酸的blastn,用于比较蛋白质的blastp等等。blastn的帮助文档包含一个-outfmt选项,用于指定输出格式,使用介于 0 到 18 之间的数字。前面的输出文件格式是“制表符”选项 6:

 *** Formatting options
 -outfmt <String>
   alignment view options:
     0 = Pairwise,
     1 = Query-anchored showing identities,
     2 = Query-anchored no identities,
     3 = Flat query-anchored showing identities,
     4 = Flat query-anchored no identities,
     5 = BLAST XML,
     6 = Tabular,
     7 = Tabular with comment lines,
     8 = Seqalign (Text ASN.1),
     9 = Seqalign (Binary ASN.1),
    10 = Comma-separated values,
    11 = BLAST archive (ASN.1),
    12 = Seqalign (JSON),
    13 = Multiple-file BLAST JSON,
    14 = Multiple-file BLAST XML2,
    15 = Single-file BLAST JSON,
    16 = Single-file BLAST XML2,
    17 = Sequence Alignment/Map (SAM),
    18 = Organism Report

当你发现制表输出文件不包含列标题时,你可能会感到奇怪。如果你仔细阅读所有的格式选项,你可能会注意到输出格式 7 是“带有注释行的制表符”,然后你可能会问自己:这个选项会包含列名吗?亲爱的读者,你会非常失望地发现它并不会。选项 7 与 NCBI BLAST 页面上的“Hits table(text)”选项相同。下载并打开该文件,你会发现它包含有关搜索的元数据,这些元数据以#字符开头的行中以非结构化文本形式存在。由于许多语言(包括 Python)使用这个作为注释字符来指示应该忽略的行,因此通常会说元数据被注释掉,许多分隔文本解析器将跳过这些行。

那么列名是什么呢?我必须解析blastn使用说明的数百行才能找到“选项 6、7、10 和 17 可以被另外配置”以包含任意的 53 个可选字段。如果未指定字段,则默认字段如下:

  • qaccver: 查询序列访问号/ID

  • saccver: 主体序列访问号/ID

  • pident: 相同匹配的百分比

  • length: 对齐长度

  • mismatch: 不匹配数

  • gapopen: 缺口开放数

  • qstart: 在查询中对齐的开始位置

  • qend: 在查询中对齐的结束位置

  • sstart: 在主体中对齐的开始位置

  • send: 在主体中对齐的结束位置

  • evalue: 期望值

  • bitscore: 比特分数

如果你再次查看csvchk的用法,你会发现有一个选项可以为记录命名-f|--fieldnames。以下是我如何查看一个 hits 文件的第一条记录并指定列名的方式:

$ csvchk -f 'qseqid,sseqid,pident,length,mismatch,gapopen,qstart,qend,\
  sstart,send,evalue,bitscore' tests/inputs/hits1.csv
// ****** Record 1 ****** //
qseqid   : CAM_READ_0234442157
sseqid   : CP031125.1
pident   : 92.941
length   : 340
mismatch : 21
gapopen  : 3
qstart   : 3
qend     : 340
sstart   : 801595
send     : 801257
evalue   : 6.81e-135
bitscore : 492

这是一个更有用的输出。如果你喜欢这个命令,你可以在bash中创建一个名为blstchk的别名,就像这样:

alias blstchk='csvchk -f "qseqid,sseqid,pident,length,mismatch,gapopen,\
    qstart,qend,sstart,send,evalue,bitscore"'

大多数 shell 允许您在每次启动新 shell 时读取的文件中定义别名,比如在 bash 中,您可以将这一行添加到您的 $HOME 目录中的一个文件中,比如 .bash_profile.bashrc.profile。其他 shell 也有类似的属性。别名是一个方便的方法,用于为常用命令创建全局快捷方式。如果您希望在特定项目或目录中创建命令快捷方式,请考虑在 Makefile 中使用目标。

下面是我如何使用 blstchk 命令的方式:

$ blstchk tests/inputs/hits1.csv
// ****** Record 1 ****** //
qseqid   : CAM_READ_0234442157
sseqid   : CP031125.1
pident   : 92.941
length   : 340
mismatch : 21
gapopen  : 3
qstart   : 3
qend     : 340
sstart   : 801595
send     : 801257
evalue   : 6.81e-135
bitscore : 492

本章程序的目标是将 BLAST 命中链接到文件 tests/inputs/meta.csv 中找到的 GOS 序列的深度和位置。我将使用 -g|--grep 选项来 csvchk 查找前一个查询序列,CAM_READ_0234442157

$ csvchk -g CAM_READ_0234442157 tests/inputs/meta.csv
// ****** Record 1 ****** //
seq_id     : CAM_READ_0234442157
sample_acc : CAM_SMPL_GS112
date       : 8/8/05
depth      : 4573
salinity   : 32.5
temp       : 26.6
lat_lon    : -8.50525,80.375583

BLAST 结果可以与元数据结合,其中前者的 qseqid 等于后者的 seq_id。有一个命令行工具叫做 join,会精确执行此操作。输入必须都排序过,我会使用 -t 选项指示逗号是字段分隔符。默认情况下,join 假定每个文件的第一列是公共值,这在这里是正确的。输出是两个文件字段的逗号分隔的联合:

$ cd tests/inputs/
$ join -t , <(sort hits1.csv) <(sort meta.csv) | csvchk -s "," -N - ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
// ****** Record 1 ****** //
Field1  : CAM_READ_0234442157
Field2  : CP046232.1
Field3  : 83.810
Field4  : 105
Field5  : 12
Field6  : 5
Field7  : 239
Field8  : 340
Field9  : 212245
Field10 : 212143
Field11 : 2.24e-15
Field12 : 95.3
Field13 : CAM_SMPL_GS112
Field14 : 8/8/05
Field15 : 4573
Field16 : 32.5
Field17 : 26.6
Field18 : -8.50525,80.375583

1

使用 shell 重定向 < 读取排序后的两个输入文件的结果作为 join 的两个位置输入。join 的输出被导向到 csvchk

虽然了解如何使用 join 是好的,但是此输出并不特别有用,因为它没有列标题。(而且,重点是学习如何在 Python 中执行此操作。)您如何向此信息添加标题?您会在 bash 脚本或 Makefile 目标中拼凑一些 shell 命令,还是会编写一个 Python 程序?让我们继续前进,好吗?接下来,我将向您展示程序应该如何工作以及它将创建的输出。

入门指南

此练习的所有代码和测试都可以在存储库的 19_blastomatic 目录中找到。切换到此目录并将第二个解决方案复制到程序 blastomatic.py

$ cd 19_blastomatic/
$ cp solution2_dict_writer.py blastomatic.py

该程序将接受 BLAST 命中和元数据文件,并将生成一个输出文件,显示序列 ID、百分比身份匹配、深度以及样品的纬度和经度。可选择性地,输出可以按百分比身份进行过滤。请求程序的帮助以查看选项:

$ ./blastomatic.py -h
usage: blastomatic.py [-h] -b FILE -a FILE [-o FILE] [-d DELIM] [-p PCTID]

Annotate BLAST output

optional arguments:
  -h, --help            show this help message and exit
  -b FILE, --blasthits FILE
                        BLAST -outfmt 6 (default: None) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
  -a FILE, --annotations FILE
                        Annotations file (default: None) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
  -o FILE, --outfile FILE
                        Output file (default: out.csv) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
  -d DELIM, --delimiter DELIM
                        Output field delimiter (default: ) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
  -p PCTID, --pctid PCTID
                        Minimum percent identity (default: 0.0) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

来自 -outfmt 6 中的 BLAST 搜索的表格输出文件。

2

一个关于序列的元数据的注释文件。

3

输出文件的名称,默认为 out.csv

4

输出文件的分隔符,默认根据输出文件扩展名猜测。

5

最小百分比标识,默认为 0

如果我使用第一个命中文件运行程序,则会将 500 条序列写入输出文件 out.csv

$ ./blastomatic.py -b tests/inputs/hits1.csv -a tests/inputs/meta.csv
Exported 500 to "out.csv".

我可以使用 csvlook--max-rows 选项查看表格的前两行:

$ csvlook --max-rows 2 out.csv
| qseqid              | pident | depth | lat_lon            |
| ------------------- | ------ | ----- | ------------------ |
| CAM_READ_0234442157 | 92.941 | 4,573 | -8.50525,80.375583 |
| CAM_READ_0234442157 | 85.000 | 4,573 | -8.50525,80.375583 |
| ...                 |    ... |   ... | ...                |

或者我可以使用 -l|--limit 选项与 csvchk 进行相同操作:

$ csvchk --limit 2 out.csv
// ****** Record 1 ****** //
qseqid  : CAM_READ_0234442157
pident  : 92.941
depth   : 4573
lat_lon : -8.50525,80.375583
// ****** Record 2 ****** //
qseqid  : CAM_READ_0234442157
pident  : 85.000
depth   : 4573
lat_lon : -8.50525,80.375583

如果我只想导出百分比标识大于或等于 90% 的命中记录,我可以使用 -p|--pctid 选项找出仅有 190 条记录:

$ ./blastomatic.py -b tests/inputs/hits1.csv -a tests/inputs/meta.csv -p 90
Exported 190 to "out.csv".

我可以查看文件,确认它似乎已选择了正确的数据:

$ csvlook --max-rows 4 out.csv
| qseqid                  | pident | depth | lat_lon              |
| ----------------------- | ------ | ----- | -------------------- |
| CAM_READ_0234442157     | 92.941 | 4,573 | -8.50525,80.375583   |
| JCVI_READ_1091145027519 | 97.368 |     2 | 44.137222,-63.644444 |
| JCVI_READ_1091145742680 | 98.714 |    64 | 44.690277,-63.637222 |
| JCVI_READ_1091145742680 | 91.869 |    64 | 44.690277,-63.637222 |
| ...                     |    ... |   ... | ...                  |

blastomatic.py 程序默认将输出写入逗号分隔的文件 out.csv。您可以使用 -d|--delimiter 选项指定不同的分隔符,并使用 -o|--outfile 选项指定不同的文件。请注意,如果未指定分隔符,将从输出文件名的扩展名猜测分隔符。扩展名 .csv 将被视为逗号,否则将使用制表符。

运行 make test 来查看完整的测试套件。当您认为理解程序应如何工作时,请重新开始:

$ new.py -fp 'Annotate BLAST output' blastomatic.py
Done, see new script "blastomatic.py".

定义参数

这是我用来定义参数的类:

class Args(NamedTuple):
    """ Command-line arguments """
    hits: TextIO ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    annotations: TextIO ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    outfile: TextIO ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    delimiter: str ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    pctid: float ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

BLAST 命中文件将是一个打开的文件句柄。

2

元数据文件将是一个打开的文件句柄。

3

输出文件将是一个打开的文件句柄。

4

输出文件分隔符将是一个字符串。

5

百分比标识将是一个浮点数。

下面是我解析和验证参数的方式:

def get_args():
    """ Get command-line arguments """

    parser = argparse.ArgumentParser(
        description='Annotate BLAST output',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('-b',
                        '--blasthits',
                        metavar='FILE',
                        type=argparse.FileType('rt'), ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                        help='BLAST -outfmt 6',
                        required=True)

    parser.add_argument('-a',
                        '--annotations',
                        help='Annotations file',
                        metavar='FILE',
                        type=argparse.FileType('rt'), ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                        required=True)

    parser.add_argument('-o',
                        '--outfile',
                        help='Output file',
                        metavar='FILE',
                        type=argparse.FileType('wt'), ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
                        default='out.csv')

    parser.add_argument('-d',
                        '--delimiter',
                        help='Output field delimiter', ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
                        metavar='DELIM',
                        type=str,
                        default='')

    parser.add_argument('-p',
                        '--pctid',
                        help='Minimum percent identity', ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
                        metavar='PCTID',
                        type=float,
                        default=0.)

    args = parser.parse_args()

    return Args(hits=args.blasthits, ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
                annotations=args.annotations,
                outfile=args.outfile,
                delimiter=args.delimiter or guess_delimiter(args.outfile.name), ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
                pctid=args.pctid)

1

BLAST 文件必须是可读的文本文件。

2

元数据文件必须是可读的文本文件。

3

输出文件必须是可写的文本文件。

4

输出字段分隔符是一个字符串,默认为我从输出文件名中猜测的空字符串。

5

最小百分比标识应为浮点数,默认为 0

6

创建 Args 对象。请注意,Args 的字段不需要与参数名匹配。

7

我编写了一个函数,从输出文件名中猜测分隔符。

这个程序有两个必需的文件参数:BLAST hits 和注释。我不想将它们作为位置参数,因为那样我的用户必须记住顺序。最好将它们作为命名选项,但这样它们就变成了可选的,而我不想这样。为了克服这个问题,我对两个文件参数都使用了required=True,以确保用户提供它们。

你可能想从guess_delimiter()函数开始。这是我编写的测试:

def test_guess_delimiter() -> None:
    """ Test guess_delimiter """

    assert guess_delimiter('/foo/bar.csv') == ','
    assert guess_delimiter('/foo/bar.txt') == '\t'
    assert guess_delimiter('/foo/bar.tsv') == '\t'
    assert guess_delimiter('/foo/bar.tab') == '\t'
    assert guess_delimiter('') == '\t'

使用一些最小的代码来启动你的main()

def main() -> None:
    args = get_args()
    print('hits', args.hits.name)
    print('meta', args.annotations.name)

确保这个工作:

$ ./blastomatic.py -a tests/inputs/meta.csv -b tests/inputs/hits1.csv
hits tests/inputs/hits1.csv
meta tests/inputs/meta.csv

到目前为止,当你运行make test时,你应该能够通过几个测试。接下来,我将向你展示如何解析分隔文本文件。

使用 csv 模块解析分隔文本文件

Python 有一个csv模块,可以轻松处理分隔文本文件,但我想首先向你展示它确切的操作,这样你就能够欣赏它节省的努力。首先,我将打开元数据文件并从第一行读取头部。我可以在文件句柄上调用fh.readline()方法来读取一行文本。这将仍然包含换行符,因此我调用str.rstrip()来移除字符串右侧的任何空白。最后,我调用str.split(',')来使用分隔逗号拆分行:

>>> fh = open('tests/inputs/meta.csv')
>>> headers = fh.readline().rstrip().split(',')
>>> headers
['seq_id', 'sample_acc', 'date', 'depth', 'salinity', 'temp', 'lat_lon']

目前为止,一切都好。我将尝试解析下一行数据:

>>> line = fh.readline()
>>> data = line.split(',')
>>> data
['JCVI_READ_1092301105055', 'JCVI_SMPL_1103283000037', '2/11/04', '1.6', '',
 '25.4', '"-0.5938889', '-91.06944"']

你能看到这里的问题吗?我已经将包含逗号的lat_lon字段分割成了两个值,给我七个字段的八个值:

>>> len(headers), len(data)
(7, 8)

使用str.split()将无法正常工作,因为它未考虑分隔符是字段值的一部分的情况。也就是说,当字段分隔符被引号括起来时,它不是字段分隔符。注意lat_lon值被正确地引用:

>>> line[50:]
'11/04,1.6,,25.4,"-0.5938889,-91.06944"\n\'

一种正确解析这一行的方法是使用pyparsing模块:

>>> import pyparsing as pp
>>> data = pp.commaSeparatedList.parseString(line).asList()
>>> data
['JCVI_READ_1092301105055', 'JCVI_SMPL_1103283000037', '2/11/04', '1.6',
 '', '25.4', '"-0.5938889,-91.06944"']

差不多了,但是lat_lon字段周围仍然有引号。我可以使用正则表达式将它们移除:

>>> import re
>>> data = list(map(lambda s: re.sub(r'^"|"$', '', s), data)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
>>> data
['JCVI_READ_1092301105055', 'JCVI_SMPL_1103283000037', '2/11/04', '1.6', '',
 '25.4', '-0.5938889,-91.06944']

1

这个正则表达式将锚定在字符串开头或结尾的引号替换为空字符串。

现在,我已经得到了headers列表和给定记录的data列表,我可以通过将它们压缩在一起创建一个字典。我在第六章和第十三章中使用了zip()函数将两个列表连接成元组的列表。因为zip()是一个惰性函数,我必须在 REPL 中使用list()函数来强制求值:

>>> from pprint import pprint
>>> pprint(list(zip(headers, data)))
[('seq_id', 'JCVI_READ_1092301105055'),
 ('sample_acc', 'JCVI_SMPL_1103283000037'),
 ('date', '2/11/04'),
 ('depth', '1.6'),
 ('salinity', ''),
 ('temp', '25.4'),
 ('lat_lon', '-0.5938889,-91.06944')]

我可以将list()函数改为dict()来将其转换为字典:

>>> pprint(dict(zip(headers, data)))
{'date': '2/11/04',
 'depth': '1.6',
 'lat_lon': '-0.5938889,-91.06944',
 'salinity': '',
 'sample_acc': 'JCVI_SMPL_1103283000037',
 'seq_id': 'JCVI_READ_1092301105055',
 'temp': '25.4'}

我可以迭代文件的每一行,并通过压缩头部和数据创建记录的字典。这样做完全可行,但是所有这些工作在csv模块中已经为我完成。以下是如何使用csv.DictReader()将同一文件解析为字典列表。默认情况下,它将使用逗号作为分隔符:

>>> import csv
>>> reader = csv.DictReader(open('tests/inputs/meta.csv'))
>>> for rec in reader:
...     pprint(rec)
...     break
...
{'date': '2/11/04',
 'depth': '1.6',
 'lat_lon': '-0.5938889,-91.06944',
 'salinity': '',
 'sample_acc': 'JCVI_SMPL_1103283000037',
 'seq_id': 'JCVI_READ_1092301105055',
 'temp': '25.4'}

这样就容易多了。以下是我如何使用它来创建以序列 ID 为键的所有注释的字典。为此,请确保添加 from pprint import pprint

def main():
    args = get_args()
    annots_reader = csv.DictReader(args.annotations, delimiter=',') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    annots = {row['seq_id']: row for row in annots_reader} ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    pprint(annots)

1

使用 csv.DictReader() 解析注释文件句柄中的 CSV 数据。

2

使用字典推导式创建以每个记录中的 seq_id 字段为键的字典。

使用输入文件运行此程序,并查看是否获得一个看起来合理的数据结构。在这里,我将 STDOUT 重定向到名为 out 的文件,并使用 head 进行检查:

$ ./blastomatic.py -a tests/inputs/meta.csv -b tests/inputs/hits1.csv > out
$ head out
{'CAM_READ_0231669837': {'date': '8/4/05',
                         'depth': '7',
                         'lat_lon': '-12.092617,96.881733',
                         'salinity': '32.4',
                         'sample_acc': 'CAM_SMPL_GS108',
                         'seq_id': 'CAM_READ_0231669837',
                         'temp': '25.8'},
 'CAM_READ_0231670003': {'date': '8/4/05',
                         'depth': '7',
                         'lat_lon': '-12.092617,96.881733',

在继续阅读 BLAST 命中结果之前,我想打开输出文件句柄。输出文件的格式应该是另一个分隔文本文件。默认情况下,它将是一个 CSV 文件,但用户可以选择其他格式,如制表符分隔。文件的第一行应该是标题行,因此我将立即写入这些内容:

def main():
    args = get_args()
    annots_reader = csv.DictReader(args.annotations, delimiter=',')
    annots = {row['seq_id']: row for row in annots_reader}

    headers = ['qseqid', 'pident', 'depth', 'lat_lon'] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    args.outfile.write(args.delimiter.join(headers) + '\n') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

这些是输出文件的列名。

2

args.outfile 是用于写入文本的文件句柄。在 args.delimiter 字符串上连接标题并写入。请务必添加换行符。

或者,您可以使用带有 file 参数的 print()

print(args.delimiter.join(headers), file=args.outfile)

接下来,我将遍历 BLAST 命中结果。由于文件的第一行缺少列名,因此需要为 csv.DictReader() 提供 fieldnames

def main():
    args = get_args()
    annots_reader = csv.DictReader(args.annotations, delimiter=',')
    annots = {row['seq_id']: row for row in annots_reader}

    headers = ['qseqid', 'pident', 'depth', 'lat_lon']
    args.outfile.write(args.delimiter.join(headers) + '\n')

    hits = csv.DictReader(args.hits, ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
                          delimiter=',',
                          fieldnames=[
                              'qseqid', 'sseqid', 'pident', 'length',
                              'mismatch', 'gapopen', 'qstart', 'qend',
                              'sstart', 'send', 'evalue', 'bitscore'
                          ])

    for hit in hits: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
        if float(hit.get('pident', -1)) < args.pctid: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
            continue
        print(hit.get('qseqid')) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

解析 BLAST CSV 文件。

2

迭代处理每个 BLAST 命中结果。

3

跳过百分比 ID 小于最小值的命中结果。使用 float() 函数将文本转换为浮点值。

4

打印查询序列 ID。

使用最小百分比 ID 为 90 运行此版本的程序,并验证您是否从第一个文件中获得了 190 个命中结果:

$ ./blastomatic.py -a tests/inputs/meta.csv -b tests/inputs/hits1.csv -p 90 \
    | wc -l
     190

如果在元数据文件中以 BLAST 命中的 qseqid 值作为 seq_id 找到,则将序列 ID、来自 BLAST 命中的百分比 ID 以及来自元数据文件的深度和纬度/经度值打印到输出文件中。这应该足以让您开始运行此程序。请务必运行测试以验证您的程序是否正确。

使用 pandas 模块解析分隔文本文件

pandas 模块提供了另一种有效的方法来读取分隔文件。 这个模块与 NumPy 一起是数据科学中使用的基本 Python 库之一。 如果您熟悉 R 语言,我将使用pd.read_csv()函数,它与 R 语言中的read_csv()函数非常相似。 注意,该函数可以读取由任何分隔符指定的文本,但默认为逗号。

通常分隔符是单个字符,但也可以使用字符串拆分文本。 如果这样做,您可能会遇到警告“ParserWarning: Falling back to the python engine because the c engine does not support regex separators (separators > 1 char and different from \s+ are interpreted as regex); you can avoid this warning by specifying engine=python.”

通常使用别名pd导入 pandas 是很常见的:

>>> import pandas as pd
>>> meta = pd.read_csv('tests/inputs/meta.csv')

pandas 的许多功能都基于 R 的思想。 pandas 数据框是一个二维对象,它将元数据文件中的所有列和行都保存在一个单独的对象中,就像 R 中的数据框一样。 也就是说,前面示例中的reader是用于顺序检索每个记录的接口,但 pandas 数据框是文件中所有数据的完整表示。 因此,数据框的大小将受到计算机内存量的限制。 就像我警告过使用fh.read()将整个文件读入内存一样,您必须谨慎选择使用 pandas 可以实际读取的文件。 如果必须处理数百万行大小为千兆字节的分隔文本文件,我建议使用csv.DictReader()逐条处理记录。

如果在 REPL 中评估meta对象,则会显示表格的样本。 您可以看到 pandas 使用文件的第一行作为列标题。 如省略号所示,由于屏幕宽度受限,一些列已被省略:

>>> meta
                     seq_id  ...                lat_lon
0   JCVI_READ_1092301105055  ...   -0.5938889,-91.06944
1   JCVI_READ_1092351051817  ...   -0.5938889,-91.06944
2   JCVI_READ_1092301096881  ...   -0.5938889,-91.06944
3   JCVI_READ_1093017101914  ...   -0.5938889,-91.06944
4   JCVI_READ_1092342065252  ...     9.164444,-79.83611
..                      ...  ...                    ...
95  JCVI_READ_1091145742670  ...   44.690277,-63.637222
96  JCVI_READ_1091145742680  ...   44.690277,-63.637222
97  JCVI_READ_1091150268218  ...   44.690277,-63.637222
98  JCVI_READ_1095964929867  ...  -1.9738889,-95.014725
99  JCVI_READ_1095994150021  ...  -1.9738889,-95.014725

[100 rows x 7 columns]

要查找数据框中行数和列数,请检查meta.shape属性。 注意,这不需要加括号,因为它不是方法调用。 此数据框有 100 行和 7 列:

>>> meta.shape
(100, 7)

我可以检查meta.columns属性获取列名:

>>> meta.columns
Index(['seq_id', 'sample_acc', 'date', 'depth', 'salinity', 'temp', 'lat_lon'],
dtype='object')

数据框的一个优点是,您可以使用类似于访问字典中字段的语法查询列中的所有值。 在这里,我将选择盐度值,并注意 pandas 已将这些值从文本转换为浮点值,缺失值用NaN(不是数字)表示:

>>> meta['salinity']
0      NaN
1      NaN
2      NaN
3      NaN
4      0.1
      ...
95    30.2
96    30.2
97    30.2
98     NaN
99     NaN
Name: salinity, Length: 100, dtype: float64

我可以使用几乎与 R 中相同的语法找到盐度大于 50 的行。 这将根据断言盐度大于 50返回一个布尔值数组:

>>> meta['salinity'] > 50
0     False
1     False
2     False
3     False
4     False
      ...
95    False
96    False
97    False
98    False
99    False
Name: salinity, Length: 100, dtype: bool

我可以使用这些布尔值作为掩码,仅选择条件为True的行:

>>> meta[meta['salinity'] > 50]
                     seq_id  ...               lat_lon
23  JCVI_READ_1092351234516  ...  -1.2283334,-90.42917
24  JCVI_READ_1092402566200  ...  -1.2283334,-90.42917
25  JCVI_READ_1092402515846  ...  -1.2283334,-90.42917

[3 rows x 7 columns]

结果是一个新的数据框,所以我可以查看找到的盐度值:

>>> meta[meta['salinity'] > 50]['salinity']
23    63.4
24    63.4
25    63.4
Name: salinity, dtype: float64

如果你使用 pandas 读取 BLAST hits 文件,需要像之前的示例一样提供列名:

>>> cols = ['qseqid', 'sseqid', 'pident', 'length', 'mismatch', 'gapopen',
'qstart', 'qend', 'sstart', 'send', 'evalue', 'bitscore']
>>> hits = pd.read_csv('tests/inputs/hits1.csv', names=cols)
>>> hits
                      qseqid      sseqid  ...         evalue  bitscore
0        CAM_READ_0234442157  CP031125.1  ...  6.810000e-135     492.0
1        CAM_READ_0234442157  LT840186.1  ...   7.260000e-90     342.0
2        CAM_READ_0234442157  CP048747.1  ...   6.240000e-16      97.1
3        CAM_READ_0234442157  CP046232.1  ...   2.240000e-15      95.3
4    JCVI_READ_1095946186912  CP038852.1  ...   0.000000e+00    1158.0
..                       ...         ...  ...            ...       ...
495  JCVI_READ_1095403503430  EU805356.1  ...   0.000000e+00    1834.0
496  JCVI_READ_1095403503430  EU804987.1  ...   0.000000e+00    1834.0
497  JCVI_READ_1095403503430  EU804799.1  ...   0.000000e+00    1834.0
498  JCVI_READ_1095403503430  EU804695.1  ...   0.000000e+00    1834.0
499  JCVI_READ_1095403503430  EU804645.1  ...   0.000000e+00    1834.0

[500 rows x 12 columns]

程序的一个要素是仅选择那些百分比 ID 大于或等于某个最小值的命中结果。pandas 会自动将pident列转换为浮点数值。在这里,我将选择那些百分比 ID 大于或等于90的命中结果:

>>> wanted = hits[hits['pident'] >= 90]
>>> wanted
                      qseqid      sseqid  ...         evalue  bitscore
0        CAM_READ_0234442157  CP031125.1  ...  6.810000e-135     492.0
12   JCVI_READ_1091145027519  CP058306.1  ...   6.240000e-06      65.8
13   JCVI_READ_1091145742680  CP000084.1  ...   0.000000e+00    1925.0
14   JCVI_READ_1091145742680  CP038852.1  ...   0.000000e+00    1487.0
111  JCVI_READ_1091145742680  CP022043.2  ...   1.320000e-07      71.3
..                       ...         ...  ...            ...       ...
495  JCVI_READ_1095403503430  EU805356.1  ...   0.000000e+00    1834.0
496  JCVI_READ_1095403503430  EU804987.1  ...   0.000000e+00    1834.0
497  JCVI_READ_1095403503430  EU804799.1  ...   0.000000e+00    1834.0
498  JCVI_READ_1095403503430  EU804695.1  ...   0.000000e+00    1834.0
499  JCVI_READ_1095403503430  EU804645.1  ...   0.000000e+00    1834.0

[190 rows x 12 columns]

要遍历数据框中的行,使用wanted.iterrows()方法。请注意,这与enumerate()函数类似,返回的是行索引和行值的元组:

>>> for i, hit in wanted.iterrows():
...     print(hit)
...     break
...
qseqid      CAM_READ_0234442157
sseqid               CP031125.1
pident                   92.941
length                      340
mismatch                     21
gapopen                       3
qstart                        3
qend                        340
sstart                   801595
send                     801257
evalue                    0.000
bitscore                492.000
Name: 0, dtype: object

要从数据框中打印出单个记录的字段,你可以像使用字典一样使用方括号访问字段或者使用熟悉的dict.get()方法。与字典类似,前一种方法如果拼写错误将会抛出异常,而后一种方法会静默地返回None

>>> for i, hit in wanted.iterrows():
...     print(hit['qseqid'], hit.get('pident'), hit.get('nope'))
...     break
...
CAM_READ_0234442157 92.941 None

如同前面的示例一样,我建议你先阅读元数据,然后迭代 BLAST 命中。你可以通过在meta数据框中搜索seq_id字段来查找元数据。元数据文件中的序列 ID 是唯一的,所以你应该最多只找到一个:

>>> seqs = meta[meta['seq_id'] == 'CAM_READ_0234442157']
>>> seqs
                 seq_id      sample_acc  ...  temp             lat_lon
91  CAM_READ_0234442157  CAM_SMPL_GS112  ...  26.6  -8.50525,80.375583

[1 rows x 7 columns]

你可以遍历匹配项,或者使用iloc访问器获取第一个(零号)记录:

>>> seqs.iloc[0]
seq_id        CAM_READ_0234442157
sample_acc         CAM_SMPL_GS112
date                       8/8/05
depth                      4573.0
salinity                     32.5
temp                         26.6
lat_lon        -8.50525,80.375583
Name: 91, dtype: object

如果未找到任何匹配项,你将得到一个空的数据框:

>>> seqs = meta[meta['seq_id'] == 'X']
>>> seqs
Empty DataFrame
Columns: [seq_id, sample_acc, date, depth, salinity, temp, lat_lon]
Index: []

你可以检查seqs.empty属性来查看它是否为空:

>>> seqs.empty
True

或者从seqs.shape中检查行数值:

>>> seqs.shape[0]
0

数据框也可以使用to_csv()方法将其值写入文件。与read_csv()类似,你可以指定任何sep字段分隔符,默认为逗号。注意,默认情况下,pandas 会将行索引包括在输出文件的第一个字段中。我通常使用index=False来省略这个。例如,我将保存盐度大于 50 的元数据记录到salty.csv文件中只需一行代码:

>>> meta[meta['salinity'] > 50].to_csv('salty.csv', index=False)

我可以使用csvchkcsvlook验证数据是否已写入:

$ csvchk salty.csv
// ****** Record 1 ****** //
seq_id     : JCVI_READ_1092351234516
sample_acc : JCVI_SMPL_1103283000038
date       : 2/19/04
depth      : 0.2
salinity   : 63.4
temp       : 37.6
lat_lon    : -1.2283334,-90.42917

对 pandas 的全面审查远远超出了本书的范围,但这应该足以让你找到一个解决方案。如果你想了解更多,我推荐阅读Python 数据分析(Wes McKinney 著,O’Reilly,2017)和Python 数据科学手册(Jake VanderPlas 著,O’Reilly,2016)。

解决方案

我有四种解决方案,两种使用csv模块,另外两种使用 pandas。所有解决方案均使用我编写的guess_delimiter()函数,代码如下:

def guess_delimiter(filename: str) -> str:
    """ Guess the field separator from the file extension """

    ext = os.path.splitext(filename)[1] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    return ',' if ext == '.csv' else '\t' ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

1

os.path.splitext()中选择文件扩展名。

2

如果文件扩展名是.csv,则返回逗号,否则返回制表符。

解决方案 1:使用字典手动连接表格

此版本紧随本章章节早期的所有建议:

def main():
    args = get_args()
    annots_reader = csv.DictReader(args.annotations, delimiter=',') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    annots = {row['seq_id']: row for row in annots_reader} ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    headers = ['qseqid', 'pident', 'depth', 'lat_lon'] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    args.outfile.write(args.delimiter.join(headers) + '\n') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

    hits = csv.DictReader(args.hits, ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
                          delimiter=',',
                          fieldnames=[
                              'qseqid', 'sseqid', 'pident', 'length',
                              'mismatch', 'gapopen', 'qstart', 'qend',
                              'sstart', 'send', 'evalue', 'bitscore'
                          ])

    num_written = 0 ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
    for hit in hits: ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
        if float(hit.get('pident', -1)) < args.pctid: ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
            continue

        if seq_id := hit.get('qseqid'): ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
            if seq := annots.get(seq_id): ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)
                num_written += 1 ![11](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/11.png)
                args.outfile.write(
                    args.delimiter.join( ![12](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/12.png)
                        map(lambda s: f'"{s}"', [
                            seq_id,
                            hit.get('pident'),
                            seq.get('depth'),
                            seq.get('lat_lon')
                        ])) + '\n')

    args.outfile.close() ![13](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/13.png)
    print(f'Exported {num_written:,} to "{args.outfile.name}".') ![14](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/14.png)

1

创建注释文件的解析器。

2

将所有注释读入以序列 ID 为键的字典中。

3

定义输出文件的标头。

4

将标头写入输出文件。

5

创建 BLAST 命中的解析器。

6

初始化记录写入计数器。

7

遍历 BLAST 命中。

8

跳过百分比 ID 小于最小值的记录。

9

尝试获取 BLAST 查询序列 ID。

10

尝试在注释中找到此序列 ID。

11

如果找到,则增加计数器并写入输出值。

12

引用所有字段以确保分隔符受保护。

13

关闭输出文件。

14

向用户打印最终状态。在 num_written 格式化的逗号将为数字添加千位分隔符。

解决方案 2:使用 csv.DictWriter() 编写输出文件

下一个解决方案与第一个的不同之处在于我使用 csv.DictWriter() 编写输出文件。我通常更喜欢使用这种方法,因为它将处理,例如,包含字段分隔符的字段正确引用:

def main():
    args = get_args()
    annots_reader = csv.DictReader(args.annotations, delimiter=',')
    annots = {row['seq_id']: row for row in annots_reader}

    writer = csv.DictWriter( ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
        args.outfile,
        fieldnames=['qseqid', 'pident', 'depth', 'lat_lon'],
        delimiter=args.delimiter)
    writer.writeheader() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)

    hits = csv.DictReader(args.hits,
                          delimiter=',',
                          fieldnames=[
                              'qseqid', 'sseqid', 'pident', 'length',
                              'mismatch', 'gapopen', 'qstart', 'qend',
                              'sstart', 'send', 'evalue', 'bitscore'
                          ])

    num_written = 0
    for hit in hits:
        if float(hit.get('pident', -1)) < args.pctid:
            continue

        if seq_id := hit.get('qseqid'):
            if seq := annots.get(seq_id):
                num_written += 1
                writer.writerow({ ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
                    'qseqid': seq_id,
                    'pident': hit.get('pident'),
                    'depth': seq.get('depth'),
                    'lat_lon': seq.get('lat_lon'),
                })

    print(f'Exported {num_written:,} to "{args.outfile.name}".') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)

1

创建一个写入器对象来创建分隔文本输出文件。

2

将标头行写入输出文件。

3

写入一行数据,传入一个与编写器定义的 fieldnames 键相同的字典。

4

使用格式化指令{:,}将使数字以千位分隔符打印。

解决方案 3:使用 Pandas 读写文件

在某些方面,Pandas 版本更为简单,而在其他方面则更为复杂。我选择将所有输出记录存储在 Python 列表中,并从中实例化新的数据帧以编写输出文件:

def main():
    args = get_args()
    annots = pd.read_csv(args.annotations, sep=',') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    hits = pd.read_csv(args.hits, ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                       sep=',',
                       names=[
                           'qseqid', 'sseqid', 'pident', 'length', 'mismatch',
                           'gapopen', 'qstart', 'qend', 'sstart', 'send',
                           'evalue', 'bitscore'
                       ])

    data = [] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    for _, hit in hits[hits['pident'] >= args.pctid].iterrows(): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
        meta = annots[annots['seq_id'] == hit['qseqid']] ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
        if not meta.empty: ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
            for _, seq in meta.iterrows(): ![7](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/7.png)
                data.append({ ![8](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/8.png)
                    'qseqid': hit['qseqid'],
                    'pident': hit['pident'],
                    'depth': seq['depth'],
                    'lat_lon': seq['lat_lon'],
                })

    df = pd.DataFrame.from_records(data=data) ![9](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/9.png)
    df.to_csv(args.outfile, index=False, sep=args.delimiter) ![10](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/10.png)

    print(f'Exported {len(data):,} to "{args.outfile.name}".') ![11](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/11.png)

1

将元数据文件读入数据框。

2

将 BLAST 命中读入数据框。

3

初始化输出数据的列表。

4

选择所有百分比 ID 大于或等于最小百分比的 BLAST 命中。

5

选择给定查询序列 ID 的元数据。

6

验证元数据不为空。

7

迭代元数据记录(尽管通常只有一个)。

8

使用输出数据存储一个新的字典。

9

从输出数据创建一个新的数据框。

10

将数据框写入输出文件,省略数据框索引值。

11

将状态打印到控制台。

解决方案 4:使用 pandas 连接文件

在最后的解决方案中,我使用 pandas 将元数据和 BLAST 数据框进行连接,就像我在本章早些时候演示的 join 程序一样:

def main():
    args = get_args()
    annots = pd.read_csv(args.annotations, sep=',', index_col='seq_id') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
    hits = pd.read_csv(args.hits,
                       sep=',',
                       index_col='qseqid', ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
                       names=[
                           'qseqid', 'sseqid', 'pident', 'length', 'mismatch',
                           'gapopen', 'qstart', 'qend', 'sstart', 'send',
                           'evalue', 'bitscore'
                       ])

    joined = hits[hits['pident'] >= args.pctid].join(annots, how='inner') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

    joined.to_csv(args.outfile, ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
                  index=True,
                  index_label='qseqid',
                  columns=['pident', 'depth', 'lat_lon'],
                  sep=args.delimiter)

    print(f'Exported {joined.shape[0]:,} to "{args.outfile.name}".')

1

读取注释文件并将索引列设置为 seq_id

2

读取 BLAST 命中并将索引列设置为 qseqid

3

选择具有所需百分比 ID 的 BLAST 命中,并使用索引列对注释执行内连接。

4

使用指定的分隔符将 joined 数据框的所需列写入输出文件。包括索引并命名为 qseqid

连接操作非常复杂,让我花点时间解释一下。首先,每个数据框必须具有唯一的索引,默认情况下是行索引:

>>> import pandas as pd
>>> annots = pd.read_csv('tests/inputs/meta.csv')
>>> annots.index
RangeIndex(start=0, stop=100, step=1)

相反,我希望 pandas 使用 seq_id 列作为索引,我使用 index_col 参数指定:

>>> annots = pd.read_csv('tests/inputs/meta.csv', index_col='seq_id')

我也可以指示零字段:

>>> annots = pd.read_csv('tests/inputs/meta.csv', index_col=0)

现在索引设置为 seq_id

>>> annots.index[:10]
Index(['JCVI_READ_1092301105055', 'JCVI_READ_1092351051817',
       'JCVI_READ_1092301096881', 'JCVI_READ_1093017101914',
       'JCVI_READ_1092342065252', 'JCVI_READ_1092256406745',
       'JCVI_READ_1092258001174', 'JCVI_READ_1092959499253',
       'JCVI_READ_1092959656555', 'JCVI_READ_1092959499263'],
      dtype='object', name='seq_id')

类似地,我希望 BLAST 命中可以根据查询序列 ID 进行索引:

>>> cols = ['qseqid', 'sseqid', 'pident', 'length', 'mismatch', 'gapopen',
 'qstart', 'qend', 'sstart', 'send', 'evalue', 'bitscore']
>>> hits = pd.read_csv('tests/inputs/hits1.csv', names=cols, index_col='qseqid')
>>> hits.index[:10]
Index(['CAM_READ_0234442157', 'CAM_READ_0234442157', 'CAM_READ_0234442157',
       'CAM_READ_0234442157', 'JCVI_READ_1095946186912',
       'JCVI_READ_1095946186912', 'JCVI_READ_1095946186912',
       'JCVI_READ_1095946186912', 'JCVI_READ_1095946186912',
       'JCVI_READ_1091145027519'],
      dtype='object', name='qseqid')

我可以选择具有 pident 大于或等于最小值的 BLAST 命中。例如,我找到了值为 90 的 190 行:

>>> wanted = hits[hits['pident'] >= 90]
>>> wanted.shape
(190, 11)

结果数据框仍然以qseqid列为索引,因此我可以将其与具有相同索引值(序列 ID)的注释进行连接。默认情况下,pandas 将执行 连接,选择第一个或数据框中的所有行,并对没有在右数据框中找到配对的行填充空值。 连接连接相反,选择数据框中的所有记录,而不考虑左数据框中是否有匹配。由于我只想要具有注释的命中,所以我使用 连接。图 19-3 演示了使用维恩图进行连接的情况。

mpfb 1903

图 19-3. 左连接选择左表中的所有记录,右连接选择右表中的所有记录,内连接仅选择两者都有的记录

连接操作创建一个新的数据框,其中包含两个数据框的所有列,就像我在 “使用 csvkit 和 csvchk” 中展示的join工具一样:

>>> joined = wanted.join(annots, how='inner')
>>> joined
                             sseqid   pident  ...  temp              lat_lon
CAM_READ_0234442157      CP031125.1   92.941  ...  26.6   -8.50525,80.375583
JCVI_READ_1091120852400  CP012541.1  100.000  ...  25.0     24.488333,-83.07
JCVI_READ_1091141680691  MN693562.1   90.852  ...  27.7  10.716389,-80.25445
JCVI_READ_1091141680691  MN693445.1   90.645  ...  27.7  10.716389,-80.25445
JCVI_READ_1091141680691  MN693445.1   91.935  ...  27.7  10.716389,-80.25445
...                             ...      ...  ...   ...                  ...
JCVI_READ_1095913058159  CP000437.1   94.737  ...   9.4  41.485832,-71.35111
JCVI_READ_1095913058159  AM286280.1   92.683  ...   9.4  41.485832,-71.35111
JCVI_READ_1095913058159  DQ682149.1   94.737  ...   9.4  41.485832,-71.35111
JCVI_READ_1095913058159  AM233362.1   94.737  ...   9.4  41.485832,-71.35111
JCVI_READ_1095913058159  AY871946.1   94.737  ...   9.4  41.485832,-71.35111

[190 rows x 17 columns]

另一种写法是使用pd.merge()函数,默认情况下会执行内连接。我必须指示左右数据框中用于连接的列,这种情况下是索引:

>>> joined = pd.merge(wanted, annots, left_index=True, right_index=True)

我可以使用joined.to_csv()方法将数据框写入输出文件。请注意,共同的序列 ID 是索引,没有列名。我希望索引包含在输出文件中,因此我使用index=Trueindex_name='qseqid'以使文件与预期输出匹配:

>>> out_fh = open('out.csv', 'wt')
>>> joined.to_csv(out_fh, index=True, index_label='qseqid',
columns=['pident', 'depth', 'lat_lon'], sep=',')

更进一步

添加按其他字段(如温度、盐度或 BLAST e 值)过滤的选项。

默认包含输出文件中来自两个文件的所有列,并添加一个选项来选择列的子集。

复习

本章的重点:

  • Shell 别名可用于为常见命令创建快捷方式。

  • 分隔文本文件不总是包含列标题。BLAST 的表格输出格式就是这种情况。

  • csvpandas 模块可以读取和写入分隔文本文件。

  • 可以使用join命令行工具或在 Python 中使用字典的共同键或 pandas 数据框的共同索引来连接数据集中的共同列。

  • 如果您需要在内存中访问所有数据(例如,进行数据的统计分析或快速访问某列的所有值),pandas 是读取分隔文件的良好选择。如果您需要解析非常大的分隔文件并可以独立处理记录,则使用csv模块以获得更好的性能。

^(1) 你可能会自言自语地说,“我的天啊!他们到底做了什么?”

附录 A. 使用 make 记录命令和创建工作流

make 程序是在 1976 年创建的,用于从源代码文件构建可执行程序。尽管最初是为了帮助使用 C 语言编程而开发的,但它不限于该语言,甚至不限于编译代码的任务。根据手册,人们可以使用它来描述任何需要在其他文件更改时自动更新一些文件的任务。make 程序已经远远超出了其作为构建工具的角色,成为一个工作流系统。

Makefiles 就像食谱一样

当你运行 make 命令时,它会在当前工作目录中寻找名为 Makefile(或 makefile)的文件。该文件包含描述组合在一起创建某些输出的离散操作的食谱。想象一下柠檬蛋白饼的配方有需要按特定顺序和组合完成的步骤。例如,我需要分别制作馅饼皮、填料和蛋白霜,然后将它们组合在一起烘烤,才能享受美味。我可以用一种称为 串图 的东西来可视化这一过程,如 图 A-1 所示。

mpfb aa01

图 A-1. 描述如何制作馅饼的串图,改编自 Brendan Fong 和 David Spivak 的《应用范畴论邀请(组合性的七个素描)》,剑桥大学出版社,2019 年。

如果你提前一天做好馅饼皮并冷藏,以及填料同样也适用,但是确实需要先把皮放入盘中,然后是填料,最后是蛋白霜。实际的食谱可能会引用其他地方的通用配方来制作馅饼皮和蛋白霜,并且只列出制作柠檬馅料和烘烤说明的步骤。

我可以编写一个 Makefile 来模拟这些想法。我将使用 shell 脚本来假装我正在将各种成分组合到像 crust.txtfilling.txt 这样的输出文件中。在 app01_makefiles/pie 目录中,我编写了一个 combine.sh 脚本,它期望一个文件名和一个要放入文件中的“成分”列表:

$ cd app01_makefiles/pie/
$ ./combine.sh
usage: combine.sh FILE ingredients

我可以像这样假装制作馅饼皮:

$ ./combine.sh crust.txt flour butter water

现在有一个名为 crust.txt 的文件,内容如下:

$ cat crust.txt
Will combine flour butter water

Makefile 中,一个食谱通常但不必要创建一个输出文件。请注意,在这个例子中,clean 目标移除文件:

all: crust.txt filling.txt meringue.txt ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
	./combine.sh pie.txt crust.txt filling.txt meringue.txt ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
	./cook.sh pie.txt 375 45

filling.txt:                            ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
	./combine.sh filling.txt lemon butter sugar

meringue.txt:                           ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
	./combine.sh meringue.txt eggwhites sugar

crust.txt:                              ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
	./combine.sh crust.txt flour butter water

clean:                                  ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
	rm -f crust.txt meringue.txt filling.txt pie.txt

1

定义了一个名为 all 的目标。当未指定目标时,将运行第一个目标。惯例是 all 目标将运行所有必要的目标以完成某个默认目标,比如构建软件。这里我想从组件文件中创建 pie.txt 文件并“烘烤”它。all 的名字并不重要,重要的是它被首先定义。目标名称后跟一个冒号,然后是运行此目标之前必须满足的任何依赖项。

2

all目标有两个要运行的命令。每个命令都用制表符缩进。

3

这是 filling.txt 目标。此目标的目标是创建名为 filling.txt 的文件。通常但不必要的是使用输出文件名作为目标名。此目标只有一个命令,即将填料的成分组合在一起。

4

这是 meringue.txt 目标,它结合了蛋清和糖。

5

这是 crust.txt 目标,它结合了面粉、黄油和水。

6

通常会有一个 clean 目标,用于删除在正常构建过程中创建的任何文件。

如前面的例子所示,目标有一个名称,后面跟着冒号。任何依赖动作都可以按你希望的顺序在冒号后列出。目标的动作必须用制表符缩进,如图 A-2 所示,你可以定义任意多个命令。

mpfb aa02

图 A-2。一个 Makefile 目标以冒号结尾,可选择跟随依赖项;所有目标的动作必须用单个制表符缩进。

运行特定目标

Makefile 中的每个动作称为 目标规则配方。目标的顺序除了第一个目标作为默认目标外并不重要。目标可以像 Python 程序中的函数一样引用早先或晚于它定义的其他目标。

要运行特定目标,我运行make target来让 make 运行给定配方的命令:

$ make filling.txt
./combine.sh filling.txt lemon butter sugar

现在有一个名为 filling.txt 的文件:

$ cat filling.txt
Will combine lemon butter sugar

如果我尝试再次运行此目标,将告知无需执行任何操作,因为文件已存在:

$ make filling.txt
make: 'filling.txt' is up to date.

make 存在的原因之一正是为了不必要地创建文件,除非某些底层源代码已更改。在构建软件或运行流水线的过程中,可能不必生成某些输出,除非输入已更改,例如修改了源代码。要强制 make 运行filling.txt目标,我可以删除该文件,或者运行make clean以删除已创建的任何文件:

$ make clean
rm -f crust.txt meringue.txt filling.txt pie.txt

运行没有目标

如果你不带任何参数运行 make 命令,它会自动运行第一个目标。这是将 all 目标(或类似的目标)放在第一位的主要原因。但要小心,不要将像 clean 这样有破坏性的目标放在第一位,否则可能会意外运行它并删除有价值的数据。

当我运行带有前述 Makefilemake 时,输出如下:

$ make                                                  ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
./combine.sh crust.txt flour butter water               ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
./combine.sh filling.txt lemon butter sugar             ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
./combine.sh meringue.txt eggwhites sugar               ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
./combine.sh pie.txt crust.txt filling.txt meringue.txt ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
./cook.sh pie.txt 375 45                                ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/6.png)
Will cook "pie.txt" at 375 degrees for 45 minutes.

1

我不带任何参数运行 make。它会在当前工作目录中的名为 Makefile 的文件中查找第一个目标。

2

crust.txt 配方首先运行。因为我没有指定目标,make 运行了第一个定义的 all 目标,而这个目标列出了crust.txt作为第一个依赖项。

3

接下来运行 filling.txt 目标。

4

然后是meringue.txt

5

接下来我组装pie.txt

6

然后我在 375 度下烤 pie 45 分钟。

如果我再次运行 make,我会看到跳过产生 crust.txtfilling.txtmeringue.txt 文件的中间步骤,因为它们已经存在:

$ make
./combine.sh pie.txt crust.txt filling.txt meringue.txt
./cook.sh pie.txt 375 45
Will cook "pie.txt" at 375 degrees for 45 minutes.

如果我想强制重新创建它们,可以运行make clean && make,其中 && 是逻辑,仅在第一个命令成功运行后才运行第二个命令:

$ make clean && make
rm -f crust.txt meringue.txt filling.txt pie.txt
./combine.sh crust.txt flour butter water
./combine.sh filling.txt lemon butter sugar
./combine.sh meringue.txt eggwhites sugar
./combine.sh pie.txt crust.txt filling.txt meringue.txt
./cook.sh pie.txt 375 45
Will cook "pie.txt" at 375 degrees for 45 minutes.

Makefile 创建 DAGs

每个目标都可以指定其他目标作为必须先完成的前提条件或依赖关系。这些操作创建了一个图结构,有一个起点和穿过目标的路径,最终创建一些输出文件。任何目标描述的路径应该是一个有向(从开始到结束)无环(没有循环或无限循环),或称为 DAG,如图 A-3 所示。

mpfb aa03

图 A-3. 这些目标可以组合在一起,描述一个有向无环图的操作,以产生某个结果

许多分析管道就是这样——一个输入的图,如 FASTA 序列文件,以及一些转换(修剪,过滤,比较),最终输出 BLAST 命中,基因预测或功能注释等结果。你会惊讶地发现 make 可以被滥用来记录你的工作甚至创建完全功能的分析管道。

使用 make 编译一个 C 程序

我相信至少在生活中使用一次 make 来理解它存在的原因是有帮助的。我会花点时间编写并编译一个用 C 语言编写的“Hello, World”示例。在 app01_makefiles/c-hello 目录中,你会找到一个简单的 C 程序,它将打印“Hello, World!”下面是 hello.c 的源代码:

#include <stdio.h>            ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
int main() {                  ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
   printf("Hello, World!\n"); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
   return 0;                  ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
}                             ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)

1

就像在 bash 中一样,# 字符引入了 C 语言中的注释,但这是一个特殊的注释,允许使用外部代码模块。在这里,我想使用 printf(打印格式)函数,所以我需要 include 标准输入/输出(input/output)模块,称为 stdio。我只需要包含“头”文件 stdio.h,以获取该模块中的函数定义。这是一个标准模块,C 编译器将在各种位置查找任何包含的文件以找到它。有时由于找不到某个头文件,你可能无法从源代码编译 C(或 C++)程序。例如,gzip 库通常用于解压/压缩数据,但它并不总是以其他程序可以 include 的库形式安装。因此,你需要下载并安装 libgz 程序,确保将头文件安装到正确的 include 目录中。请注意,像 apt-getyum 这样的软件包管理器通常有 -dev-devel 包,你必须安装这些包来获取这些头文件;也就是说,你需要安装 libgzlibgz-dev 或类似的包。

2

这是在 C 中函数声明的开始。函数名(main)前面是它的返回类型(int)。函数的参数列在名称后的括号内。在这种情况下没有参数,所以括号为空。开头的大括号({)表示函数代码的开始。请注意,C 将自动执行 main() 函数,并且每个 C 程序必须有一个 main() 函数,这是程序的起点。

3

printf() 函数将给定的字符串打印到命令行。此函数定义在 stdio 库中,这就是为什么我需要 #include 上面的头文件。

4

return将退出函数并返回值0。因为这是main()函数的返回值,这将是整个程序的退出值。值0表示程序正常运行—即“零错误”。任何非零值都表示失败。

5

结束花括号(})是第 2 行的配对符号,并标记了main()函数的结束。

要将其转换为可执行程序,您需要在计算机上安装 C 编译器。例如,我可以使用 GNU C 编译器gcc

$ gcc hello.c

这将创建一个名为a.out的文件,这是一个可执行文件。在我的 Macintosh 上,file会报告如下:

$ file a.out
a.out: Mach-O 64-bit executable arm64

然后我可以执行它:

$ ./a.out
Hello, World!

我不喜欢a.out这个名称,所以我可以使用-o选项来命名输出文件为hello

$ gcc -o hello hello.c

运行生成的hello可执行文件。您应该看到相同的输出。

而不是每次修改hello.c时都要输入gcc -o hello hello.c,我可以将其放入Makefile中:

hello:
	gcc -o hello hello.c

现在我可以运行make hello或者如果这是第一个目标,只需运行make

$ make
gcc -o hello hello.c

如果我再次运行make,因为hello.c文件没有改变,所以什么都不会发生:

$ make
make: 'hello' is up to date.

如果我将hello.c代码更改为打印“Hola”而不是“Hello”,然后再次运行make会发生什么?

$ make
make: 'hello' is up to date.

我可以通过使用-B选项强制make运行目标:

$ make -B
gcc -o hello hello.c

现在新程序已经编译完成:

$ ./hello
Hola, World!

这只是一个简单的示例,您可能想知道这如何节省时间。在 C 或任何语言的实际项目中,可能会有多个.c文件,以及描述它们函数的头文件(.h文件),以便其他.c文件可以使用它们。C 编译器需要将每个.c文件转换为.o.out)文件,然后将它们链接在一起成为单个可执行文件。想象一下,您有数十个.c文件,并且更改一个文件中的一行代码。您想要手动输入数十个命令重新编译和链接所有代码吗?当然不。您会构建一个工具来自动化这些操作。

我可以向Makefile添加不生成新文件的目标。通常会有一个clean目标,用于清理不再需要的文件和目录。这里我可以创建一个clean目标来删除hello可执行文件:

clean:
	rm -f hello

如果我希望在运行hello目标之前始终删除可执行文件,我可以将其作为依赖项添加:

hello: clean
	gcc -o hello hello.c

对于make来说,很好地记录这是一个phony目标,因为目标的结果不是一个新创建的文件。我使用.PHONY:目标并列出所有虚假目标。现在完整的Makefile如下:

$ cat Makefile
.PHONY: clean

hello: clean
	gcc -o hello hello.c

clean:
	rm -f hello

如果您在c-hello目录中运行make与前述的Makefile,您应该看到这样的输出:

$ make
rm -f hello
gcc -o hello hello.c

现在您的目录中应该有一个hello可执行文件可以运行:

$ ./hello
Hello, World!

注意,clean目标可以在甚至在目标本身被提到之前就列为hello目标的依赖项。make会读取整个文件,然后使用依赖关系来解析图形。如果你将foo作为hello的附加依赖项并再次运行make,你会看到这样的情况:

$ make
make: *** No rule to make target 'foo', needed by 'hello'.  Stop.

Makefile允许我编写独立的动作组,这些动作按它们的依赖顺序排列。它们就像高级语言中的函数。我基本上写了一个输出为另一个程序的程序。

我建议你运行cat hello来查看hello文件的内容。它主要是一些看起来像乱码的二进制信息,但你可能也能看到一些明文。你也可以使用strings hello来提取文本字符串。

使用 make 作为快捷方式

让我们看看如何滥用Makefile来为命令创建快捷方式。在app01_makefiles/hello目录中,你会找到以下Makefile

$ cat Makefile
.PHONY: hello            ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

hello:                   ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
	echo "Hello, World!" ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)

1

由于hello目标不生成文件,我将其列为伪目标。

2

这是hello目标。目标的名称应该只由字母和数字组成,在其前面不应有空格,并在冒号(:)之后。

3

在以制表符缩进的行上列出了hello目标要运行的命令(们)。

我可以用make来执行这个:

$ make
echo "Hello, World!"
Hello, World!

我经常使用Makefile来记住如何使用各种参数调用命令。也就是说,我可能会编写一个分析流水线,然后记录如何在各种数据集上运行程序及其所有参数。通过这种方式,我可以立即通过运行目标来复现我的工作。

定义变量

这里是我写的一个Makefile的示例,用来记录我如何使用 Centrifuge 程序对短读取进行分类分配:

INDEX_DIR = /data/centrifuge-indexes         ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)

clean_paired:
    rm -rf $(HOME)/work/data/centrifuge/paired-out

paired: clean_paired                         ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
    ./run_centrifuge.py \                    ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
    -q $(HOME)/work/data/centrifuge/paired \ ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
    -I $(INDEX_DIR) \                        ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
    -i 'p_compressed+h+v' \
    -x "9606, 32630" \
    -o $(HOME)/work/data/centrifuge/paired-out \
    -T "C/Fe Cycling"

1

这里我定义了变量INDEX_DIR并赋了一个值。注意=两边必须有空格。我个人偏好使用全大写来命名我的变量,但这是我的个人喜好。

2

在运行此目标之前,请先运行clean_paired目标。这确保没有上一次运行留下的输出。

3

由于这个操作很长,所以我使用反斜杠(\)像在命令行上一样表示命令延续到下一行。

4

要使make解除引用或使用$HOME环境变量的值,请使用语法$(HOME)

5

$(INDEX_DIR)是指顶部定义的变量。

编写工作流程

app01_makefiles/yeast目录中有一个示例,展示了如何将工作流程写成make目标。目标是下载酵母基因组并将各种基因类型(如“可疑”,“未分类”,“已验证”等)进行特征化。这通过一系列命令行工具(如wgetgrepawk)以及名为download.sh的自定义 Shell 脚本组合在一起,并按顺序由make运行:

.PHONY: all fasta features test clean

FEATURES = http://downloads.yeastgenome.org/curation/$\
    chromosomal_feature/
    SGD_features.tab

all: fasta genome chr-count chr-size features gene-count verified-genes \
     uncharacterized-genes gene-types terminated-genes test

clean:
	find . \( -name \*gene\* -o -name chr-\* \) -exec rm {} \;
	rm -rf fasta SGD_features.tab

fasta:
	./download.sh

genome: fasta
	(cd fasta && cat *.fsa > genome.fa)

chr-count: genome
	grep -e '^>' "fasta/genome.fa" | grep 'chromosome' | wc -l > chr-count

chr-size: genome
	grep -ve '^>' "fasta/genome.fa" | wc -c > chr-size

features:
	wget -nc $(FEATURES)

gene-count: features
	cut -f 2 SGD_features.tab | grep ORF | wc -l > gene-count

verified-genes: features
	awk -F"\t" '$$3 == "Verified" {print}' SGD_features.tab | \
		wc -l > verified-genes

uncharacterized-genes: features
	awk -F"\t" '$$2 == "ORF" && $$3 == "Uncharacterized" {print $$2}' \
		SGD_features.tab | wc -l > uncharacterized-genes

gene-types: features
	awk -F"\t" '{print $$3}' SGD_features.tab | sort | uniq -c > gene-types

terminated-genes:
	grep -o '/G=[^ ]*' palinsreg.txt | cut -d = -f 2 | \
		sort -u > terminated-genes

test:
	pytest -xv ./test.py

我不打算评论所有命令。我主要想演示我可以滥用Makefile创建工作流程的程度。我不仅记录了所有步骤,而且使用make命令就可以运行它们。如果不使用make,我将不得不编写一个 Shell 脚本来完成这个任务,或者更可能切换到像 Python 这样的更强大的语言。用任一语言编写的结果程序可能会更长,更多错误,更难理解。有时候,你只需要一个Makefile和一些 Shell 命令。

其他工作流程管理器

当您遇到make的限制时,您可以选择切换到工作流程管理器。有许多选择。例如:

  • Snakemake 通过 Python 扩展了make的基本概念。

  • 通用工作流程语言(CWL)在配置文件(YAML 格式)中定义工作流程和参数,您可以使用cwltoolcwl-runner(均采用 Python 实现)等工具执行另一个配置文件描述的工作流程。

  • 工作流描述语言(WDL)采用类似的方法来描述工作流和参数,并且可以使用 Cromwell 引擎运行。

  • Pegasus 允许您使用 Python 代码来描述一个工作流,然后将其写入 XML 文件,这是引擎的输入,用于运行您的代码。

  • Nextflow 类似,您可以使用名为 Groovy(Java 的子集)的完整编程语言编写可以由 Nextflow 引擎运行的工作流。

所有这些系统都遵循与make相同的基本思想,因此理解make的工作原理以及如何编写工作流程的各个部分以及它们之间的交互是您可能创建的任何更大分析工作流程的基础。

进一步阅读

这里有一些其他资源可以帮助您学习make

附录 B. 理解 $PATH 和安装命令行程序

PATH 是一个环境变量,定义了在给定命令搜索时将要查找的目录。也就是说,如果我输入 foo,并且在我的 PATH 中既没有内置命令、shell 函数、命令别名,也没有可以作为 foo 执行的程序,那么我将会收到找不到该命令的提示:

$ foo
-bash: foo: command not found

在 Windows PowerShell 中,我可以使用 echo $env:Path 检查 PATH,而在 Unix 平台上,我使用 echo $PATH 命令。两个路径都作为一个长字符串打印出来,没有空格,列出所有目录名称,Windows 上以分号分隔,Unix 上以冒号分隔。如果操作系统没有路径的概念,它将不得不在机器上的 每个目录 中搜索给定的命令。这可能需要几分钟到几小时,因此将搜索限制在几个目录中是有道理的。

接下来是我在我的 Macintosh 上的路径。注意,我必须在名称前面加上一个美元符号 ($),告诉我的 shell (bash) 这是一个变量,而不是字面字符串 PATH。为了使这更易读,我将使用 Perl 将冒号替换为换行符。请注意,此命令仅在安装了 Perl 的 Unix 命令行上才能正常工作:

$ echo $PATH | perl -pe 's/:/\n/g' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/1.png)
/Users/kyclark/.local/bin ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/2.png)
/Library/Frameworks/Python.framework/Versions/3.9/bin ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/3.png)
/usr/local/bin ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/4.png)
/usr/bin ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/rprd-bioinfo-py/img/5.png)
/bin
/usr/sbin
/sbin

1

Perl 替换 (s//) 命令将第一个模式 (:) 全局替换为第二个 (\n)。

2

这是我通常为安装自己的程序创建的自定义目录。

3

这是 Python 安装自身的位置。

4

这是一个标准的用户安装软件目录。

5

其余的目录都是用来查找程序的更标准的目录。

目录将按照它们定义的顺序进行搜索,因此顺序可能非常重要。例如,Python 路径在系统路径之前列出,以便当我输入 python3 时,它会使用我本地 Python 目录中找到的版本,而不是可能预先安装在系统上的版本。

注意,我 PATH 中的所有目录名称都以 bin 结尾。这是 二进制 的缩写,来自于许多程序以二进制形式存在的事实。例如,C 程序的源代码以一种伪英语语言编写,编译成机器可读的可执行文件。该文件的内容是二进制编码的指令,操作系统可以执行。

相比之下,Python 程序通常安装为其源代码文件,在运行时由 Python 执行。如果你想全局安装你的 Python 程序之一,我建议你将其复制到已列出在你的PATH中的目录之一。例如,/usr/local/bin是用户通过本地安装软件的典型目录。这是一个如此常见的目录,以至于通常出现在PATH中。如果你在个人设备上工作,比如笔记本电脑,在这里你拥有管理员权限,你应该能够将新文件写入此位置。

例如,如果我想要能够在第一章中运行dna.py程序而不提供源代码的完整路径,我可以将其复制到我的PATH中的某个位置:

$ cp 01_dna/dna.py /usr/local/bin

然而,你可能没有足够的权限来执行此操作。Unix 系统从一开始就设计成多租户操作系统,这意味着它们支持许多不同的人同时使用系统。重要的是防止用户写入和删除他们不应该的文件,因此操作系统可能会阻止你将dna.py写入你不拥有的目录。例如,如果你在大学的共享高性能计算(HPC)系统上工作,你肯定没有这样的特权。

当无法安装到系统目录时,最简单的方法是在你的HOME目录中创建一个位置用于这些文件。在我的笔记本电脑上,这就是我的HOME目录:

$ echo $HOME
/Users/kyclark

在我的几乎所有系统上,我创建了一个$HOME/.local目录用于安装程序。大多数 shell 将波浪号(~)解释为HOME

$ mkdir ~/.local

按照惯例,以点开头的文件和目录通常被ls命令隐藏。你可以使用ls -a列出目录的所有内容。你可能会注意到许多其他点文件,它们被各种程序用来持久化选项和程序状态。我喜欢称其为.local,这样在我的目录列表中通常看不到它。

为软件安装在HOME中创建一个目录在生物信息学中编译程序从源代码是特别有用的。这种安装大多数情况下开始使用configure程序收集关于你的系统的信息,比如你的 C 编译器的位置等等。这个程序几乎总是有一个--prefix选项,我将设置为这个目录:

$ ./configure --prefix=$HOME/.local

结果安装将把二进制编译文件放入$HOME/.local/bin。它还可能安装头文件和手册页以及其他支持数据到$HOME/.local中的其他目录。

无论你决定在何处安装本地程序,你都需要确保你的PATH已更新以在该目录中搜索,除了其他目录外。我倾向于使用bash shell,我HOME中的一个点文件是一个名为.bashrc(有时是.bash_profile.profile)的文件。我可以添加这行来将我的自定义目录放在PATH的最前面:

export PATH=$HOME/.local/bin:$PATH

如果你使用不同的 shell,可能需要稍作调整。最近,macOS 开始使用zsh(Z shell)作为默认 shell,或者你的 HPC 系统可能使用另一种 shell。它们都有PATH的概念,并且都允许你以某种方式进行自定义。在 Windows 上,你可以使用以下命令将目录追加到你的路径中:

> $env:Path += ";~/.local/bin"

这里是我可以创建目录并复制程序的方法:

$ mkdir -p ~/.local/bin
$ cp 01_dna/dna.py ~/.local/bin

现在我应该能够在 Unix 机器的任何位置执行dna.py

$ dna.py
usage: dna.py [-h] DNA
dna.py: error: the following arguments are required: DNA

Windows 的 shell 如cmd.exe和 PowerShell 不会像 Unix shell 那样读取和执行 shebang,因此在程序名之前,你需要包括命令python.exepython3.exe

> python.exe C:\Users\kyclark\.local\bin\dna.py
usage: dna.py [-h] DNA
dna.py: error: the following arguments are required: DNA

确保python.exe --version显示你正在使用的是版本 3 而不是版本 2。你可能需要安装最新版本的 Python。我仅展示了使用python.exe的 Windows 命令,假设这意味着 Python 3,但根据你的系统,你可能需要使用python3.exe

结语

我们使用的工具深刻(并且狡猾!)地影响着我们的思维习惯,因此也影响我们的思维能力。

艾兹格·迪科斯特拉

本书灵感来自罗莎琳德问题,多年来我一直在重新审视这些问题,首先是为了更深入地了解生物学,然后是在学习新的编程语言时。最初我用 Perl 尝试解决这些问题,之后又尝试使用 JavaScript、Haskell、Python 和 Rust,成功的程度各有不同。我想 challenge 你也用你懂的其他语言写出解决方案。

我尝试向你展示在 Python 中你可以重复使用的模式。最重要的是,我希望我已经证明了类型、测试以及各种格式化和 linting 工具能极大地改善你编写的程序。

posted @ 2024-06-17 17:11  绝不原创的飞龙  阅读(10)  评论(0编辑  收藏  举报