Snowflakeの異常検知ML関数でデータ品質チェック

この記事では、Snowflake の ML 関数の 1 つである異常検知の機能を使ってデータ品質の問題を検知しようと試してみたことを記します。

# 1. はじめに

データ品質に関する書籍 [Data Quality Fundamentals](https://www.oreilly.com/library/view/data-quality-fundamentals/9781098112035/) の 4 章に "Monitoring and Anomaly Detection for Your Data Pipelines" という章があります。

4 章の大まかな趣旨は、データ品質には以下の 2 種類の問題があり、2 番目の「予測できない問題」に対して異常検知のアルゴリズムを適用することで特定しようということになります。

- 予測できる問題（known unknowns）
    - 「必須項目に null が入る」や「データ鮮度が決められた時間より悪くなる」など、テストを行えば検知できる問題
- 予測できない問題（unknown unknowns）
    - 「データの分布がおかしい」や「一部のデータが欠落して KPI がおかしくなる」などテストではカバーしづらい問題

この書籍の中で SQL を利用して簡単な異常検知を行う例があるのですが、この記事ではその例を Snowflake の ML 関数の１つである異常検知の機能を使って再現したいと思います。

# 2. 環境準備

この章では Snowflake の仮想ウェアハウスや DB、権限の作成を行います。本題ではないので、読み飛ばしていただいても構いません。

まず、利用する仮想ウェアハウス ```dqad_wh``` と DB ```dqad_db``` を作成します。

```sql
use role sysadmin;

create or replace warehouse dqad_wh
    auto_suspend = 60
    initially_suspended = true
;
create or replace database dqad_db
    data_retention_time_in_days = 0
    max_data_extension_time_in_days = 0
;
```

次に異常検知を実行するためのロール ```dqad_role``` を作成します。仮想ウェアハウスや DB・スキーマの通常の権限に加え、異常検知モデルを作成するスキーマに対して ```create snowflake.ml.anomaly_detection``` の権限が必要です。

```sql
use role securityadmin;

create role dqad_role;
grant all on warehouse dqad_wh to role dqad_role;
grant all on database dqad_db to role dqad_role;
grant all on schema dqad_db.public to role dqad_role;
grant create snowflake.ml.anomaly_detection on schema dqad_db.public to role dqad_role;
grant role dqad_role to user <実行ユーザー>;
```

# 3. 対象データの準備と内容の確認

書籍 "Data Quality Fundamentals" の中では太陽系外惑星の観測データを例示用データとして扱っています。このデータは以下のページで公開されており、この記事でも同じデータを利用します。

https://github.com/monte-carlo-data/data-observability-in-practice?tab=readme-ov-file

対象データを入れるテーブル ```exoplanets``` を作成します。

```sql
use role dqad_role;
use warehouse dqad_wh;
use schema dqad_db.public;

create or replace table exoplanets (
    _id            varchar(36) primary key,
    distance       float,
    g              float,
    orbital_period float,
    avg_temp       float,
    date_added     date
);
```

このテーブルに公開ページからしたデータを投入します。投入した結果として、以下のようなデータが入っています。

```sql
select * from exoplanets limit 20;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/31724a4d-ab1d-429f-e630-63e3586f35dd.png)

```avg_temp``` 列に null が格納されているのが確認できます。これ自体は問題ないのですが、以下に示すようにある期間で null が格納されている率が急に高くなることがあります（赤丸で囲った部分）。

```sql
create table avg_temp_null_percent
as
select
    date_added,
    sum(case when avg_temp is null then 1 else 0 end)
        / sum(1) null_percent
from
    exoplanets
group by all;

select * from avg_temp_null_percent order by date_added;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/8087d561-786c-080d-a243-539b18f8204a.png)

（棒が表示されていない期間がいくつかありますが、これは null 率が 0 % ではなく、データがそもそも存在しません）

この null 率の急上昇を Snowflake の異常検知 ML 関数を使って特定することが今回のトライの目的になります。

# 4. シンプルな異常検知の適用

Snowflake の異常検知 ML 関数は時系列データに対して異常な値を検知できます。大まかな利用の流れは、

