Slide 1

Slide 1 text

テーブルデータの推論結果をS3に出⼒する pipelineを を使って書く 2023/07/10 Sansan株式会社 技術本部 研究開発部 齋藤

Slide 2

Slide 2 text

⾃⼰紹介 東北大学大学院工学研究科博士前期課程修了。日系SIer や外資系IT企業で、電力・保険・製薬など多様な業界に 対する、機械学習を用いたシステムの提案やPoC・開 発・保守運用に従事。現在は請求書データや企業情報の 分析を担当。Kaggle Expert。 齋藤 慎⼀朗 Shinichiro Saito 研究員 Twitter @sinchir0 Virtual Card

Slide 3

Slide 3 text

➔ pipelineの再現性が担保される ➔ classの書き方がメンバー間で共通化されるため、コードレビュ ーの負荷が下がる gokartを利⽤するメリット

Slide 4

Slide 4 text

- 機械学習ワークフローライブラリ - Luigi(Spotifyが開発しているPythonによるパイプラインパッケージ)のラッパー - https://github.com/spotify/luigi - 最小単位は下記 gokartとは class ExampleTaskA(gokart.TaskOnKart): param = luigi.Parameter() int_param = luigi.IntParameter(default=2) def run(self): self.dump(f'DONE {self.param}_{self.int_param}') https://github.com/m3dev/gokart/blob/master/examples/gokart_notebook_example.ipynb より引用

Slide 5

Slide 5 text

- Taskごとの依存関係をrequiresで表現し、再現性を担保 gokartとは class ExampleTaskC(gokart.TaskOnKart): def run(self): self.dump('TASKC') class ExampleTaskD(gokart.TaskOnKart): def run(self): self.dump('TASKD') class ExampleTaskB(gokart.TaskOnKart): param = luigi.Parameter() def requires(self): return dict(task_c=ExampleTaskC(), task_d=ExampleTaskD()) def run(self): task_c = self.load('task_c') task_d = self.load('task_d') self.dump(f'DONE {self.param}_{task_c}_{task_d}') https://github.com/m3dev/gokart/blob/master/examples/gokart_notebook_example.ipynb より引用 ExampleTaskC ExampleTaskD ExampleTaskB

Slide 6

Slide 6 text

- gokart.TaskInstanceParameterを使うとtaskを変数として受け取れる gokartとは class ExampleTaskC(gokart.TaskOnKart): def run(self): self.dump('TASKC') class ExampleTaskD(gokart.TaskOnKart): def run(self): self.dump('TASKD') class ExampleTaskB(gokart.TaskOnKart): param = luigi.Parameter() task_1 = gokart.TaskInstanceParameter() task_2 = gokart.TaskInstanceParameter() def requires(self): return dict(task_1=self.task_1, task_2=self.task_2) # required tasks are decided from the task parameters `task_1` and `task_2` def run(self): task_1 = self.load('task_1') task_2 = self.load('task_2') self.dump(f'DONE {self.param}_{task_1}_{task_2}') task_b = ExampleTaskB(param='example', task_1=ExampleTaskC(), task_2=ExampleTaskD()) # Dependent tasks are defined here output = gokart.build(task=task_b) print(output) https://github.com/m3dev/gokart/blob/master/examples/gokart_notebook_example.ipynb より引用

Slide 7

Slide 7 text

➔ TaskInstanceParameterを用いたpipelineの構築例、その時の tipsを紹介します。 本⽇の紹介内容

Slide 8

Slide 8 text

アジェンダ - pipelineの全体像 - requiresにpipelineを書く - 精度の確認用のtaskを挟む - staticmethodでテストをする - panderaによるデータのvalidation - 推論ファイル配置用のS3にputをする - pipelineが巨大になった際に、requiresを更にnestする

Slide 9

Slide 9 text

repo - https://github.com/sinchir0/gokart_pipeline_sample

Slide 10

Slide 10 text

