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