通過實例學習 PySpark

通過實例學習 PySpark

最近學習了一下 PySpark, 目標是在工作中能將其用上. 在實踐過程中發現, 通過一個個具體的問題來進行學習, 很多內容掌握起來更爲容易. 因此後面如果寫相關的文章, 也會採用實例的方式來介紹.

下面要解決的問題是:

假設用戶購買商品, 其點擊時間記錄在點擊表中, 其下單時間記錄在下單表中, 另外還有一張表記錄用戶的特徵. 現在的目標是, 獲取每個用戶從點擊時間到下單時間的時間間隔, 並和特徵進行拼接. 比如用戶 A:

用戶    點擊時間     下單時間      時間間隔    特徵
user1  2020-05-13  2020-05-14   24*3600s  sex:male age:28

瞭解問題的目標之後, 首先是獲取原始數據.

原始數據獲取

## data.py
click_time = [
    ['user1', '2020-05-13 10:46:43'],
    ['user2', '2020-05-22 08:26:42'],
    ['user3', '2020-05-17 02:42:31'],
    ['user4', '2020-05-23 18:25:23'],
    ['user5', '2020-05-19 13:29:05'],
    ['user6', '2020-05-16 19:48:23'],
    ['user7', '2020-05-20 16:56:13'],
]

order_time = [
    ['user3', '2020-05-18 10:46:43'],
    ['user1', '2020-05-22 08:26:42'],
    ['user5', '2020-05-27 02:42:31'],
    ['user7', '2020-05-23 18:25:23'],
    ['user4', '2020-05-29 13:29:05'],
    ['user2', '2020-05-26 19:48:23'],
    ['user6', '2020-05-20 16:56:13'],
]

features = [
    ['age:26', 'weight:70', 'sex:male', 'id:user2'],
    ['weight:50', 'age:22', 'sex:female', 'id:user1'],
    ['weight:70', 'sex:male'],
    ['age:16', 'weight:63', 'sex:male', 'id:user7'],
    ['age:22', 'sex:male'],
    ['age:33', 'weight:72', 'sex:female', 'id:user5'],
    ['weight:63', 'age:46', 'sex:female', 'id:user4'],
    ['age:45', 'weight:73'],
]

with open('click_time.txt', 'w') as f:
    content = '\n'.join(['\t'.join(item) for item in click_time])
    f.write('{}\n'.format(content))

with open('order_time.txt', 'w') as f:
    content = '\n'.join(['\t'.join(item) for item in order_time])
    f.write('{}\n'.format(content))

with open('features.txt', 'w') as f:
    content = '\n'.join(['\t'.join(item) for item in features])
    f.write('{}\n'.format(content))

SparkSession

下一步, 創建 SparkSession.

from pyspark.sql import SparkSession, Row

spark = SparkSession.builder \
        .appName('test') \
        .master('local') \
        .enableHiveSupport() \
        .getOrCreate()

加載數據

數據文件生成後, 現在使用 PySpark 讀入文件生成 DataFrame. 目前我發現有三種讀入文件的方法.

加載點擊時間表

  1. 使用 HiveQL 語句讀入文件:
click_table = 'click_table'
click_file = 'click_time.txt'
spark.sql("""
    create table if not exists `{}` (
            id STRING,
            click_time STRING
    )
    using hive options (fileFormat 'textfile', fieldDelim '\t')
""".format(click_table))
spark.sql("""
    load data local inpath '{click_file}' into table `{click_table}`
""".format(click_file=click_file, click_table=click_table))
click_df = spark.sql("select * from `{}`".format(click_table))

其中 using hive options (fileFormat 'textfile', fieldDelim '\t') 也可以用 row format delimited fields terminated by '\t' 替換.

  1. 使用 spark.read 讀入文件:
click_file = 'click_time.txt'
df = spark.read.text(click_file).toDF('info')
  1. 使用 sc.textFile 讀入文件:
click_file = 'click_time.txt'
sc = spark.sparkContext
df = sc.textFile(click_file).map(lambda x: Row(info=x)).toDF()

需要注意的是, 使用第 2 以及第 3 種方法, 爲了獲得 id 以及 click_time 兩個 field, 還需要額外的處理:

def time_split(row):
    id, time = row.split('\t')
    return (id, time)

