静かなる名辞

pythonとプログラミングのこと



【python】matplotlibのhistで棒の上にラベルを表示

 plt.histはデータを与えるとそのままヒストグラムをプロットしてくれますが、棒との位置関係に基づいてテキストなどを表示させようとすると、ちょっと困ります。

 しかし、plt.histの返り値を利用して棒の頂点の座標を取得すれば、そのままプロットすることができます。

Returns:
n : array or list of arrays
The values of the histogram bins. See normed or density and weights for a description of the possible semantics. If input x is an array, then this is an array of length nbins. If input is a sequence of arrays [data1, data2,..], then this is a list of arrays with the values of the histograms for each of the arrays in the same order.

bins : array
The edges of the bins. Length nbins + 1 (nbins left edges and right edge of last bin). Always a single array even when multiple data sets are passed in.

patches : list or list of lists
Silent list of individual patches used to create the histogram or list of such list if multiple input datasets.

matplotlib.pyplot.hist — Matplotlib 3.0.2 documentation

 nとbinsがほしい情報です。patchesはとりあえず使いません。

 xy座標でいうと、nがy、binsがxです。ただしbinsは棒の両端の座標で、棒の数+1の要素数を持ちます。平均してやれば真ん中になります。

 以下は各棒の上に棒の高さ(データ数)をプロットするサンプルです。

import numpy as np
import matplotlib.pyplot as plt

n, bins, _ = plt.hist(np.random.uniform(size=1000))
xs = (bins[:-1] + bins[1:])/2
ys = n.astype(int)

for x, y in zip(xs, ys):
    plt.text(x, y, str(y), horizontalalignment="center")
plt.savefig("result.png")

result.png
result.png

 このように、座標さえ取得できれば任意のテキストや図形をプロットすることができます。

【python】自分自身を要素に持つlist・循環参照するlistなどを作ってみる

 これまでやったことがなかったので、試してみたというだけです。データ構造によってはこういうのを作りたくなることもあるでしょう。

>>> lst = [0]
>>> lst.append(lst)
>>> lst
[0, [...]]

 なるほど、こういう形で印字されるのか。特に問題はなさそう。

 次に、互いに互いを要素に持つ2つのlist。

>>> lst1 = [0]
>>> lst2 = [1]
>>> lst1.append(lst2)
>>> lst2.append(lst1)
>>> lst1
[0, [1, [...]]]
>>> lst2
[1, [0, [...]]]

 循環構造がある場合、このように表記が省略される。

 辞書の場合。

>>> d = {}
>>> d["key"] = d
>>> d
{'key': {...}}
>>> d1 = {}
>>> d2 = {}
>>> d1["key1"] = d2
>>> d2["key2"] = d1
>>> d1
{'key1': {'key2': {...}}}
>>> d2
{'key2': {'key1': {...}}}

 set……はunhashableなのでなし。また、tupleはimmutableなので、上のようにオブジェクトを生成してから値を挿入することができない。ただし要素にlistを持つことは可能なため、それを利用して循環構造を作り出すことはできる。

>>> tup = ([],)
>>> tup[0].append(tup)
>>> tup
([(...)],)
>>> tup[0]
[([...],)]
>>> tup[0][0]
([(...)],)

 numpyも大丈夫そう。

>>> import numpy as np
>>> a = np.array([1], dtype=object)
>>> a
array([1], dtype=object)
>>> a[0] = a
>>> a
array([array(..., dtype=object)], dtype=object)

 要するに、組み込み型やちゃんとしたライブラリのコレクション型においては、適切に文字列に変換されるので特に心配要りません。ただオリジナルのコレクション型を定義するような場合、__repr__および__str__の実装次第では事故る可能性があるでしょう(ナイーブに子要素に対してstrやreprを呼ぶような実装にすると、無限に再帰し得る)。

multiprocessing.PoolでAttributeError: Can't get attribute '***' on <module '__main__' from '***.py'>みたいなエラー

概要

 multiprocessing.Poolで並列化じゃ! と調子に乗ってコードを書いていると表題のようなエラーに遭遇することがあります。

 再現するコード。

poolerrortest.py

from multiprocessing import Pool

p = Pool(2)

def f(x):
    print(x)

