なぜ 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 と違い、こうしたデータを 高速かつベクトル化された操作で扱えるため、大規模解析にも非常に有効です。