NpzFileオブジェクトはdict-likeだけどdictじゃない

# はじめに

初めてQiitaに投稿します。よろしくお願いします。

numpyのndarrayを格納したファイルを読み込む`numpy load()`は、単一のアレイを格納した`.npy`ファイルと複数のアレイを格納した`.npz`ファイルの両方に対応しています。このうち後者のケースではdict-likeなオブジェクトが返されるということは何となくわかっていたのですが、それをdictのつもりで扱ったところ思い通りの結果が得られないという事態が発生しました。今回はそれをきっかけに調べてわかったことを書いてみます。

# 環境
以下の環境で確認しました。
* OS: Ubuntu 22.04.5 LTS (Windows 11のWSL2)
* Python: 3.12.10
* Numpy: 2.2.5

以下、プロンプトが >>> であるものはPythonの対話モード、$であるものはBashの画面です。

# 準備

簡単なデータを作って`np.savez()`で保存します。保存の際には名前を指定しておきます。

    >>> import numpy as np
    >>> a = np.array([1, 2, 3])
    >>> b = np.array([4, 5, 6])
    >>> np.savez("data", a=a, b=b)

読み込みます。

    >>> data_npz = np.load("data.npz")
    >>> type(data_npz)
    <class 'numpy.lib.npyio.NpzFile'>

読み込まれたのはNpzFileオブジェクトです。

# dict-likeな性質

保存時に指定した名前で値を取り出せます。

    >>> data_npz["a"]
    array([1, 2, 3])
    >>> data_npz["b"]
    array([4, 5, 6])

`.keys()`、`.values()`、`.items()`も使えます。

    >>> for key in data_npz.keys():
    ...     print(key)
    ...
    a
    b
    >>> for value in data_npz.values():
    ...     print(value)
    ...
    [1 2 3]
    [4 5 6]
    >>> for key, value in data_npz.items():
    ...     print(key, value)
    ...
    a [1 2 3]
    b [4 5 6]

# dictじゃない性質

値を書き換えることができません。itemそのものを入れ替えようとするとエラーになります。

    >>> data_npz["a"] = np.array([0, 2, 3])
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    TypeError: 'NpzFile' object does not support item assignment

itemの中身を書き換えようとするとエラーにはなりません。でも値は書き換わりません。

    >>> data_npz["a"]
    array([1, 2, 3])
    >>> data_npz["a"][0] = 0
    >>> data_npz["a"]
    array([1, 2, 3])

冒頭書いた「思い通りの結果が得られなかった」というのはこれが原因でした。`.npz`ファイルから読み込んだデータを処理する既存のプログラムがあり、読み込むファイルの一部のデータに誤りがあるのでパッチ的に書き換えたい、という状況が発生したため、後続の処理を変えなくて済むように読み込んだオブジェクトを書き換える処理を追加してみたのですが、修正が反映されなかったのです。しばらく悩んでしまいました。

ちなみに各itemを他の変数に代入すれば普通に書き換えできます。

    >>> a2 = data_npz["a"]
    >>> a2
    array([1, 2, 3])
    >>> a2[0] = 0
    >>> a2
    array([0, 2, 3])

dictとして扱いたい場合は`dict()`で変換するという手もあります。上述の件はこのやり方で対処しました。

    >>> data2 = dict(data_npz)
    >>> data2
    {'a': array([1, 2, 3]), 'b': array([4, 5, 6])}
    >>> data2["a"][0] = 0
    >>> data2
    {'a': array([0, 2, 3]), 'b': array([4, 5, 6])}

# ファイルオープンの問題

私がハマった点は上述の通りですが、実際にはこちらの方が大事かもしれません。`numpy.load()`で`.npz`ファイルを読み込んだ場合、ファイルはオープンされたままになります。上記の例を実行した後に別のシェルを開いてlsofで確認したのがこちら。

    $ lsof data.npz
    COMMAND   PID      USER   FD   TYPE DEVICE SIZE/OFF   NODE NAME
    python  21595 yoshimura    4r   REG   8,32      538 388163 data.npz

