データ接続と統合Python (Spark)PySparkリファレンススタイルガイド

注: 以下の翻訳の正確性は検証されていません。AIPを利用して英語版の原文から機械的に翻訳されたものです。

スタイルガイド

PySpark は、Apache Spark バックエンドとやり取りしてデータを高速に処理できるラッパー言語です。Spark は、サーバーの分散ネットワーク上で非常に大きなデータセットを処理でき、正しく使用するとパフォーマンスと信頼性が大幅に向上します。ただし、PySpark の構文は Spark の JVM 由来の設計を引き継いでおり、なじみのないコードパターンを採用しているため、経験豊富な Python 開発者にとっても難しい面があります。

この PySpark コードスタイルガイドは、特定の方針に基づき、これまでに扱った PySpark リポジトリで最も頻繁に見られるトピックをもとに、よくある状況と、それに対応するベストプラクティスを紹介します。

一貫したコードスタイルを徹底するため、主要なリポジトリではそれぞれ、同じ設定で Pylint ↗ を有効にする必要があります。このドキュメントに記載されたルールに合わせるため、Pylint に追加できる PySpark 固有のチェッカー ↗を提供しています。Python リポジトリ向けの組み込み Pylint プラグインの詳細については、スタイルチェックの有効化に関するドキュメントを参照してください。

PySpark 固有の事項に加えて、クリーンコードの一般的なプラクティスも PySpark リポジトリでは重要です。Google の PyGuide ↗ は、よい出発点になります。

曖昧さを解消する場合を除き、直接アクセスよりも暗黙的な列選択を優先する

Copied!
1 2 3 4 5 # 悪い例 df = df.select(F.lower(df1.colA), F.upper(df2.colB)) # 良い例 df = df.select(F.lower(F.col("colA")), F.upper(F.col("colB")))

推奨する方法は、より複雑で長く、余計な記述が多いように見えるかもしれません。その見方は正しく、実際には F.col() をまったく使わずに済むのが最善です。ただし、F.col() の使用、または代替となる明示的な選択が避けられない場合もあります。それでも、1つ目の例より2つ目の例を優先するのには、十分な理由があります。

1つ目のケースのように列を明示的に指定すると、データフレーム名とスキーマの両方がデータフレーム変数に明示的に結び付けられます。そのため、df1 を削除したり名前を変更したりすると、参照 df1.colA が機能しなくなります。

一方、F.col("colA") は常に、操作対象のデータフレームにある “colA” という名前の列を参照します。この例では、そのデータフレームの名前がたまたま df です。ほかのデータフレームの状態をまったく追跡する必要がないため、コードの処理がより局所的になり、デバッグが難しいことの多い「離れた箇所での不可解なインタラクション」が起こりにくくなります。

1つ目のケースを避けるべき理由は、ほかにもあります。

  • データフレーム変数の名前が長いと、それを含む式はすぐに扱いにくくなります。
  • 列名に空白やその他のサポートされていない文字が含まれ、角括弧演算子によるアクセスが必要な場合、df1["colA"] を書く手間は F.col(“colA”) と変わりません。
  • F.col("prod_status") == 'Delivered' のような抽象的な式を変数に代入すれば、複数のデータフレームで再利用できますが、df.prod_status == 'Delivered' は常に df に結び付いています。

幸い、通常は F.col() を使った複雑な式は必要ありません。例外は F.lower、F.upper、…およびこちら ↗のみです。

注意事項

状況によっては、複数のデータフレームの列にアクセスでき、列名が重複していることがあります。よくある例は、df.join(df2, on=(df.key == df2.key), how='left') のような照合式です。 このような場合は、データフレームを使って列を直接参照してもかまいません。また、データフレームのエイリアスを使って結合の曖昧さを解消することもできます(詳細は、このガイドの結合セクションを参照してください)。

DataFrame オブジェクトではなく Column オブジェクトを操作する

トランスフォームのロジックを記述するときは、DataFrame オブジェクトを繰り返し操作するのではなく、Column オブジェクトを使います。この方法により、バグを減らし、コードを再利用できるようになります。

最初に Column オブジェクトを定義する

