230
263

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

データサイエンティストはテストコードを書いてコーディング規約を守ろう

Posted at

データサイエンティストの書くコードは汚い

あなたはデータサイエンティストでしょうか?この記事ではデータサイエンティストが学んでおくべきソフトウェア開発技法のうち、筆者が特に重要と考えることについて実践的に学んでいきます。

あなたがデータサイエンティストという肩書きで働いている場合、あなたが書いているコードは汚い可能性が高いでしょう。どう汚いかというと、ソフトウェアエンジニアにコードをそのまま渡し、ソフトウェアやシステムに組み込んでくれと頼んだ場合、まず間違いなく嫌な顔をされます。ソフトウェアエンジニアからデータサイエンティストに転向した人は大丈夫でしょう。この記事で学ぶことはありません。

データサイエンティストという職業は、Pythonをゴリゴリと書くエンジニアっぽい人もいれば、BIツール等を駆使するコンサルタントっぽい人もいると思います。この記事では、前者のエンジニアっぽいデータサイエンティストを対象として書かれています。具体的には、AI・DXソリューションを行うシステムを開発してビジネス的な価値を上げることを最終目的として、その工程の最序盤にて以下の業務を実施する人です。

  • AIシステムのPoC作成
  • データ分析
  • 機械学習モデルや処理のロジックを検証

あなたが同じチームで働くソフトウェアエンジニアに面倒なことを押し付けたくない善良なデータサイエンティストであるならば、いますぐこの記事に書かれていることを学習し、実践することをお勧めします。

データサイエンティストが高品質なコードを書く必要性

あなたの書くコードが汚いのは役割の違いによるものであり、仕方のないことです。基本的には(そう、基本的にはですよ)、あなたの職責はPoC作成やデータ分析、機械学習モデルの検証を行うことであり、システムを安定稼働させ、アップデートし続けるためのコードを書くことではありません。品質の高いコードを書く責任は、基本的には(そう、基本的にはですよ)ソフトウェアエンジニアの職務です。

しかしそれでも、データサイエンティストはある程度品質の高いコードを書くように務めるべきだと筆者は考えます。ソフトウェアエンジニアがデータサイエンティストの成果を品質の高いコードに書き直すためには、その成果の内容をそこそこ以上に理解する必要があります。その理解を助けるために、または成果をそのままシステムに適用する際に、そこそこにでも品質の良いコードがあると非常に助かります。

データサイエンティストの成果について一番よく分かっているのはそのデータサイエンティスト自身であり、その成果のうちのコードの品質を高める作業負荷が一番小さいのは、やはりそのデータサイエンティスト自身なのです。 チームで作業を分担する時、一番作業の負荷がかからない人が実施するのはとても自然なことだと思うのです。

何もプロダクトレベルの完璧なコードを書けと言っているのではありません。それこそソフトウェアエンジニアの仕事です。ただ、ちょっとしたことだけでも、基礎的なことだけでもデータサイエンティストが実施した方がチームメンバー全員が幸せになることもあります。この記事では、そのちょっとしたことを学んでいきます。

本記事で学ぶこと

データサイエンティストが高品質なコードを書く必要性や、そのためのヒントは多くの既存の記事で論じられています。特に以下の記事の内容に、筆者は同意します。

この記事では、データサイエンティストが高品質なコードを書くためのヒントとして、5つのヒントが提示されています。それらを以下に引用します。

  • 関数を簡潔に書く。具体的には1つのタスクだけを実行し、内容を反映した変数名を使う。
  • Print文は不要になったら削除するか、ログを採取するコードに置き替える。
  • スタイルガイドを採用し、それを遵守する。
  • テストコードを作成し、保存する。
  • コードレビューを実施する

本記事では、これら5つの中から、以下の2つにフォーカスして学んでいきます。

  • テストコードを作成し、保存する。
  • スタイルガイドを採用し、それを遵守する。

他の3つは、これら2つを遵守していると自然に満たすことができることであったり、ソフトウェアエンジニアの負担が軽微であったり、一人では実践できないもののため、ここでは取り扱いません。

