🗂

CSVファイルのメタデータについて

2021/07/25に公開

概要

CSVファイルを管理するためにFrictionless Dataで定義された標準に従いメタデータを作成します。

動機と疑問

CSVやエクセルなどの表形式データの読み込み・操作・書き込みをするとき、入出力ファイルの管理をどうするのが良いのでしょうか?

例えば2つのCSVファイルを結合して1つのCSVファイルに書き出す操作があります。

"""
例:
    '台帳.csv'と'品目詳細.csv'を読み込み、
    インデックス'図番'について結合して、
    '台帳+品目詳細.csv'を新規に作成する
"""
import pandas as pd

(
    pd.read_csv(
        "台帳.csv", encoding="shift-jis",                # 1つ目のファイル
	header=0, names=["図番", "品名", "説明"]
    )
    .set_index("図番")
    .join(
        pd.read_csv("品目詳細.csv", encoding="utf-8")     # 2つ目のファイル
        .set_index("図番"),
        lsuffix='_caller',
        rsuffix='_other'
    )
    .to_csv("台帳+品目詳細.csv", encoding="shift-jis")    # 結合したファイル
)

この例の様に入出力ファイルの指定が必要なのですが、もう少し見通し良くできないでしょうか。ここで必要な情報は少なくとも3つあり、1つはファイルのパスで、2つ目はヘッダ、3つ目がエンコーディングです。

CSVを含む表形式データにはヘッダ部分とデータ部分の2つがあり、ヘッダがないとデータが何の値を表しているか分からなくなるため、ヘッダの情報は不可欠です。とは言ってもヘッダはCSVファイル自体に含まれていることが多いので、書かなくて済む場合も多いです。
またエクセルでCSVファイルを開く場合、通常は"shift-jis"でエンコーディングされたファイルとして開かれるので、"shift-jis"で保存しておいたほうが便利です。そのようなこともあり"utf-8"以外のエンコーディングにも頻繁に遭遇するので、エンコーディングの情報も不可欠です。

このような入出力ファイルの情報をまとめて管理したいと思い、初めに考えたのは次のようにJSON形式で書く方法です。

入出力CSVファイル辞書.json
{
    "台帳.csv": {
        "path": "台帳.csv",
	"encoding": "shift-jis",
	"headers": ["図番", "品名", "説明"]
    },
    "品目詳細.csv": {
        "path": "品目詳細.csv",
	"encoding": "utf-8",
	"headers": ["図番", "定格電圧", "消費電流"]
    },
    "台帳+品目詳細.csv": {
        "path": "台帳+品目詳細.csv",
	"encoding": "shift-jis",
	"headers": ["図番", "品名", "説明", "定格電圧", "消費電流"]
    }
}

しかしCSVは広く使われているデータ形式なので、CSVファイルに付随するヘッダやエンコーディングなどのメタデータ管理には一般的に使われている方法や標準があるのでは?という疑問がありました。もし一般的に使われている方法があるならば、それに従った方が外部とのデータのやり取りが楽になりそうです。

さて、こういったCSVファイルについての情報はどのように管理するのが良いのでしょうか?一般的な管理方法があるのでしょうか?

Frictionless Data

上記のような疑問があったので考え方や方法を探していた所、Frictionless Dataというツールキットおよび標準を見つけました。

Frictionless is an open-source toolkit that brings simplicity to the data experience - whether you're wrangling a CSV or engineering complex pipelines.

ひとまず基本的な例を見てみると、CSVファイルに対応するメタデータとスキーマをYAMLファイル (JSONもあります)で定義しています。

countries.csv
# clean this data!
id,neighbor_id,name,population
1,Ireland,Britain,67
2,3,France,n/a,find the population
3,22,Germany,83
4,,Italy,60
5
countries.resource.yaml
encoding: utf-8
format: csv
hashing: md5
layout:
  headerRows:
    - 2
name: countries
path: countries.csv
profile: tabular-data-resource
schema:
  fields:
    - name: id
      type: integer
    - name: neighbor_id
      type: integer
    - name: name
      type: string
    - name: population
      type: integer
  foreignKeys:
    - fields:
        - neighbor_id
      reference:
        fields:
          - id
        resource: ''
  missingValues:
    - ''
    - n/a