Pipelineの全体像 このpipelineが、SansanのR&Dが持つ最速でリリースす るための基盤、Circuit上にて稼働しており、定期的に自 動実行するようになっています。 https://speakerdeck.com/sansan_randd/about-circuit-r-and-ds-application-platform - よくある機械学習のワークフロー - inputデータは定期的に更新される想定 - 下記パイプラインを数ヶ月に一度実行する必要がある データの ダウンロード 前処理 学習・推論 (LightGBM) 性能の確認 推論結果配置用 のS3にpush

Slide 11

Slide 11 text

ディレクトリ構成 ❯ tree -L 3 . ├── README.md ├── conf │ ├── logging.ini │ └── param.ini ├── gokart_pipeline_sample │ ├── __init__.py │ ├── pipeline │ │ ├── __init__.py │ │ ├── __pycache__ │ │ ├── check.py │ │ ├── evaluate.py │ │ ├── gokart_util.py │ │ ├── load_data.py │ │ ├── make_output.py │ │ ├── pred.py │ │ ├── preprocess.py │ │ ├── run.py │ │ ├── run_middle.py │ │ ├── schemas │ │ ├── split_data.py │ │ ├── train.py │ │ └── upload.py │ └── utils │ └── template.py ├── main.py ├── poetry.lock ├── pyproject.toml └── test ├── __init__.py └── unit_test ├── __pycache__ └── test_preprocess.py

Slide 12

Slide 12 text

requiresにpipelineを書く - 処理の全体像がすぐにわかる class RunTask(GokartTask): require_acc = luigi.FloatParameter(default=0.9) def requires(self) -> gokart.TaskInstanceParameter: data_task = LoadDataTask() data_task = PreprocessTask(data_task=data_task) data_task = SplitDataTask(data_task=data_task) train_data_task = GetDataTask(data_task=data_task, name="train") valid_data_task = GetDataTask(data_task=data_task, name="valid") test_data_task = GetDataTask(data_task=data_task, name="test") model_task = TrainTask(train_task=train_data_task, valid_task=valid_data_task) pred_task = PredTask(model_task=model_task, test_task=test_data_task) output_task = MakeOutputTask(pred_task=pred_task, test_task=test_data_task) checked_output_task = CheckAccuracyTask( pred_task=output_task, test_task=test_data_task, require_acc=self.require_acc ) s3_upload_task = UploadS3Task(output_task=checked_output_task) return s3_upload_task def run(self): self.dump(self.__class__.__name__)

Slide 13

Slide 13 text

- 精度の確認用のtaskを挟むことで、ドメインシフトなどによる精度低下を自動で 検知 精度の確認⽤のtaskを挟む class CheckAccuracyTask(GokartTask): pred_task = gokart.TaskInstanceParameter() test_task = gokart.TaskInstanceParameter() require_acc = luigi.FloatParameter() def requires(self) -> dict[str, gokart.TaskInstanceParameter]: return {"pred": self.pred_task, "test": self.test_task} def run(self) -> None: pred = self.load("pred") test = self.load("test") y_test = test["y_test"] acc = accuracy_score(y_test, pred["pred"]) if acc <= self.require_acc: raise Exception(f"必要な精度に達していません 必要精度:{self.require_acc} 実際の精度:{acc}") self.dump(pred)

Slide 14

Slide 14 text

- テストが必要な関数はstaticmethodやclassmethodに切り出す class PreprocessTask(GokartTask): data_task = gokart.TaskInstanceParameter() def requires(self) -> gokart.TaskInstanceParameter: return self.data_task @staticmethod def make_area(length: pd.Series, width: pd.Series) -> pd.Series: return length * width @pa.check_types def add_area_feature(self, data: DataFrame[IrisFeatureSchema]) -> DataFrame[PreprocessedSchema]: # 花びらの面積を新たな特徴量として追加 data["petal_area"] = self.make_area(data["petal_length"], data["petal_width"]) return data def run(self) -> None: data = self.load_data_frame() df = self.add_area_feature(data=data) self.dump(df) staticmethodでテストをする class TestMakeAreaTask: @pytest.mark.parametrize( ("length", "width", "expect"), [ ( pd.Series([2.0, 4.0, 6.0]), pd.Series([1.0, 2.0, 3.0]), pd.Series([2.0, 8.0, 18.0]) ) ], ) def test_make_area( self, length: pd.Series, width: pd.Series, expect: pd.Series ) -> None: actual = PreprocessTask.make_area(length, width) assert actual.equals(expect)