具体的には、以下のステップに分けて、段階的にJupyte Notebookのファイル(.ipynbファイル)をPythonコード(.pyファイル)に書き直しながら解説を進めていきます。

  • Step 0: 書き直すファイルの確認
  • Step 1: 必要なところだけPythonコードに転写
  • Step 2: テストコードを作成し、保存する
  • Step 3: スタイルガイドを採用し、それを遵守する

Step 0は準備、Step 1は本格的なコードの品質向上の前ステップの作業です。Step 2とStep 3が、その題名からも分かるとおりメインのコンテンツとなります。

実践的に学ぶ

セットアップ

本記事のコードは、以下の環境で動作確認をしております。

  • python3.10.13
  • 必要ライブラリ
    • numpy
    • pandas
    • scikit-learn
    • pyteset
    • seaborn
    • matplotlib
    • flake8 (ツール)
    • black (ツール)

必要ライブラリのインストールコマンドは以下の通りです。全てpipで入ります。

% pip install numpy pandas scikit-learn pytest seaborn matplotlib flake8 black

また、以下のページから全てのファイルをダウンロードできます。詳細なライブラリのバージョン等はrequirements.txtを参照してください。

Step 0: 書き直すファイルの確認

まず、書き直し元の.ipynbファイルを見ていきましょう。そのファイルを step_0.ipynbという名前とします。step_0.ipynb上のそれぞれに記載されているセルを、順番に列挙します。

step_0.ipynb セル[1]
from pathlib import Path
import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
import seaborn as sns
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.manifold import TSNE
from collections import Counter

random_state = 2525
step_0.ipynb セル[2]
# データの読み込み
# https://www.kaggle.com/datasets/imakash3011/customer-personality-analysis
base = Path('__file__').resolve().parent
csv_path = base / 'marketing_campaign.csv'
df = pd.read_csv(csv_path, delimiter='\t')
step_0.ipynb セル[3]
sns.histplot(df['Year_Birth'])

step_0.ipynb セル[4]
# 異常値と外れ値の削除
df= df.dropna(how='any')
df = df[(df['Year_Birth'] > 1920) & (df['Income'] < 666666)]

# 子供の人数を合算したカラムを追加
df['Childrenhome'] = df['Kidhome'] + df['Teenhome']
step_0.ipynb セル[5]
# Marital_Statusを2種類に分ける
df.loc[:, 'Marital_Status'] = df['Marital_Status'].mask(df['Marital_Status'] == 'Married', 'Together')
df.loc[:, 'Marital_Status'] = df['Marital_Status'].mask(df['Marital_Status'] == 'Divorced', 'Single')
df.loc[:, 'Marital_Status'] = df['Marital_Status'].mask(df['Marital_Status'] == 'Widow', 'Single')
df.loc[:, 'Marital_Status'] = df['Marital_Status'].mask(df['Marital_Status'] == 'Alone', 'Single')
df = df[df['Marital_Status'].isin(['Together', 'Single'])]
step_0.ipynb セル[6]
# クラスタリングと可視化
targets = ['MntWines', 'MntFruits', 'MntMeatProducts', 'MntFishProducts', 'MntSweetProducts', 'MntGoldProds', 'NumDealsPurchases', 'NumWebPurchases', 'NumCatalogPurchases', 'NumStorePurchases', 'NumWebVisitsMonth', 'Year_Birth', 'Income', 'Childrenhome']
df = df[targets]
X = StandardScaler().fit_transform(df.to_numpy())
clusters = KMeans(3, n_init='auto', random_state=random_state).fit_predict(X)
X_embedded = TSNE(n_components=2).fit_transform(X)
plt.clf()
for label in sorted(set(clusters)):
    xy = X_embedded[clusters == label]
    plt.scatter(x=xy[:, 0], y=xy[:, 1], label=label)
plt.legend()
plt.show()