click_df = spark.createDataFrame(
    df.rdd.map(lambda x: time_split(x[0])),
    ['id', 'click_time']
)

注意 dfDataFrame 對象, 使用 .rdd 轉換爲 RDD 對象, 之後使用 .map 方法處理 RDD 中的每個 Row 對象, 在 How to get a value from the Row object in Spark Dataframe?
中談到, Row 繼承於 namedtuple, 因此代碼中的 x[0] (通過索引訪問) 含義是取出 Row 中的值, 當然, 可以使用 x.info (通過 field 訪問) 獲取 Row 中的值.

生成 click_df 後, 可以顯示部分數據看看是否符合預期:

click_df.limit(3).show()
## 或者
click_df.show(3)

效果如下:

注意: 後面爲了數據處理的一致性, 我一律採用第二種方法來讀入數據.

加載下單時間表

基本和加載點擊時間表邏輯相同.

order_file = 'order_time.txt'
df = spark.read.text(order_file).toDF('info')
order_df = spark.createDataFrame(
    df.rdd.map(lambda x: time_split(x[0])),
    ['id', 'order_time']
)

加載 user 特徵表

注意對特徵表的過濾, 因爲有些記錄存在內容缺失, 比如找不到 id 或者 sex.

def feature_split(row):
	row = row.split('\t')
	feature_dict = {item.split(':')[0]: item.split(':')[1] for item in row}
	return feature_dict

df = spark.read.text('features.txt').toDF('info')
feature_df = spark.createDataFrame(
	df.rdd.map(lambda x: feature_split(x[0])),
	['id', 'sex', 'age', 'weight']
)
feature_df = feature_df.filter((feature_df.id.isNotNull()) & \
							   (feature_df.sex.isNotNull())
                               )

schema

注意在加載 user 特徵表時, 對於 sex, age, weight 之類的特徵, 沒有指定它們的類型, 可能默認就是字符串類型了. 爲了顯式指定對應的類型, 需要自定義 schema, 參考: pyspark: ValueError: Some of types cannot be determined after inferring. (我原來遇到過一個錯誤: ValueError: Some of types cannot be determined by the first 100 rows, please try again with sampling 也可以通過自定義 Schema 解決).

代碼如下:

StructField 中的第三個參數含義爲: Boolean nullable, 即是否可以被設置爲 null. (具體參見: spark.sql.types.StructField)

之所以下面的 schema 中全部設置爲 StringType, 是因爲我在 feature_split 函數中將結果均表示成字符串的形式, 比如 age 中的結果不是 int 而是字符串. 如果希望設置 ageIntegerType, 那麼應該修改 feature_split 中的代碼, 將 age 對應的結果用 int() 方法做轉換.

from pyspark.sql.types import (StructType, 
                               StructField,
                               StringType,
                               IntegerType, 
                               DoubleType,
                              )

df = spark.read.text('features.txt').toDF('info')
schema = StructType([StructField("id", StringType(), True), 
                     StructField("sex", StringType(), True),
                     StructField("age", StringType(), True),
                     StructField("weight", StringType(), True),
                ])
feature_df = spark.createDataFrame(
    df.rdd.map(lambda x: feature_split(x[0])),
    schema=schema
)
feature_df = feature_df.filter((feature_df.id.isNotNull()) & \
                               (feature_df.sex.isNotNull()) & \
                               (feature_df.age.isNotNull()) & \
                               (feature_df.weight.isNotNull())
                               )

獲取下單和點擊的時間間隔

爲了獲取下單和點擊的時間間隔, 需要將點擊時間表和下單時間表進行 Left Outer Join, 以獲取每個 user 對應的點擊時間以及下單時間. 然而, 由於 user 可能只進行點擊而未下單, 因此要對結果過濾.

join_df = click_df.join(order_df, click_df.id == order_df.id, how='left') \
                  .select(click_df.id, click_df.click_time, order_df.order_time) \
                  .filter(order_df.order_time.isNotNull())

上面代碼獲取了每個 user 對應的點擊時間以及下單時間, 爲了獲得時間間隔, 需要額外的函數進行處理:

from datetime import datetime

def convert2datetime(s, format='%Y-%m-%d %H:%M:%S'):
    return datetime.strptime(s, format)

def convert2str(s, format='%Y-%m-%d %H:%M:%S'):
    return datetime.strftime(s, format)

