ETLのE(Extract)実践

こちらを書いてから、「やっぱりExtract(抽出)も重要だなー」と思った次第です。

https://qiita.com/taka_yayoi/items/51bd9a1a895109ec1625

オプションの指定が必要なので、設定がしやすいPySparkで処理します。以下のようなケースをカバーします。

- データベースの抽出
- 単一CSVファイルの抽出
- 複数CSVファイルの抽出
- Parquetファイルの抽出
- JSONファイルの抽出
- 画像ファイルの抽出

抽出結果はすべてSparkデータフレームになるので、データフレームに変換処理(T)を適用したり、ターゲットデータベースにロード(L)することもできます。

# 抽出処理の実践

## データの準備

ちょうど、Databricksにはサンプルデータがあるのでこれらに対してExtract処理を実行してみます。

https://qiita.com/taka_yayoi/items/3f8ccad13c6efd242be1

## 抽出戦略の立案

兎にも角にもソースデータの場所とフォーマットを確認することからです。

1. データの所在の確認: ストレージ上のファイルかデータベースかメッセージか
1. データフォーマットの確認: ファイルの場合は中身を確認します。それ以外の場合には仕様を確認しましょう
1. (ファイルの場合)ファイル個数の確認: ファイルは一つか複数か

## データベースの抽出