トランスフォームの先頭で、入力 DataFrame から必要な Column オブジェクトを定義します。DataFrame メソッドを繰り返し呼び出すのではなく、これらのオブジェクトにロジックを適用します。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 悪い例 - DataFrame を繰り返し操作しています df = df.withColumn('status_lower', F.lower(df.status)) df = df.withColumn('is_active', df.status_lower == 'active') df = df.withColumn('category', F.when(df.is_active, 'A').otherwise('B')) # 良い例 - 列を抽出して操作し、最後に追加します status_col = F.col('status') status_lower = F.lower(status_col) is_active = status_lower == 'active' category = F.when(is_active, F.lit('A')).otherwise(F.lit('B')) df = df.select( '*', status_lower.alias('status_lower'), is_active.alias('is_active'), category.alias('category') )

Column オブジェクトを受け取り、返すヘルパー関数を書く

DataFrame 全体を受け取り、返すヘルパー関数を書くのではなく、Column オブジェクトを入力として受け取り、Column オブジェクトを返す関数を書きます。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 悪い例 - ヘルパー関数が DataFrame を操作しています def add_derived_columns(df): df = df.withColumn('full_name', F.concat(df.first_name, F.lit(' '), df.last_name)) df = df.withColumn('name_length', F.length(df.full_name)) return df # 良い例 - ヘルパー関数が Column を操作しています def make_full_name(first_name_col, last_name_col): return F.concat(first_name_col, F.lit(' '), last_name_col) def name_length(name_col): return F.length(name_col) # 使用方法 full_name = make_full_name(F.col('first_name'), F.col('last_name')) df = df.select('*', full_name.alias('full_name'), name_length(full_name).alias('name_length'))

DataFrame に列を追加するタイミングを遅らせる

トランスフォームの処理中に中間列を DataFrame に追加することは避けます。計算済みの Column オブジェクトを DataFrame に追加して最終的な名前を付けるのは、できる限り最後のタイミング、理想的には出力データセットを書き込む直前にします。

DataFrame の列を決して上書きしない

DataFrame 上の既存の列を同じ名前で上書きしないでください。これにより、列の意図しない再利用やシャドーイングによる混乱や、気付きにくいバグを減らせます。

Copied!
1 2 3 4 5 6 7 8 # 悪い例 - 'amount' 列を上書きしています df = df.withColumn('amount', F.col('amount') * 100) df = df.withColumn('amount', F.round(F.col('amount'), 2)) # これはどの 'amount' ですか? # 良い例 - 異なる名前を使うか、Column オブジェクトを操作します amount_cents = F.col('amount') * 100 amount_rounded = F.round(amount_cents, 2) df = df.select('*', amount_rounded.alias('amount_cents'))

変換関数に列を文字列として決して渡さない

列を変換する関数を書くときは、列名を文字列として渡すのではなく、必ず実際の Column オブジェクトを渡します。

Copied!
1 2 3 4 5 6 7 8 9 10 # 悪い例 - 列名を文字列として渡しています def normalize_column(df, column_name): return df.withColumn(column_name, F.lower(F.col(column_name))) # 良い例 - Column オブジェクトを渡しています def normalize(col): return F.lower(col) # 使用方法 df = df.select('*', normalize(F.col('status')).alias('status_normalized'))

これらのルールに従うと、DataFrame レベルの操作を最上位に保ち、再利用可能で組み合わせられるロジックを Column レベルの関数として実装するスタイルにつながります。

列の反復処理を避け、リスト内包表記を使う