def convert(row):
    id, click_time, order_time = row
    click_time, order_time = list(map(convert2datetime, [click_time, order_time]))
    diff = (order_time - click_time).total_seconds()
    return (id, diff)

join_df = spark.createDataFrame(join_df.rdd.map(convert), ['id', 'diff'])

Join 特徵表

最後只需要 Join 特徵表就能達到我們最終的目的:

final_df = join_df.join(feature_df, join_df.id == feature_df.id, how='inner') \
                  .select(join_df.id, feature_df.sex, feature_df.age, feature_df.weight)

可以考慮使用 .show() 輸出結果看看是否符合預期. 此外, 如果想將結果保存在目錄中, 可以使用如下方式完成:

def create(row):
    row = map(str, row)
    line = '\t'.join(row)
    return line

output_dir = 'output'
final_df.rdd.map(create).repartition(2).saveAsTextFile(output_dir)

另外注意, 如果 output_dir 已經存在, 需要提前刪除, 否則程序會報錯.

觀察 output 的文件:

可以發現結果保存在兩個分區中, 比如 part-00001 中保存着:

終曲 & 尾聲

不要忘記

spark.stop()

完整代碼

以上完整代碼如下, 運行起來, 去感受 Spark 的強大 😂😂😂

(發表完博客後補充: 文章在草稿中保存了幾天, 發出來後發現, 結果好像跟一開始設置的目標不太一樣啊 🤣🤣🤣 忘了把時間間隔加到結果中了, 不過這無傷大雅~ 果然寫博客還是得一氣呵成! )

from datetime import datetime
from pyspark.sql import SparkSession, Row
from pyspark.sql.types import (StructType, 
                               StructField,
                               StringType,
                               IntegerType, 
                               DoubleType,
                              )

def time_split(row):
    id, time = row.split('\t')
    return (id, time)

def feature_split(row):
    row = row.split('\t')
    feature_dict = {item.split(':')[0]: item.split(':')[1] for item in row}
    return feature_dict

def convert2datetime(s, format='%Y-%m-%d %H:%M:%S'):
    return datetime.strptime(s, format)

def convert2str(s, format='%Y-%m-%d %H:%M:%S'):
    return datetime.strftime(s, format)

def convert(row):
    id, click_time, order_time = row
    click_time, order_time = list(map(convert2datetime, [click_time, order_time]))
    diff = (order_time - click_time).total_seconds()
    return (id, diff)

def create(row):
    row = map(str, row)
    line = '\t'.join(row)
    return line

spark = SparkSession.builder \
        .appName('test') \
        .master('local') \
        .enableHiveSupport() \
        .getOrCreate()

click_file = 'click_time.txt'
df = spark.read.text(click_file).toDF('info')
click_df = spark.createDataFrame(
    df.rdd.map(lambda x: time_split(x[0])),
    ['id', 'click_time']
)

click_df.show(3)

order_file = 'order_time.txt'
df = spark.read.text(order_file).toDF('info')
order_df = spark.createDataFrame(
    df.rdd.map(lambda x: time_split(x[0])),
    ['id', 'order_time']
)

df = spark.read.text('features.txt').toDF('info')
schema = StructType([StructField("id", StringType(), True), 
                     StructField("sex", StringType(), True),
                     StructField("age", StringType(), True),
                     StructField("weight", StringType(), True),
                ])
feature_df = spark.createDataFrame(
    df.rdd.map(lambda x: feature_split(x[0])),
    schema=schema
)
feature_df = feature_df.filter((feature_df.id.isNotNull()) & \
                               (feature_df.sex.isNotNull()) & \
                               (feature_df.age.isNotNull()) & \
                               (feature_df.weight.isNotNull())
                               )

join_df = click_df.join(order_df, click_df.id == order_df.id, how='left') \
                  .select(click_df.id, click_df.click_time, order_df.order_time) \
                  .filter(order_df.order_time.isNotNull())

join_df = spark.createDataFrame(join_df.rdd.map(convert), ['id', 'diff'])

feature_df.show(3)

final_df = join_df.join(feature_df, join_df.id == feature_df.id, how='inner') \
                  .select(join_df.id, feature_df.sex, feature_df.age, feature_df.weight)

output_dir = 'output'
final_df.rdd.map(create).repartition(2).saveAsTextFile(output_dir)

spark.stop()
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章