本記事では、step_0.ipynbをデータサイエンティストの成果として、これをソフトウェアエンジニアに渡す用のPythonコードに手直ししていきます。処理自体は、学習用に簡素なものとしています。CSVファイルを読み込んで、異常値と外れ値の削除を行い、クラスタリングして可視化を行なっています。クラスタリング結果の画像が最終的な出力です。

対象としているCSVファイルは、以下のページから拝借しました。ライセンスを確認すると配布も可能だったとのことで、上述した本記事で扱うコードを公開している、Githubのリポジトリにも含めています。

Step 1: 必要なところだけPythonコードに転写

最初に、step_0.ipynbの内容のうち、必要なところだけをPythonコードに転写します。行っていることはシンプルで、最終的な結果に影響を与える処理と与えない処理を区別し、必要な処理だけを順番にPythonコードに転写していきます。

なぜこの作業をデータサイエンティストが実施するべきなのかというと、step_0.ipynbの中で必要な処理または不要な処理を一番知っているのは、データサイエンティストだからです。 もしソフトウェアエンジニアがこの作業をやるとすると、最初にあなたのコードをかなりのレベルで理解する必要が出てきます。最終的に理解はしてほしいところですが、最初にこの作業をやらされるとなるとかなり時間を要してしまうので、テンションが著しく下がることでしょう。

実際にstep_0.ipynbの中で、不要な処理が何なのかを考えてみてください。この記事で勉強をしているあなたは、このstep_0.ipynbは初見のはずです。ちょっとでも「面倒だな」と思うのではないでしょうか?しかも不要な処理を見つけたとしても、「本当に消していいのか?」と疑問になったりしないでしょうか?step_0.ipynbはまだ行数が少ないので良いかもしれませんが、これよりもっと複雑で長大なコードを読んだ時の気持ちが実際のソフトウェアエンジニアの気持ちです。自分で書いたコードなら比較的簡単にできると思うのですが、他人がやるとものすごく大変になるのです。

step_0.ipynbの場合ですと、セル[3]とセル[5]は最終的な結果に全く影響を与えません。なので、そこ以外をPythonコードに転写すれば良いです。セル[3]は、おそらく前処理の前にデータの中身を確認するためのコードですね。Jupyter Notebookで書いていると、こういったコードも残ったままになりがちではないでしょうか(気楽にかける便利さの表れだとは思いますが)。

転写後の.pyファイルを step_1.pyとします。

step_1.py
from pathlib import Path
import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
import seaborn as sns
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.manifold import TSNE
from collections import Counter

random_state = 2525

# データの読み込み
# https://www.kaggle.com/datasets/imakash3011/customer-personality-analysis
base = Path('__file__').resolve().parent
csv_path = base / 'marketing_campaign.csv'
df = pd.read_csv(csv_path, delimiter='\t')

# 異常値と外れ値の削除
df= df.dropna(how='any')
df = df[(df['Year_Birth'] > 1920) & (df['Income'] < 666666)]

# 子供の人数を合算したカラムを追加
df['Childrenhome'] = df['Kidhome'] + df['Teenhome']

# クラスタリングと可視化
targets = ['MntWines', 'MntFruits', 'MntMeatProducts', 'MntFishProducts', 'MntSweetProducts', 'MntGoldProds', 'NumDealsPurchases', 'NumWebPurchases', 'NumCatalogPurchases', 'NumStorePurchases', 'NumWebVisitsMonth', 'Year_Birth', 'Income', 'Childrenhome']
df = df[targets]
X = StandardScaler().fit_transform(df.to_numpy())
clusters = KMeans(3, n_init='auto', random_state=random_state).fit_predict(X)
X_embedded = TSNE(n_components=2).fit_transform(X)
plt.clf()
for label in sorted(set(clusters)):
    xy = X_embedded[clusters == label]
    plt.scatter(x=xy[:, 0], y=xy[:, 1], label=label)
plt.legend()
plt.show()

まずは最初のステップが完了しました。step_0.ipynbはまだシンプルなのでマシですが、実際のコードはもっと複雑になっていることでしょう。実務にはここは慎重に行うべき作業となります。この段階で、一度step_1.pyを実行してみて、step_0.ipynbの最下部にある画像と同じ画像が表示されるか確かめた方がいいでしょう。