p.map(f, [1,2])
# 中略
AttributeError: Can't get attribute 'f' on <module '__main__' from 'poolerrortest.py'>

原因と対策

 Poolのインスタンスが作られた時点でプロセスがforkしますが、その時点ではfが定義されていません。よって子プロセスの側でfが見つけられない、というエラーになります。

 Poolのインスタンスを作るタイミングを調整することで回避できます。

from multiprocessing import Pool

def f(x):
    print(x)

p = Pool(2)
p.map(f, [1,2])

""" =>
1
2
"""

 子プロセスの実行に必要なものの定義が終わってからPoolのインスタンスを生成する必要があります。

multiprocessing.Poolがやたらメモリを消費するときの対策

概要

 multiprocessing.Poolは原理的にプロセスをforkさせるので、メインプロセスに大きなデータが残っているとそれが丸々コピーされてメモリ領域を食います。

 グローバル関数限定ですが、initializerを使って必要ないデータを消すことができます。また、Poolを作るタイミングを工夫することでそもそも大きいデータが子プロセスに引き継がれないようにすることができます。

前提状況の説明

 以下のようなコードです。

import subprocess
from multiprocessing import Pool

import numpy as np

a = np.arange(10**7)

def f():
    subprocess.run("ps -aux | grep [m]emory_test", shell=True)

p = Pool(1)
p.apply(f)
p.close()
p.terminate()
print(a.shape)

 見るからにメモリをドカ食いしそうな10**7のnumpy配列を確保しています。実行すると、以下のようになります。

username      7407 44.0  1.2 543952 100056 pts/0   Sl+  20:25   0:00 python memory_test.py
username      7411  0.0  1.1 347344 94640 pts/0    S+   20:25   0:00 python memory_test.py
(10000000,)

 もし子プロセスで走らせたいのがaを使わない処理なら、無駄に大容量のメモリを食っていることになります。

対策1:initializerで消す

実験

 以下のようなコードを書いてみます。

import subprocess
from multiprocessing import Pool

import numpy as np

a = np.arange(10**7)

def f():
    subprocess.run("ps -aux | grep [m]emory_test", shell=True)

def initializer():
    del globals()["a"]

p = Pool(1, initializer=initializer)
p.apply(f)
p.close()
p.terminate()
print(a.shape)  # ちゃんといることの確認
username      7427  0.0  1.2 543948 100112 pts/0   Sl+  20:26   0:00 python memory_test.py
username      7431  0.0  0.2 269212 16548 pts/0    S+   20:26   0:00 python memory_test.py
(10000000,)

 だいぶ改善しました。

本末転倒というか・・・

 まあ、見ての通りエレガントな方法ではありません。また、globals()は書き換えられてもlocals()は書き換えられないので、ローカル変数には効きません。

 そこで2番目の対策を考えます。

対策2:早めにPoolを作る

説明

 上のコードでaが作られる以前にPoolを作れば、その時点でforkするのでメモリどか食い現象は回避できます。

 こんな感じですね。

import subprocess
from multiprocessing import Pool

import numpy as np

def f():
    subprocess.run("ps -aux | grep [m]emory_test", shell=True)

p = Pool(1)
a = np.arange(10**7)

p.apply(f)
p.close()
p.terminate()
print(a.shape)
username      7525  0.0  1.2 543952 100116 pts/0   Sl+  20:31   0:00 python memory_test.py
username      7529  0.0  0.2 269216 16512 pts/0    S+   20:31   0:00 python memory_test.py
(10000000,)

 initializerで消すのと同等の効果がありますが、こちらだとローカル変数でも大丈夫です。また、グローバル変数をdelする方法だと、initializerが走るまでの一瞬の間は無駄なデータがメモリを消費する訳で、そういう面でもこちらの方が有利だと思います。

まとめ

 早めに(重いデータがメモリに読み込まれる前に)forkしておくのが基本ですが、どうしても駄目なときは削除も試してみましょう。

追記

 プロセスの開始方法に"spawn"を指定することでも可能だとコメントでご指摘をいただきました。

spawn
親プロセスは新たに python インタープリタープロセスを開始します。子プロセスはプロセスオブジェクトの run() メソッドの実行に必要なリソースのみ継承します。特に、親プロセスからの不要なファイル記述子とハンドルは継承されません。この方式を使用したプロセスの開始は fork や forkserver に比べ遅くなります。