一般に、Spark では for ループは非効率です。大まかに言えば、Spark は遅延評価を行い、一度に1つの for ループしか処理しないためです。ループのすべての部分を一度に処理できる場合、これによって実行時間が長くなり、ドライバーのメモリー不足エラー(OOM)が発生することがあります。データセットのすべての列名を大文字から小文字に変更する場合は、以下の1つ目の例(# bad と記載)の代わりに、リスト内包表記を使うことを推奨します(# good と記載された2つ目の例のように)。

Copied!
1 2 3 # 悪い例 for colm in df.columns: df = df.withColumnRenamed(colm, colm.lower())
Copied!
1 2 3 4 # 良い例 df = df.select( *[F.col(colm).alias(colm.lower()) for colm in df.columns] )

# good の例のようにリスト内包表記を使うと、同じ目的の結果を得ながら、前述したパフォーマンスの低下やクエリプランの問題を回避できます。

複雑な論理演算をリファクタリングする

.filter() や F.when() の中に記述されることの多い論理演算は、読みやすくする必要があります。関数のチェーンと同じルールを適用し、同じコードブロック内の論理式を最大3個の式に抑えます。それより長くなる場合、多くはコードを簡素化したり、外に切り出したりできる兆候です。複雑な論理演算を変数や関数に切り出すと、コードを読みやすく、その動作を理解しやすくなり、バグの削減にもつながります。

Copied!
1 2 3 4 # 悪い例 F.when( (df.prod_status == 'Delivered') | (((F.datediff(df.deliveryDate_actual, df.current_date) < 0) & ((df.currentRegistration.rlike('.+')) | ((F.datediff(df.deliveryDate_actual, df.current_date) < 0) & (df.originalOperator.rlike('.+') | df.currentOperator.rlike('.+')))))), 'In Service')

上記のコードは、さまざまな方法で簡素化できます。まず、ロジックの各ステップを、名前を付けた少数の変数にまとめることに注目します。PySpark では、式を丸括弧で囲む必要があります。これが論理演算をグループ化するための丸括弧と混在すると、読みやすさが損なわれることがあります。たとえば、上記のコードには冗長な (F.datediff(df.deliveryDate_actual, df.current_date) < 0) がありますが、非常に見つけにくいため、元の作成者は気付きませんでした。

Copied!
1 2 3 4 5 6 7 # より良い例 has_operator = (df.originalOperator.rlike('.+') | df.currentOperator.rlike('.+')) delivery_date_passed = (F.datediff(df.deliveryDate_actual, df.current_date) < 0) has_registration = (df.currentRegistration.rlike('.+')) is_delivered = (df.prod_status == 'Delivered') F.when(is_delivered | (delivery_date_passed & (has_registration | has_operator)), 'In Service')

上記の例は読みやすくなり、冗長な式も取り除かれています。演算の数を減らすことで、さらに改善できます。

Copied!
1 2 3 4 5 6 7 8 # 良い例 has_operator = (df.originalOperator.rlike('.+') | df.currentOperator.rlike('.+')) delivery_date_passed = (F.datediff(df.deliveryDate_actual, df.current_date) < 0) has_registration = (df.currentRegistration.rlike('.+')) is_delivered = (df.prod_status == 'Delivered') is_active = (has_registration | has_operator) F.when(is_delivered | (delivery_date_passed & is_active), 'In Service')

F.when の式が簡潔で読みやすくなり、このコードをレビューする人なら誰でも、意図した動作を理解できるようになった点に注目してください。読み手は、個々の式にエラーがあると疑う場合にのみ、その式を確認すれば済みます。また、コードに単体テストがあり、ロジックの各部分を関数として抽象化したい場合にも、それぞれの部分をテストしやすくなります。

最後の例には、まだコードの重複が残っています。その重複を取り除く方法は、読み手の演習として残しておきます。

select 文を使用してスキーマ契約を指定する

PySpark トランスフォームの冒頭、または値を返す前に select を実行することは、優れたプラクティスとされています。この select 文は、入力と出力で期待されるデータフレームのスキーマについて、コードの読み手とコードの両方に対する契約を指定します。

select は、トランスフォームの次のステップでデータフレームを利用できるように準備するクリーニング操作と捉える必要があります。

select 文は常にできるだけシンプルに保つことを目指します。一般的な SQL の慣例に従い、選択する列ごとに spark.sql.function の関数を最大 1個まで使用し、必要に応じて .alias() で意味のある名前を付けることができます。ただし、このような使い方は最小限にとどめてください。同じ select 内でこうした使用が 3個を超える場合は、clean_<dataframe name>() のような別の関数にリファクタリングして、操作をカプセル化します。

複数のデータフレームを含む式や、.when() のような条件付き操作は、select 内で決して使用しないでください。

Copied!
1 2 3 4 5 6 7 8 9 10 # 悪い例 aircraft = aircraft.select( 'aircraft_id', 'aircraft_msn', F.col('aircraft_registration').alias('registration'), 'aircraft_type', F.col('number_of_economy_seats').cast('long'), 'operator_code', F.col('number_of_business_seats').cast('long'), )

同じ種類の操作をまとめます。個々の列はすべて先頭に列挙し、spark.sql.function の関数呼び出しは別々の行に記述します。

Copied!
1 2 3 4 5 6 7 # 良い例 aircraft = aircraft.select( 'aircraft_id', 'aircraft_msn', 'aircraft_type', 'operator_code', F.col('aircraft_registration').alias('registration'), F.col('number_of_economy_seats').cast('long'), F.col('number_of_business_seats').cast('long'), )

select() 文は、その性質上、データフレームのスキーマを再定義するため、新旧の列の追加や除外、および既存の列の再定義を自然にサポートします。こうした操作をすべて1つの文にまとめると、最終的なスキーマを特定しやすくなり、コードの可読性が向上します。また、コードも少し簡潔になります。

withColumnRenamed() を呼び出す代わりに、エイリアスを使用します。

Copied!
1 2 3 4 5 # 悪い例 df.select('key', 'comments').withColumnRenamed('comments', 'num_comments') # 良い例 df.select('key', F.col('comments').alias('num_comments'))

withColumn() を使用してデータ型を再定義する代わりに、select 内でキャストします。

Copied!
1 2 3 4 5 # 悪い例 df.select('comments').withColumn('comments', F.col('comments').cast('double')) # 良い例 df.select(F.col('comments').cast('double'))

ただし、シンプルに保ちます。

Copied!
1 2 3 4 5 6 7 8 9 10 11 # 悪い例 df.select( ((F.coalesce(F.unix_timestamp('closed_at'), F.unix_timestamp()) - F.unix_timestamp('created_at')) / 86400).alias('days_open') ) # 良い例 df.withColumn( 'days_open', (F.coalesce(F.unix_timestamp('closed_at'), F.unix_timestamp()) - F.unix_timestamp('created_at')) / 86400 )

使用しない列を select 文に含めることは避け、代わりに列を明示的に選択します。これは .drop() を使用するよりも推奨される方法です。スキーマが変更されても、予期しない列によってデータフレームが肥大化することを防げるためです。ただし、列の削除がどのような場合でも推奨されないというわけではありません。たとえば、結合では冗長な列が追加されることが多いため、結合後に列を削除するのは一般的に適切です。

最後に、select 文で新しい列を追加する代わりに、.withColumn() を使用することを推奨します。

空の列

スキーマを満たすために空の列を追加する必要がある場合は、必ず F.lit(None) を使用してその列に値を設定します。空の文字列や、空の値を示すほかの文字列(NA など)は決して使用しないでください。

この方法は意味的に正しいだけでなく、空の文字列、null、'NA' などをそれぞれ確認する必要がなく、isNull のようなユーティリティを引き続き使用できるという実用上の理由もあります。

Copied!
1 2 3 4 5 6 7 8 # 悪い例 df = df.withColumn('foo', F.lit('')) # 悪い例 df = df.withColumn('foo', F.lit('NA')) # 良い例 - `None` にはデータ型がないため、キャストが必要なことに注意してください。想定される用途に基づいて適切なデータ型を選択します。 df = df.withColumn('foo', F.lit(None).cast('string'))

コメントを使用する

コメントはコードに関する有用なインサイトを提供できますが、多くの場合、コードをリファクタリングして可読性を向上させる方が価値があります。コードはそれ自体で読みやすいものであるべきです。コメントを使ってロジックをステップごとに説明している場合は、コードをリファクタリングする必要があります。

Copied!
1 2 3 4 5 6 # 悪い例 # タイムスタンプ列をキャストします cols = ["start_date", "delivery_date"] for c in cols: df = df.withColumn(c, F.from_unixtime(df[c] / 1000).cast(TimestampType()))

上の例では、それらの列がタイムスタンプにキャストされていることがわかります。このコメントにはあまり価値がありません。さらに、詳しいコメントであっても、コードにすでに含まれている情報を説明するだけなら、役に立たない場合があります。たとえば、次のようなコメントです。

Copied!
1 2 3 4 5 6 # 悪い例 # 各列を順に処理し、ミリ秒なので1000を除去してタイムスタンプにキャストします cols = ["start_date", "delivery_date"] for c in cols: df = df.withColumn(c, F.from_unixtime(df[c] / 1000).cast(TimestampType()))

記述したロジックを説明するだけのコメントを残すのではなく、コードを書く際の判断の「理由」を説明し、背景を伝えるコメントを残すことを目指します。これは PySpark では特に重要です。読み手はコードを理解できても、PySpark トランスフォームに入力されるデータの背景を知らないことが多いためです。わずかなロジックであっても、正しい動作を理解するためにデータを何時間も調べた結果かもしれません。そのような場合、根拠を説明するコメントは特に価値があります。

Copied!
1 2 3 4 5 6 7 8 # 良い例 # このデータセットの利用者は日付ではなくタイムスタンプを想定しており、 # 元のデータソースがこれらをミリ秒として保存しているため、時間を1000で調整する必要があります。 # ドキュメントには実際は日付であると記載されているものの、ミリ秒で保存されています。 cols = ["start_date", "delivery_date"] for c in cols: df = df.withColumn(c, F.from_unixtime(df[c] / 1000).cast(TimestampType()))

UDF(ユーザー定義関数)

PySpark のネイティブ関数で同じロジックを表現できる場合は、UDF の使用を避けてください。UDF は大幅に遅いためです。使用しなければならない場合は、実装のガイダンスと例についてユーザー定義関数を参照してください。

Collect(および関連する関数)

次のような、Spark ドライバーにデータを収集する関数の使用は常に避けてください。

  • DataFrame.collect()
  • DataFrame.first()
  • DataFrame.head(...)
  • DataFrame.take(...)
  • DataFrame.show(...)

これらの関数を使用すると、Spark のような分散フレームワークの利点が失われ、パフォーマンスの低下やメモリー不足エラーにつながります。これらの関数の代わりに、次を使用することを強く推奨します。

  • トランスフォームの開発時に変数(Spark DataFrame を含む)の状態を確認するには、Code Repositories のプレビューとデバッグ機能を使用します。
  • DataFrame 内の値のマッピングや集計を処理するには、可能な場合は PySpark のネイティブ関数を使用し、ネイティブ関数でロジックを記述できないまれな場合には UDF を使用します。

結合

結合には注意が必要です。左結合を実行し、右側にあるキーに対して複数の一致がある場合、その行は一致した数だけ複製されます。これは「結合の爆発」と呼ばれ、データセットのサイズを大幅に肥大化させる可能性があります。行が増えることを想定している場合を除き、結合に使用するキーが一意であることを、常に前提に立ち返って再確認してください。

不適切な結合は、デバッグが難しい多くの問題の原因になります。デフォルト値(inner)を使用する場合でも how を明示的に指定するなど、役立つ方法があります。

Copied!
1 2 3 4 5 6 7 8 # 悪い例 flights = flights.join(aircraft, 'aircraft_id') # これも悪い例 flights = flights.join(aircraft, 'aircraft_id', 'inner') # 良い例 flights = flights.join(aircraft, 'aircraft_id', how='inner')

また、right 結合も避けてください。right 結合を使用しようとしている場合は、データフレームの順序を入れ替えて、代わりに left 結合を使用します。操作対象のデータフレームが結合の中心となるため、こちらの方が直感的です。

Copied!
1 2 3 4 5 # 悪い例 flights = aircraft.join(flights, 'aircraft_id', how='right') # 良い例 flights = flights.join(aircraft, 'aircraft_id', how='left')

データフレームを結合する際は、出力で列を重複させる式を使用しないでください。

Copied!
1 2 3 4 5 # 悪い例 - 出力で aircraft_id 列が重複します output = flights.join(aircraft, flights.aircraft_id == aircraft.aircraft_id, how='inner') # 良い例 output = flights.join(aircraft, 'aircraft_id', how='inner')

名前の衝突を避けるために、すべての列の名前を変更することは避けてください。代わりに、データフレーム全体にエイリアスを付け、そのエイリアスを使用して最終的に必要な列を選択できます。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # 悪い例 columns = ["start_time", "end_time", "idle_time", "total_time"] for col in columns: flights = flights.withColumnRenamed(col, 'flights_' + col) parking = parking.withColumnRenamed(col, 'parking_' + col) flights = flights.join(parking, on="flight_code", how="left") flights = flights.select( F.col("flights_start_time").alias("flight_start_time"), F.col("flights_end_time").alias("flight_end_time"), F.col("parking_total_time").alias("client_parking_total_time") ) # 良い例 flights = flights.alias("flights") parking = parking.alias("parking") flights = flights.join(parking, on="flight_code", how="left") flights = flights.select( F.col("flights.start_time").alias("flight_start_time"), F.col("flights.end_time").alias("flight_end_time"), F.col("parking.total_time").alias("client_parking_total_time") )

ただし、このような場合には、次の点に注意してください。

  • 重複する列の両方が必要でなければ、結合する前にそれらの列を削除するのが最善と考えられます。
  • 両方が必要な場合は、結合する前に片方の名前を変更するのが最善かもしれません。
  • トランスフォームの実行が完了すると、曖昧な列を区別できなくなるため、データセットを出力する前に必ず曖昧さを解消してください。

結合について最後に、.dropDuplicates() や .distinct() を安易な対処として使用しないでください。予期しない重複行が見つかった場合、その重複行が現れる根本的な理由がほぼ必ずあります。.dropDuplicates() を追加しても、この問題を覆い隠すだけであり、実行時間のオーバーヘッドも増加します。

式のチェーン

式のチェーンには議論の余地がありますが、使用にあたっては一定の制限を設けることを推奨します。この推奨の根拠については、このセクションの結論を参照してください。

種類の異なる式をチェーンして、複数行にわたる式にすることは避けてください。特に、動作やコンテキストが異なる場合は注意が必要です。たとえば、列の作成や結合を、選択や絞り込みと混在させる場合が該当します。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 # 悪い例 df = ( df .select("a", "b", "c", "key") .filter(df.a == "truthiness") .withColumn("boverc", df.b / df.c) .join(df2, "key", how="inner") .join(df3, "key", how="left") .drop('c') ) # より良い例(ステップに分けます) # 第1段階:必要なデータを選択して絞り込みます # 第2段階:必要な列を作成します # 第3段階:ほかの DataFrame と結合します df = ( df .select("a", "b", "c", "key") .filter(df.a == "truthiness") ) df = df.withColumn("boverc", df.b / df.c) df = ( df .join(df2, "key", how="inner") .join(df3, "key", how="left") .drop('c') )

式のグループをそれぞれ独立した論理的なコードブロックに分けると、読みやすくなり、関連するロジックを見つけやすくなります。

たとえば、以下のコードを読む人は、データフレームが代入されている df = df... の箇所に目を向ける可能性が高いでしょう。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 悪い例 df = ( df .select('foo', 'bar', 'foobar', 'abc') .filter(df.abc == 123) .join(another_table, 'some_field') ) # より良い例 df = ( df .select('foo', 'bar', 'foobar', 'abc') .filter(F.col('abc') == 123) ) df = df.join(another_table, 'some_field', how='inner')

式をチェーンすることには正当な理由もあります。通常、こうしたチェーンは不可分なロジックのステップを表しており、問題ありません。コードの読みやすさを保つため、同じブロック内でチェーンする式の数に上限を設けるルールを適用します。チェーンは最大3〜5文にとどめることを推奨します。

チェーンが長くなっている場合や、変数のサイズが原因で問題が生じている場合は、ロジックを別の関数に抽出することを検討してください。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 # 悪い例 customers_with_shipping_address = ( customers_with_shipping_address .select("a", "b", "c", "key") .filter(F.col("a") == "truthiness") .withColumn("boverc", F.col("b") / F.col("c")) .join(df2, "key", how="inner") ) # これも悪い例 customers_with_shipping_address = customers_with_shipping_address.select("a", "b", "c", "key") customers_with_shipping_address = customers_with_shipping_address.filter(df.a == "truthiness") customers_with_shipping_address = customers_with_shipping_address.withColumn("boverc", F.col("b") / F.col("c")) customers_with_shipping_address = customers_with_shipping_address.join(df2, "key", how="inner") # より良い例 def join_customers_with_shipping_address(customers, df_to_join): customers = ( customers .select("a", "b", "c", "key") .filter(df.a == "truthiness") ) customers = customers.withColumn("boverc", F.col("b") / F.col("c")) customers = customers.join(df_to_join, "key", how="inner") return customers

実際、3文を超えるチェーンは、すでにカプセル化された独立したロジックのブロックであるため、適切な名前を付けた別の関数に切り出す有力な候補です。

根拠

チェーンにこうした制限を設ける理由はいくつかあります。

  • PySpark のコードと SQL のコードを区別するためです。チェーンは、Python のほかのスタイルのすべてとはいわないまでも、そのほとんどに反します。Python ではチェーンではなく、代入を行います。
  • 大きな単一のコードブロックの作成を避けるためです。こうしたブロックは、名前を付けた関数として抽出したほうが理にかなっていることがよくあります。
  • チェーンを全面的に許可するか禁止するかの二者択一にする必要はありません。チェーンの上限を3〜5行にすると、実用性と読みやすさのバランスが取れます。
  • IDE を使用している場合、自動抽出やコードの移動(たとえば、PyCharm の Ctrl/Cmd+Shift+Up)がしやすくなります。
  • 長いチェーンは、特に入れ子になっている場合、読みづらく、保守しづらくなります。

複数行の式

式をチェーンできるのは、PySpark が、JVM 言語に由来する Spark をもとに開発されたためです。そのため、いくつかの設計パターン、具体的にはチェーン可能性が引き継がれました。

ただし、Python は複数行の式を自然には扱えず、明示的な改行を指定するか、式を括弧で囲むかのどちらかしか方法がありません。明示的な改行を指定する必要があるのは、チェーンがルートノードで行われる場合だけです。以下に例を示します。

Copied!
1 2 3 4 5 6 7 8 9 # `\` が必要です df = df.filter(F.col('event') == 'executing')\ .filter(F.col('has_tests') == True)\ .drop('has_tests') # ルートノードの下のチェーンには `\` は不要です df = df.withColumn('safety', F.when(F.col('has_tests') == True, 'is safe') .when(F.col('has_executed') == True, 'no tests but runs') .otherwise('not safe'))

一貫性を保つため、式全体を括弧で囲み、\ の使用は避けてください。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 # 悪い例 df = df.filter(F.col('event') == 'executing')\ .filter(F.col('has_tests') == True)\ .drop('has_tests') # 良い例 df = ( df .filter(F.col('event') == 'executing') .filter(F.col('has_tests') == True) .drop('has_tests') )

その他の考慮事項と推奨事項

  • 関数が大きくなりすぎないよう注意してください。一般的な目安として、ファイルは250行以内、関数は70行以内に収めるべきです。
  • コードは論理的なブロックにまとめるようにしてください。たとえば、同じものを参照する行が複数ある場合は、それらをまとめておきます。離して配置すると、読む人がコンテキストを把握しづらくなります。
  • コードをテストしてください。ローカルテストを実行できる場合は実行し、新しいコードがテストでカバーされていることを確認してください。ローカルテストを実行できない場合は、このブランチでデータセットをビルドし、データが期待される状態になっていることを手動で確認してください。
  • 汎用的なフォールバックとして .otherwise(value) を使用することは避けてください。キーのリストを値のリストに対応付ける際に、不明なキーがいくつも現れた場合、otherwise を使用すると、それらがすべて同じ値に置き換えられ、見えなくなります。
  • コメントアウトしたコードをリポジトリにチェックインしたままにしないでください。これは、1行のコード、関数、クラス、モジュールのいずれにも当てはまります。代わりに、git のブランチ機能や履歴の参照機能を活用してください。
  • 複数の異なるソーステーブルを統合する大きな単一の変換がある場合は、自然なサブステップに分割し、ロジックを関数に抽出します。これにより、全体像を読み取りやすくなり、コードの再利用やトランスフォーム間の一貫性を確保できます。
  • 関数や変数の名前は、できる限り明確で内容を説明するものにするよう心がけてください。オブジェクトの変換をラップする最上位のトランスフォームを除き、内部で使われるオブジェクトだけに基づいて名前を付けるのではなく、関数が実際に行う処理を表すようにしてください。
  • 十分な理由がない限り、新しいインポートエイリアスの導入は慎重に検討してください。たとえば、インポートしたモジュールが異なるファイル内で何度も呼び出され、ほぼすべての開発者にとって一般的でなじみのあるモジュールであれば、妥当な場合があります。定着している例としては、PySpark の types と functions に対する from pyspark.sql import types as T, functions as F があります。
  • 絞り込み条件や新しい列の値などで、文字列リテラルや整数リテラルを使用することは避けてください。代わりに、その意味を表す変数、定数、辞書、クラスに代入します。この方法により、コードが読みやすくなり、リポジトリ全体で一貫性が高まります。