※ 不要なライブラリがまだインポートされていると気付いた方は鋭いです。後のステップのために敢えて残しています。

Step 2: テストコードを作成し、保存する

続いて、テストコードを書いていきます。その過程として、処理の一部を関数化していきます。

テストコードを書く理由やその方法論については、それだけで専用の書籍が出版されるほど奥が深く語ると日が暮れてしまいます。そこで、ここではデータサイエンティストがテストコードを書くべきと主張する、最低限の理由だけ説明します。それは、テストコードは処理の仕様を把握してないと書けないからであり、その仕様を一番把握しているのはやはりデータサイエンティストだからです。 ここもほとんど前ステップと同じ理由ですね。データサイエンティストがテストコードを書くメリットがあるというより、ソフトウェアエンジニアがテストケースを書くデメリットが大きいのです。データサイエンティストは自分の書いた処理をソフトウェアエンジニアに伝える義務があります。テストコードは、そのための有用なツールとなりえます。その他の、データサイエンティストがテストコードを書く意義は後述します。

何も完璧なテストコードを書けとは言いません。ヘタクソでも、書いていると書いていないでは、ソフトウェアエンジニアの負担が大きく変わってきます。 より良いテストコードは、ソフトウェアエンジニアが改めて書いてくれる、という気構えで構わないと筆者は思います。 そもそもコードの品質について一番責任を負うのはソフトウェアエンジニアですしね。

処理の一部を関数化する

それでは、テストコードを書く前にstep_1.pyの処理の一部を関数化していきましょう。関数化はその人のセンスが出るところだと思いますが、ここでは簡単な処理を関数化しましょう。step_1.pyの以下の箇所を関数化します。

# 異常値と外れ値の削除
df = df.dropna(how='any')
df = df[(df['Year_Birth'] > 1920) & (df['Income'] < 666666)]

ここを、以下のようにremove_missing_and_outlier関数として、libs.pyという別ファイルに書きます。

libs.py
import pandas as pd


def remove_missing_and_outlier(
    df: pd.DataFrame,
    how: str = "any",
    year_birth_lower: int = 1920,
    income_upper: int = 666666,
) -> pd.DataFrame:
    df = df.dropna(how=how)
    df = df[(df["Year_Birth"] > year_birth_lower) & (df["Income"] < income_upper)]
    return df

ハードコーディング数値や文字列も、折角なので関数の引数としましょう。後に仕様が変わったときに変更しやすくなりますし、テストしやすくなります。

また、該当箇所のメソッドを呼び出すために、step_1.pyを書き直してstep_2.pyとして新たに作ります。全コードを書くと冗長なので、書き換えた処理の前後は省略して表記しておきます。

step_2.py
... 省略 ...

from libs import remove_missing_and_outlier

random_state = 2525

# データの読み込み
# https://www.kaggle.com/datasets/imakash3011/customer-personality-analysis
base = Path('__file__').resolve().parent
csv_path = base / 'marketing_campaign.csv'
df = pd.read_csv(csv_path, delimiter='\t')

# 異常値と外れ値の削除
df = remove_missing_and_outlier(df, how='any', year_birth_lower=1920, income_upper=666666)

... 省略 ...

これでテストの準備が整いました。次に、remove_missing_and_outlier関数のテストコードを書きます。

実際にテストコードを書く

ここでは、おそらく最も有名なPythonの単体テストフレームワークである pytest を使ってテストを書きます。テストコードは以下の通りです。

tests.py
import pandas as pd
from libs import remove_missing_and_outlier


def test_remove_missing_and_outlier():
    df = pd.DataFrame(
        {
            "Year_Birth": [1921, 1920, 2000, 2000, 2000, 2000],
            "Income": [100000, 100000, 666665, 666666, 100000, 100000],
            "Any": [True, True, True, True, True, None],
        }
    )
    df = remove_missing_and_outlier(df,
                                    how="any",
                                    year_birth_lower=1920,
                                    income_upper=666666)
    assert len(df) == 3
    assert df.index[0] == 0
    assert df.index[1] == 2
    assert df.index[2] == 4

