なぜ Awkward を使うか#
本章では、Python の配列ライブラリ Awkward Array がどのようなケースで便利なのかを、NumPy との比較を通して説明します。Awkward Array は「不規則(ragged)な配列」「欠損値」「複雑なネスト構造」を高速に扱える強力なツールです。
まずは必要なモジュールを読み込みます。
from pathlib import Path
import numpy as np
import awkward as ak
from IPython.display import display
サイズが揃っていないデータを扱う場合#
NumPy の ndarray は「矩形の配列」を前提にしているため、要素数が行ごとに異なるようなデータを素直に扱うことができません。 次のコードは NumPy でフィルタをかけた例ですが、戻り値は 1 次元に潰れてしまいます。
np.random.seed(42)
a1 = np.random.randint(0, 10, size=(3, 10))
a1[a1 > 5]
array([6, 7, 6, 9, 6, 7, 7, 7, 7, 9, 8, 9, 6], dtype=int32)
Awkward Array に変換すると、NumPy と同じフィルタ操作ができる一方、構造を保ったまま処理を続けられるという違いがあります。
a2 = ak.Array(a1)
display(a2)
display(a2[a2 > 5])
[[6, 3, 7, 4, 6, 9, 2, 6, 7, 4], [3, 7, 7, 2, 5, 4, 1, 7, 5, 1], [4, 0, 9, 5, 8, 0, 9, 2, 6, 3]] -------------------------------- backend: cpu nbytes: 120 B type: 3 * 10 * int32
[6, 7, 6, 9, 6, 7, 7, 7, 7, 9, 8, 9, 6] --- backend: cpu nbytes: 52 B type: 13 * int32
Awkward では行の不規則性を扱うため、ak.from_regular を使って可変長(var)リストへ変換できます。
a3 = ak.from_regular(a2)
a3
[[6, 3, 7, 4, 6, 9, 2, 6, 7, 4], [3, 7, 7, 2, 5, 4, 1, 7, 5, 1], [4, 0, 9, 5, 8, 0, 9, 2, 6, 3]] -------------------------------- backend: cpu nbytes: 152 B type: 3 * var * int32
この不規則配列にフィルタをかけた結果が以下です。行ごとに残る要素数が異なっても問題ありません。
a4 = a3[a3 > 5]
a4
[[6, 7, 6, 9, 6, 7], [7, 7, 7], [9, 8, 9, 6]] -------------------- backend: cpu nbytes: 84 B type: 3 * var * int32
さらに、NumPy のように axis を指定して集約(sum)を行うことができます。
np.sum(a4, axis=1)
[41, 21, 32] ---- backend: cpu nbytes: 24 B type: 3 * int64
np.sum(a4, axis=0)
[22, 22, 22, 15, 6, 7] ---- backend: cpu nbytes: 48 B type: 6 * int64
Awkward Array は 不規則データでもベクトル演算が可能で、Python ループを回す必要が無いため高速です。 次の例は NumPy の分割と平均計算を Awkward で高速化した例です。
n = 1000000
a = np.random.randint(0, 10, n, dtype=np.int16)
index = np.where(np.diff(a) < 0)[0] + 1
まず NumPy で処理した場合:
%%time
a_splitted1 = np.split(a, index)
a_mean1 = np.array([np.mean(x) for x in a_splitted1])
CPU times: total: 7.64 s
Wall time: 8.03 s
次に Awkward を使った高速版:
%%time
lengths = np.diff(index, prepend=0, append=len(a))
a_splitted2 = ak.unflatten(a, lengths)
a_mean2 = ak.mean(a_splitted2, axis=1)
CPU times: total: 109 ms
Wall time: 91.7 ms
結果は同じであることを確認できます。
np.all(a_mean1 == a_mean2)
np.True_
冒頭の 10 個の分割結果を比べると、NumPy と Awkward が同じデータを表現していることが分かります。
a_splitted1[:10]
[array([8, 8], dtype=int16),
array([2, 4, 4, 5], dtype=int16),
array([2, 8], dtype=int16),
array([6, 8], dtype=int16),
array([4, 7, 8], dtype=int16),
array([3, 6, 7], dtype=int16),
array([1, 5], dtype=int16),
array([3, 8], dtype=int16),
array([4], dtype=int16),
array([3, 3], dtype=int16)]
a_splitted2[:10]
[[8, 8], [2, 4, 4, 5], [2, 8], [6, 8], [4, 7, 8], [3, 6, 7], [1, 5], [3, 8], [4], [3, 3]] -------------- backend: cpu nbytes: 2.0 MB type: 10 * var * int16
欠損を含むデータ#
NumPy には masked_array による欠損表現があります。
a = np.arange(10)
am = np.ma.masked_array(a, a % 2 == 0)
display(am)
np.sum(am)
masked_array(data=[--, 1, --, 3, --, 5, --, 7, --, 9],
mask=[ True, False, True, False, True, False, True, False,
True, False],
fill_value=999999)
np.int64(25)
Awkward Array でも欠損を持つ配列を自然に扱えます。
a2 = ak.Array(a)
am2 = a2.mask[a2 % 2 != 0]
display(am2)
ak.sum(am2)
[None, 1, None, 3, None, 5, None, 7, None, 9] ------ backend: cpu nbytes: 90 B type: 10 * ?int64
np.int64(25)
Awkward はさらに一歩進んで リスト自体の欠損 も扱うことができます。 次の例では、値の欠損に加えて「行全体が None になるケース」も表現されています。
np.random.seed(2)
a = ak.Array(np.random.randint(0, 10, size=(6, 9)))
a = a.mask[a >= 3]
a = a.mask[ak.count(a, axis=1) >= 7]
ak.mean(a, axis=1)
[None, 4.78, 5.5, None, 7.62, None] ------ backend: cpu nbytes: 72 B type: 6 * ?float64
欠損値を含む複雑なデータでも簡単に演算できます。
異なるデータ型が混在する場合#
Awkward Array は、異なる型の要素を同じ配列に格納できる ユニオン型(union type) をサポートしており、NumPy では扱いづらいこうした混合型データのユースケースにも対応できます。
arr = ak.Array([1.0, 2.0, '34 ms', '23.4 ms', 1.2])
arr
[1,
2,
'34 ms',
'23.4 ms',
1.2]
-----------
backend: cpu
nbytes: 105 B
type: 5 * union[
float64,
string
]文字列に含まれる " ms" を削除し、数値へ一括変換することも簡単です。
ak.strings_astype(ak.str.replace_substring(arr, " ms", ""), np.float64)
[1, 2, 34, 23.4, 1.2] ------ backend: cpu nbytes: 40 B type: 5 * float64
文字列配列の処理#
まず、パラメータを表す 1 つの大きな文字列を行ごとに辞書に変換する処理例を示します。
s = """pos_x: 1
pos_y: 20
pos_z: 3000
v_x: 2
v_y: 10
v_z: 230"""
items = [dict(name=name, value=float(value)) for name, value in [line.split(":") for line in s.split("\n")]]
items
[{'name': 'pos_x', 'value': 1.0},
{'name': 'pos_y', 'value': 20.0},
{'name': 'pos_z', 'value': 3000.0},
{'name': 'v_x', 'value': 2.0},
{'name': 'v_y', 'value': 10.0},
{'name': 'v_z', 'value': 230.0}]
NumPy の char.split を用いて同じことを行うと、Python オブジェクト配列(dtype=object)になってしまいます。
a1 = np.array([s])
a2 = np.char.split(a1, '\n')
print(repr(a1))
print(repr(a2))
array(['pos_x: 1\npos_y: 20\npos_z: 3000\nv_x: 2\nv_y: 10\nv_z: 230'],
dtype='<U54')
array([list(['pos_x: 1', 'pos_y: 20', 'pos_z: 3000', 'v_x: 2', 'v_y: 10', 'v_z: 230'])],
dtype=object)
Awkward Array なら文字列を自然に分割し、入れ子のリスト構造もそのまま扱えます。
a1 = ak.Array([s])
a2 = ak.str.split_pattern(a1, "\n")[0]
a3 = ak.str.split_pattern(a2, ":")
display(a1)
display(a2)
display(a3)
['pos_x: 1\npos_y: 20\npos_z: 3000\nv_x: 2\nv_y: 10\nv_z: 230'] --------------------------------------------------------------- backend: cpu nbytes: 70 B type: 1 * string
['pos_x: 1', 'pos_y: 20', 'pos_z: 3000', 'v_x: 2', 'v_y: 10', 'v_z: 230'] --------------- backend: cpu nbytes: 105 B type: 6 * string
[['pos_x', ' 1'], ['pos_y', ' 20'], ['pos_z', ' 3000'], ['v_x', ' 2'], ['v_y', ' 10'], ['v_z', ' 230']] -------------------- backend: cpu nbytes: 175 B type: 6 * var * string
続いて、構造化データ(レコード)へ変換します。
items = ak.zip({
"name": a3[:, 0],
"value": ak.strings_astype(a3[:, 1], np.float64)
})
items
[{name: 'pos_x', value: 1},
{name: 'pos_y', value: 20},
{name: 'pos_z', value: 3e+03},
{name: 'v_x', value: 2},
{name: 'v_y', value: 10},
{name: 'v_z', value: 230}]
-------------------------------
backend: cpu
nbytes: 187 B
type: 6 * {
name: string,
value: float64
}name が "pos" で始まる値だけ抽出して展開できます。
x, y, z = items[ak.str.starts_with(items.name, 'pos'), 'value']
print(x, y, z)
1.0 20.0 3000.0
複雑なネスト構造#
最後に、実際の深くネストした JSON データを扱う例です。
fn = r'data/employees_1MB.json'
data = ak.from_json(Path(fn))
data.type.show()
566 * {
employee: {
id: string,
name: string,
position: string,
department: {
id: string,
name: string,
manager: {
id: string,
name: string,
contact: {
email: string,
phone: string
}
}
},
projects: var * {
projectId: string,
projectName: string,
startDate: string,
tasks: var * {
taskId: string,
title: string,
status: string,
details: {
hoursSpent: int64,
technologiesUsed: var * string,
completionDate: ?string,
expectedCompletion: ?string
}
}
}
}
}
この JSON は、社員 → プロジェクト → タスク → 技術 のような 4 段階以上のネストを含む複雑な構造です。Awkward Array を使うことで、これを自然に配列として扱えます。
例えば、すべての technologiesUsed を取り出し、利用回数を集計する処理は次のように書けます。
tech = ak.ravel(data['employee', 'projects', 'tasks', 'details', 'technologiesUsed'])
tech_sorted = ak.sort(tech)
tech_grouped = ak.unflatten(tech_sorted, ak.run_lengths(tech_sorted))
ak.zip(
{
"name": tech_grouped[:, 0],
"count": ak.num(tech_grouped, axis=1)
}
)
[{name: 'Java', count: 356},
{name: 'JavaScript', count: 342},
{name: 'Kubernetes', count: 337},
{name: 'NumPy', count: 333},
{name: 'Pandas', count: 329},
{name: 'PyTorch', count: 343},
{name: 'Python', count: 337},
{name: 'SQL', count: 347},
{name: 'Scikit-learn', count: 326},
{name: 'TensorFlow', count: 333}]
------------------------------------
backend: cpu
nbytes: 24.8 kB
type: 10 * {
name: string,
count: int64
}複雑な JSON を解析し、技術名の使用回数を集計するような処理も、Awkward ならわずかなコードで表現できます。
まとめ#
Awkward Array は次のようなケースで特に強力です。
行ごとに要素数が異なる「不規則データ」
欠損を含むデータ(値単位・行単位の欠損)
異なる型が混在するユニオン型データ
文字列処理やレコード構造の処理
深くネストした JSON の解析
NumPy と違い、こうしたデータを 高速かつベクトル化された操作で扱えるため、大規模解析にも非常に有効です。