オープンされています。だから、例えばこんな風に5個ファイルを作って

    $ for i in 1 2 3 4 5; do cp data.npz data_${i}.npz; done

それぞれの内容を読み込んで配列に入れて

    >>> data_list = []
    >>> for i in range(1, 6):
    ...     d = np.load(f"data_{i}.npz")
    ...     data_list.append(d)
    ...

lsofしてみると全部開いたままです。

    $ lsof data_*.npz
    COMMAND   PID      USER   FD   TYPE DEVICE SIZE/OFF  NODE NAME
    python  22147 yoshimura    3r   REG   8,32      538 19978 data_1.npz
    python  22147 yoshimura    4r   REG   8,32      538 21746 data_2.npz
    python  22147 yoshimura    5r   REG   8,32      538 21788 data_3.npz
    python  22147 yoshimura    6r   REG   8,32      538 21851 data_4.npz
    python  22147 yoshimura    7r   REG   8,32      538 24330 data_5.npz

うっかり数千個のファイルを読もうとして、ファイルオープンの上限を超えてしまってエラーになる、ということも十分あり得ます。

[numpy API Referenceのnumpy.loadの項](https://numpy.org/doc/2.2/reference/generated/numpy.load.html)を見ると以下の記述がありました。

> <dl><dt>Returns:</dt>
> <dd><dl><dt>result</dt>
> <dd>array, tuple, dict, etc.
>Data stored in the file. For .npz files, the returned instance of NpzFile class must be closed to avoid leaking file descriptors.</dd></dl></dd></dl>

[numpy.lib.npyio.NpzFile](https://numpy.org/doc/2.2/reference/generated/numpy.lib.npyio.NpzFile.html)も確認してみましょう。

> A dictionary-like object with lazy-loading of files in the zipped archive provided on construction.

なるほど。遅延ロードということは、NpzFileオブジェクトはファイルの中身へのアクセスを提供するものだと認識すべきものなのですね。それなら書き換えられないことも、生きている間はファイルオープンされたままになることも、当然であると理解できます。

ファイルクローズするには読み込んだオブジェクトを`.close()`する必要がありますが、`with`ブロックも使えるのでこちらの方が簡単で安全です ([API Reference](view-source:https://numpy.org/doc/2.2/reference/generated/numpy.load.html)のNotes参照)。

    >>> with np.load("data.npz") as d:
    ...     a = d["a"]
    ...     b = d["b"]
    ...
    >>> a
    array([1, 2, 3])
    >>> b
    array([4, 5, 6])

# ちなみに`.npy`は？

順序が後先になりますが、単純にndarrayをsaveした`.npy`の場合も確認しておきます。

    >>> np.save("data_a", a)
    >>> a_npy = np.load("data_a.npy")
    >>> type(a_npy)
    <class 'numpy.ndarray'>
    >>> a_npy
    array([1, 2, 3])
    >>> a_npy[0] = 0
    >>> a_npy
    array([0, 2, 3])

得られるのは単純なndarrayです。書き換えもできますし、ファイルは自動的に閉じられます。逆に`with`を使うとエラーになります。ndarrayですから当然と言えば当然ですが。

    >>> with np.load("data_a.npy") as d:
    ...     a = d
    ...
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    TypeError: 'numpy.ndarray' object does not support the context manager protocol

# まとめ

`.npz`ファイルを`numpy.load()`で読み込んで得られるNpzFileオブジェクトには、個々のitemに対するdict-likeなデータアクセス手段が備わっていますが、データをdictにまとめたものとは似て非なるもので、ファイルの中身へのアクセスを提供するものだと認識すべきです。特に、データを書き換えられないことや、生きている間はファイルオープンされたままになることには注意が必要です。`with`ブロックを使用することで簡単かつ安全に取り扱うことができます。