Slide 15

Slide 15 text

panderaによるデータのvalidation - inputとoutputのpandas.DataFrameのvalidationを行うために、panderaを利用 - add_area_featureメソッドを作成し、inputとoutputの型ヒントを付与 class PreprocessTask(GokartTask): data_task = gokart.TaskInstanceParameter() def requires(self) -> gokart.TaskInstanceParameter: return self.data_task @staticmethod def make_area(length: pd.Series, width: pd.Series) -> pd.Series: return length * width @pa.check_types def add_area_feature(self, data: DataFrame[IrisFeatureSchema]) -> DataFrame[PreprocessedSchema]: # 花びらの面積を新たな特徴量として追加 data["petal_area"] = self.make_area(data["petal_length"], data["petal_width"]) return data def run(self) -> None: data = self.load_data_frame() df = self.add_area_feature(data=data) self.dump(df)

Slide 16

Slide 16 text

- 指定したデータ以外を検知した場合に、Errorを出す panderaによるデータのvalidation class PreprocessedSchema(BaseSchema): """ 前処理済データのスキーマ """ id: Series[int] = pa.Field(nullable=False, description="id") sepal_length: Series[float] = pa.Field(gt=0.0, nullable=False, description="がく片の長さ(cm)") sepal_width: Series[float] = pa.Field(gt=0.0, nullable=False, description="がく片の幅(cm)") petal_length: Series[float] = pa.Field(gt=0.0, nullable=False, description="花びらの長さ(cm)") petal_width: Series[float] = pa.Field(gt=0.0, nullable=False, description="花びらの幅(cm)") petal_area: Series[float] = pa.Field(gt=0.0, nullable=False, description="花びらの面積(cm^2)") target: Series[int] = pa.Field(isin=(0, 1, 2), nullable=False, description="あやめの種類")

Slide 17

Slide 17 text

推論ファイル配置⽤のS3にputをする - Circuit基盤上では意図的にファイル出力を制限をしているため、dataframeをスト リームで直接putする class UploadS3Task(GokartTask): output_task = gokart.TaskInstanceParameter() def requires(self) -> gokart.TaskInstanceParameter: return self.output_task def put_file(self, output_data: pd.DataFrame) -> None: s3 = boto3.resource("s3") bucket_name = os.environ["BUCKET_NAME"] with StringIO() as csv_buffer: output_data.to_csv(csv_buffer, index=False) s3.Object(bucket_name, "output.csv").put(Body=csv_buffer.getvalue()) def run(self) -> None: output = self.load_data_frame() self.put_file(output) self.dump(self.__class__.__name__)

Slide 18

Slide 18 text

pipelineが巨⼤になった際に、requiresを更にnestする - requiresの中身が巨大になった際には、requiresのtaskを更にnestするようなtask を作成 - この程度だとあまり差はないが、巨大になった場合は見通しが改善する class RunTask(GokartTask): def requires(self) -> gokart.TaskInstanceParameter: data_task = LoadDataTask() data_task = PreprocessTask(data_task=data_task) data_task = SplitDataTask(data_task=data_task) –中略-- return s3_upload_task def run(self) -> None: self.dump(self.__class__.__name__) class RunNestTask(GokartTask): def requires(self) -> gokart.TaskInstanceParameter: data_task = PrepareDataTask() –中略-- return s3_upload_task def run(self) -> None: self.dump(self.__class__.__name__) class PrepareDataTask(GokartTask): def requires(self) -> gokart.TaskInstanceParameter: data_task = LoadDataTask() data_task = PreprocessTask(data_task=data_task) data_task = SplitDataTask(data_task=data_task) return data_task def run(self) -> None: data = self.load() self.dump(data)

Slide 19

Slide 19 text

No content