次の形式のPySpark DataFrameがあるとします。
+----+--------+
|time|messages|
+----+--------+
| t01| [m1]|
| t03|[m1, m2]|
| t04| [m2]|
| t06| [m3]|
| t07|[m3, m1]|
| t08| [m1]|
| t11| [m2]|
| t13|[m2, m4]|
| t15| [m2]|
| t20| [m4]|
| t21| []|
| t22|[m1, m4]|
+----+--------+
同じメッセージを含む実行を圧縮するためにそれをリファクタリングしたいと思います(出力の順序はあまり重要ではありませんが、わかりやすくするために並べ替えました)。
+----------+--------+-------+
|start_time|end_time|message|
+----------+--------+-------+
| t01| t03| m1|
| t07| t08| m1|
| t22| t22| m1|
| t03| t04| m2|
| t11| t15| m2|
| t06| t07| m3|
| t13| t13| m4|
| t20| t20| m4|
| t22| t22| m4|
+----------+--------+-------+
(つまり、message
列をシーケンスとして扱い、各メッセージの「実行」の開始と終了を識別します)、
Sparkでこの変換を行うクリーンな方法はありますか?現在、これを6 GBのTSVとしてダンプし、命令的に処理しています。
これをtoPandas
- ingして、Pandasがこの集約を行うクリーンな方法を持っている場合、ドライバーに蓄積する可能性があります。
(ナイーブなベースライン実装については 以下の私の答え を参照してください)。
ここでは、 配列関数の情報をspark 2.4 で確認できます。explode_outerは、空の配列での分解です。 「null」値を持つ行を生成します。
最初に、各瞬間、開始するメッセージの配列、および各瞬間に終了するメッセージの配列(start_ofおよびend_of)を取得します。
次に、メッセージが開始または終了する瞬間のみを保持し、作成してから分解して、各メッセージの開始と終了ごとに1つずつ、3つの列を持つデータフレームを作成します。 m1とm2が作成される瞬間には2つの開始行が生成され、m1が開始して終了する瞬間には2つの行が生成され、m1スターとm1終了が生成されます。
そして最後に、ウィンドウ関数を使用して「メッセージ」ごとにグループ化し、時間順に並べ替えます。メッセージが開始し、同時に終了する(同時に)場合は、最初に開始するようにしてください。これで、各開始後に終了行があることを保証できます。それらを混ぜると、各メッセージの開始と終了があります。
考えるのに最適な練習です。
私はscalaで例を作成しましたが、簡単に翻訳できるはずです。 showAndContinueとしてマークされた各行は、その状態での例を出力して、その動作を示します。
val w = Window.partitionBy().orderBy("time")
val w2 = Window.partitionBy("message").orderBy($"time", desc("start_of"))
df.select($"time", $"messages", lag($"messages", 1).over(w).as("pre"), lag("messages", -1).over(w).as("post"))
.withColumn("start_of", when($"pre".isNotNull, array_except(col("messages"), col("pre"))).otherwise($"messages"))
.withColumn("end_of", when($"post".isNotNull, array_except(col("messages"), col("post"))).otherwise($"messages"))
.filter(size($"start_of") + size($"end_of") > 0)
.showAndContinue
.select(explode(array(
struct($"time", $"start_of", array().as("end_of")),
struct($"time", array().as("start_of"), $"end_of")
)).as("elem"))
.select("elem.*")
.select($"time", explode_outer($"start_of").as("start_of"), $"end_of")
.select( $"time", $"start_of", explode_outer($"end_of").as("end_of"))
.filter($"start_of".isNotNull || $"end_of".isNotNull)
.showAndContinue
.withColumn("message", when($"start_of".isNotNull, $"start_of").otherwise($"end_of"))
.showAndContinue
.select($"message", when($"start_of".isNotNull, $"time").as("starts_at"), lag($"time", -1).over(w2).as("ends_at"))
.filter($"starts_at".isNotNull)
.showAndContinue
そしてテーブル
+----+--------+--------+--------+--------+--------+
|time|messages| pre| post|start_of| end_of|
+----+--------+--------+--------+--------+--------+
| t01| [m1]| null|[m1, m2]| [m1]| []|
| t03|[m1, m2]| [m1]| [m2]| [m2]| [m1]|
| t04| [m2]|[m1, m2]| [m3]| []| [m2]|
| t06| [m3]| [m2]|[m3, m1]| [m3]| []|
| t07|[m3, m1]| [m3]| [m1]| [m1]| [m3]|
| t08| [m1]|[m3, m1]| [m2]| []| [m1]|
| t11| [m2]| [m1]|[m2, m4]| [m2]| []|
| t13|[m2, m4]| [m2]| [m2]| [m4]| [m4]|
| t15| [m2]|[m2, m4]| [m4]| []| [m2]|
| t20| [m4]| [m2]| []| [m4]| [m4]|
| t22|[m1, m4]| []| null|[m1, m4]|[m1, m4]|
+----+--------+--------+--------+--------+--------+
+----+--------+------+
|time|start_of|end_of|
+----+--------+------+
| t01| m1| null|
| t03| m2| null|
| t03| null| m1|
| t04| null| m2|
| t06| m3| null|
| t07| m1| null|
| t07| null| m3|
| t08| null| m1|
| t11| m2| null|
| t13| m4| null|
| t13| null| m4|
| t15| null| m2|
| t20| m4| null|
| t20| null| m4|
| t22| m1| null|
| t22| m4| null|
| t22| null| m1|
| t22| null| m4|
+----+--------+------+
+----+--------+------+-------+
|time|start_of|end_of|message|
+----+--------+------+-------+
| t01| m1| null| m1|
| t03| m2| null| m2|
| t03| null| m1| m1|
| t04| null| m2| m2|
| t06| m3| null| m3|
| t07| m1| null| m1|
| t07| null| m3| m3|
| t08| null| m1| m1|
| t11| m2| null| m2|
| t13| m4| null| m4|
| t13| null| m4| m4|
| t15| null| m2| m2|
| t20| m4| null| m4|
| t20| null| m4| m4|
| t22| m1| null| m1|
| t22| m4| null| m4|
| t22| null| m1| m1|
| t22| null| m4| m4|
+----+--------+------+-------+
+-------+---------+-------+
|message|starts_at|ends_at|
+-------+---------+-------+
| m1| t01| t03|
| m1| t07| t08|
| m1| t22| t22|
| m2| t03| t04|
| m2| t11| t15|
| m3| t06| t07|
| m4| t13| t13|
| m4| t20| t20|
| m4| t22| t22|
+-------+---------+-------+
作成された最初のテーブルで、同じ瞬間に開始および終了するすべての要素を抽出して最適化することができるため、開始と終了を「一致」させる必要はありませんが、これが一般的なケースであるかどうかによって異なります。またはほんの少しのケース。最適化するとこのようになります(同じウィンドウ)
val dfStartEndAndFiniteLife = df.select($"time", $"messages", lag($"messages", 1).over(w).as("pre"), lag("messages", -1).over(w).as("post"))
.withColumn("start_of", when($"pre".isNotNull, array_except(col("messages"), col("pre"))).otherwise($"messages"))
.withColumn("end_of", when($"post".isNotNull, array_except(col("messages"), col("post"))).otherwise($"messages"))
.filter(size($"start_of") + size($"end_of") > 0)
.withColumn("start_end_here", array_intersect($"start_of", $"end_of"))
.withColumn("start_of", array_except($"start_of", $"start_end_here"))
.withColumn("end_of", array_except($"end_of", $"start_end_here"))
.showAndContinue
val onlyStartEndSameMoment = dfStartEndAndFiniteLife.filter(size($"start_end_here") > 0)
.select(explode($"start_end_here"), $"time".as("starts_at"), $"time".as("ends_at"))
.showAndContinue
val startEndDifferentMoment = dfStartEndAndFiniteLife
.filter(size($"start_of") + size($"end_of") > 0)
.showAndContinue
.select(explode(array(
struct($"time", $"start_of", array().as("end_of")),
struct($"time", array().as("start_of"), $"end_of")
)).as("elem"))
.select("elem.*")
.select($"time", explode_outer($"start_of").as("start_of"), $"end_of")
.select( $"time", $"start_of", explode_outer($"end_of").as("end_of"))
.filter($"start_of".isNotNull || $"end_of".isNotNull)
.showAndContinue
.withColumn("message", when($"start_of".isNotNull, $"start_of").otherwise($"end_of"))
.showAndContinue
.select($"message", when($"start_of".isNotNull, $"time").as("starts_at"), lag($"time", -1).over(w2).as("ends_at"))
.filter($"starts_at".isNotNull)
.showAndContinue
val result = onlyStartEndSameMoment.union(startEndDifferentMoment)
result.orderBy("col", "starts_at").show()
そしてテーブル
+----+--------+--------+--------+--------+------+--------------+
|time|messages| pre| post|start_of|end_of|start_end_here|
+----+--------+--------+--------+--------+------+--------------+
| t01| [m1]| null|[m1, m2]| [m1]| []| []|
| t03|[m1, m2]| [m1]| [m2]| [m2]| [m1]| []|
| t04| [m2]|[m1, m2]| [m3]| []| [m2]| []|
| t06| [m3]| [m2]|[m3, m1]| [m3]| []| []|
| t07|[m3, m1]| [m3]| [m1]| [m1]| [m3]| []|
| t08| [m1]|[m3, m1]| [m2]| []| [m1]| []|
| t11| [m2]| [m1]|[m2, m4]| [m2]| []| []|
| t13|[m2, m4]| [m2]| [m2]| []| []| [m4]|
| t15| [m2]|[m2, m4]| [m4]| []| [m2]| []|
| t20| [m4]| [m2]| []| []| []| [m4]|
| t22|[m1, m4]| []| null| []| []| [m1, m4]|
+----+--------+--------+--------+--------+------+--------------+
+---+---------+-------+
|col|starts_at|ends_at|
+---+---------+-------+
| m4| t13| t13|
| m4| t20| t20|
| m1| t22| t22|
| m4| t22| t22|
+---+---------+-------+
+----+--------+--------+--------+--------+------+--------------+
|time|messages| pre| post|start_of|end_of|start_end_here|
+----+--------+--------+--------+--------+------+--------------+
| t01| [m1]| null|[m1, m2]| [m1]| []| []|
| t03|[m1, m2]| [m1]| [m2]| [m2]| [m1]| []|
| t04| [m2]|[m1, m2]| [m3]| []| [m2]| []|
| t06| [m3]| [m2]|[m3, m1]| [m3]| []| []|
| t07|[m3, m1]| [m3]| [m1]| [m1]| [m3]| []|
| t08| [m1]|[m3, m1]| [m2]| []| [m1]| []|
| t11| [m2]| [m1]|[m2, m4]| [m2]| []| []|
| t15| [m2]|[m2, m4]| [m4]| []| [m2]| []|
+----+--------+--------+--------+--------+------+--------------+
+----+--------+------+
|time|start_of|end_of|
+----+--------+------+
| t01| m1| null|
| t03| m2| null|
| t03| null| m1|
| t04| null| m2|
| t06| m3| null|
| t07| m1| null|
| t07| null| m3|
| t08| null| m1|
| t11| m2| null|
| t15| null| m2|
+----+--------+------+
+----+--------+------+-------+
|time|start_of|end_of|message|
+----+--------+------+-------+
| t01| m1| null| m1|
| t03| m2| null| m2|
| t03| null| m1| m1|
| t04| null| m2| m2|
| t06| m3| null| m3|
| t07| m1| null| m1|
| t07| null| m3| m3|
| t08| null| m1| m1|
| t11| m2| null| m2|
| t15| null| m2| m2|
+----+--------+------+-------+
+-------+---------+-------+
|message|starts_at|ends_at|
+-------+---------+-------+
| m1| t01| t03|
| m1| t07| t08|
| m2| t03| t04|
| m2| t11| t15|
| m3| t06| t07|
+-------+---------+-------+
+---+---------+-------+
|col|starts_at|ends_at|
+---+---------+-------+
| m1| t01| t03|
| m1| t07| t08|
| m1| t22| t22|
| m2| t03| t04|
| m2| t11| t15|
| m3| t06| t07|
| m4| t13| t13|
| m4| t20| t20|
| m4| t22| t22|
+---+---------+-------+
ウィンドウ操作を適用するときにパーティション分割できる場合、これを適切にスケーリングする適切な方法を見つけました(実際のデータセットで実行できるはずですが、この問題の原因となったデータセットでも実行できました)。
説明のために、それをチャンクに分割しました(インポートは最初のスニペットのみです)。
セットアップ:
# Need these for the setup
import pandas as pd
from pyspark.sql.types import ArrayType, StringType, StructField, StructType
# We'll need these later
from pyspark.sql.functions import array_except, coalesce, col, explode, from_json, lag, lit, rank
from pyspark.sql.window import Window
rows = [
['t01',['m1']],
['t03',['m1','m2']],
['t04',['m2']],
['t06',['m3']],
['t07',['m3','m1']],
['t08',['m1']],
['t11',['m2']],
['t13',['m2','m4']],
['t15',['m2']],
['t20',['m4']],
['t21',[]],
['t22',['m1','m4']],
]
pdf = pd.DataFrame(rows,columns=['time', 'messages'])
schema = StructType([
StructField("time", StringType(), True),
StructField("messages", ArrayType(StringType()), True)
])
df = spark.createDataFrame(pdf,schema=schema)
時間順に並べ替え、メッセージ配列の差分を生成して、実行の開始と終了を識別します。
w = Window().partitionBy().orderBy('time')
df2 = df.withColumn('messages_lag_1', lag('messages', 1).over(w))\
.withColumn('end_time', lag('time', 1).over(w))\
.withColumnRenamed('time', 'start_time')\
.withColumn('messages_lag_1', # Replace nulls with []
coalesce( # cargoculted from
col('messages_lag_1'), # https://stackoverflow.com/a/57198009
from_json(lit('[]'), ArrayType(StringType()))
)
)\
.withColumn('message_run_starts', array_except('messages', 'messages_lag_1'))\
.withColumn('message_run_ends', array_except('messages_lag_1', 'messages'))\
.drop(*['messages', 'messages_lag_1']) # ^ only on Spark > 2.4
+----------+--------+------------------+----------------+
|start_time|end_time|message_run_starts|message_run_ends|
+----------+--------+------------------+----------------+
| t01| null| [m1]| []|
| t03| t01| [m2]| []|
| t04| t03| []| [m1]|
| t06| t04| [m3]| [m2]|
| t07| t06| [m1]| []|
| t08| t07| []| [m3]|
| t11| t08| [m2]| [m1]|
| t13| t11| [m4]| []|
| t15| t13| []| [m4]|
| t20| t15| [m4]| [m2]|
| t21| t20| []| [m4]|
| t22| t21| [m1, m4]| []|
+----------+--------+------------------+----------------+
時間とメッセージでグループ化し、開始テーブルと終了テーブルの両方にランクを適用します。結合し、nullの場合はstart_time
からend_time
:
w_start = Window().partitionBy('message_run_starts').orderBy(col('start_time'))
df3 = df2.withColumn('message_run_starts', explode('message_run_starts')).drop('message_run_ends', 'end_time')
df3 = df3.withColumn('start_row_id',rank().over(w_start))
w_end = Window().partitionBy('message_run_ends').orderBy(col('end_time'))
df4 = df2.withColumn('message_run_ends', explode('message_run_ends')).drop('message_run_starts', 'start_time')
df4 = df4.withColumn('end_row_id',rank().over(w_end))
df_combined = df3\
.join(df4, (df3.message_run_starts == df4.message_run_ends) & (df3.start_row_id == df4.end_row_id), how='full')\
.drop(*['message_run_ends','start_row_id','end_row_id'])\
.withColumn('end_time',coalesce(col('end_time'),col('start_time')))
df_combined.show()
+----------+------------------+--------+
|start_time|message_run_starts|end_time|
+----------+------------------+--------+
| t01| m1| t03|
| t07| m1| t08|
| t22| m1| t22|
| t03| m2| t04|
| t11| m2| t15|
| t06| m3| t07|
| t13| m4| t13|
| t20| m4| t20|
| t22| m4| t22|
+----------+------------------+--------+