scheme: file

上記で考えていたパスやエンコーディングも含まれていますね。加えて、JSONスキーマやRDFスキーマのように、CSVに対するスキーマが定義されています。ヘッダに加えてデータの型も指定するようになっていて、SQLのような外部キーも書けるようになっています。

Frictionless Frameworkを使って最初の例を書き直す

CSVファイルのメタデータをYAMLで書く形式だけ拝借して最初に挙げた変換例を書き直します。

事前準備としてメタデータを書き出します。

frictionless describe ledger.csv --yaml > ledger.resource.yaml
frictionless describe item.csv --yaml > item.resource.yaml

書き出し先のCSVファイルのメタデータは手動で作成します。

ledger_amd_item.resorce.yaml
path: ledger_and_item.csv
encoding: shift-jis
format: csv
hashing: md5
profile: tabular-data-resource
name: ledger_and_item
scheme: file

冒頭で挙げた例ではCSVファイル名に日本語を使っていましたがprofileのtabular-data-resourceはnameのパターンを"^([-a-z0-9._/])+$"として定義しているため日本語が使えません。

定義したメタデータを使って入出力ファイルを指定するようにします。

import pandas as pd
from frictionless import Resource

# metadataのパスだけ管理する
resource_metadata = [
    'ledger.resource.yaml',
    'item.resource.yaml',
    'ledger_and_item.resource.yaml'
]
# Resourceオブジェクトを作成し、アクセスしやすいように辞書を作成する
resources = [Resource(path) for path in resource_metadata]
name_resource_map = {r.name: r for r in resources}

(
    name_resource_map["ledger"].to_pandas()        # DataFrameに変換
    .set_index("図番")
    .join(
        name_resource_map["item"].to_pandas()      # DataFrameに変換
        .set_index("図番"),
        lsuffix='_caller',
        rsuffix='_other'
    )
    .to_csv(
        name_resource_map["ledger_and_item"].path,
        encoding=name_resource_map["ledger_and_item"].encoding
    )
)

入力ファイルはすっきりしましたが、出力は若干込み入っているためpd.api.extensions.register_dataframe_accessorを使ってすっきりさせます。

@pd.api.extensions.register_dataframe_accessor("fl")
class FlAccessor:
    def __init__(self, pandas_obj):
        self._obj = pandas_obj

    def to_csv(self, resource):
        self._obj.to_csv(
            resource.path,
            encoding=resource.encoding
	)

これを使うと次のように書くことができます。

(
    name_resource_map["ledger"].to_pandas()        # DataFrameに変換
    .set_index("図番")
    .join(
        name_resource_map["item"].to_pandas()      # DataFrameに変換
        .set_index("図番"),
        lsuffix='_caller',
        rsuffix='_other'
    )
    .fl.to_csv(name_resource_map["ledger_and_item"])
)

メタデータを書くことで入出力ファイルを統一的な形で指定しやすくなったように思います。

(おまけ)全てをfrictionlessで書き直した場合

折角なので、全てをfrictionlessを使って書き直します。

from frictionless import Package, Resource, transform, steps

# metadataのパスだけ管理する
resource_metadata = [
    'ledger.resource.yaml',
    'item.resource.yaml',
    'ledger_and_item.resource.yaml'
]
# Resourceオブジェクトを作成する
resources = [Resource(path) for path in resource_metadata]

# Define source package
source = Package(resources=resources)

target = transform(
    source,
    steps=[
        steps.table_join(resource="item", field_name="図番"),
        steps.table_write(
	    path=source.get_resource("ledger_and_item").path
        ),
    ]
)

まだ使い方が分からず四苦八苦しています。

  1. steps.table_write()はResourceを引数に取らないのでpathを指定していますが、書き出しファイルの方はResourceで表すものではないのでしょうか。
  2. steps.table_join()のときヘッダが重複した場合はエラーが出るのですが、どこを修正したら良いのでしょうか。重複しないように前段のstepでヘッダ名を変更するのが正解?

Frictionless Dataの言うData ContainerあるいはData Packageという考え方は便利に使えそうな感じがするので、フレームワークに沿ってData Packageを使えるようになりたい所です。

Discussion