錯(cuò)速查與方法論)
Python 報(bào)錯(cuò)速查:AttributeError / FileNotFoundError / IndentationError 到底在說什么本文是《手寫 FASTA 解析器》系列第 2 篇上篇:手寫FASTA解析器踩了9個(gè)坑 —— 從0條記錄到3條的完整調(diào)試實(shí)錄中篇:Python報(bào)錯(cuò)速查 —— AttributeError / FileNotFoundError / IndentationError 到底在說什么我是生信方向研一新手,Python 零基礎(chǔ)起步,研究方向是小鼠VNTR(可變數(shù)目串聯(lián)重復(fù))變異分析。下篇:同樣的解析邏輯,字典版和 yield 生成器版差了 4 萬倍內(nèi)存為了搞懂序列文件是怎么被讀進(jìn)程序的,我從零手寫了一個(gè) FASTA 解析器 ——改了 9 版才對(duì)。這個(gè)系列記錄這 9 版每一次錯(cuò)在哪、現(xiàn)象是什么、病根在哪。所有性能數(shù)字都是實(shí)測(cè)值,不是估算。上篇講的前 5 版,錯(cuò)誤多少還會(huì)報(bào)錯(cuò)。本篇這四版是另一個(gè)物種:它們?nèi)疾粓?bào)錯(cuò),只是安靜地給你一個(gè)錯(cuò)結(jié)果。其中最陰的一版,assert len(recs) 3照樣通過,而三條序列全部錯(cuò)位。另一版三個(gè)測(cè)試全綠,但在真實(shí)數(shù)據(jù)上要跑 85 天。一、四版沉默的錯(cuò)誤第 6 版最陰的一版 —— 數(shù)據(jù)錯(cuò)位assert卻通過了seq_idline[1:].split()[0]# 錯(cuò):先換標(biāo)簽records[seq_id].join(chunks)# 再結(jié)賬 - 舊貨貼了新標(biāo)簽輸出共讀到 3 條記錄 VNTR_01 0 bp VNTR_02 69 bp VNTR_03 32 bp --- assert len(recs) 3 沒有報(bào)錯(cuò),程序自認(rèn)為成功 ---標(biāo)簽實(shí)際裝進(jìn)去的應(yīng)該裝VNTR_01空0 bp69 bpVNTR_0269 bp← 這是 VNTR_01 的序列40 bpVNTR_0332 bp碰巧對(duì)32 bp序列整體往后挪了一位被貼上了錯(cuò)的標(biāo)簽。放到真實(shí)分析里這意味著 VNTR_02 的拷貝數(shù)報(bào)告寫的其實(shí)是 VNTR_01 的數(shù)據(jù) ——論文發(fā)出去都不知道錯(cuò)在哪。道理結(jié)賬必須用舊 ID。一旦執(zhí)行seq_id 新ID舊 ID 就被覆蓋、再也找不回來了籃子里裝的還是舊貨標(biāo)簽卻已經(jīng)換成新的。所以先結(jié)賬、再開新里的先后不是風(fēng)格問題是數(shù)據(jù)正確性問題。附帶一個(gè)自查信號(hào)seq_id ...的下一行寫if seq_id is not None:這個(gè)判斷永遠(yuǎn)成立剛賦完值怎么可能是None。如果一個(gè)if永遠(yuǎn)為真說明它被挪錯(cuò)位置了。順便說說斷言怎么寫這一版能騙過assert len(recs) 3說明斷言只查數(shù)量不查內(nèi)容等于沒查assertlen(recs)3,f記錄數(shù)不對(duì),實(shí)際{len(recs)}條assertlen(recs[VNTR_01])69,fVNTR_01 長(zhǎng)度不對(duì),實(shí)際{len(recs[VNTR_01])}assertNonenotinrecs,出現(xiàn)了 None 鍵,說明 ID 沒取到寫測(cè)試的原則斷言要卡在錯(cuò)了就一定露餡的地方。條數(shù)容易碰巧對(duì)上長(zhǎng)度和鍵名不會(huì)。第 7 版主流程終于對(duì)了但空文件冒出鬼記錄test_vntr.fa - 3 條,69/40/32 ? test_empty.fa - {None: } ? 空文件卻讀出 1 條黑話邊界條件edge case。大白話正常數(shù)據(jù)誰都能跑對(duì)程序是死在邊角料上的??瘴募?、只有一條記錄、ID 重復(fù)、序列帶奇怪字符 —— 真實(shí)的重測(cè)序數(shù)據(jù)里這些全都會(huì)出現(xiàn)。病因循環(huán)外那句收尾結(jié)賬是無條件執(zhí)行的。文件空的時(shí)候根本沒有記錄要結(jié)賬它卻照樣結(jié)了一次用的是初始值Nonerecords{}seq_idNonechunks[]# 文件是空的 - for 循環(huán)體一行都沒執(zhí)行 - 三個(gè)變量原封不動(dòng)records[seq_id].join(chunks)# seq_id 還是 Noneprint(records)# {None: }修法 —— 給它也加一道門ifseq_idisnotNone:# 加這一行records[seq_id].join(chunks)規(guī)律看到None鍵就是結(jié)賬沒設(shè)門。注意是鍵是None值是空字符串。第 8 版三個(gè)測(cè)試全綠縮進(jìn)差 4 格 慢 125 倍我把收尾結(jié)賬的if加對(duì)了但縮進(jìn)多了 4 格它跑進(jìn) for 循環(huán)里面去了forlineinf:...ifseq_idisnotNone:# 12 格:在循環(huán)里 - 執(zhí)行 1 萬次ifseq_idisnotNone:# 8 格:和 for 平齊 - 執(zhí)行 1 次三個(gè)測(cè)試全部通過結(jié)果完全正確。換成 1 萬行的文件收尾在循環(huán)里: 0.500 秒 收尾在循環(huán)外: 0.004 秒 ← 快 125 倍,結(jié)果一模一樣為什么縮進(jìn)決定這句話被執(zhí)行多少次n0foriinrange(10000):nn1# 縮進(jìn)在 for 里面print(n)# 10000n0foriinrange(10000):passnn1# 縮進(jìn)和 for 平齊print(n)# 1同一句代碼只因?yàn)榭s進(jìn)不同執(zhí)行次數(shù)差 1 萬倍。而且每次的工作量還在長(zhǎng)大 —— 放在循環(huán)里等于每讀一行都把已攢的所有碎片重拼一遍在循環(huán)外: join 執(zhí)行 1 次,搬運(yùn) 600,000 個(gè)字符 在循環(huán)里: join 執(zhí)行 10000 次,搬運(yùn) 3,000,300,000 個(gè)字符搬運(yùn)量差 5000 倍。根本原因是字符串不可變immutable.join()沒法在原字符串上追加每次都要另開一塊新內(nèi)存、把所有內(nèi)容重抄一遍。黑話時(shí)間復(fù)雜度time complexity。O(n)文件大 10 倍 → 慢 10 倍可接受O(n2)文件大 10 倍 → 慢100 倍災(zāi)難按這個(gè)趨勢(shì)外推到小鼠參考基因組 GRCm39約 4300 萬行寫法推算耗時(shí)收尾在循環(huán)里約85 天收尾在循環(huán)外約17 秒純按復(fù)雜度外推實(shí)際上內(nèi)存會(huì)先撐不住作業(yè)在集群上會(huì)被 SLURM 因超內(nèi)存 kill。性能三問這行在哪層循環(huán)里會(huì)執(zhí)行幾次每次干多少活第 9 版else被搶走了append成了死代碼我想加重復(fù) ID 打印警告順手多包了一層if line:ifnotline:continueifline:# ← 新加的,縮進(jìn) 12ifline.startswith():# ← 縮進(jìn) 16...else:# ← 縮進(jìn) 12,和 if line: 對(duì)齊chunks.append(line)# 所以它配的是 if line:!結(jié)果共讀到 3 條記錄 VNTR_01 0 bp VNTR_02 0 bp VNTR_03 0 bp條數(shù)對(duì)序列全空 —— 籃子一次都沒裝進(jìn)東西。else跟誰配對(duì)看縮進(jìn)和哪個(gè)if對(duì)齊。我的else和if line:平齊所以它配的是if line:不再是if line.startswith()。而if line:永遠(yuǎn)為真—— 上一行if not line: continue已經(jīng)把空行全擋掉了。永遠(yuǎn)為真的if它的else就永遠(yuǎn)執(zhí)行不到。forxin[CAGCAG,VNTR_01]:ifx:# 永遠(yuǎn)為真ifx.startswith():print(x,- 標(biāo)題行分支)else:print(x,- 序列行分支)# 永遠(yuǎn)到不了# 輸出只有 VNTR_01 - 標(biāo)題行分支# CAGCAG 那一輪什么都沒打印,它掉進(jìn)了 if line: 的空檔里黑話不可達(dá)分支unreachable branch死代碼dead code。大白話寫了但永遠(yuǎn)跑不到的代碼。Python 不會(huì)警告你它就靜靜待著不干活。附加坑參數(shù)寫死靜默處理錯(cuò)樣本整個(gè)過程中我有幾版把路徑寫死在open()里defread_fasta(path):withopen(D:/work/test_vntr.fa,encodingutf-8)asf:# 參數(shù) path 沒人理后果有多離譜傳 test_dup.fa - VNTR_01/02/03 (69/40/32) 傳 test_empty.fa - VNTR_01/02/03 (69/40/32) 傳一個(gè)根本不存在的文件 - VNTR_01/02/03 (69/40/32)連不存在.fa都能返回 3 條記錄。這類 bug 在真實(shí)分析里是災(zāi)難級(jí)的你以為在處理樣本 B實(shí)際一直在讀樣本 A跑出來的所有結(jié)果都指向錯(cuò)誤的樣本而且全程不報(bào)錯(cuò)。二、報(bào)錯(cuò)速查表讀法最后一行 病名倒數(shù)第二行 案發(fā)行號(hào)報(bào)錯(cuò)前那句可疑輸出往往才是病根。報(bào)錯(cuò)病名先查什么AttributeError: str object has no attribute items對(duì)字符串用了字典的方法是不是把字典整體覆蓋成字符串了FileNotFoundError找不到文件cwd 站對(duì)了嗎十次九次是這個(gè)不是文件沒了IndentationError: unindent does not match...同塊內(nèi)縮進(jìn)格數(shù)不一致這行比上下行多/少幾格TabError: inconsistent use of tabs and spacesTab 和空格混用肉眼看不出統(tǒng)一用空格AssertionError自檢沒過看 assert 后面那句話給的實(shí)際值KeyError字典里沒這個(gè)鍵鍵名拼寫、是不是還沒存進(jìn)去關(guān)于縮進(jìn)Python 的兩條規(guī)則同一個(gè)代碼塊里每一行的縮進(jìn)必須完全一樣差一個(gè)空格都報(bào)錯(cuò)塊縮進(jìn)多少格無所謂只要比外層深 —— 4 格、8 格甚至 1 格都合法# IndentationError: unindent does not match any outer indentation levelifTrue:a1b2# TabError: inconsistent use of tabs and spaces in indentationifTrue:a1b2# 這行是 TabTabError最陰因?yàn)槿庋劭粗荒R粯訉?。?guī)范PEP 8是4 個(gè)空格編輯器里統(tǒng)一用空格別混。三、兩個(gè)高價(jià)值 debug 套路這兩條幫我定位了 9 版里的 4 版套路 1某變量始終保持初始值 → 去查它的賦值語句為什么沒執(zhí)行到。第 5 版的輸出是None 32 bp—— ID 打印出None而None正是seq_id的初始值。這直接說明seq_id line[1:].split()[0]一次都沒跑過于是馬上找到了鑰匙鎖在門里。套路 2某個(gè)if永遠(yuǎn)為真 → 它被挪錯(cuò)了位置。第 6 版里if seq_id is not None:緊跟在seq_id ...后面必然為真。這個(gè)判斷原本是要保護(hù)還沒有上一條的情況 —— 它永遠(yuǎn)為真就說明它跟保護(hù)對(duì)象被拆開了。四、方法論三關(guān)分開過這 9 版讓我明白代碼正確不是一個(gè)判斷是三個(gè)。關(guān)查什么我的實(shí)際情況功能關(guān)正常輸入結(jié)果對(duì)不對(duì)第 7 版才過邊界關(guān)空輸入、重復(fù)輸入、異常輸入第 8 版才過性能關(guān)代價(jià)隨數(shù)據(jù)量怎么長(zhǎng)第 8 版全綠但慢 125 倍三個(gè)測(cè)試全綠不等于代碼沒問題。測(cè)試查的是結(jié)果對(duì)不對(duì)查不出代價(jià)有多大。配套的工程習(xí)慣回歸測(cè)試regression test—— 大白話確認(rèn)你修好新問題的時(shí)候沒把老功能弄壞。我準(zhǔn)備了三個(gè)輸入文件每改一次就全跑一遍test_vntr.fa - 3 條,69/40/32 test_empty.fa - {} test_dup.fa - 打印警告,保留最后一條第 8 版就是靠這個(gè)發(fā)現(xiàn)修好空文件的同時(shí)把性能弄壞了。關(guān)于重復(fù) ID 該怎么處理—— 我一開始寫成ifseq_idinrecords:print(warning)else:records[seq_id].join(chunks)# 重復(fù)時(shí)就不存了這是丟數(shù)據(jù)比覆蓋更危險(xiǎn) —— 覆蓋至少條數(shù)還對(duì)丟了連痕跡都沒有。正確做法是照存、額外喊一聲ifseq_idinrecords:print(f警告:ID 重復(fù){seq_id})# 多出來的一句,不是 elserecords[seq_id].join(chunks)另外print(warning)這種日志等于沒寫 ——出問題時(shí)它不告訴你是哪個(gè) ID。日志要帶上下文。中篇小結(jié)這四版的共同點(diǎn):Python 一聲不吭。版本現(xiàn)象報(bào)錯(cuò)嗎第 6 版序列整體錯(cuò)位,標(biāo)簽配錯(cuò)貨不報(bào),assert還通過第 7 版空文件冒出{None: }鬼記錄不報(bào)第 8 版三個(gè)測(cè)試全綠,慢 125 倍不報(bào)第 9 版append成了死代碼,序列全空不報(bào),條數(shù)還對(duì)能靠報(bào)錯(cuò)發(fā)現(xiàn)的問題,是最容易的那一類。真正要命的是這四種 —— 只能靠主動(dòng)設(shè)計(jì)測(cè)試(空輸入、重復(fù)輸入、大輸入)去逼它們現(xiàn)形。下篇會(huì)講兩件事:一是這套緩沖累積邏輯怎么照搬到 VCF / GTF / SAM二是把字典版改成yield生成器版 —— 邏輯一個(gè)字不改,內(nèi)存占用從 8.4 MB 降到 200 字節(jié),這也是 pysam 能扛住幾千萬條 reads 的原因。