Unix と Windows で利用可能。Windows でのデフォルト。

17.2. multiprocessing — プロセスベースの並列処理 — Python 3.6.5 ドキュメント

 multiprocessing.get_context('spawn')とすると、multiprocessingモジュールと同じAPIを持つオブジェクトが返り、これからPoolを作ることで解決できます。これを利用しても良さそうです。

複数のnumpy配列を同時にシリアライズできるnumpy.savezの使い方を解説

はじめに

 numpy.savezは最近使ってみてけっこう良い感じだったのですが、日本語のわかりやすい説明が少なかったので解説記事を書いてみます。

 なお、以下のドキュメントも併せて参考にしてください。

numpy.savez — NumPy v1.15 Manual

基本的な使い方

 まず、シリアライズする際は以下のようにすることができます。

>>> import numpy as np
>>> a = np.arange(10)
>>> np.savez("dump", a)

 拡張子は自動的に.npzになり、この場合dump.npzというファイル名で保存されます。dump.npzというファイル名を指定するとdump.npzのまま出力されますが、たとえばdump.binなどとするとdump.bin.npzにされるので注意が必要です。

 デシリアライズはnumpy.loadを用います。

>>> import numpy as np
>>> np.load("dump.npz")
<numpy.lib.npyio.NpzFile object at 0x7f7af22d6710>

 numpy.lib.npyio.NpzFileというよくわからない型で返りますが、実体としてはdictのようなものです*1

>>> list(np.load("dump.npz"))
['arr_0']

 dict→list変換では辞書のキーのlistになりますから、"arr_0"というキーでアクセスすれば良いということになります。

>>> np.load("dump.npz")["arr_0"]
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

 ちゃんと元の配列が取り出せました。

 仕掛けとしては、savezの際の位置引数はすべて"arr_0","arr_1",...というキーに変換されています。savezの際のキーワード引数はキーワード引数がそのままキーになるので、こちらを使ったほうが便利です。

>>> a = np.arange(10)
>>> b = np.arange(20).reshape(4,5)
>>> np.savez("dump2", a=a, b=b)
>>> npz = np.load("dump2.npz")
>>> npz["a"]
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> npz["b"]
array([[ 0,  1,  2,  3,  4],
       [ 5,  6,  7,  8,  9],
       [10, 11, 12, 13, 14],
       [15, 16, 17, 18, 19]])

 このように使うことができます。

用途

 たとえば機械学習で使うデータをシリアライズする際に、データとラベルを一緒に保存するとか、学習データとテストデータを一緒に保存するといった用途があります。

 他にも「このデータはひとまとめにしてシリアライズしたい」と思うような場合など様々な用途に幅広く使えます。

numpy.savez_compressed

 numpy.savez_compressedという関数があり、自動的にデータを圧縮してファイル容量を削減してくれます。それでいて使い勝手はまったく同じです。

numpy.savez_compressed — NumPy v1.15 Manual

>>> c = np.arange(15)
>>> d = np.arange(15).reshape(3,5)
>>> np.savez_compressed("dump3", c=c, d=d)
>>> npz = np.load("dump3.npz")
>>> npz["c"]
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14])
>>> npz["d"]
array([[ 0,  1,  2,  3,  4],
       [ 5,  6,  7,  8,  9],
       [10, 11, 12, 13, 14]])

 こだわりがなければこちらを使った方がストレージに優しいです。ただし圧縮・解凍に余計な時間がかかりますが、ファイル容量が小さくなる分ストレージからの読み出しが短時間で終わって相殺される可能性もあります。

 難点はちょっと名前が長いことくらいでしょうか。

まとめ

 このように簡単に使えます。複数のnumpy配列をシリアライズしたい場合、辞書に入れてpickleで……とか難しいことを考えずに、こちらを使うことをおすすめします。

*1:collections.abc.Mappingを継承しています

numpy配列の直列化方法によるファイル容量の違いを比較

はじめに

 numpy配列を直列化する方法はいろいろあります。numpyから使える方法に限っても4つあります*1