[前回作成](https://qiita.com/taka_yayoi/items/51bd9a1a895109ec1625)したブロンズテーブルから抽出するものとします。`spark.table`を用いることで、データがSparkデータフレームとして読み込まれます。

```py:Python
df = spark.table("takaakiyayoi_catalog.etl_in_action.bronze")
display(df)
```
![Screenshot 2023-11-26 at 13.43.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/8edc6ab9-10aa-8e16-d7db-307f5be03a6f.png)

## 単一CSVファイルの抽出

ファイルを確認します。マジックコマンド`%fs`を用いることで、DBFSに対するコマンドを実行することができます。このケースでは`head`が適しています。

```
%fs
head /databricks-datasets/Rdatasets/data-001/csv/ggplot2/diamonds.csv
```

ヘッダーありのCSVであることがわかります。
```
[Truncated to first 65536 bytes]
"","carat","cut","color","clarity","depth","table","price","x","y","z"
"1",0.23,"Ideal","E","SI2",61.5,55,326,3.95,3.98,2.43
"2",0.21,"Premium","E","SI1",59.8,61,326,3.89,3.84,2.31
"3",0.23,"Good","E","VS1",56.9,65,327,4.05,4.07,2.31
"4",0.29,"Premium","I","VS2",62.4,58,334,4.2,4.23,2.63
"5",0.31,"Good","J","SI2",63.3,58,335,4.34,4.35,2.75
"6",0.24,"Very Good","J","VVS2",62.8,57,336,3.94,3.96,2.48
```

```py:Python
df = spark.read.option("header", True).csv("dbfs:/databricks-datasets/Rdatasets/data-001/csv/ggplot2/diamonds.csv")
display(df)
```
![Screenshot 2023-11-26 at 13.45.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/e6c0beab-0e8a-0483-13f6-9707ea1c0719.png)


単一ファイルなのでpandasでも読み込めます。

```py:Python
import pandas as pd
pdf = pd.read_csv("/dbfs/databricks-datasets/Rdatasets/data-001/csv/ggplot2/diamonds.csv")
display(pdf)
```

## 複数CSVファイルの抽出

Sparkの場合、同じディレクトリに格納されているスキーマが同一のファイルは、ディレクトリ指定で一括で読み込むことができます。なお、pandasの場合にはリストやループなどで実装しないといけないですし、そもそもメモリに乗り切らなくてエラーになる可能性が高まります。

```py:Python
df = spark.read.option("header", False).csv("dbfs:/databricks-datasets/airlines/")
display(df)
```
![Screenshot 2023-11-26 at 13.48.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/a5c334a3-ab54-99bc-0f05-ebe3c134379a.png)

おや、ヘッダーが無いようです。もう少し調査してみます。

```
%fs
ls dbfs:/databricks-datasets/airlines/
```
![Screenshot 2023-11-26 at 13.53.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/04bd7d0e-bc9b-91a1-7497-c0eb6a71e147.png)

README.mdを確認します。

```
%fs
head dbfs:/databricks-datasets/airlines/README.md
```

有用なことは書いてありません。
```
================================================
Airline On-Time Statistics and Delay Causes
================================================

## Background
The U.S. Department of Transportation's (DOT) Bureau of Transportation Statistics (BTS) tracks the on-time performance of domestic flights operated by large air carriers. Summary information on the number of on-time, delayed, canceled and diverted flights appears in DOT's monthly Air Travel Consumer Report, published about 30 days after the month's end, as well as in summary tables posted on this website. BTS began collecting details on the causes of flight delays in June 2003. Summary statistics and raw data are made available to the public at the time the Air Travel Consumer Report is released.


FAQ Information is available at http://www.rita.dot.gov/bts/help_with_data/aviation/index.html


## Data Source
http://www.transtats.bts.gov/OT_Delay/OT_DelayCause1.asp
```

最初のデータファイルを確認します。
```
%fs 
head dbfs:/databricks-datasets/airlines/part-00000
```
```
[Truncated to first 65536 bytes]
Year,Month,DayofMonth,DayOfWeek,DepTime,CRSDepTime,ArrTime,CRSArrTime,UniqueCarrier,FlightNum,TailNum,ActualElapsedTime,CRSElapsedTime,AirTime,ArrDelay,DepDelay,Origin,Dest,Distance,TaxiIn,TaxiOut,Cancelled,CancellationCode,Diverted,CarrierDelay,WeatherDelay,NASDelay,SecurityDelay,LateAircraftDelay,IsArrDelayed,IsDepDelayed
1987,10,14,3,741,730,912,849,PS,1451,NA,91,79,NA,23,11,SAN,SFO,447,NA,NA,0,NA,0,NA,NA,NA,NA,NA,YES,YES
1987,10,15,4,729,730,903,849,PS,1451,NA,94,79,NA,14,-1,SAN,SFO,447,NA,NA,0,NA,0,NA,NA,NA,NA,NA,YES,NO
1987,10,17,6,741,730,918,849,PS,1451,NA,97,79,NA,29,11,SAN,SFO,447,NA,NA,0,NA,0,NA,NA,NA,NA,NA,YES,YES
1987,10,18,7,729,730,847,849,PS,1451,NA,78,79,NA,-2,-1,SAN,SFO,447,NA,NA,0,NA,0,NA,NA,NA,NA,NA,NO,NO
```

このファイルにはヘッダーがあります。つまり、最初のデータファイルのみにヘッダーがあるということです。やり方はいくつかありますが、最初のデータファイルはヘッダーありで読み込み、残りのデータファイルはヘッダーなしで読み込んで明示的にヘッダーを付与し、これらを結合することにします。

### ヘッダーありファイルの抽出

```py:Python
df_first = spark.read.option("header", True).csv("dbfs:/databricks-datasets/airlines/part-00000")
display(df_first)
```

ヘッダー情報を取得しておきます。
```py:Python
df_schema = df_first.schema
df_schema
```
```
StructType([StructField('Year', StringType(), True), StructField('Month', StringType(), True), StructField('DayofMonth', StringType(), True), StructField('DayOfWeek', StringType(), True), StructField('DepTime', StringType(), True), StructField('CRSDepTime', StringType(), True), StructField('ArrTime', StringType(), True), StructField('CRSArrTime', StringType(), True), StructField('UniqueCarrier', StringType(), True), StructField('FlightNum', StringType(), True), StructField('TailNum', StringType(), True), StructField('ActualElapsedTime', StringType(), True), StructField('CRSElapsedTime', StringType(), True), StructField('AirTime', StringType(), True), StructField('ArrDelay', StringType(), True), StructField('DepDelay', StringType(), True), StructField('Origin', StringType(), True), StructField('Dest', StringType(), True), StructField('Distance', StringType(), True), StructField('TaxiIn', StringType(), True), StructField('TaxiOut', StringType(), True), StructField('Cancelled', StringType(), True), StructField('CancellationCode', StringType(), True), StructField('Diverted', StringType(), True), StructField('CarrierDelay', StringType(), True), StructField('WeatherDelay', StringType(), True), StructField('NASDelay', StringType(), True), StructField('SecurityDelay', StringType(), True), StructField('LateAircraftDelay', StringType(), True), StructField('IsArrDelayed', StringType(), True), StructField('IsDepDelayed', StringType(), True)])
```

### ヘッダーのないファイルの抽出

ヘッダーのないファイルのみを対象にしたいので、オプション`pathGlobFilter`を使用します。[こちら](https://stackoverflow.com/questions/65175959/how-to-read-multiple-csv-leaving-out-specific-ones-from-a-nested-directory-in)を参考にさせていただきました。ただ、正規表現に自信がなかったので確認のためのロジックを組み込んでいます。`input_file_name`は入力ファイルのパスを取得できるので、これを追加カラムとして組み込んでいます。自信がある場合には確認はスキップしてください(でも、確認することをお勧めします)。

```py:Python
from pyspark.sql.functions import input_file_name

df_rest = spark.read.option("header", False).csv(
    "dbfs:/databricks-datasets/airlines/",
    schema=df_schema,
    pathGlobFilter="part-[0-9][0-9][0-9][0-9][1-9]",
)
df_rest = df_rest.withColumn("filename", input_file_name())
display(df_rest)
```
![Screenshot 2023-11-26 at 14.30.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/76a7a1b3-77cc-b8ff-0400-a43b411cb391.png)

このデータフレームをテーブルに見立ててSQLで確認したいので、一時ビューとして登録します。

```py:Python
df_rest.createOrReplaceTempView("df_rest")
```

これでSQLからアクセスすることができます。

```sql:SQL
%sql
SELECT DISTINCT filename FROM df_rest;
```

`part-00000`がスキップされていることが確認できました。
![Screenshot 2023-11-26 at 14.32.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/d753e82a-ddab-d57b-a6c6-a9b859867396.png)

結合に不要な列があるので、もう一度データフレームを作り直してから`union`で結合します。

```py:Python
df_rest = spark.read.option("header", False).csv(
    "dbfs:/databricks-datasets/airlines/",
    schema=df_schema,
    pathGlobFilter="part-[0-9][0-9][0-9][0-9][1-9]",
)
df_all = df_first.union(df_rest)
display(df_all)
```

完了です。
![Screenshot 2023-11-26 at 14.35.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/806c78e9-4acd-1d13-e792-755fbc16c208.png)

## Parquetファイルの抽出

フォーマットで`Parquet`を指定するだけで大丈夫です。

```py:Python
df = spark.read.format("parquet").option("header", True).load("dbfs:/databricks-datasets/amazon/data20K/")
display(df)
```
![Screenshot 2023-11-26 at 14.47.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/0ed2b724-549d-f085-4978-4d0e98e71135.png)

## JSONファイルの抽出

フォーマットで`json`を指定するだけで大丈夫です。

```py:Python
df = spark.read.format("json").load("dbfs:/databricks-datasets/definitive-guide/data/activity-data/")
display(df)
```
![Screenshot 2023-11-26 at 14.48.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/833856b5-02ab-383e-fc52-898ff8c4598d.png)

## 画像ファイルの抽出

Sparkでは[画像データソース](https://qiita.com/taka_yayoi/items/8d4b1b61699d68a34e58)もサポートしています。お手軽にディレクトリに格納されている画像ファイルをデータフレームに取り込むことができます。

```
%fs
ls dbfs:/databricks-datasets/cctvVideos/train_images/
```

`label=x`というディレクトリが格納されています。
![Screenshot 2023-11-26 at 14.53.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/bd5b14c4-4d88-6e11-8ba0-4b2582059651.png)

それぞれのディレクトリに画像(JPG)ファイルが格納されています。

```
%fs
ls dbfs:/databricks-datasets/cctvVideos/train_images/label=0/
```
![Screenshot 2023-11-26 at 14.53.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/6ad7b734-80c9-6e3c-9397-fe187d0403af.png)

`format`で`image`を指定します。

```py:Python
df = spark.read.format("image").load("dbfs:/databricks-datasets/cctvVideos/train_images/")
display(df)
```

画像がデータフレームとして読み込まれ、親ディレクトリの`label=x`も列に追加されています。この機能を活用すると、画像分類タスクなどにおける教師データとして容易に活用できるようになります。
![Screenshot 2023-11-26 at 14.54.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1168882/400b1331-a838-557a-f286-00a8c4ae519a.png)

# まとめ

- まずはソースデータを確認しましょう。`head`コマンドが一番お手軽かと。
- 時には複雑な前処理が必要になる場合がありますが、必要な関数などは揃ってます！
- 大量データを処理する場合には、コア数の多いマシンで並列度を高めてSparkで高速に処理しましょう。

### Databricksクイックスタートガイド

[Databricksクイックスタートガイド](https://www.amazon.co.jp/dp/B09V1YXFVQ/)


### Databricks無料トライアル

[Databricks無料トライアル](https://databricks.com/jp/try-databricks)