最初に、テストケース用のPandas.DataFrameを作り(df)、その後remove_missing_and_outlierに入力し、その出力されてきたDataFrameの色々な状態をassertでチェックしています。pytestがインストールされているなら、以下のようにして実行できます。

% pytest tests.py
====================================================================== test session starts ======================================================================
platform darwin -- Python 3.10.13, pytest-8.2.0, pluggy-1.5.0
rootdir: /Users/kawaguchi/tech-blog-codes/20240507_coding-techniques-that-data-scientists-should-learn
collected 1 item                                                                                                                                                

tests.py .                                                                                                                                                [100%]

======================================================================= 1 passed in 0.26s =======================================================================

本当は色々なDataFrameを入力のテストケースとして用意しチェックする方がベターなんでしょうが、この記事はテストコードを書くことの重要性を説明するために書かれているので、テストコードの書き方の方法論については触れないでおきます。私がこの場で記事を書くよりも、より良い学習コンテンツがたくさんあるはずです。

データサイエンティストがテストコードを書く意義

データサイエンティストがテストケースを書く意義を説明します。

まず、テストコードがあることで、ソフトウェアエンジニアが即座にリファクタリングできるようになります。今回の例で言うと、remove_missing_and_outlier関数の中についてはソフトウェアエンジニアは安心して中の処理を書き換えることができます。なぜなら、何かデータサイエンティストの意図と違う処理に書き換えてしまったとしても、テストコードの実行によって即座に気付くことができるからです。

また、処理の一部を関数にしたことで、コード内容がわかりやすくなります。そうなるとデータサイエンティスト自身もバグに気づきやすくなりますし、ソフトウェアエンジニアの理解を助けることができます。これは、テストコードを書くための過程で関数を書く必要が出てきたために起きた、副次的なメリットと言えます。

このように、テストコードを書くことは、ソフトウェアエンジニアを助け、チーム全体のパフォーマンスを効率的に上げることに繋がるのです。

Step 3 スタイルガイドを採用し、それを遵守する

最後のステップはとてもシンプルで、そのプロジェクトのコーディング規約に合わせてコードを書き換えておくことです。ソフトウェアエンジニアに、コーディング規約を聞いてみましょう。

ここで大事なのは、どんなコーディング規約が優れているとか、どんなコードが美しいかを考えることではありません。 とにかく決まったコーディング規約を遵守することが大事です。 自分の美的感覚よりも、コーディング規約を信じましょう。

まだコーディング規約が決まっていない場合であったり、自分がコーディング規約を決めていい場合は、PEP8に従うといいでしょう。これはPythonの標準ライブラリに対するコーディング規約です。標準ライブラリのコーディング規約なので、一番標準的な規約なのではないでしょうか?

このコーディング規約に則っているかどうかは、Flake8 というツールを使えば簡単に調べられます。Flake8はpipで簡単にインストールできます。ではここで、step_2.pyにflake8をかけてみましょう。

% flake8 step_2.py 
step_2.py:2:1: F401 'numpy as np' imported but unused
step_2.py:5:1: F401 'seaborn as sns' imported but unused
step_2.py:9:1: F401 'collections.Counter' imported but unused
step_2.py:21:80: E501 line too long (90 > 79 characters)
step_2.py:27:80: E501 line too long (254 > 79 characters)

出力の最初の3行は、不要なライブラリがインポートされていることを示していますね。またお尻の2行は、1行が長すぎることを示しています。
これらを直した結果を以下の step_3.pyとします。

step_3.py
from pathlib import Path
import pandas as pd
from matplotlib import pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.manifold import TSNE
from libs import remove_missing_and_outlier

random_state = 2525

# データの読み込み
# https://www.kaggle.com/datasets/imakash3011/customer-personality-analysis
base = Path("__file__").resolve().parent
csv_path = base / "marketing_campaign.csv"
df = pd.read_csv(csv_path, delimiter="\t")