numpy.savetxt — NumPy v1.15 Manual
numpy.save — NumPy v1.15 Manual
numpy.savez — NumPy v1.15 Manual
numpy.savez_compressed — NumPy v1.15 Manual

 出力されるファイルの容量はどれが少ないのでしょうか? なんとなく予想できる気もしますが、検討してみました。

それぞれの簡単な説明

  • numpy.savetxt

 テキスト形式で保存する。1次元ないし2次元配列しか出力できない

  • numpy.save

 いわゆる普通の直列化。.npyという拡張子を使うらしい

  • numpy.savez

 複数の配列を同時に直列化できる。.npzという拡張子を使う。読み込むときは辞書

  • numpy.savez_compressed

 savezを圧縮して出力するだけ

 numpy.savetxtで出力したものはnumpy.loadtxtで読み込む必要があります。それ以外はnumpy.loadで行けるようです。

容量比較

 次のようなコードを書きました。

import numpy as np

A = np.arange(10**6).astype(np.float64).reshape(10**3, 10**3)

np.savetxt("savetxt.txt", A)
np.save("save.npy", A)
np.savez("savez.npz", A)
np.savez_compressed("savez_compressed.npz", A)

 出力されたファイルの容量は、

関数 容量[MB]
savetxt.txt 25.0
save.npy 8.0
savez.npz 8.0
savez_compressed.npz 1.3

 のように、numpy.savez_compressedを使うのが最良でした。

 まあ、やる前からわかっていましたが。

考察とまとめ

 特にこだわりがなければnumpy.savez_compressedが良さげです。圧縮に時間はかかるはずですが、20倍弱違うとストレージI/Oの時間が短縮できるので相殺できる気がします(未検証)。

 難点は.npz形式への親しみのなさでしょうか。save_compressedがあれば良かった気もしますが(特に単一の配列をシリアライズしたい場合など)、同じようなものを複数作る必要はないという思想なのでしょう。しょせんloadするとただの辞書が返るだけなので、そんなに難しく考える必要もなく、numpy.savez_compressed使えば良いですね。

*1:というか、もう少しいろいろある気もする。詳細は Input and output — NumPy v1.15 Manual を参照

sklearnのfetch_20newsgroups_vectorizedでベクトル化された20 newsgroupsを試す

はじめに

 20 newsgroupsはこのブログでも過去何回か取り上げまたしが、ベクトル化済みのデータを読み込めるfetch_20newsgroups_vectorizedは意図的にスルーしていました。

 使えるかどうか気になったので、試してみます。

sklearn.datasets.fetch_20newsgroups_vectorized — scikit-learn 0.20.2 documentation

使い方

 単純なので基本的にはドキュメントを見てください。一応引数の説明だけ引用で貼りますが。

subset : ‘train’ or ‘test’, ‘all’, optional
  Select the dataset to load: ‘train’ for the training set, ‘test’ for the test set, ‘all’ for both, with shuffled ordering.

remove : tuple
  May contain any subset of (‘headers’, ‘footers’, ‘quotes’). Each of these are kinds of text that will be detected and removed from the newsgroup posts, preventing classifiers from overfitting on metadata.

  ‘headers’ removes newsgroup headers, ‘footers’ removes blocks at the ends of posts that look like signatures, and ‘quotes’ removes lines that appear to be quoting another post.

data_home : optional, default: None
  Specify an download and cache folder for the datasets. If None, all scikit-learn data is stored in ‘~/scikit_learn_data’ subfolders.

download_if_missing : optional, True by default
  If False, raise an IOError if the data is not locally available instead of trying to download the data from the source site.

return_X_y : boolean, default=False.
  If True, returns (data.data, data.target) instead of a Bunch object.

  New in version 0.20.

 sklearn 0.19とsklearn 0.20で仕様が変わっていて、新バージョンでは何かと親切な引数が増えています。この記事では0.19相当の機能しか使っていませんが。

確認する

 とりあえずデータ数と次元数でも見てみます。初回の実行では時間がかかりますが、内部的にはCountVectorizerでベクトル化を行う実装のようです(ベクトル化されたデータをダウンロードしてくる訳ではない)。二回目以降はキャッシュされてpickleの読み込み時間だけになります。実装は単純なので、確認したい人はドキュメントからsourceのリンクに飛んで見てみてください。

from sklearn.datasets import fetch_20newsgroups_vectorized

