Kapitel 1. Tetranukleotid-Frequenz: Das Zählen der Dinge
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Das Zählen der Basen in der DNA ist vielleicht das "Hallo, Welt!" der Bioinformatik. Die Rosalind-DNA-Herausforderung beschreibt ein Programm, das aus einer DNA-Sequenz die Anzahl der gefundenen As, Cs, Gsund Tsausgibt. Es gibt überraschend viele Möglichkeiten, Dinge in Python zu zählen, und ich werde erkunden, was die Sprache zu bieten hat. Ich werde auch zeigen, wie man ein gut strukturiertes, dokumentiertes Programm schreibt, das seine Argumente validiert, und wie man Tests schreibt und ausführt, um sicherzustellen, dass das Programm richtig funktioniert.
In diesem Kapitel erfährst du:
-
So starten Sie ein neues Programm mit
new.py
-
So definieren und validieren Sie Befehlszeilenargumente mit
argparse
-
So führen Sie eine Testsuite mit
pytest
-
Wie man die Zeichen einer Zeichenkette iteriert
-
Möglichkeiten zum Zählen von Elementen in einer Sammlung
-
Wie man einen Entscheidungsbaum mit
if
/elif
Anweisungen erstellt -
Wie man Strings formatiert
Erste Schritte
Bevor du beginnst, solltest du den Abschnitt "Getting the Code and Tests" im Vorwort gelesen haben. Sobald du eine lokale Kopie des Code-Repositorys hast, wechsle in das Verzeichnis 01_dna:
$ cd 01_dna
Hier findest du mehrere solution*.py
Programme zusammen mit Tests und Eingabedaten, mit denen du überprüfen kannst, ob die Programme richtig funktionieren. Um eine Vorstellung davon zu bekommen, wie dein Programm funktionieren sollte, kopiere zunächst die erste Lösung in ein Programm namens dna.py
:
$ cp solution1_iter.py dna.py
Führe das Programm nun ohne Argumente oder mit den Flags -h
oder --help
aus. Es wird eine Dokumentation über die Verwendung ausgeben (beachte, dass Verwendung das erste Wort der Ausgabe ist):
$ ./dna.py usage: dna.py [-h] DNA dna.py: error: the following arguments are required: DNA
Wenn du eine Fehlermeldung wie "Zugriff verweigert" erhältst, musst du möglicherweise chmod +x dna.py
um den Modus des Programms zu ändern, indem du das ausführbare Bit hinzufügst.
Das ist eines der ersten Elemente der Reproduzierbarkeit:Programme sollten dokumentieren, wie sie funktionieren.Während es üblich ist, ein Programm in einer README-Datei oder sogar in einem Aufsatz zu beschreiben, muss das Programm selbst eine Dokumentation seiner Parameter und Ausgaben liefern.
Ich zeige dir, wie du das Modul argparse
verwendest, um die Argumente zu definieren und zu validieren sowie die Dokumentation zu erstellen, sodass es keine Möglichkeit gibt, dass die vom Programm generierte Verwendungsangabe falsch ist. Wenn du das damit vergleichst, dass README-Dateien, Änderungsprotokolle und Ähnliches schnell den Anschluss an die Entwicklung eines Programms verlieren können, wirst du hoffentlich verstehen, dass diese Art der Dokumentation sehr effektiv ist.
Aus der Verwendungszeile kannst du ersehen, dass das Programm etwas wie DNA
als Argument erwartet, also geben wir ihm eine Sequenz.Wie auf der Rosalind-Seite beschrieben, gibt das Programm die Anzahl für jede der Basen A, C, G und T aus, und zwar in dieser Reihenfolge und jeweils durch ein einzelnes Leerzeichen getrennt:
$ ./dna.py ACCGGGTTTT 1 2 3 4
Wenn du eine Aufgabe auf der Rosalind.info-Website löst, wird die Eingabe für dein Programm als heruntergeladene Datei bereitgestellt; deshalb schreibe ich das Programm so, dass es auch den Inhalt einer Datei liest. Ich kann mit dem Befehl cat
(für concatenate) verwenden, um den Inhalt einer der Dateien im Verzeichnis tests/inputs zu drucken:
$ cat tests/inputs/input2.txt AGCTTTTCATTCTGACTGCAACGGGCAATATGTCTCTGTGTGGATTAAAAAAAGAGTGTCTGATAGCAGC
Das ist dieselbe Sequenz wie im Beispiel auf der Website. Ich weiß also, dass die Ausgabe des Programms so aussehen sollte:
$ ./dna.py tests/inputs/input2.txt 20 12 17 21
Im Laufe des Buches werde ich das Werkzeug pytest
verwenden, um die Tests auszuführen, die sicherstellen, dass Programme wie erwartet funktionieren. Wenn ich den Befehl pytest
ausführe, wird das aktuelle Verzeichnis rekursiv nach Tests und Funktionen durchsucht, die wie Tests aussehen. Beachte, dass du eventuell die Befehle python3 -m pytest
oder pytest.exe
Wenn du unter Windows arbeitest, solltest du etwa folgendes sehen: Das Programm hat alle vier Tests in der Datei tests/dna_test.py bestanden:
$ pytest =========================== test session starts =========================== ... collected 4 items tests/dna_test.py .... [100%] ============================ 4 passed in 0.41s ============================
Ein Schlüsselelement beim Testen von Software ist, dass du dein Programm mit bekannten Eingaben ausführst und überprüfst, ob es die richtige Ausgabe erzeugt.Das mag zwar naheliegend erscheinen, aber ich musste mich schon gegen "Testverfahren" wehren, bei denen Programme einfach nur ausgeführt wurden, ohne zu überprüfen, ob sie sich richtig verhalten.
Das Programm mit new.py erstellen
Wenn du eine der Lösungen kopiert hast, wie im vorangegangenen Abschnitt gezeigt, dann lösche dieses Programm, damit du von vorne anfangen kannst:
$ rm dna.py
Ohne dir meine Lösungen anzusehen, möchte ich, dass du versuchst, dieses Problem zu lösen. Wenn du denkst, dass du alle Informationen hast, die du brauchst, kannst du loslegen und deine eigene Version von dna.py
schreiben, indem du pytest
benutzt, um die bereitgestellten Tests auszuführen. Lies weiter, wenn du mit mir Schritt für Schritt lernen willst, wie man das Programm schreibt und die Tests ausführt.
Jedes Programm in diesem Buch akzeptiert ein oder mehrere Kommandozeilenargumente und erzeugt eine Ausgabe, z. B. Text auf der Kommandozeile oder neue Dateien. Ich werde immer das im Vorwort beschriebene Programm new.py
verwenden, um zu beginnen, aber das ist keine Voraussetzung. Du kannst deine Programme schreiben, wie du willst, und von einem beliebigen Punkt aus beginnen, aber von deinen Programmen wird erwartet, dass sie dieselben Funktionen haben, wie z. B. das Erzeugen von Verwendungsnachweisen und die korrekte Validierung von Argumenten.
Erstelle dein Programm dna.py
im Verzeichnis 01_dna, da dieses die Testdateien für das Programm enthält. So starte ich das Programm dna.py
. Das Argument --purpose
wird in der Dokumentation des Programms verwendet:
$ new.py --purpose 'Tetranucleotide frequency' dna.py Done, see new script "dna.py."
Wenn du das neue Programm dna.py
ausführst, wirst du sehen, dass es viele verschiedene Arten von Argumenten definiert, die in Kommandozeilenprogrammen üblich sind:
$ ./dna.py --help usage: dna.py [-h] [-a str] [-i int] [-f FILE] [-o] str Tetranucleotide frequency positional arguments: str A positional argument optional arguments: -h, --help show this help message and exit -a str, --arg str A named string argument (default: ) -i int, --int int A named integer argument (default: 0) -f FILE, --file FILE A readable file (default: None) -o, --on A boolean flag (default: False)
Die
--purpose
vonnew.py
wird hier verwendet, um das Programm zu beschreiben.Das Programm akzeptiert ein einzelnes positionelles String-Argument.
Die Flags
-h
und--help
werden automatisch vonargparse
hinzugefügt und lösen die Verwendung aus.Dies ist eine benannte Option mit kurzen (
-a
) und langen (--arg
) Namen für einen String-Wert.Dies ist eine benannte Option mit kurzen (
-i
) und langen (--int
) Namen für einen ganzzahligen Wert.Dies ist eine benannte Option mit kurzen (
-f
) und langen (--file
) Namen für ein Dateiargument.Dies ist ein boolesches Flag, das
True
lautet, wenn entweder-o
oder--on
vorhanden ist, undFalse
, wenn sie nicht vorhanden sind.
Dieses Programm braucht nur das Positionsargument str
und du kannst DNA
für den Wert metavar
verwenden, um dem Benutzer einen Hinweis auf die Bedeutung des Arguments zu geben. Lösche alle anderen Parameter. Beachte, dass du die Flags -h
und --help
nie definierst, da argparse
diese intern verwendet, um auf Verwendungsanfragen zu reagieren. Versuche, dein Programm so zu verändern, dass es die folgende Verwendung erzeugt (wenn du die Verwendung noch nicht erzeugen kannst, keine Sorge, ich zeige es im nächsten Abschnitt):
$ ./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
Wenn du es schaffst, das Programm zum Laufen zu bringen, möchte ich dich darauf hinweisen, dass dieses Programm genau ein Positionsargument akzeptiert. Wenn du versuchst, es mit einer anderen Anzahl von Argumenten auszuführen, wird das Programm sofort anhalten und eine Fehlermeldung ausgeben:
$ ./dna.py AACC GGTT usage: dna.py [-h] DNA dna.py: error: unrecognized arguments: GGTT
Ebenso weist das Programm alle unbekannten Flags oder Optionen zurück. Mit nur wenigen Codezeilen hast du ein dokumentiertes Programm erstellt, das die Argumente des Programms überprüft.Das ist ein sehr grundlegender und wichtiger Schritt zur Reproduzierbarkeit.
Argparse verwenden
Das Programm, das von new.py
erstellt wird, verwendet das Modul argparse
, um die Programmparameter zu definieren, zu überprüfen, ob die Argumente korrekt sind, und um die Nutzungsdokumentation für den Benutzer zu erstellen. Das Modul argparse
ist ein Standard-Python-Modul, d.h. es ist immer vorhanden.Andere Module können diese Aufgaben ebenfalls übernehmen, und es steht dir frei, jede beliebige Methode zu verwenden, um diesen Aspekt deines Programms zu behandeln. Du musst nur sicherstellen, dass deine Programme die Tests bestehen.
Ich habe eine Version von new.py
für Tiny Python Projects geschrieben, die du im bin-Verzeichnis des GitHub-Repos dieses Buches finden kannst.Diese Version ist etwas einfacher als die Version, die du verwenden sollst. Ich zeige dir zunächst eine Version von dna.py
, die mit dieser früheren Version von new.py
erstellt wurde:
#!/usr/bin/env python3 """ Tetranucleotide frequency """ import argparse # -------------------------------------------------- def get_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') return parser.parse_args() # -------------------------------------------------- def main(): """ Make a jazz noise here """ args = get_args() print(args.dna) # -------------------------------------------------- if __name__ == '__main__': main()
Der umgangssprachliche Shebang (
#!
) sagt dem Betriebssystem, dass es den Befehlenv
(Umgebung) verwenden soll, umpython3
zu finden und den Rest des Programms auszuführen.Dies ist ein Docstring (Dokumentationsstring) für das Programm oder Modul als Ganzes.
Ich importiere das Modul
argparse
, um mit Kommandozeilenargumenten umzugehen.Ich definiere immer eine
get_args()
Funktion, um denargparse
Code zu bearbeiten.Dies ist ein Docstring für eine Funktion.
Das
parser
Objekt wird verwendet, um die Parameter des Programms zu definieren.Ich definiere ein
dna
Argument, das positional sein wird, weil der Namedna
nicht mit einem Bindestrich beginnt. Dasmetavar
ist eine kurze Beschreibung des Arguments, die in der Kurzverwendung erscheinen wird. Andere Argumente werden nicht benötigt.Die Funktion gibt die Ergebnisse des Parsens der Argumente zurück. Die Hilfe-Flags oder Probleme mit den Argumenten führen dazu, dass
argparse
eine Benutzungsmeldung/Fehlermeldung ausgibt und das Programm beendet.Alle Programme in diesem Buch beginnen immer mit der Funktion
main()
.Der erste Schritt in
main()
ist immer der Aufruf vonget_args()
. Wenn dieser Aufruf erfolgreich ist, müssen die Argumente gültig gewesen sein.Der Wert
DNA
ist im Attributargs.dna
verfügbar, da dies der Name des Arguments ist.Dies ist ein gängiges Idiom in Python-Programmen, um zu erkennen, wann das Programm ausgeführt wird (im Gegensatz zum Import) und um die Funktion
main()
auszuführen.
Die Shebang-Zeile wird von der Unix-Shell verwendet, wenn das Programm als Programm aufgerufen wird, wie ./dna.py
. Sie funktioniert nicht unter Windows, wo du das Programm mit python.exe dna.py
um das Programm auszuführen.
Dieser Code funktioniert zwar völlig ausreichend, aber der Wert, der von get_args()
zurückgegeben wird, ist ein argparse.Namespace
Objekt, das dynamisch erzeugt wird, wenn das Programm läuft.Das heißt, ich verwende Code wie parser.add_argument()
, um die Struktur dieses Objekts zur Laufzeit zu ändern, so dass Python zur Kompilierzeit nicht wissen kann, welche Attribute in den geparsten Argumenten verfügbar sein werden oder welche Typen sie haben. Während es für dich offensichtlich sein mag, dass es nur ein einziges, erforderliches String-Argument geben kann, gibt es nicht genügend Informationen im Code, damit Python dies erkennen kann.
Ein Programm zu kompilieren bedeutet, es in den Maschinencode zu verwandeln, den ein Computer ausführen kann. Einige Sprachen, wie C, müssen separat kompiliert werden, bevor sie ausgeführt werden können. Python-Programme werden oft in einem Schritt kompiliert und ausgeführt, aber es gibt trotzdem eine Kompilierungsphase. Einige Fehler können bei der Kompilierung abgefangen werden, andere tauchen erst zur Laufzeit auf. Syntaxfehler verhindern zum Beispiel die Kompilierung. Es ist besser, Fehler bei der Kompilierung als bei der Ausführung zu haben.
Um zu sehen, warum das ein Problem sein könnte, ändere ich die Funktion main()
, um einen Typfehler einzuführen. Das heißt, ich missbrauche absichtlich den Typ des Werts args.dna
. Wenn nicht anders angegeben, sind alle Argumentwerte, die von argparse
aus der Befehlszeile zurückgegeben werden, Strings. Wenn ich versuche, den String args.dna
durch den Integer-Wert 2 zu teilen, löst Python eine Ausnahme aus und lässt das Programm zur Laufzeit abstürzen:
def main(): args = get_args() print(args.dna / 2)
Wenn ich das Programm ausführe, stürzt es wie erwartet ab:
$ ./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'
Unsere großen, schwammigen Gehirne wissen, dass dies ein unvermeidlicher Fehler ist, aber Python kann das Problem nicht erkennen. Was ich brauche, ist eine statische Definition der Argumente, die nicht geändert werden kann, wenn das Programm ausgeführt wird. Lies weiter, um zu sehen, wie Typkommentare und andere Werkzeuge diese Art von Fehlern erkennen können.
Werkzeuge zum Auffinden von Fehlern im Code
Ziel ist es, korrekte, reproduzierbare Programme in Python zu schreiben.Gibt es Möglichkeiten, Probleme wie die falsche Verwendung einer Zeichenkette in einer numerischen Operation zu erkennen und zu vermeiden?Der Interpreter python3
fand keine Probleme, die mich daran hinderten, den Code auszuführen.
Das heißt, das Programm ist syntaktisch korrekt, sodass der Code im vorangegangenen Abschnitt einen Laufzeitfehler erzeugt, weil der Fehler erst auftritt, wenn ich das Programm ausführe.Vor Jahren habe ich in einer Gruppe gearbeitet, in der wir scherzten: "Wenn es sich kompilieren lässt, schicke es!" Das ist eindeutig ein kurzsichtiger Ansatz beim Programmieren in Python.
Ich kann Werkzeuge wie Linters und Typprüfungen verwenden, um einige Probleme im Code zu finden.Linters sind Werkzeuge, die den Programmstil und viele andere Arten von Fehlern jenseits von fehlerhafter Syntax überprüfen. Das Werkzeugpylint
ist ein beliebter Python-Linter, den ich fast jeden Tag verwende.Kann es dieses Problem finden? Anscheinend nicht, denn es gibt den größten Daumen hoch:
$ pylint dna.py ------------------------------------------------------------------- Your code has been rated at 10.00/10 (previous run: 9.78/10, +0.22)
Das flake8
ist ein weiterer Linter, den ich oft in Kombination mit pylint
verwende, da er verschiedene Arten von Fehlern meldet.Wenn ich flake8 dna.py
ausführe, erhalte ich keine Ausgabe, was bedeutet, dass er keine Fehler gefunden hat, die er melden kann.
Das mypy
ist ein statischer Typprüfer für Python, d.h. es wurde entwickelt, um missbräuchlich verwendete Typen zu finden, wie z.B. den Versuch, eine Zeichenkette durch eine Zahl zu dividieren. Weder pylint
noch flake8
wurden entwickelt, um Typfehler zu finden, daher kann ich nicht überrascht sein, dass sie den Fehler übersehen haben. Was sagt mypy
?
$ mypy dna.py Success: no issues found in 1 source file
Das ist ein bisschen enttäuschend, aber du musst verstehen, dass mypy
ein Problem nicht meldet , weil es keine Typinformationen gibt. mypy
hat also keine Informationen, um zu sagen, dass die Division von args.dna
durch 2 falsch ist. Ich werde das in Kürze korrigieren.
Einführung von benannten Tupeln
Um die Probleme mit dynamisch erzeugten Objekten zu vermeiden, verwenden alle Programme in diesem Buch eine benannte Tupel-Datenstruktur, um die Argumente von get_args()
statisch zu definieren.Tupel sind im Wesentlichen unveränderliche Listen und werden in Python oft zur Darstellung von Datenstrukturen verwendet, die wie Datensätze aussehen. Es gibt eine ganze Menge zu entpacken, also lass uns zurück zu Listen gehen.
Listen sind geordnete Abfolgen von Elementen. Die Elemente können heterogen sein; in der Theorie bedeutet das, dass alle Elemente von verschiedenen Typen sein können, aber in der Praxis ist das Mischen von Typen oft eine schlechte Idee. Ich werde die python3
REPL verwenden, um einige Aspekte von Listen zu demonstrieren.Ich empfehle dir, die help(list)
um die Dokumentation zu lesen.
Verwende leere eckige Klammern ([]
), um eine leere Liste zu erstellen, die einige Sequenzen enthält:
>>> seqs = []
Die Funktion list()
erstellt auch eine neue, leere Liste:
>>> seqs = list()
Überprüfe, ob es sich um eine Liste handelt, indem du die Funktion type()
verwendest, um den Typ der Variablen zurückzugeben:
>>> type(seqs) <class 'list'>
Listen haben Methoden, mit denen du Werte an das Ende der Liste anhängen kannst, z.B. list.append()
, um einen Wert hinzuzufügen:
>>> seqs.append('ACT') >>> seqs ['ACT']
und list.extend()
, um mehrere Werte hinzuzufügen:
>>> seqs.extend(['GCA', 'TTT']) >>> seqs ['ACT', 'GCA', 'TTT']
Wenn du die Variable selbst in der REPL eingibst, wird sie ausgewertet und in eine Textdarstellung umgewandelt:
>>> seqs ['ACT', 'GCA', 'TTT']
Das ist im Grunde dasselbe, was passiert, wenn du eine Variable print()
:
>>> print(seqs) ['ACT', 'GCA', 'TTT']
Erinnere dich daran, dass alle Indizierungen in Python 0-basiert sind, also ist 0 das erste Element.Ändere die erste Sequenz in TCA
:
>>> seqs[0] = 'TCA'
Überprüfe, ob sie geändert wurde:
>>> seqs ['TCA', 'GCA', 'TTT']
Wie Listen sind auch Tupel geordnete Abfolgen von möglicherweise heterogenen Objekten. Immer wenn du Kommas zwischen die Elemente einer Reihe setzt, erstellst du ein Tupel:
>>> seqs = 'TCA', 'GCA', 'TTT' >>> type(seqs) <class 'tuple'>
Es ist üblich, Klammern um Tupelwerte zu setzen, um dies zu verdeutlichen:
>>> seqs = ('TCA', 'GCA', 'TTT') >>> type(seqs) <class 'tuple'>
Im Gegensatz zu Listen können Tupel nicht geändert werden, wenn sie einmal erstellt wurden. Wenn du help(tuple)
liest, wirst du sehen, dass ein Tupel eine eingebaute unveränderliche Sequenz ist, ich kann also keine Werte hinzufügen:
>>> seqs.append('GGT') Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'tuple' object has no attribute 'append'
oder ändere bestehende Werte:
>>> seqs[0] = 'TCA' Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'tuple' object does not support item assignment
In Python ist es üblich, Tupel zur Darstellung von Datensätzen zu verwenden.Ich könnte zum Beispiel eine Sequence
mit einer eindeutigen ID und einer Reihe von Basen darstellen:
>>> seq = ('CAM_0231669729', 'GTGTTTATTCAATGCTAG')
Es ist zwar möglich, die Werte eines Tupels wie bei einer Liste zu indizieren, aber das ist umständlich und fehleranfällig. Beibenannten Tupeln kann ich den Feldern Namen zuweisen, was ihre Verwendung ergonomischer macht. Um benannte Tupel zu verwenden, kann ich die Funktion namedtuple()
aus dem Modul collections
importieren:
>>> from collections import namedtuple
Wie in Abbildung 1-2 dargestellt, verwende ich die Funktion namedtuple()
, um die Idee eines Sequence
zu erstellen, das Felder für id
und seq
hat:
>>> Sequence = namedtuple('Sequence', ['id', 'seq'])
Was genau ist Sequence
hier?
>>> type(Sequence) <class 'type'>
Ich habe gerade einen neuen Typ erstellt. Du könntest die Funktion Sequence()
als Fabrik bezeichnen, weil sie dazu dient, neue Objekte der Klasse Sequence
zu erzeugen.Es ist eine übliche Namenskonvention für diese Fabrikfunktionen und Klassennamen, dass sie zur Unterscheidung in TitleCase geschrieben werden.
Genauso wie ich mit der Funktion list()
eine neue Liste erstellen kann, kann ich mit der Funktion Sequence()
ein neues Objekt Sequence
erstellen. Ich kann die Werte id
und seq
in der Reihenfolge übergeben, in der sie in der Klasse definiert sind:
>>> seq1 = Sequence('CAM_0231669729', 'GTGTTTATTCAATGCTAG') >>> type(seq1) <class '__main__.Sequence'>
Oder ich kann die Feldnamen verwenden und sie als Schlüssel/Wert-Paare in beliebiger Reihenfolge übergeben:
>>> seq2 = Sequence(seq='GTGTTTATTCAATGCTAG', id='CAM_0231669729') >>> seq2 Sequence(id='CAM_0231669729', seq='GTGTTTATTCAATGCTAG')
Es ist zwar möglich, Indizes zu verwenden, um auf die ID und die Reihenfolge zuzugreifen:
>>> 'ID = ' + seq1[0] 'ID = CAM_0231669729' >>> 'seq = ' + seq1[1] 'seq = GTGTTTATTCAATGCTAG'
...der Sinn von benannten Tupeln ist es, die Feldnamen zu verwenden:
>>> 'ID = ' + seq1.id 'ID = CAM_0231669729' >>> 'seq = ' + seq1.seq 'seq = GTGTTTATTCAATGCTAG'
Die Werte des Datensatzes bleiben unveränderlich:
>>> seq1.id = 'XXX' Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: can't set attribute
Ich möchte oft eine Garantie, dass ein Wert in meinem Code nicht versehentlich geändert werden kann. Python bietet keine Möglichkeit, eine Variable als konstant oder unveränderlich zu deklarieren. Tupel sind standardmäßig unveränderlich, und ich halte es für sinnvoll, die Argumente eines Programms mit einer Datenstruktur darzustellen, die nicht verändert werden kann. Die Eingaben sind unantastbar und sollten (fast) nie verändert werden.
Hinzufügen von Typen zu benannten Tupeln
So schön namedtuple()
auch ist, ich kann es noch besser machen, indem ich die Klasse NamedTuple
aus dem Modul typing
importiere, um sie als Basisklasse für Sequence
zu verwenden. Außerdem kann ich den Feldern mit dieser Syntax Typen zuweisen. Beachte, dass du in der REPL eine leere Zeile verwenden musst, um anzuzeigen, dass der Block vollständig ist:
>>> from typing import NamedTuple >>> class Sequence(NamedTuple): ... id: str ... seq: str ...
Die ...
, die du siehst, sind Zeilenfortsetzungen. Die REPL zeigt an, dass das, was du bisher eingegeben hast, kein vollständiger Ausdruck ist. Du musst eine Leerzeile eingeben, damit die REPL weiß, dass du mit dem Codeblock fertig bist.
Wie bei der Methode namedtuple()
ist Sequence
ein neuer Typ:
>>> type(Sequence) <class 'type'>
Der Code zum Instanziieren eines neuen Sequence
Objekts ist derselbe:
>>> seq3 = Sequence('CAM_0231669729', 'GTGTTTATTCAATGCTAG') >>> type(seq3) <class '__main__.Sequence'>
Ich kann immer noch über die Namen auf die Felder zugreifen:
>>> seq3.id, seq3.seq ('CAM_0231669729', 'GTGTTTATTCAATGCTAG')
Da ich definiert habe, dass beide Felder den Typ str
haben, könntest du annehmen, dass das nicht funktioniert:
>>> seq4 = Sequence(id='CAM_0231669729', seq=3.14)
Es tut mir leid, dir sagen zu müssen, dass Python selbst die Typinformationen ignoriert.Du kannst sehen, dass das seq
Feld, von dem ich hoffte, dass es ein str
sein würde, in Wirklichkeit ein float
ist:
>>> seq4 Sequence(id='CAM_0231669729', seq=3.14) >>> type(seq4.seq) <class 'float'>
In der REPL hilft es mir nicht, aber wenn ich Typen zu meinem Quellcode hinzufüge, können Typüberprüfungsprogramme wie mypy
solche Fehler finden.
Darstellung der Argumente mit einem NamedTuple
Ich möchte, dass die Datenstruktur, die die Argumente des Programms repräsentiert, Typinformationen enthält. Wie bei der Klasse Sequence
kann ich eine Klasse definieren, die vom Typ NamedTuple
abgeleitet ist und in der ich die Datenstruktur statisch mit Typen definieren kann. Ich nenne diese Klasse Args
, aber du kannst sie nennen, wie du willst. Ich weiß, dass das wahrscheinlich so aussieht, als würde man einen Nagel mit einem Vorschlaghammer einschlagen, aber vertrau mir, diese Art von Detailarbeit wird sich in Zukunft auszahlen.
Die neueste Version von new.py
verwendet die Klasse NamedTuple
aus dem Modul typing
. Ich schlage vor, dass du die Argumente folgendermaßen definierst und darstellst:
#!/usr/bin/env python3 """Tetranucleotide frequency""" import argparse from typing import NamedTuple class Args(NamedTuple): """ Command-line arguments """ dna: str # -------------------------------------------------- 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() return Args(args.dna) # -------------------------------------------------- def main() -> None: """ Make a jazz noise here """ args = get_args() print(args.dna / 2) # -------------------------------------------------- if __name__ == '__main__': main()
Importiere die Klasse
NamedTuple
aus dem Modultyping
.Definiere eine
class
für die Argumente, die auf der KlasseNamedTuple
basiert. Siehe den folgenden Hinweis.Die Klasse hat ein einziges Feld namens
dna
, das den Typstr
hat.Die Typ-Annotation der Funktion
get_args()
zeigt, dass sie ein Objekt des TypsArgs
zurückgibt.Analysiere die Argumente wie zuvor.
Gib ein neues
Args
Objekt zurück, das den Einzelwert ausargs.dna
enthält.Die Funktion
main()
hat keine Anweisungreturn
, daher gibt sie den StandardwertNone
zurück.Dies ist der Typfehler aus dem früheren Programm.
Wenn du pylint
für dieses Programm ausführst, kann es sein, dass du die Fehler "Inheriting NamedTuple, which is not a class. (inherit-non-class)" und "Zu wenige öffentliche Methoden (0/2) (too-few-public-methods)". Du kannst diese Warnungen deaktivieren, indem du "inherit-non-class" und "too-few-public-methods" in den Abschnitt "disable" deiner pylintrc-Datei einfügst oder die pylintrc-Datei im Stammverzeichnis des GitHub-Repositorys verwendest.
Wenn du dieses Programm ausführst, wirst du sehen, dass es immer noch dieselbe nicht abgefangene Ausnahme erzeugt. Sowohl flake8
als auch pylint
werden weiterhin berichten, dass das Programm in Ordnung ist, aber schau mal, was mypy
mir jetzt sagt:
$ mypy dna.py dna.py:32: error: Unsupported operand types for / ("str" and "int") Found 1 error in 1 file (checked 1 source file)
Die Fehlermeldung zeigt, dass es in Zeile 32 ein Problem mit den Operanden gibt, die die Argumente für den Divisionsoperator (/
) sind. Ich mische String- und Integer-Werte. Ohne die Typ-Annotationen wäre mypy
nicht in der Lage, einen Fehler zu finden. Ohne diese Warnung von mypy
müsste ich mein Programm ausführen, um ihn zu finden, wobei ich sicher sein müsste, dass ich den Codezweig ausführe, der den Fehler enthält.
In diesem Fall ist alles ziemlich offensichtlich und trivial, aber in einem viel größeren Programm mit Hunderten oder Tausenden von Codezeilen (LOC) mit vielen Funktionen und logischen Verzweigungen (wie if
/else
) würde ich vielleicht nicht über diesen Fehler stolpern. Ich verlasse mich auf Typen und Programme wie mypy
(und pylint
und flake8
und so weiter), um diese Art von Fehlern zu korrigieren, anstatt mich nur auf Tests zu verlassen oder, noch schlimmer, darauf zu warten, dass Benutzer Fehler melden.
Eingaben von der Kommandozeile oder aus einer Datei lesen
Wenn du versuchst, auf der Rosalind.info-Website zu beweisen, dass dein Programm funktioniert, lädst du eine Datei herunter, die die Eingaben für dein Programm enthält. Normalerweise sind diese Daten viel größer als die Beispieldaten, die in der Aufgabe beschrieben sind. Die Beispiel-DNA-Kette für dieses Problem ist zum Beispiel 70 Basen lang, aber die, die ich für einen meiner Versuche heruntergeladen habe, war 910 Basen lang.
Lassen wir das Programm Eingaben sowohl von der Befehlszeile als auch aus einer Textdatei lesen, damit du den Inhalt einer heruntergeladenen Datei nicht kopieren und einfügen musst. Das ist ein gängiges Muster, das ich verwende, und ich ziehe es vor, diese Option innerhalb der Funktion get_args()
zu behandeln, da es sich um die Verarbeitung der Befehlszeilenargumente handelt.
Korrigiere das Programm zunächst so, dass es den Wert args.dna
ohne die Division ausgibt:
def main() -> None: args = get_args() print(args.dna)
Prüfe, ob es funktioniert:
$ ./dna.py ACGT ACGT
Für den nächsten Teil musst du das Modul os
einbinden, um mit deinem Betriebssystem zu interagieren. Füge import os
zu den anderen import
Anweisungen oben hinzu und füge dann diese beiden Zeilen zu deiner get_args()
Funktion hinzu:
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): args.dna = open(args.dna).read().rstrip() return Args(args.dna)
Prüfe, ob der Wert
dna
eine Datei ist.Rufe
open()
auf, um einen Filehandle zu öffnen, verkette dann die Methodefh.read()
, um einen String zurückzugeben, und verkette dann die Methodestr.rstrip()
, um nachstehende Leerzeichen zu entfernen.
Die Funktion fh.read()
liest eine ganze Datei in eine Variable ein. In diesem Fall ist die Eingabedatei klein und daher sollte das in Ordnung sein, aber in der Bioinformatik ist es sehr üblich, Dateien zu verarbeiten, die Gigabytes groß sind. Wenn du read()
für eine große Datei verwendest, könnte dein Programm oder sogar dein ganzer Computer abstürzen. Später zeige ich dir, wie du eine Datei Zeile für Zeile lesen kannst, um das zu vermeiden.
Führe nun dein Programm mit einem String-Wert aus, um sicherzustellen, dass es funktioniert:
$ ./dna.py ACGT ACGT
und verwende dann eine Textdatei als Argument:
$ ./dna.py tests/inputs/input2.txt AGCTTTTCATTCTGACTGCAACGGGCAATATGTCTCTGTGTGGATTAAAAAAAGAGTGTCTGATAGCAGC
Jetzt hast du ein flexibles Programm, das Eingaben aus zwei Quellen liest. Führe mypy dna.py
um sicherzustellen, dass es keine Probleme gibt.
Dein Programm testen
Aus der Beschreibung von Rosalind weißt du, dass das Programm bei der Eingabe ACGT
1 1 1 1
ausgeben sollte, da dies die Anzahl der As, Cs, Gsund Tsist. Im Verzeichnis 01_dna/tests gibt es eine Datei namens dna_test.py, die Tests für das Programm dna.py
enthält.
Ich habe diese Tests für dich geschrieben, damit du siehst, wie es ist, ein Programm mit einer Methode zu entwickeln, die dir mit einiger Sicherheit sagt, ob dein Programm richtig ist. Die Tests sind ganz einfach: Wenn du einen Eingabe-String erhältst, sollte das Programm die richtigen Zahlen für die vier Nukleotide ausgeben. Wenn das Programm die richtigen Zahlen meldet, funktioniert es.
Im Verzeichnis 01_dna sollst du Folgendes ausführen pytest
(oder python3 -m pytest
oder pytest.exe
unter Windows). Das Programm sucht rekursiv nach allen Dateien, deren Namen mit test_ beginnen oder mit _test.py enden.
Wenn du pytest
aufrufst, siehst du eine Menge Ausgaben, von denen die meisten fehlgeschlagene Tests sind. Um zu verstehen, warum diese Tests fehlschlagen, schauen wir uns das Modul tests/dna_test.py an:
""" Tests for dna.py """ import os import platform from subprocess import getstatusoutput PRG = './dna.py' RUN = f'python {PRG}' if platform.system() == 'Windows' else PRG TEST1 = ('./tests/inputs/input1.txt', '1 2 3 4') TEST2 = ('./tests/inputs/input2.txt', '20 12 17 21') TEST3 = ('./tests/inputs/input3.txt', '196 231 237 246')
Dies ist der Docstring für das Modul.
Das Standardmodul
os
wird mit dem Betriebssystem interagieren.Das Modul
platform
wird verwendet, um festzustellen, ob es unter Windows ausgeführt wird.Aus dem Modul
subprocess
importiere ich eine Funktion, um das Programmdna.py
auszuführen und die Ausgabe und den Status zu erfassen.Diese folgenden Zeilen sind globale Variablen für das Programm. Ich neige dazu, globale Variablen zu vermeiden, außer in meinen Tests. Hier möchte ich einige Werte definieren, die ich in den Funktionen verwenden werde. Ich verwende gerne UPPERCASE_NAMES, um die globale Sichtbarkeit hervorzuheben.
Die Variable
RUN
bestimmt, wie das Programmdna.py
ausgeführt werden soll. Unter Windows muss der Befehlpython
verwendet werden, um ein Python-Programm auszuführen, aber auf Unix-Plattformen kann das Programmdna.py
direkt ausgeführt werden.Die
TEST*
Variablen sind Tupel, die eine Datei mit einer DNA-Kette und die erwartete Ausgabe des Programms für diese Kette definieren.
Das Modul pytest
führt die Testfunktionen in der Reihenfolge aus, in der sie in der Testdatei definiert sind. Ich strukturiere meine Tests oft so, dass sie von den einfachsten Fällen zu den komplexeren fortschreiten, so dass es normalerweise keinen Sinn macht, nach einem Fehlschlag weiterzumachen. Der erste Test besteht zum Beispiel immer darin, dass das zu testende Programm existiert. Wenn das nicht der Fall ist, macht es keinen Sinn, weitere Tests auszuführen. Ich empfehle dir, pytest
mit dem Flag -x
auszuführen, um beim ersten fehlgeschlagenen Test anzuhalten, sowie mit dem Flag -v
für eine ausführliche Ausgabe.
Schauen wir uns den ersten Test an. Die Funktion heißt test_exists()
, damit pytest
sie findet. Im Hauptteil der Funktion verwende ich eine oder mehrere assert
Anweisungen, um zu prüfen, ob eine Bedingung erfüllt ist.1 Hier behaupte ich, dass das Programm dna.py
existiert. Deshalb muss dein Programm in diesem Verzeichnis existieren - sonst würde es von dem Test nicht gefunden werden:
def test_exists(): """ Program exists """ assert os.path.exists(PRG)
Der Funktionsname muss mit
test_
beginnen, um vonpytest
gefunden zu werden.Die Funktion
os.path.exists()
gibtTrue
zurück, wenn das angegebene Argument eine Datei ist. Wenn sieFalse
zurückgibt, schlägt die Behauptung fehl und dieser Test ist nicht erfolgreich.
Der nächste Test, den ich schreibe, prüft, ob das Programm eine Verwendungsanweisung für die Flags -h
und --help
ausgibt. Die Funktion subprocess.getstatusoutput()
führt das Programm dna.py
mit den Flags short und long help aus. In jedem Fall möchte ich sehen, dass das Programm einen Text ausgibt, der mit dem Wort usage: beginnt. Das ist kein perfekter Test. Er prüft nicht, ob die Dokumentation korrekt ist, sondern nur, ob sie den Anschein erweckt, dass es sich um eine Verwendungsanweisung handeln könnte. Ich finde nicht, dass jeder Test vollständig sein muss. Hier ist der Test:
def test_usage() -> None: """ Prints usage """ for arg in ['-h', '--help']: rv, out = getstatusoutput(f'{RUN} {arg}') assert rv == 0 assert out.lower().startswith('usage:')
Iteriere über die kurzen und langen Hilfeflaggen.
Führe das Programm mit dem Argument aus und erfasse den Rückgabewert und die Ausgabe.
Überprüfe, ob das Programm einen erfolgreichen Exit-Wert von 0 meldet.
Stelle sicher, dass die klein geschriebene Ausgabe des Programms mit dem Text usage: beginnt.
Kommandozeilenprogramme zeigen dem Betriebssystem einen Fehler an, indem sie einen Wert ungleich Null zurückgeben. Wenn das Programm erfolgreich läuft, sollte es einen 0
zurückgeben. Manchmal kann dieser Wert ungleich Null mit einem internen Fehlercode zusammenhängen, aber oft bedeutet er einfach, dass etwas schief gelaufen ist. Die Programme, die ich schreibe, werden sich ebenfalls bemühen, bei erfolgreicher Ausführung 0
und bei Fehlern einen Wert ungleich Null zu melden.
Als Nächstes möchte ich sicherstellen, dass das Programm stirbt, wenn es keine Argumente erhält:
def test_dies_no_args() -> None: """ Dies with no arguments """ rv, out = getstatusoutput(RUN) assert rv != 0 assert out.lower().startswith('usage:')
Erfasse den Rückgabewert und die Ausgabe, wenn du das Programm ohne Argumente ausführst .
Überprüfe, ob der Rückgabewert ein Fehlercode ungleich Null ist.
Prüfe, ob die Ausgabe wie ein Verwendungsnachweis aussieht.
An diesem Punkt der Prüfung weiß ich, dass ich ein Programm mit dem richtigen Namen habe, das ausgeführt werden kann, um die Dokumentation zu erstellen. Das bedeutet, dass das Programm zumindestsyntaktisch korrekt ist, was ein guter Ausgangspunkt für die Prüfung ist. Wenn dein Programm Tippfehler hat, musst du diese korrigieren, um überhaupt zu diesem Punkt zu kommen.
Das Programm ausführen, um die Ausgabe zu testen
Jetzt muss ich sehen, ob das Programm das tut, was es tun soll. Es gibt viele Möglichkeiten, Programme zu testen, und ich verwende gerne zwei grundlegende Ansätze, die ich Inside-Out und Outside-In nenne. Der Inside-Out-Ansatz beginnt auf der Ebene des Testens einzelner Funktionen innerhalb eines Programms.Dies wird oft als Unit-Test bezeichnet, da Funktionen als eine grundlegende Einheit der Datenverarbeitung angesehen werden können, worauf ich im Abschnitt Lösungen eingehen werde. Ich beginne mit dem Outside-in-Ansatz.Das bedeutet, dass ich das Programm von der Kommandozeile aus so ausführe, wie es der Benutzer ausführen wird. Das ist ein ganzheitlicher Ansatz, um zu prüfen, ob die Teile des Codes zusammenarbeiten können, um die richtige Ausgabe zu erzeugen, und wird deshalb manchmal auch Integrationstest genannt.
Der erste dieser Tests übergibt die DNA-Zeichenkette als Befehlszeilenargument und prüft, ob das Programm die richtigen Zählungen in der richtigen Zeichenkette formatiert ausgibt:
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
Entpacke die Tupel in die
file
, die einen String aus DNA und denexpected
Wert des Programms enthält, wenn es mit dieser Eingabe ausgeführt wird.Öffne die Datei und lies die
dna
aus dem Inhalt.Führe das Programm mit der angegebenen DNA-Zeichenkette aus, indem du die Funktion
subprocess.getstatusoutput()
aus, die mir sowohl den Rückgabewert des Programms als auch die Textausgabe (auchSTDOUT
genannt) liefert.Bestätige, dass der Rückgabewert
0
ist, was Erfolg (oder 0 Fehler) bedeutet.Bestätige, dass die Ausgabe des Programms die erwartete Zahlenfolge ist.
Der nächste Test ist fast identisch, aber dieses Mal übergebe ich den Dateinamen als Argument an das Programm, um zu überprüfen, ob es die DNA korrekt aus einer Datei liest:
def test_file(): """ Uses file arg """ for file, expected in [TEST1, TEST2, TEST3]: retval, out = getstatusoutput(f'{RUN} {file}') assert retval == 0 assert out == expected
Der einzige Unterschied zum ersten Test ist, dass ich den Dateinamen statt des Inhalts der Datei übergebe.
Nachdem du dir die Tests angeschaut hast, führe sie noch einmal durch. Diesmal verwendest du pytest -xv
wobei das -v
Flag für eine ausführliche Ausgabe steht. Da sowohl -x
als auch -v
kurze Flags sind, kannst du sie wie -xv
oder -vx
kombinieren. Lies dir die Ausgabe genau durch und bemerke, dass das Programm versucht, dir mitzuteilen, dass es die DNA-Sequenz ausgibt, der Test aber eine Zahlenfolge erwartet:
$ 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 E AssertionError: assert 'ACCGGGTTTT' == '1 2 3 4' 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 ==========================
Die
>
am Anfang dieser Zeile zeigt, dass dies die Quelle des Fehlers ist.Die Ausgabe des Programms war die Zeichenkette
ACCGGGTTTT
, aber der erwartete Wert war1 2 3 4
. Da diese nicht gleich sind, wird eineAssertionError
Ausnahme ausgelöst.
Wenn du glaubst, dass du weißt, wie du das Programm beenden kannst, kannst du dich gleich an die Lösung machen. Vielleicht kannst du zuerst versuchen, dein Programm zu starten, um zu überprüfen, ob es die richtige Anzahl von Asmeldet:
$ ./dna.py A 1 0 0 0
Und dann Cs:
$ ./dna.py C 0 1 0 0
und so weiter mit Gsund Ts. Dann führe pytest
um zu sehen, ob es alle Tests besteht.
Wenn du eine funktionierende Version hast, solltest du versuchen, so viele verschiedene Wege wie möglich zu finden, um dieselbe Antwort zu erhalten.Das nennt man Refactoring eines Programms. Du musst mit etwas anfangen, das richtig funktioniert, und dann versuchen, es zu verbessern. Die Verbesserungen können auf viele Arten gemessen werden. Vielleicht findest du einen Weg, dieselbe Idee mit weniger Code zu schreiben, oder du findest eine Lösung, die schneller läuft. Egal, welche Messgröße du verwendest, führe immer wieder pytest
um sicherzustellen, dass das Programm korrekt ist.
Lösung 1: Iterieren und Zählen der Zeichen in einer Zeichenkette
Wenn du nicht weißt, wo du anfangen sollst, gehe ich die erste Lösung mit dir durch. Das Ziel ist es, durch alle Basen in der DNA-Kette zu reisen. Zuerst muss ich also eine Variable namens dna
erstellen, indem ich ihr in der REPL einen Wert zuweise:
>>> dna = 'ACGT'
Beachte, dass jeder Wert, der in Anführungszeichen eingeschlossen ist, egal ob einfach oder doppelt, eine Zeichenkette ist. Sogar ein einzelnes Zeichen wird in Python als Zeichenkette betrachtet. Ich verwende oft die Funktion type()
, um den Typ einer Variablen zu überprüfen, und hier sehe ich, dass dna
von der Klasse str
(string) ist:
>>> type(dna) <class 'str'>
Gib help(str)
in der REPL ein, um all die wunderbaren Dinge zu sehen, die du mit Strings machen kannst.Dieser Datentyp ist besonders wichtig in der Genomik, wo Strings einen großen Teil der Daten ausmachen.
In der Sprache von Python möchte ich die Zeichen einer Zeichenkette durchlaufen, die in diesem Fall die Nukleotide der DNA sind.Eine for
Schleife wird das tun. Python sieht eine Zeichenkette als eine geordnete Folge von Zeichen und eine for
Schleife wird jedes Zeichen vom Anfang bis zum Ende besuchen:
>>> for base in dna: ... print(base) ... A C G T
Jedes Zeichen in der Zeichenfolge
dna
wird in die Variablebase
kopiert. Du kannst dieschar
nennen, oderc
für Zeichen, oder was auch immer du willst.Jeder Aufruf von
print()
endet mit einem Zeilenumbruch, sodass du jede Base in einer eigenen Zeile siehst.
Später wirst du sehen, dass for
Schleifen mit Listen, Wörterbüchern, Mengen und Zeilen in einer Datei verwendet werden können - im Grunde mit jeder iterierbaren Datenstruktur.
Zählen der Nukleotide
Da ich nun weiß, wie ich jede Base in der Sequenz besuchen kann, muss ich jede Base zählen, anstatt sie auszudrucken. Das bedeutet, dass ich einige Variablen benötige, um die Zahlen für jedes der vier Nukleotide im Auge zu behalten. Eine Möglichkeit, dies zu tun, ist, vier Variablen zu erstellen, die ganzzahlige Zählungen enthalten, eine für jede Base. Ich werde vier Variablen für die Zählung initialisieren, indem ich ihre Anfangswerte auf 0
setze:
>>> count_a = 0 >>> count_c = 0 >>> count_g = 0 >>> count_t = 0
Ich könnte dies in einer Zeile schreiben, indem ich die Tupel-Entpacksyntax verwende, die ich zuvor gezeigt habe :
>>> count_a, count_c, count_g, count_t = 0, 0, 0, 0
Ich muss mir jede Basis ansehen und bestimmen, welche Variable ich erhöhen muss, damit ihr Wert um 1 steigt. Wenn die aktuelle base
zum Beispiel ein C ist, sollte ich die Variable count_c
erhöhen. Ich könnte Folgendes schreiben:
for base in dna: if base == 'C': count_c = count_c + 1
Der
==
Operator wird verwendet, um zwei Werte auf Gleichheit zu vergleichen. Hier möchte ich wissen, ob der aktuellebase
gleich der ZeichenketteC
ist.Setze
count_c
gleich 1 größer als den aktuellen Wert.
Der ==
Operator wird verwendet, um zwei Werte auf Gleichheit zu vergleichen. Er funktioniert, um zwei Strings oder zwei Zahlen zu vergleichen. Ich habe bereits gezeigt, dass die Division mit /
eine Ausnahme auslöst, wenn du Zeichenketten und Zahlen mischst. Was passiert, wenn du mit diesem Operator Typen mischst, zum Beispiel '3' == 3
? Ist es sicher, diesen Operator zu verwenden, ohne vorher die Typen zu vergleichen?
Wie in Abbildung 1-3 zu sehen ist, gibt es eine kürzere Möglichkeit, eine Variable zu erhöhen, indem der Operator +=
verwendet wird, um die rechte Seite (oft als RHS bezeichnet) des Ausdrucks zur linken Seite (oder LHS) zu addieren:
Da ich vier Nukleotide zu überprüfen habe, brauche ich eine Möglichkeit, drei weitere if
Ausdrücke zu kombinieren. Die Syntax in Python ist, elif
für else if und else
für jeden finalen oder Standardfall zu verwenden. Hier ist ein Codeblock, den ich in das Programm oder die REPL eingeben kann und der einen einfachen Entscheidungsbaum implementiert:
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
Am Ende sollte ich für jede der sortierten Basen die Zahlen 1, 2, 3 und 4 erhalten:
>>> count_a, count_c, count_g, count_t (1, 2, 3, 4)
Jetzt muss ich dem Benutzer das Ergebnis mitteilen:
>>> print(count_a, count_c, count_g, count_t) 1 2 3 4
Das ist genau die Ausgabe, die das Programm erwartet.Beachte, dass print()
mehrere Werte zum Drucken akzeptiert und zwischen jedem Wert ein Leerzeichen einfügt. Wenn du help(print)
in der REPL liest, wirst du feststellen, dass du dies mit dem Argument sep
ändern kannst:
>>> print(count_a, count_c, count_g, count_t, sep='::') 1::2::3::4
Die Funktion print()
fügt außerdem einen Zeilenumbruch am Ende der Ausgabe ein, was du ebenfalls mit der Option end
ändern kannst:
>>> print(count_a, count_c, count_g, count_t, end='\n-30-\n') 1 2 3 4 -30-
Schreiben und Prüfen einer Lösung
Mit dem obigen Code solltest du in der Lage sein, ein Programm zu erstellen, das alle Tests besteht. Während du schreibst, würde ich dir raten, regelmäßig pylint
, flake8
und mypy
auszuführen, um deinen Quellcode auf mögliche Fehler zu überprüfen. Ich würde sogar noch weiter gehen und vorschlagen, dass du die pytest
Erweiterungen für diese Programme installierst, damit du solche Tests routinemäßig einbauen kannst:
$ python3 -m pip install pytest-pylint pytest-flake8 pytest-mypy
Alternativ dazu habe ich eine requirements.txt-Datei im Hauptverzeichnis des GitHub-Repos platziert, in der verschiedene Abhängigkeiten aufgelistet sind, die ich im Laufe des Buches verwenden werde. Du kannst alle diese Module mit dem folgenden Befehl installieren:
$ python3 -m pip install -r requirements.txt
Mit diesen Erweiterungen kannst du den folgenden Befehl ausführen, um nicht nur die in der Datei tests/dna_test.py definierten Tests auszuführen, sondern auch Tests für Linting und Typprüfung mit diesen Tools:
$ 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 ======================
Einige Tests werden übersprungen, wenn eine gecachte Version anzeigt, dass sich seit dem letzten Test nichts geändert hat. Führe pytest
mit der Option ---cache-clear
aus, um die Durchführung der Tests zu erzwingen. Außerdem kann es passieren, dass dir die Linting-Tests fehlschlagen, wenn dein Code nicht richtig formatiert oder eingerückt ist. Du kannst deinen Code mit yapf
oder black
automatisch formatieren. Die meisten IDEs und Editoren bieten eine Option zur automatischen Formatierung.
Das ist eine Menge zu tippen, deshalb habe ich eine Abkürzung in Form eines Makefiles im Verzeichnis für dich erstellt:
$ 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
Mehr über diese Dateien erfährst du in Anhang A. Für den Moment reicht es, wenn du verstehst, dass du, wenn du make
auf deinem System installiert hast, mit dem Befehl make test
verwenden, um den Befehl im test
Ziel des Makefiles auszuführen. Wenn du make
nicht installiert hast oder es nicht verwenden willst, ist das auch in Ordnung, aber ich schlage vor, du erkundest, wie ein Makefile verwendet werden kann, um Prozesse zu dokumentieren und zu automatisieren.
Es gibt viele Möglichkeiten, eine passable Version von dna.py
zu schreiben, und ich möchte dich ermutigen, weiter zu erforschen, bevor du die Lösungen liest. Vor allem möchte ich dich an den Gedanken gewöhnen, dein Programm zu ändern und dann die Tests laufen zu lassen, um zu sehen, ob es funktioniert. Das ist der Zyklus der testgetriebenen Entwicklung, bei dem ich zunächst eine Metrik erstelle, um zu entscheiden, ob das Programm korrekt funktioniert. In diesem Fall ist das das Programm dna_test.py, das von pytest
ausgeführt wird.
Die Tests stellen sicher, dass ich nicht vom Ziel abweiche, und sie lassen mich auch wissen, wenn ich die Anforderungen des Programms erfüllt habe. Sie sind die Spezifikationen (auch Specs genannt), die als Programm verkörpert werden, das ich ausführen kann.Wie sonst könnte ich jemals wissen, ob einProgramm funktioniert oder fertig ist? Oder, wie Louis Srygley es ausdrückt: "Ohne Anforderungen oder Design ist Programmieren die Kunst, Fehler in eine leere Textdatei einzufügen."
Testen ist das A und O, um reproduzierbare Programme zu erstellen. Wenn du nicht absolut und automatisch die Korrektheit und Vorhersagbarkeit deines Programms beweisen kannst, wenn es mit guten und schlechten Daten ausgeführt wird, dann schreibst du keine gute Software.
Zusätzliche Lösungen
Das Programm, das ich weiter oben in diesem Kapitel geschrieben habe, ist die Version solution1_iter.py im GitHub-Repository, also werde ich mir nicht die Mühe machen, diese Version zu rezensieren. Ich möchte dir mehrere alternative Lösungen zeigen, die sich von einfacheren zu komplexeren Ideen entwickeln. Bitte verstehe das nicht so, dass sie sich von schlechter zu besser entwickeln. Alle Versionen bestehen die Tests, also sind sie alle gleichwertig. Es geht darum, herauszufinden, was Python für die Lösung gängiger Probleme zu bieten hat. Beachte, dass ich den Code, den sie alle gemeinsam haben, wie die Funktion get_args()
, weglassen werde.
Lösung 2: Erstellen einer count()-Funktion und Hinzufügen eines Unit-Tests
Die erste Variante, die ich zeigen möchte, verschiebt den gesamten Code in der Funktion main()
, die die Zählung vornimmt, in eine Funktion count()
. Du kannst diese Funktion an jeder beliebigen Stelle deines Programms definieren, aber ich mag es im Allgemeinen, wenn get_args()
an erster Stelle steht, main()
an zweiter Stelle und andere Funktionen danach, aber vor dem letzten Couplet, das main()
aufruft.
Für die folgende Funktion musst du auch den Wert typing.Tuple
importieren:
def count(dna: str) -> Tuple[int, int, int, int]: """ Count bases in DNA """ 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 return (count_a, count_c, count_g, count_t)
Die Typen zeigen, dass die Funktion eine Zeichenkette annimmt und ein Tupel mit vier ganzzahligen Werten zurückgibt.
Dies ist der Code von
main()
, der die Zählung durchgeführt hat.Gib ein Tupel mit den vier Zählungen zurück.
Es gibt viele Gründe, diesen Code in eine Funktion auszulagern.Zunächst einmal handelt es sich um eine Recheneinheit - gib einen DNA-Strang mit der Tetranukleotidhäufigkeit zurück -, so dass es sinnvoll ist, sie zu kapseln. Dadurch wird main()
kürzer und lesbarer, und ich kann einen Unit-Test für die Funktion schreiben. Da die Funktion count()
heißt, nenne ich den Unit-Test gerne test_count()
.
Ich habe diese Funktion in das Programm dna.py
eingefügt, direkt nach der Funktion count()
und nicht in das Programm dna_test.py
. Bei kurzen Programmen neige ich dazu, meine Funktionen und Unit-Tests zusammen in den Quellcode zu packen, aber wenn die Projekte größer werden, werde ich die Unit-Tests in ein separates Modul auslagern. Hier ist die Testfunktion:
def test_count() -> None: """ Test count """ assert count('') == (0, 0, 0, 0) assert count('123XYZ') == (0, 0, 0, 0) assert count('A') == (1, 0, 0, 0) 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)
Der Funktionsname muss mit
test_
beginnen, um vonpytest
gefunden zu werden. Die Typen hier zeigen, dass der Test keine Argumente akzeptiert und, da er keinereturn
Anweisung hat, den StandardwertNone
zurückgibt.Ich teste Funktionen gerne mit erwarteten und unerwarteten Werten, um sicherzustellen, dass sie etwas Vernünftiges zurückgeben. Der leere String sollte nur Nullen zurückgeben.
Der Rest der Tests stellt sicher, dass jede Basis an der richtigen Stelle gemeldet wird.
Um zu überprüfen, ob meine Funktion funktioniert, kann ich pytest
für das Programm dna.py
verwenden:
$ pytest -xv dna.py =========================== test session starts =========================== ... dna.py::test_count PASSED [100%] ============================ 1 passed in 0.01s ============================
Der erste Test übergibt die leere Zeichenkette und erwartet, dass die Zählerstände alle Nullen sind. Das ist eine Ermessensentscheidung, ehrlich gesagt. Du könntest entscheiden, dass dein Programm sich beim Benutzer beschweren soll, dass es keine Eingabe gibt. Das heißt, es ist möglich, das Programm mit der leeren Zeichenkette als Eingabe zu starten, und diese Version wird Folgendes melden:
$ ./dna.py "" 0 0 0 0
Wenn ich eine leere Datei übergebe, erhalte ich die gleiche Antwort. touch
Befehl, um eine leere Datei zu erstellen:
$ touch empty $ ./dna.py empty 0 0 0 0
Auf Unix-Systemen ist /dev/null
ein spezieller Filehandle, der nichts zurückgibt:
$ ./dna.py /dev/null 0 0 0 0
Du kannst der Meinung sein, dass keine Eingabe ein Fehler ist und ihn als solchen melden. Das Wichtige an dem Test ist, dass er mich zwingt, darüber nachzudenken. Soll zum Beispiel die Funktion count()
Nullen zurückgeben oder eine Ausnahme auslösen, wenn sie eine leere Zeichenkette erhält? Soll das Programm bei einer leeren Eingabe abstürzen und mit einem Status ungleich Null beendet werden? Das sind Entscheidungen, die du für deine Programme treffen musst.
Jetzt, da ich einen Einheitstest im dna.py
Code habe, kann ich pytest
auf diese Datei anwenden, um zu sehen, ob er funktioniert:
$ pytest -v dna.py ============================ test session starts ============================= ... collected 1 item dna.py::test_count PASSED [100%] ============================= 1 passed in 0.01s ==============================
Wenn ich Code schreibe, schreibe ich gerne Funktionen, die nur eine begrenzte Aufgabe mit so wenigen Parametern wie möglich erfüllen.Dann schreibe ich gerne einen Test mit einem Namen wie test_
und dem Namen der Funktion, normalerweise direkt nach der Funktion im Quellcode.Wenn ich feststelle, dass ich viele solcher Unit-Tests habe, verschiebe ich sie vielleicht in eine separate Datei und lasse pytest
diese Datei ausführen.
Um diese neue Funktion zu nutzen, ändere main()
wie folgt:
def main() -> None: args = get_args() count_a, count_c, count_g, count_t = count(args.dna) print('{} {} {} {}'.format(count_a, count_c, count_g, count_t))
Packe die vier von
count()
zurückgegebenen Werte in separate Variablen aus.Verwende
str.format()
, um die Ausgabezeichenfolge zu erstellen.
Konzentrieren wir uns kurz auf die Python-Funktion str.format()
. Wie in Abbildung 1-4 zu sehen ist, ist die Zeichenkette '{} {} {} {}'
eine Vorlage für die Ausgabe, die ich erzeugen möchte, , und ich rufe die Funktion str.format()
direkt auf einem String-Literal auf. Das ist eine gängige Redewendung in Python, die du auch bei der Funktion str.join()
sehen wirst.Es ist wichtig, sich daran zu erinnern, dass in Python auch ein String-Literal (eine Zeichenkette, die buchstäblich in deinem Quellcode in Anführungszeichen steht) ein Objekt ist, auf dem du Methoden aufrufen kannst.
Jede {}
in der String-Vorlage ist ein Platzhalter für einen Wert, der als Argument für die Funktion angegeben wird. Wenn du diese Funktion verwendest, musst du sicherstellen, dass du die gleiche Anzahl an Platzhaltern wie Argumente hast. Die Argumente werden in der Reihenfolge eingefügt, in der sie angegeben werden. Ich werde später noch viel mehr über die str.format()
Funktion später mehr sagen.
Ich muss das Tupel, das von der Funktion count()
zurückgegeben wird, nicht auspacken. Ich kann das gesamte Tupel als Argument an die Funktion str.format()
übergeben, wenn ich es durch Hinzufügen eines Sternchens (*
) aufspalte. Damit wird Python angewiesen, das Tupel in seine Werte zu zerlegen:
def main() -> None: args = get_args() counts = count(args.dna) print('{} {} {} {}'.format(*counts))
Die Variable
counts
ist ein 4-Tupel mit den ganzzahligen Basiszahlen.Die
*counts
Syntax erweitert das Tupel in die vier Werte, die der Formatstring benötigt; andernfalls würde das Tupel als ein einziger Wert interpretiert werden.
Da ich die Variable counts
nur einmal verwende, kann ich die Zuweisung überspringen und diese auf eine Zeile verkürzen:
def main() -> None: args = get_args() print('{} {} {} {}'.format(*count(args.dna)))
Die erste Lösung ist wohl einfacher zu lesen und zu verstehen, und Tools wie flake8
können erkennen, wenn die Anzahl der {}
Platzhalter nicht mit der Anzahl der Variablen übereinstimmt. Einfacher, ausführlicher, offensichtlicher Code ist oft besser als kompakter, cleverer Code. Trotzdem ist es gut, über das Auspacken von Tupeln und das Aufteilen von Variablen Bescheid zu wissen, da ich diese Ideen in späteren Programmen verwenden werde.
Lösung 3: Verwendung von str.count()
Die vorherige Funktion count()
ist ziemlich langatmig. Mit der Methode str.count()
kann ich die Funktion in einer einzigen Codezeile schreiben. Diese Funktion zählt, wie oft eine Zeichenkette innerhalb einer anderen Zeichenkette vorkommt. Ich zeige es dir in der REPL:
>>> seq = 'ACCGGGTTTT' >>> seq.count('A') 1 >>> seq.count('C') 2
Wenn die Zeichenkette nicht gefunden wird, meldet es 0
. Damit ist es sicher, alle vier Nukleotide zu zählen, auch wenn in der Eingabesequenz eine oder mehrere Basen fehlen:
>>> 'AAA'.count('T') 0
Hier ist eine neue Version der Funktion count()
, die diese Idee nutzt:
def count(dna: str) -> Tuple[int, int, int, int]: """ Count bases in DNA """ return (dna.count('A'), dna.count('C'), dna.count('G'), dna.count('T'))
Die Unterschrift ist die gleiche wie zuvor.
Rufe die Methode
dna.count()
für jede der vier Basen auf.
Dieser Code ist viel prägnanter, und ich kann mit demselben Unit-Test überprüfen, ob er korrekt ist. Das ist ein wichtiger Punkt: Funktionen sollten sich wie Blackboxen verhalten. Das heißt, ich weiß nicht, was in der Box passiert, und es interessiert mich auch nicht, was in der Box passiert. Etwas geht hinein, eine Antwort kommt heraus, und mich interessiert nur, dass die Antwort korrekt ist. Ich kann ändern, was in der Box passiert, solange der Vertrag nach außen - die Parameter und der Rückgabewert - gleich bleibt.
Hier ist eine weitere Möglichkeit, den Ausgabestring in der Funktion main()
mit der f-string-Syntax von Python zu erstellen :
def main() -> None: args = get_args() count_a, count_c, count_g, count_t = count(args.dna) print(f'{count_a} {count_c} {count_g} {count_t}')
Packe das Tupel in jede der vier Zählungen aus.
Verwende eine f-Zeichenkette, um eine variable Interpolation durchzuführen.
Er wird f-String genannt, weil die f
vor den Anführungszeichen steht. Ich verwende das mnemonische Format, um mich daran zu erinnern, dass dies die Formatierung eines Strings ist. In Python gibt es auch eine rohe Zeichenkette, der ein r
vorangestellt ist, worauf ich später eingehen werde. Alle Zeichenketten in Python - ob rohe, f- oder r-Zeichenketten - können in einfache oder doppelte Anführungszeichen gesetzt werden. Das macht keinen Unterschied.
Bei f-Strings können die Platzhalter {}
eine Variableninterpolation durchführen, was ein 50-Cent-Wort ist und bedeutet, dass eine Variable in ihren Inhalt umgewandelt wird. Diese geschweiften Klammern können sogar Code ausführen. Die Funktion len()
gibt zum Beispiel die Länge einer Zeichenkette zurück und kann innerhalb der geschweiften Klammern ausgeführt werden:
>>> seq = 'ACGT' >>> f'The sequence "{seq}" has {len(seq)} bases.' 'The sequence "ACGT" has 4 bases.'
Ich finde f-Strings in der Regel leichter zu lesen als den entsprechenden Code mit str.format()
. Welche du wählst, ist vor allem eine stilistische Entscheidung. Ich würde die Variante empfehlen, die deinen Code besser lesbar macht.
Lösung 4: Ein Wörterbuch verwenden, um alle Zeichen zu zählen
Bisher habe ich über Strings, Listen und Tupel in Python gesprochen. In der nächsten Lösung geht es um Dictionaries, also Schlüssel/Wertespeicher.Ich möchte eine Version der Funktion count()
zeigen, die intern Dictionaries verwendet, damit ich einige wichtige Punkte zum Verständnis ansprechen kann:
def count(dna: str) -> Tuple[int, int, int, int]: """ Count bases in DNA """ counts = {} for base in dna: if base not in counts: counts[base] = 0 counts[base] += 1 return (counts.get('A', 0), counts.get('C', 0), counts.get('G', 0), counts.get('T', 0))
Intern werde ich ein Wörterbuch verwenden, aber an der Funktionssignatur ändert sich nichts.
Initialisiere ein leeres Wörterbuch, um die
counts
zu speichern.Verwende eine
for
Schleife, um die Sequenz zu durchlaufen.Prüfe, ob die Basis noch nicht im Wörterbuch vorhanden ist.
Initialisiere den Wert für diese Basis auf
0
.Erhöhe den Zähler für diese Basis um 1.
Verwende die Methode
dict.get()
, um die Anzahl der einzelnen Basen zu ermitteln, oder den Standardwert0
.
Auch hier hat sich der Vertrag für diese Funktion - die Typsignatur - nicht geändert. Es ist immer noch ein String in und ein 4-Tupel Ganzzahlen out. Innerhalb der Funktion werde ich ein Wörterbuch verwenden, das ich mit den leeren geschweiften Klammern initialisiere:
>>> counts = {}
Ich könnte auch die Funktion dict()
verwenden. Beides ist nicht wünschenswert:
>>> counts = dict()
Ich kann die Funktion type()
verwenden, um zu prüfen, ob es sich um ein Wörterbuch handelt:
>>> type(counts) <class 'dict'>
Die Funktion isinstance()
ist eine weitere Möglichkeit, den Typ einer Variablen zu überprüfen:
>>> isinstance(counts, dict) True
Mein Ziel ist es, ein Wörterbuch zu erstellen, das jede Base als Schlüssel und die Anzahl ihrer Vorkommen als Wert enthält. Bei der Sequenz ACCGGGTTT
zum Beispiel soll counts
so aussehen:
>>> counts {'A': 1, 'C': 2, 'G': 3, 'T': 4}
Ich kann auf jeden der Werte zugreifen, indem ich eckige Klammern und einen Schlüsselnamen wie folgt verwende:
>>> counts['G'] 3
Python löst eine KeyError
Ausnahme aus, wenn ich versuche, auf einen Wörterbuchschlüssel zuzugreifen, der nicht existiert:
>>> counts['N'] Traceback (most recent call last): File "<stdin>", line 1, in <module> KeyError: 'N'
Ich kann das Schlüsselwort in
verwenden, um zu sehen, ob ein Schlüssel in einem Wörterbuch existiert:
>>> 'N' in counts False >>> 'T' in counts True
Während ich die einzelnen Basen in der Sequenz durchlaufe, muss ich prüfen, ob eine Base im counts
Wörterbuch vorhanden ist. Wenn nicht, muss ich es auf 0
initialisieren. Dann kann ich die Zuweisung +=
verwenden, um die Anzahl der Basen um 1 zu erhöhen:
>>> 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}
Schließlich möchte ich ein 4er-Tupel mit den Zählungen für jede der Basen zurückgeben. Man könnte meinen, das würde funktionieren:
>>> counts['A'], counts['C'], counts['G'], counts['T'] (1, 2, 3, 4)
Frag dich aber, was passieren würde, wenn eine der Basen in der Sequenz fehlen würde. Würde das den von mir geschriebenen Unit-Test bestehen? Definitiv nicht. Er würde schon beim ersten Test mit einer leeren Zeichenkette fehlschlagen, weil er eine KeyError
Ausnahme erzeugen würde. Der sichere Weg, ein Wörterbuch nach einem Wert zu fragen, ist die Methode dict.get()
.Wenn der Schlüssel nicht existiert, wird None
zurückgegeben:
>>> counts.get('T') 4 >>> counts.get('N')
Die Methode dict.get()
akzeptiert ein optionales zweites Argument, das der Standardwert ist, der zurückgegeben wird, wenn der Schlüssel nicht vorhanden ist:
>>> counts.get('A', 0), counts.get('C', 0), counts.get('G', 0), counts.get('T', 0) (1, 2, 3, 4)
Lösung 5: Zählen nur der gewünschten Basen
Die vorherige Lösung zählt jedes Zeichen in der Eingabesequenz, aber was ist, wenn ich nur die vier Nukleotide zählen will?In dieser Lösung werde ich ein Wörterbuch mit Werten von 0
für die gewünschten Basen initialisieren. Ich muss auch typing.Dict
einbinden, um diesen Code auszuführen:
def count(dna: str) -> Dict[str, int]: """ Count bases in DNA """ counts = {'A': 0, 'C': 0, 'G': 0, 'T': 0} for base in dna: if base in counts: counts[base] += 1 return counts
Die Signatur gibt nun an, dass ich ein Wörterbuch zurückgeben werde, das Strings als Schlüssel und Integers als Werte hat.
Initialisiere das
counts
Wörterbuch mit den vier Basen als Schlüssel und den Werten von0
.Iteriere durch die Basen.
Prüfe, ob die Basis als Schlüssel im
counts
Wörterbuch gefunden wird.Wenn ja, erhöhe die
counts
für diese Basis um 1.Gib das
counts
Wörterbuch zurück.
Da die Funktion count()
jetzt ein Wörterbuch statt eines Tupels zurückgibt, muss die Funktion test_count()
geändert werden:
def test_count() -> None: """ Test count """ assert count('') == {'A': 0, 'C': 0, 'G': 0, 'T': 0} assert count('123XYZ') == {'A': 0, 'C': 0, 'G': 0, 'T': 0} 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}
Das zurückgegebene Wörterbuch enthält immer die Schlüssel
A
,C
,G
undT
. Selbst bei einer leeren Zeichenkette sind diese Schlüssel vorhanden und auf0
gesetzt.Alle anderen Tests haben die gleichen Eingaben, aber jetzt prüfe ich, dass die Antwort als Wörterbuch zurückkommt.
Beachte beim Schreiben dieser Tests, dass die Reihenfolge der Schlüssel in den Wörterbüchern nicht wichtig ist. Die beiden Wörterbücher im folgenden Code haben denselben Inhalt, obwohl sie unterschiedlich definiert wurden:
>>> counts1 = {'A': 1, 'C': 2, 'G': 3, 'T': 4} >>> counts2 = {'T': 4, 'G': 3, 'C': 2, 'A': 1} >>> counts1 == counts2 True
Ich möchte darauf hinweisen, dass die Funktion test_count()
die Funktion testet, um sicherzustellen, dass sie korrekt ist, und auch als Dokumentation dient. Das Lesen dieser Tests hilft mir, die Struktur der möglichen Eingaben und erwarteten Ausgaben der Funktion zu erkennen.
So muss ich die Funktion main()
ändern, um das zurückgegebene Wörterbuch zu verwenden:
def main() -> None: args = get_args() counts = count(args.dna) print('{} {} {} {}'.format(counts['A'], counts['C'], counts['G'], counts['T']))
Lösung 6: Verwendung von collections.defaultdict()
Ich kann meinen Code von allen früheren Bemühungen befreien, Wörterbücher zu initialisieren und nach Schlüsseln und dergleichen zu suchen, indem ich die Funktion defaultdict()
aus dem Modul collections
verwende:
>>> from collections import defaultdict
Wenn ich die Funktion defaultdict()
verwende, um ein neues Wörterbuch zu erstellen, teile ich ihr den Standardtyp für die Werte mit. Ich muss nicht mehr nach einem Schlüssel suchen, bevor ich ihn verwende, weil der Typ defaultdict
automatisch jeden Schlüssel erstellt, auf den ich verweise, indem ich einen repräsentativen Wert des Standardtyps verwende. Für den Fall, dass ich die Nukleotide zähle, möchte ich den Typ int
verwenden:
>>> counts = defaultdict(int)
Der Standardwert int
ist 0
. Jeder Verweis auf einen nicht existierenden Schlüssel führt dazu, dass er mit dem Wert 0
erstellt wird:
>>> counts['A'] 0
Das bedeutet, dass ich jede Basis in einem Schritt instanziieren und inkrementieren kann:
>>> counts['C'] += 1 >>> counts defaultdict(<class 'int'>, {'A': 0, 'C': 1})
So könnte ich die Funktion count()
nach dieser Idee umschreiben:
def count(dna: str) -> Dict[str, int]: """ Count bases in DNA """ counts: Dict[str, int] = defaultdict(int) for base in dna: counts[base] += 1 return counts
Die
counts
wird einedefaultdict
mit ganzzahligen Werten sein. Die Typ-Anmerkung hier wird vonmypy
benötigt, damit es sicher sein kann, dass der zurückgegebene Wert korrekt ist.Ich kann die
counts
für diese Basis sicher erhöhen.
Die Funktion test_count()
sieht ganz anders aus. Ich kann auf den ersten Blick sehen, dass die Antworten ganz anders sind als in den vorherigen Versionen:
def test_count() -> None: """ Test count """ assert count('') == {} assert count('123XYZ') == {'1': 1, '2': 1, '3': 1, 'X': 1, 'Y': 1, 'Z': 1} assert count('A') == {'A': 1} 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}
Wenn du eine leere Zeichenkette angibst, wird ein leeres Wörterbuch zurückgegeben.
Beachte, dass jedes Zeichen in der Zeichenkette ein Schlüssel im Wörterbuch ist.
Nur
A
ist vorhanden, mit einem Wert von 1.
Da das zurückgegebene Wörterbuch möglicherweise nicht alle Basen enthält, muss der Code in main()
die Methode count.get()
verwenden, um die Häufigkeit der einzelnen Basen zu ermitteln:
def main() -> None: args = get_args() counts = count(args.dna) print(counts.get('A', 0), counts.get('C', 0), counts.get('G', 0), counts.get('T', 0))
Lösung 7: Verwendung von collections.Counter()
Vollkommenheit ist nicht dann erreicht, wenn es nichts mehr hinzuzufügen gibt, sondern wenn es nichts mehr wegzunehmen gibt.
Antoine de Saint-Exupéry
Die letzten drei Lösungen gefallen mir eigentlich nicht so gut, aber ich musste mal durchgehen, wie man ein Wörterbuch sowohl manuell als auch mit defaultdict()
benutzt, damit du die Einfachheit von collections.Counter()
schätzen lernst:
>>> from collections import Counter >>> Counter('ACCGGGTTT') Counter({'G': 3, 'T': 3, 'C': 2, 'A': 1})
Der beste Code ist der, den du nie schreibst, und Counter()
ist eine vorgefertigte Funktion, die ein Wörterbuch mit der Häufigkeit der Elemente zurückgibt, die in der übergebenen Iterablen enthalten sind.Du hörst vielleicht auch, dass dies eine Tasche oder ein Multiset genannt wird.Hier ist die Iterable eine Zeichenkette, die aus Zeichen zusammengesetzt ist, und so bekomme ich das gleiche Wörterbuch zurück wie in den letzten beiden Lösungen, aber ich habe keinen Code geschrieben.
Es ist so einfach, dass du auf die Funktionen von count()
und test_count()
verzichten und es direkt in dein main()
integrieren könntest:
def main() -> None: args = get_args() counts = Counter(args.dna) print(counts.get('A', 0), counts.get('C', 0), counts.get('G', 0), counts.get('T', 0))
counts
wird ein Wörterbuch sein, das die Häufigkeit der Zeichen inargs.dna
enthält.Am sichersten ist es immer noch,
dict.get()
zu verwenden, da ich nicht sicher sein kann, dass alle Basen vorhanden sind.
Ich könnte argumentieren, dass dieser Code in eine count()
Funktion gehört und die Tests beibehalten, aber die Counter()
Funktion ist bereits getestet und hat eine gut definierte Schnittstelle. Ich denke, es macht mehr Sinn, diese Funktion inline zu verwenden.
Weiter gehen
Die Lösungen hier behandeln nur DNA-Sequenzen, die als GROSSBUCHSTABEN TEXT geliefert werden. Es ist nicht ungewöhnlich, dass diese Sequenzen als Kleinbuchstaben geliefert werden. In der Pflanzengenomik ist es zum Beispiel üblich, Kleinbuchstaben zu verwenden, um Regionen mit sich wiederholender DNA zu kennzeichnen. Ändere dein Programm so, dass es sowohl Groß- als auch Kleinbuchstaben verarbeiten kann, indem du Folgendes tust:
-
Füge eine neue Eingabedatei hinzu, die den Fall mischt.
-
Füge einen Test zu tests/dna_test.py hinzu, der diese neue Datei verwendet und die erwartete Anzahl unabhängig von der Groß- und Kleinschreibung angibt.
-
Führe den neuen Test aus und stelle sicher, dass dein Programm fehlschlägt.
-
Ändere das Programm, bis es den neuen Test und alle vorherigen Tests besteht.
Die Lösungen, die Wörterbücher verwenden, um alle verfügbaren Zeichen zu zählen, scheinen flexibler zu sein. Das heißt, dass einige der Tests nur die Basen A, C, G und T berücksichtigen, aber wenn die Eingabesequenz mit IUPAC-Codes kodiert wäre, um mögliche Mehrdeutigkeiten in der Sequenzierung darzustellen, müsste das Programm komplett neu geschrieben werden. Ein Programm, das nur die vier Nukleotide berücksichtigt, wäre auch für Proteinsequenzen, die ein anderes Alphabet verwenden, unbrauchbar. Überlege dir, ob du eine Version des Programms schreibst, die zwei Spalten mit jedem gefundenen Zeichen in der ersten Spalte und der Häufigkeit des Zeichens in der zweiten Spalte ausgibt. Der Benutzer kann nach beiden Spalten auf- oder absteigend sortieren.
Überprüfung
Die folgenden Kapitel werden etwas kürzer sein, da ich auf vielen der hier behandelten grundlegenden Ideen aufbauen werde:
-
Du kannst das Programm
new.py
verwenden, um die Grundstruktur eines Python-Programms zu erstellen, das Befehlszeilenargumente akzeptiert und validiert (argparse
). -
Das Modul
pytest
führt alle Funktionen aus, deren Namen mittest_
beginnen, und meldet die Ergebnisse, wie viele Tests bestanden wurden. -
Unit-Tests sind für Funktionen, und Integrationstests prüfen, ob ein Programm als Ganzes funktioniert.
-
Programme wie
pylint
,flake8
undmypy
können verschiedene Arten von Fehlern in deinem Code finden. Du kannstpytest
auch automatisch Tests durchführen lassen, um zu sehen, ob dein Code diese Prüfungen besteht. -
Komplizierte Befehle können als Ziel in einem Makefile gespeichert und mit dem Befehl
make
ausgeführt werden. -
Du kannst einen Entscheidungsbaum mit einer Reihe von
if
/else
Anweisungen erstellen. -
Es gibt viele Möglichkeiten, alle Zeichen in einer Zeichenkette zu zählen. Die Funktion
collections.Counter()
ist vielleicht die einfachste Methode, um ein Wörterbuch der Buchstabenhäufigkeiten zu erstellen. -
Du kannst Variablen und Funktionen mit Typen annotieren und mit
mypy
sicherstellen, dass die Typen korrekt verwendet werden. -
Die Python REPL ist ein interaktives Werkzeug zum Ausführen von Codebeispielen und zum Lesen der Dokumentation.
-
Die Python-Gemeinschaft folgt im Allgemeinen den Stilrichtlinien wie PEP8. Werkzeuge wie
yapf
undblack
können Code automatisch nach diesen Vorschlägen formatieren, und Werkzeuge wiepylint
undflake8
melden Abweichungen von den Richtlinien. -
Python Strings, Listen, Tupel und Dictionaries sind sehr mächtige Datenstrukturen, die jeweils über nützliche Methoden und eine ausführliche Dokumentation verfügen.
-
Du kannst eine benutzerdefinierte, unveränderliche, typisierte
class
erstellen, die von benannten Tupeln abgeleitet ist.
Vielleicht fragst du dich, welche der sieben Lösungen die beste ist. Wie bei vielen Dingen im Leben kommt es darauf an. Manche Programme sind kürzer zu schreiben und leichter zu verstehen, können aber bei großen Datenmengen schlecht abschneiden. In Kapitel 2 zeige ich dir, wie du Programme in mehreren Durchläufen mit großen Eingaben gegeneinander antreten lässt, um die beste Lösung zu finden.
1 Boolesche Typen sind True
oder False
, aber viele andere Datentypen sind wahrheitsgemäß oder umgekehrt falsch. Der leere str
(""
) ist falsch, also ist jeder nicht leere String wahrheitsgemäß. Die Zahl 0
ist falsch, d.h. jeder Wert, der nicht Null ist, ist wahrheitsgemäß. Ein leeres list
, set
oder dict
ist falsch, also ist jeder nicht leere Wert davon wahr.
Get Python für die Bioinformatik beherrschen now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.