# 異常値と外れ値の削除
df = remove_missing_and_outlier(
    df, how="any", year_birth_lower=1920, income_upper=666666
)

# 子供の人数を合算したカラムを追加
df["Childrenhome"] = df["Kidhome"] + df["Teenhome"]

# クラスタリングと可視化
targets = [
    "MntWines",
    "MntFruits",
    "MntMeatProducts",
    "MntFishProducts",
    "MntSweetProducts",
    "MntGoldProds",
    "NumDealsPurchases",
    "NumWebPurchases",
    "NumCatalogPurchases",
    "NumStorePurchases",
    "NumWebVisitsMonth",
    "Year_Birth",
    "Income",
    "Childrenhome",
]
df = df[targets]
X = df.to_numpy()
X = StandardScaler().fit_transform(X)
clusters = KMeans(3, n_init="auto", random_state=random_state).fit_predict(X)
X_embedded = TSNE(n_components=2).fit_transform(X)
plt.clf()
for label in sorted(set(clusters)):
    xy = X_embedded[clusters == label]
    plt.scatter(x=xy[:, 0], y=xy[:, 1], label=label)
plt.legend()
plt.show()

実は black というツールを使えば、ある程度は直してくれるので試してもいいかもしれません。上述したstep_3.pyにも使っています。

以上でJupyter Notebookから(比較的に)高品質なコードへの書き直しについての解説は終わりです。ソフトウェアエンジニアには、Jupyter Notebookの.ipynbファイルを渡すだけでなく、以下の手直しの後ファイルを渡せば良いです。

  • step_3.py
  • libs.py
  • tests.py

このタイミングで、ソフトウェアエンジニアにコードレビューをしてもらいたいところですね。出来れば頼んでみましょう。教育熱心なエンジニアであれば、快く改善点を教えてくれると思います。

その他に意識しておいた方がいいこと

以上で重要なことは解説しました。ここでは、その他に意識しておいた方が良いことを解説しておきます。

型アノテーション

型アノテーションは書いた方がいいでしょう。例えば、Step2で処理の切り出しとして書いたremove_missing_and_outlier関数にも型アノテーションを入れています。確かにプロトタイピング時は、柔軟性のために書かないで良いかもしれません。しかし、ソフトウェアエンジニアにコードを渡す段階になったときには、そのフェーズは概ね終わっています。ここでは、ソフトウェアエンジニアにコードの意図を伝えるため、そしてコーディングをサポートするツールを使えるようにするためにも書いておいた方が親切です。

書き直し工数の見積もり

あなたがデータサイエンティストとしての経験が薄いうちは、コードを書き直す手間の多さに驚くでしょう。自分が書いたJupyter Notebookと同じくらいの時間かそれ以上の時間がかかると思っておきましょう。2週間かかったなら、コードを書き直す時間に2週間はみておきましょう(慣れているともっと早くできると思います)。プロジェクトマネージャーに伝えるスケジュール調整は慎重にいきましょう。

まとめ

おつかれさまです!ここまで根気強く本記事にお付き合いくださりありがとうございます!

本記事では、データサイエンティストが高品質なコードを書く必要性を説明し、その具体的な手順を解説しました。記事で紹介した学習用のコードは、解説が目的のため元々かなりシンプルですが、実際のプロジェクトで考えられる処理はもっと複雑で入り組んでいるはずです。その場合は、本記事で紹介した手順を踏むことの意義がより大きくなるはずです。

大事なのは、ソフトウェアエンジニアが書くレベルのコードを書くのではなく、自分にできる範囲でやれることをやることです。その一歩が、チーム全体のパフォーマンスを最も効率よく上げてくれます。より良いテストコードを書いたり、より読みやすいコードを書く勉強をしてみても良いでしょう。以下に、おすすめの書籍を貼っておきます。

  • テストコードの考え方: 単体テストの考え方/使い方

  • 読みやすいコードを書くため: リーダブルコード

本記事が、少しでも多くのプロジェクトの助けになり、より良い価値を提供できるようになれば幸いです。

230
263
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
230
263

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?