for subset in ["train", "test", "all"]:
    dataset =  fetch_20newsgroups_vectorized(subset=subset)
    print(dataset.data.shape)

""" =>
(11314, 130107)
(7532, 130107)
(18846, 130107)
"""

 13107次元という次元数は、昔の記事でも確認した通り低頻度語を落とす処理などは一切為されていない場合の次元数です。

 さて、この関数にはremoveという引数があります。これを使うと無駄なメッセージヘッダだのフッタだの、引用部だのを除去できます。

from sklearn.datasets import fetch_20newsgroups_vectorized

for subset in ["train", "test", "all"]:
    dataset =  fetch_20newsgroups_vectorized(
        subset=subset, remove=("headers", "footers", "quotes"))
    print(dataset.data.shape)

""" =>
(11314, 101631)
(7532, 101631)
(18846, 101631)
"""

 気持ち程度(3万弱)次元が下がります。

 なお、昔記事にしたときには気づきませんでしたが、この辺の機能は素のfetch_20newsgroupsにもあります(だからtrainのデータを更に分割して分類の学習データとテストデータに分けるなんてアホなことをしていた訳ですが……)。なので、これを使うためにfetch_20newsgroups_vectorizedを使う必要があるという話ではまったくありません。

使ってみる

 とりあえず、10万次元なんて使い物にならないことだけは何もしなくてもわかるので、次元数を下げる算段を考えることにします。

 CountVectorizerなら頻度が高すぎる、低すぎる単語は落とすオプションがデフォルトであるのですが、そういう便利機能はfetch_20newsgroups_vectorizedにはありません。また、その変換に対応したモデルもsklearn.preprocessingやsklearn.model_selectionには用意されていません。困ったものです。

 仕方ないので、SelectKBestを引っ張り出し、score_funcはnumpyで書くことにします。

 単に入力の頻度を数える関数はこんな感じです。

def freq(X, y=None):
    return np.sum(X, axis=0).A.reshape(-1)

 なんか無駄なものが後ろに付いている気がする? 私もそう思いますが、なんとSelectKBestがmatrix型で投げてくるので、arrayに直して返却する必要があります。えー、なんでー、って感じ。すでに非推奨扱いなのに

 とにかく、これを使って特徴選択します。高頻度語だけ残すという特徴選択になります。

import numpy as np
from sklearn.feature_selection import SelectKBest
from sklearn.datasets import fetch_20newsgroups_vectorized

def freq(X, y=None):
    return np.sum(X, axis=0).A.reshape(-1)

train =  fetch_20newsgroups_vectorized(
    subset="train", remove=("headers", "footers", "quotes"))

skb = SelectKBest(score_func=freq, k=2000)
print(skb.fit_transform(train.data, train.target).shape)

""" =>
(11314, 2000)
"""

 できそうですね。

 試しに分類やってみましょう。分類器は、BoWなのでMultinomialNBにしてみます。

sklearn.naive_bayes.MultinomialNB — scikit-learn 0.20.2 documentation

import numpy as np
from sklearn.feature_selection import SelectKBest
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import Pipeline
from sklearn.datasets import fetch_20newsgroups_vectorized
from sklearn.metrics import classification_report

def freq(X, y=None):
    return np.sum(X, axis=0).A.reshape(-1)

def main():
    train =  fetch_20newsgroups_vectorized(
        subset="train", remove=("headers", "footers", "quotes"))
    test =  fetch_20newsgroups_vectorized(
        subset="test", remove=("headers", "footers", "quotes"))

    skb = SelectKBest(score_func=freq, k=2000)
    nb = MultinomialNB()
    pl = Pipeline([("skb", skb), ("nb", nb)])

    pl.fit(train.data, train.target)
    pred = pl.predict(test.data)
    print(classification_report(test.target, pred, 
                                target_names=test.target_names))

if __name__ == "__main__":
    main()