1. トレーニングデータをインプットに異常検知モデル（Snowflake の anomaly_detection オブジェクト）を作成
1. 1. で作成したモデルを分析データに適用して、分析データ内の異常値を検知

となります。今回は ```date_added``` が　```2020-01-01``` ～ ```2020-07-17``` のデータを持っているため、

- トレーニングデータ：```2020-01-01``` ～ ```2020-04-30```
- 分析データ：```2020-05-01``` ～ ```2020-07-17```

と分割して利用します。それぞれ以下のようにビューとして定義します。

```sql
create or replace view train_set
as
select
    to_timestamp_ntz(date_added) date_added,
    null_percent
from
    avg_temp_null_percent
where
    date_added <= '2020-04-30'
;

create or replace view analysis_set
as
select
    to_timestamp_ntz(date_added) date_added,
    null_percent
from
    avg_temp_null_percent
where
    date_added >= '2020-05-01'
;
```

まず初めに、異常検知モデルを作成します。

```sql
create or replace snowflake.ml.anomaly_detection dq_anomaly_model(
    input_data => system$reference('view', 'train_set'),
    timestamp_colname => 'date_added',
    target_colname => 'null_percent',
    label_colname => ''
);
```

|オプション|指定内容|
|---|---|
|```input_data``` |トレーニングデータのテーブルやビューを指定|
|```timestamp_column``` |時刻を表すカラムを指定|
|```target_colname``` |異常を検知したいデータを含むカラムを指定|
|```Label_colname``` |その行が異常か否か（正解データ）を表すカラムを指定できますが、今回は教師なし学習として実行するため指定しません|

次に、作成したモデルを分析データに適用して、異常を検知します。作成したモデルの ```detect_anomalies``` メソッドを呼び出すことで適用できます。call 文は検知結果を返します。その内容を後で参照しやすくするために、直後の CTAS でその結果をテーブルに保存します。

```sql
call dq_anomaly_model!detect_anomalies(
    input_data => system$reference('view', 'analysis_set'),
    timestamp_colname => 'date_added',
    target_colname => 'null_percent'
);

create or replace table result_set
as
select
    *
from
    table(result_scan(-1))
;
```

検知結果を確認します。

```sql
select * from result_set order by ts limit 20;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/4f1155aa-fcc4-70c8-05ce-0ae42a8d2117.png)

検知結果のカラムの意味は以下になります。

|カラム|意味|
|---|---|
|ts|時刻|
|y|分析データの実際の値|
|forcast|直前までの値に基づいた予測値|
|lower_bound|予測区間（99.9%：設定で変更可能）の下限値|
|upper_bound|予測区間（99.9%：設定で変更可能）の上限値|
|is_anomaly|異常かそうでないか|

異常検知の仕組みですが、時系列予測を行い、実際の値が予測区間から外れた場合に異常値とみなすようです。

実際にどのような値が異常と検知されたかをグラフ化して確認します。

```sql
select
    a.date_added,
    case when r.is_anomaly = false then a.null_percent else 0 end nomal,
    case when r.is_anomaly = true  then a.null_percent else 0 end anomal
from
    analysis_set a
inner join
    result_set r on (a.date_added = r.ts)
order by
    a.date_added
;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/dead1c5d-ed6b-4503-1d11-02df27169919.png)

黄色が異常とされた値になります。最初の 5 本（```2020-06-02``` ～ ```2020-06-08```）は異常として検知されるのが正しいですが、それ以降は誤検知とみなすのが良さそうです。

なぜ誤検知が起きているかを確認するために、予測区間を確認します。

