【Python】巨大なテキストを丸ごと読まない:行単位で抽出・集計する

PythonのTopに戻る

全行をリストにせず、結果も順に書き出す

大きなテキストから条件に合う測定行を抜き出すとき、read()やreadlines()で全件を保持する必要はない。ファイルをforで一行ずつ読み、必要な行をその場で出力し、件数だけを更新すれば、入力件数に比例したリストを作らずに済む。不正な行もリストへためず、行番号と理由を別ファイルへ順に残す。

ここでは「ID、タブ、値」という一行一記録の形式を使い、値が2以上の行を保存する。引用符付きの複数行CSVは対象ではない。空行や列数違い、数値化できない値、NaNなどの非有限値はエラー件数へ含める。小さなサンプルで結果を確かめ、実データへ適用しても同じ判定が使える形にする。

入力・出力・エラー記録を同時に開く

example.py

from pathlib import Path
from tempfile import TemporaryDirectory
import math


def filter_rows(source, destination, rejected, threshold=2.0):
    paths = [Path(path).resolve() for path in (source, destination, rejected)]
    if len(set(paths)) != 3:
        raise ValueError("input and outputs must be distinct")
    total = kept = errors = 0
    with paths[0].open(encoding="utf-8") as incoming, \
         paths[1].open("x", encoding="utf-8", newline="\n") as outgoing, \
         paths[2].open("x", encoding="utf-8", newline="\n") as bad:
        for number, raw in enumerate(incoming, start=1):
            total += 1
            fields = raw.rstrip("\r\n").split("\t")
            try:
                if len(fields) != 2 or not fields[0]:
                    raise ValueError("columns")
                value = float(fields[1])
                if not math.isfinite(value):
                    raise ValueError("non-finite")
            except ValueError:
                errors += 1
                bad.write(f"{number}\tinvalid row\n")
                continue
            if value >= threshold:
                outgoing.write("\t".join(fields) + "\n")
                kept += 1
    return {"total": total, "kept": kept, "errors": errors}


with TemporaryDirectory() as temporary:
    root = Path(temporary)
    source, output, bad = (root / name for name in ("input.tsv", "selected.tsv", "rejected.tsv"))
    source.write_text("A\t1.5\nB\t2\nC\t3.5\nbroken\nD\tnan\nE\t4", encoding="utf-8")
    counts = filter_rows(source, output, bad)
    assert counts == {"total": 6, "kept": 3, "errors": 2}
    assert output.read_text() == "B\t2\nC\t3.5\nE\t4\n"
    assert bad.read_text() == "4\tinvalid row\n5\tinvalid row\n"
    assert source.read_text().endswith("E\t4")
    print(counts)
    print(output.read_text(), end="")

実行結果

{'total': 6, 'kept': 3, 'errors': 2}
B	2
C	3.5
E	4

何を保持し、何を保持しないか

処理中に保持するのは現在の行、分割した列、小さなカウンターである。正常行と不正行をすべてリストへ追加すると、読み込みだけ一行ずつでも全体のメモリは増え続ける。この例では出力も逐次書き込みにしており、最後に返すのは3つの件数だけである。

行末の改行だけを除去するため、末尾に改行のない最終行も最後の文字を失わない。line[:-1]のように無条件で一文字落とす処理では、例のEの値4を消してしまう。出力側は一行ごとに改行を付け直すので、最終行の改行有無をそのまま保存する仕様ではないことにも注意する。

float()で読めても、nanやinfは通常の閾値判定に使いたくない場合がある。math.isfinite()で除外すれば、NaNとの比較がFalseになったことを単なる条件不一致として見落とさずに済む。文字コードのデコード失敗は行のValueError処理へまとめず、ファイル全体の読み込み問題として外へ伝える。

メモリが一定という説明の条件

一行ずつ処理する方式でも、一行が極端に長ければその行の分のメモリを使う。出力バッファやPythonの管理領域もあるので、どんな入力でも厳密な定数メモリと考えない。外部からの任意ファイルを扱う場合は、行長やファイルサイズの上限、読み取り時間も別に制限する必要がある。

全体の並べ替え、中央値、行同士の大量の照合などは、このカウンターだけの方式ではそのまま実現できない。必要な計算に応じて分割集計、外部ソート、データベースを使うことになる。平均や件数なら逐次計算しやすいが、どの結果を求めるかによって保存すべき状態が変わる。

出力途中の失敗と上書き防止

出力をxモードで開くので、同名ファイルがすでにあれば失敗し、既存の結果を上書きしない。入力と出力が同じパスになっていないかも確認している。ただし、二つの出力をまとめて原子的に作る処理ではない。片方を作成した後で失敗すれば空や途中までの出力が残る場合がある。

途中結果を完成品と見分けたいなら、専用の一時領域へ出力し、処理が成功して件数検査を終えてから確定する。元の入力を直接書き換えるin-place処理は避けた方が調査しやすい。totalは正常・不正を含む入力行数なので、条件不一致の正常行数はtotalからkeptとerrorsを引いて確認できる。

実際の測定ファイルでは、閾値の単位、ヘッダー行、欠損値の記号も先に決める。ヘッダーを無条件に数値化してエラーへ入れたり、別の単位の値を同じ閾値で比較したりしないよう、ファイル形式の仕様と抽出条件を同じ場所に記録しておくとよい。閾値に等しい値を含めるかも、境界値のテストで確認する。

実行環境と関連情報

掲載コードはLinux上のCPython 3.12.14で実行した。OS固有のファイル操作や対話環境の違いは、本文に記した条件に従って扱う。

関連:測定ログを列に分ける:名前付きグループで正規表現を読む / 保存途中で元ファイルを壊さない:一時ファイルから置き換える

PythonのTopに戻る