""" =>
                          precision    recall  f1-score   support

             alt.atheism       0.51      0.07      0.12       319
           comp.graphics       0.50      0.56      0.53       389
 comp.os.ms-windows.misc       0.61      0.53      0.57       394
comp.sys.ibm.pc.hardware       0.54      0.60      0.57       392
   comp.sys.mac.hardware       0.72      0.45      0.55       385
          comp.windows.x       0.65      0.62      0.63       395
            misc.forsale       0.78      0.75      0.77       390
               rec.autos       0.60      0.60      0.60       396
         rec.motorcycles       0.64      0.56      0.59       398
      rec.sport.baseball       0.68      0.57      0.62       397
        rec.sport.hockey       0.50      0.74      0.60       399
               sci.crypt       0.58      0.64      0.61       396
         sci.electronics       0.49      0.38      0.43       393
                 sci.med       0.55      0.56      0.55       396
               sci.space       0.66      0.50      0.57       394
  soc.religion.christian       0.24      0.89      0.38       398
      talk.politics.guns       0.53      0.49      0.51       364
   talk.politics.mideast       0.71      0.62      0.66       376
      talk.politics.misc       0.82      0.05      0.09       310
      talk.religion.misc       0.50      0.00      0.01       251

               micro avg       0.53      0.53      0.53      7532
               macro avg       0.59      0.51      0.50      7532
            weighted avg       0.59      0.53      0.51      7532

"""

 極端に駄目なクラスが幾つかある。なんか上手く行ってない予感。

 次元数を4000まで増やし、MultinomialNBのパラメタで簡単にいじれて意味のありそうなalphaを調整します(alphaはデータ全体に加算されるスムージングパラメータ(たぶん)。余計なことしない方が良いと考えてごく小さめに設定する)。

import numpy as np
from sklearn.feature_selection import SelectKBest
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import Pipeline
from sklearn.datasets import fetch_20newsgroups_vectorized
from sklearn.metrics import classification_report

def freq(X, y=None):
    return np.sum(X, axis=0).A.reshape(-1)

def main():
    train =  fetch_20newsgroups_vectorized(
        subset="train", remove=("headers", "footers", "quotes"))
    test =  fetch_20newsgroups_vectorized(
        subset="test", remove=("headers", "footers", "quotes"))

    skb = SelectKBest(score_func=freq, k=4000)
    nb = MultinomialNB(alpha=0.01)
    pl = Pipeline([("skb", skb), ("nb", nb)])

    pl.fit(train.data, train.target)
    pred = pl.predict(test.data)
    print(classification_report(test.target, pred, 
                                target_names=test.target_names))

if __name__ == "__main__":
    main()

""" =>
                          precision    recall  f1-score   support

             alt.atheism       0.47      0.38      0.42       319
           comp.graphics       0.54      0.64      0.59       389
 comp.os.ms-windows.misc       0.61      0.55      0.57       394
comp.sys.ibm.pc.hardware       0.60      0.59      0.60       392
   comp.sys.mac.hardware       0.68      0.59      0.63       385
          comp.windows.x       0.75      0.68      0.71       395
            misc.forsale       0.79      0.77      0.78       390
               rec.autos       0.67      0.67      0.67       396
         rec.motorcycles       0.67      0.72      0.69       398
      rec.sport.baseball       0.82      0.74      0.78       397
        rec.sport.hockey       0.55      0.84      0.67       399
               sci.crypt       0.74      0.68      0.71       396
         sci.electronics       0.59      0.50      0.54       393
                 sci.med       0.73      0.66      0.70       396
               sci.space       0.65      0.69      0.67       394
  soc.religion.christian       0.50      0.83      0.63       398
      talk.politics.guns       0.52      0.68      0.59       364
   talk.politics.mideast       0.76      0.74      0.75       376
      talk.politics.misc       0.58      0.35      0.43       310
      talk.religion.misc       0.54      0.15      0.23       251

               micro avg       0.64      0.64      0.64      7532
               macro avg       0.64      0.62      0.62      7532
            weighted avg       0.64      0.64      0.63      7532

"""

 それなりにマシになりました。

まとめ

 fetch_20newsgroups_vectorizedが使えるか? というと、「使うとかえって余計な手間がかかるから、おとなしくfetch_20newsgroups呼んでCountVectorizerでよくね」という結論に達さざるを得ません。

 自然言語処理は特徴抽出が命みたいなところがあるので、そこを雑にやられて投げてこられても魅力を感じません。

 低頻度語とストップワードを除去する前処理でも勝手にかけてくれて、使いやすいサイズ感のデータで返してくれればまた違った感想になったと思うので、ちょっと残念な感じです。