```sql
select
    a.date_added,
    a.null_percent,
    r.lower_bound,
    r.upper_bound
from
    analysis_set a
inner join
    result_set r on (a.date_added = r.ts)
order by
    a.date_added
;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/543a5bc4-4ca7-ef1c-f492-e91d2ecca832.png)

黄色と緑のラインの間が予測区間で、実際の値（青色）がこの区間の外にある場合は異常値とみなされます。

誤検知が発生している期間に注目すると、予測区間が若干上にずれているために異常と検知されていることが分かります。おそらく null 率が 100 % 近い異常値も予測に使われているため、その直後の期間の予測区間が上振れし、結果として誤検知に繋がっていると思われます。

異常を検知した直後は注意が必要な期間であることには間違いないので 100 歩譲れば妥協できなくはないのですが、あまりうれしい結果ではないです。次の章で改善策を試してみたいと思います。

ちなみに、以下は試しましたが誤検知は解消されませんでした。

- 予測区間を 99.9% から 99.99% に変更する
- トレーニングデータにラベル付けして教師あり学習にする

# 5. 誤検知回避の工夫

工夫の中身を述べる前に、異常検知の実運用での実行の仕方について触れます。

前の章では手元にある全データをトレーニングデータと分析データに分割して適用しましたが、実際の運用では以下の流れになります。

1. ある期間のデータをトレーニングデータとして一度異常検知モデルを作成する
1. その後、新たに発生したデータについて定期的に異常検知モデルを適用するを定期的に繰り返す（例えば、日次バッチで昨日に発生したデータの異常検知を行うなど）
1. 2. の中で異常検知の精度が悪化したら、再度トレーニングデータを変えて異常検知モデルを作り直す

ポイントは、2. で、一度異常検知を行った時点（今回だと　```2020-06-08``` まで）とそれ以降の期間における異常検知は別のタイミングで実行されるのが普通です。そのため、一度実行した異常検知の結果を次の異常検知の実行に反映させることができます。

そこで、今回は ```2020-06-08``` まで一度異常検知を行い、```2020-06-02``` ～ ```2020-06-08``` に異常が見つかったという前提で、それ以降の異常検知を行います。その際に、検知済みの異常値は除外します。これにより予測区間の上振れが防げるのではと予想します。

分析データから ```2020-06-02``` ～ ```2020-06-08``` の異常値を除外した新たな分析データを以下のように作成します。

```sql
create or replace view analysis_set_wo_anomaly
as
select
    to_timestamp_ntz(date_added) date_added,
    null_percent
from
    avg_temp_null_percent
where
    date_added >= '2020-05-01'
and
    date_added not between '2020-06-02' and '2020-06-08'
;
```

検出済み異常値を除外した分析データに異常検知モデルを適用します。

```sql
call dq_anomaly_model!detect_anomalies(
    input_data => system$reference('view', 'analysis_set_wo_anomaly'),
    timestamp_colname => 'date_added',
    target_colname => 'null_percent'
);

create or replace table result_set_20200609
as
select
    *
from
    table(result_scan(-1))
;
```

そして結果を確認します。

```sql
select
    a.date_added,
    case when r.is_anomaly = false then a.null_percent else 0 end nomal,
    case when r.is_anomaly = true  then a.null_percent else 0 end anomal
from
    analysis_set a
inner join
    result_set_20200609 r on (a.date_added = r.ts)
order by
    a.date_added
;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/52f73dcc-6a45-22ce-22bf-960786d4b2f3.png)

予想通り、誤検知は解消されているようです。

# 6. まとめ

この記事では、Snowflake の異常検知の機能を使ってデータ品質の問題を検知することに取り組んでみました。今回は非常に簡単な例ですが、工夫次第で想定撮りの検知ができることを確認しました。

ちなみに、今回の ```avg_temp``` の null 率をチェックするだけなら、通常の null 率を可視化・チェックして、適当なしきい値（例えば 30-50%）を設定し、そこから外れたら異常と検知すれば事足ります。ただし、データ品質チェックすべきカラム数やルールが増えてくると、それぞれに対してしきい値を人が検討するというのは大変かと思います（もちろん見直しも含む）。

今回の異常検知の仕組みは、わざわざ人がしきい値を検討しなくとも過去の傾向から自動で検知してくれるという観点では意義があるアプローチかなと思います。

もちろん "Data Quality Fundamentals" で "there’s no perfect classifier for any anomaly detection problem."（どのような異常検知問題に対しても完璧な分類器は存在しない）と述べているように、異常検知アルゴリズムの選択やフィードバックによる精度改善は必要ですが。

また、今回は null 率を事前に集計した上で異常検知を行っていますが、この部分は Snowflake の Data Metric Function を活用すると今後効率化できるかもしれません。現状では機能不足な感はあり、今後に期待と言ったところですが。

今回はデータ品質＋異常検知の可能性を感じられたという感想で締めたいと思います。