Data Engineer · Hands-on test

Data Toolkit 速查

Alteryx、Tableau、PySpark 常用寫法,同埋同一個 use case 喺三個工具點做。考試前掃一次,做題時當字典查。

AlteryxTableauPySpark / Delta通用

常用操作對比 AlteryxPySparkTableau

同一個操作喺三個工具點做。PySpark 例子假設已經 from pyspark.sql import functions as F, Window。Tableau 以 Desktop 為準,有啲操作應該喺上游做,會註明。

操作解釋AlteryxPySparkTableau
讀取 / 輸出
讀 CSV讀一個檔做 table。DE 做法係全部欄先當 string 讀,之後自己轉 type,避免 infer 錯。 Input Data → 揀檔 → ✅ First Row Contains Field Names
df = (spark.read
  .option("header", True)
  .csv("/data/claims.csv"))
Connect → Text file → 揀檔
讀多個檔 + 記來源每月一個檔,一次過讀,加一欄記住每行嚟自邊個檔,方便追查。 Input Data 路徑用 *.csv;Options → Output File Name as Field。Schema 唔同就分開讀再 Union by name
df = (spark.read.option("header", True)
  .csv("/data/pos/*.csv")
  .withColumn("_src",
     F.col("_metadata.file_path")))
Data Source → 拉 New Union → Wildcard;自動多咗 Path 欄
讀 JSONJSONL 每行一個 object,入面可能有 nested array。 Input(CSV,delimiter \0,加大 field length)→ JSON Parse
js = spark.read.json("/data/events.jsonl")
js.printSchema()
Connect → JSON file → 揀要嘅 schema level
寫輸出寫落檔或者 table。重跑要 overwrite,唔好 append 兩次。 Output Data:out.xlsx|||Sheet1,Overwrite Sheet;或者 .hyper
(df.write.format("delta")
   .mode("overwrite")
   .saveAsTable("silver.claims"))
Worksheet → Export → Data;Data Source → Extract(.hyper)
揀欄 / 改名 / Type
揀欄 + 改名只留需要嘅欄,改做清楚嘅名。 Select:取消勾選、改 Rename 格
df = df.select(
  "claim_id",
  F.col("amt").alias("claim_amt"))
欄位右掣 → Hide / Rename
改 data typeString 轉數字 / 日期先可以計數同做時間軸。 Select → Type 揀 Double / Date;或者 Formula 新欄揀 type
df = df.withColumn("paid",
  F.col("paid").cast("double"))
FLOAT([paid])
DATE([claim_date])
STR([store_id])
排欄次序整理輸出,ID 放前,衍生欄放後。 Select → ▲▼ 或拖行
df = df.select("claim_id", "policy_id",
               "claim_dt", "status")
唔需要,view 自己決定
Filter
條件 filter只留符合條件嘅行。Alteryx 有 T / F 兩個 output,唔符合嘅都攞得返。
[status] = "Approved"
AND [claim_amt] > 1000
df.filter((F.col("status") == "Approved")
        & (F.col("claim_amt") > 1000))
拉欄落 Filters shelf;或者 calc filter:
[status] = "Approved"
Filter Null攞走或者搵出空值。
!IsNull([claim_amt])
IsEmpty([x])  // null 或 ""
df.filter(F.col("claim_amt").isNotNull())
Filter → Special → Non-null values
IN list值喺一個清單入面。
[district] IN ("Wan Chai", "Eastern")
F.col("district").isin("Wan Chai", "Eastern")
[district] IN ("Wan Chai", "Eastern")
清理
Trim + 大小寫統一 " APPROVED "、approved 做 Approved。
TitleCase(Trim([status]))
或者 Data Cleansing
F.initcap(F.trim("status"))
PROPER(TRIM([status]))
Replace / Regex刪走貨幣符號、逗號,淨返數字。
ToNumber(REGEX_Replace(
  [amt], "[^0-9.]", ""))
F.expr("try_cast(regexp_replace("
  "amt,'[^0-9.]','') AS DOUBLE)")
FLOAT(REGEXP_REPLACE(
  [amt], "[^0-9.]", ""))
Null 填值Count 類可以補 0。金額未知就保持 Null,唔好亂填。
IIF(IsNull([cnt]), 0, [cnt])
或者 Data Cleansing → Replace Nulls
df.fillna(0, subset=["cnt"])
F.coalesce("cnt", F.lit(0))
ZN(SUM([cnt]))
IFNULL([x], "Unknown")
去重同一個 key 出現幾次,只留一行。 Unique(揀 key)→ U = 留低,D = 重複
df.dropDuplicates(["claim_id"])
上游做;計數用 COUNTD([claim_id])
新欄 / 條件
計算欄由現有欄計出新欄。
// Formula,新欄 type Double
[paid_amt] / [claim_amt]
df.withColumn("pay_rate",
  F.col("paid_amt") / F.col("claim_amt"))
// Row level
[paid_amt] / [claim_amt]
// Aggregate level(KPI 用呢個)
SUM([paid_amt]) / SUM([claim_amt])
IF / CASE按條件分類。
IF [amt] >= 10000 THEN "High"
ELSEIF [amt] >= 1000 THEN "Mid"
ELSE "Low" ENDIF
F.when(F.col("amt") >= 10000, "High")
 .when(F.col("amt") >= 1000, "Mid")
 .otherwise("Low")
IF [amt] >= 10000 THEN "High"
ELSEIF [amt] >= 1000 THEN "Mid"
ELSE "Low" END
Mapping / lookupDistrict → Region 呢類對照。值多就用對照表 join。
Switch([district], "NT",
  "Wan Chai", "HK Island",
  "Kwun Tong", "Kowloon")
或者 Find Replace / Join 對照表
m = {"Wan Chai": "HK Island",
     "Kwun Tong": "Kowloon"}
mp = F.create_map([F.lit(x) for kv
       in m.items() for x in kv])
df.withColumn("region",
  F.coalesce(mp[F.col("district")],
             F.lit("NT")))
欄位右掣 → Create → Group;或者:
CASE [district]
WHEN "Wan Chai" THEN "HK Island"
ELSE "NT" END
日期 / 時間
Parse 日期String 轉 Date。多個 format 就逐個試。
DateTimeParse([d], "%d/%m/%Y")
F.coalesce(
  F.expr("try_to_date(d,'yyyy-MM-dd')"),
  F.expr("try_to_date(d,'dd/MM/yyyy')"))
DATEPARSE("dd/MM/yyyy", [d])
攞日期部分年、月、鐘數、星期幾,用嚟分組。
DateTimeYear([dt])
DateTimeMonth([dt])
DateTimeHour([dt])
DateTimeFormat([dt], "%a")  // Mon
F.year("dt"); F.month("dt")
F.hour("ts")
F.date_format("dt", "E")    # Mon
YEAR([dt])  MONTH([dt])
DATEPART('hour', [ts])
DATENAME('weekday', [dt])
日期差兩個日期相隔幾耐。注意次序:遲嗰個放前。
DateTimeDiff([end], [start], "days")
F.datediff("end", "start")
# 帶小數日數
(F.unix_timestamp("end")
 - F.unix_timestamp("start")) / 86400
DATEDIFF('day', [start], [end])
日期加減例如 due date = 開單日 + 30 日。
DateTimeAdd([dt], 30, "days")
F.date_add("dt", 30)
F.add_months("dt", 1)
DATEADD('day', 30, [dt])
Truncate 到月將日期變做該月第一日,做月度分組。
DateTimeTrim([dt], "month")
DateTimeFormat([dt], "%Y-%m")
F.date_trunc("month", "dt")
F.date_format("dt", "yyyy-MM")
DATETRUNC('month', [dt])
或者日期右掣 → Month(continuous)
Epoch / 時區Epoch 秒數轉時間;UTC 轉香港時間(+8,冇夏令時間)。
DateTimeAdd("1970-01-01 00:00:00",
  [epoch], "seconds")
DateTimeAdd([utc_ts], 8, "hours")
F.from_utc_timestamp(
  F.timestamp_seconds("epoch"),
  "Asia/Hong_Kong")
DATEADD('hour', 8,
  DATEADD('second', [epoch],
    #1970-01-01#))
Aggregate
Group by + sum / count按 dimension 分組計總數。 Summarize:Group By product;Sum paid_amt;Count claim_id
df.groupBy("product").agg(
  F.sum("paid_amt").alias("paid"),
  F.count("*").alias("n"))
product 拉落 Rows,SUM([paid_amt]) 拉落 Columns
Count distinct唯一值數量,例如 order 數(唔係 line 數)。 Summarize → Count Distinct
F.countDistinct("order_id")
COUNTD([order_id])
% of total每組佔總數幾多。 Summarize 計 total → Append Fields → Formula
[paid] / [total_paid]
w = Window.partitionBy()
df.withColumn("pct",
  F.col("paid") / F.sum("paid").over(w))
SUM([paid]) / TOTAL(SUM([paid]))
或者 Quick Table Calc → Percent of Total
固定 grain 計算喺指定層級計,唔受 view 影響。例如每單 order 總額。 Summarize 按 order_id → Join 返原 data
w = Window.partitionBy("order_id")
df.withColumn("order_total",
  F.sum("amt").over(w))
{FIXED [order_id] : SUM([amt])}
合併
Inner join兩邊都有嘅 key 先留。 Join → By Specific Fields → 用 J output
c.join(p, "policy_id", "inner")
Data Source:拖兩個 table,揀 Inner,設 key
Left join左邊全部保留,右邊對唔到就係 Null。 Join → Union(J + L)
c.join(p, "policy_id", "left")
Join 揀 Left;或者用 Relationship(預設)
搵孤兒(anti join)左邊有、右邊冇嘅 key,係 DQ 問題。 Join 嘅 L output
c.join(p, "policy_id", "left_anti")
Left join 後 filter:
ISNULL([policy_id (policies)])
Union上下疊埋。一定要按欄名對齊,唔好按位置。 Union → Auto Config by Name
a.unionByName(b,
  allowMissingColumns=True)
Data Source → New Union
Cross join每個組合都出一行,用嚟整 scaffold(product × 月份)。 Append Fields(T = 月份,S = product)
months.crossJoin(products)
Join calculation 兩邊都填 1
Range join按時間範圍對版本,例如買嘢嗰刻嘅會員 tier。 Join(member_id)→ Filter:
[ts] >= [start] AND
([ts] < [end] OR IsNull([end]))
cond = ((f.mid == d.mid)
  & (f.ts >= d.start)
  & (f.ts < F.coalesce(d.end,
      F.lit("9999-12-31")
       .cast("timestamp"))))
f.join(d, cond, "left")
上游做;Tableau 做好慢
排序 / Window
Sort排序。Unique、Multi-Row 之前一定要做。 Sort:policy_id ↑,claim_dt ↑
df.orderBy("policy_id",
           F.desc("claim_dt"))
撳軸上 sort icon;或者右掣 → Sort
Rank / 每組 Top N每個 region 攞頭 3 名。 Sort → Sample:First 3,Group By region
w = (Window.partitionBy("region")
     .orderBy(F.desc("paid")))
top3 = (df
  .withColumn("rk", F.dense_rank().over(w))
  .filter("rk <= 3"))
RANK(SUM([paid]))
Compute Using 設 adjuster,filter ≤ 3
上一行 / 下一行同上一單比較,例如相隔幾多日。 Multi-Row Formula(Group By policy_id):
DateTimeDiff([claim_dt],
  [Row-1:claim_dt], "days")
w = (Window.partitionBy("policy_id")
     .orderBy("claim_dt"))
df.withColumn("prev",
  F.lag("claim_dt").over(w))
LOOKUP(SUM([sales]), -1)
累計Running total,由頭加到而家。 Running Total tool(Group By 可選)
w = (Window.partitionBy("store")
  .orderBy("dt")
  .rowsBetween(Window.unboundedPreceding, 0))
df.withColumn("cum", F.sum("sales").over(w))
RUNNING_SUM(SUM([sales]))
移動平均例如 7 日平均,令趨勢平滑啲。 Multi-Row Formula:
([Row-0:x]+[Row-1:x]+[Row-2:x])/3
w = (Window.partitionBy("store")
     .orderBy("dt").rowsBetween(-6, 0))
df.withColumn("ma7", F.avg("sales").over(w))
WINDOW_AVG(SUM([sales]), -6, 0)
重塑
Pivot(長 → 闊)行 = product,欄 = 年份。 Cross Tab:Group By product,Header year,Value paid,Sum
df.groupBy("product").pivot("year")
  .sum("paid")
product → Rows,YEAR → Columns,SUM(paid) → Text
Unpivot(闊 → 長)將幾個月份欄變返做行。 Transpose:Key = product,Data = 2024、2025
df.unpivot(["product"], ["2024","2025"],
  "year", "paid")
Data Source:揀欄 → 右掣 Pivot
Explode array一行有幾個 item,拆做每個 item 一行。 JSON Parse → Text To Columns(拆 items.0.sku)→ Cross Tab
js.select("order_id",
  F.explode("items").alias("it"))
  .select("order_id", "it.sku", "it.qty")
JSON connector 揀 items level,自動拆
Split 字串例如 "HK-001" 拆做地區同編號。 Text To Columns:delimiter -,Split to columns
F.split("code", "-").getItem(0)
SPLIT([code], "-", 1)
其他
生日期序列整一張完整月份表,用嚟補 0。 Generate Rows:
Init:  "2024-01-01"
Cond:  [m] <= "2025-12-01"
Loop:  DateTimeAdd([m],1,"months")
spark.sql("""SELECT explode(sequence(
  DATE'2024-01-01', DATE'2025-12-01',
  INTERVAL 1 MONTH)) AS m""")
日期右掣 → Show Missing Values
Sample / Limit睇頭幾行或者抽樣。 Sample:First N / Random %
df.limit(10); df.sample(0.1, seed=42)
Filter → Top N;Extract → Sample
數行數每一步驗證用。 Browse 睇 record 數;或者 Count Records tool
df.count()
COUNT([claim_id])
或者 claims (Count) 欄
寫 SQL熟 SQL 就直接用。 In-DB tools;或者 Input Data 寫 SQL query(連 DB 時)
df.createOrReplaceTempView("c")
spark.sql("SELECT product, SUM(paid) "
          "FROM c GROUP BY product")
Data Source → New Custom SQL

常見 use case 三個工具對照

同一個問題,三個工具嘅做法。考試通常要求三邊數字一樣,所以每個都要識。

Use caseAlteryxPySparkTableau
Profile / 搵 DQ 問題null、unique、format 唔一致 Browse → Profile tab;Basic Data Profile(CountNull、CountUnique、MinLength) df.summary();null 數:df.select([F.sum(F.col(c).isNull().cast("int")).alias(c) for c in df.columns]) Data Source tab 睇 type;拉欄落 Rows 睇 distinct 值
去重exact duplicate Unique(按 key)→ U / D;勾晒所有欄再 Unique 驗證 df.dropDuplicates(["order_id","line_no"]) 唔適合喺 Tableau 做,上游處理
同一 key 留最新版本upsert / CDC Union → Sort updated_at ↓ → Unique(key) row_number().over(Window.partitionBy("id").orderBy(F.desc("updated_at"))) → rn==1;或 Delta MERGE {FIXED [id] : MAX([updated_at])} = [updated_at] 做 filter
清文字大小寫、空格 TitleCase(Trim([x])) 或 Data Cleansing F.initcap(F.trim("x"));F.upper / F.lower PROPER(TRIM([x]))(Tableau 2023+ 有 PROPER)
多 format 日期yyyy-mm-dd、dd/mm/yyyy IF Contains([d],"/") THEN DateTimeParse([d],"%d/%m/%Y") ELSE DateTimeParse(Left([d],10),"%Y-%m-%d") ENDIF F.coalesce(F.expr("try_to_date(d,'yyyy-MM-dd')"), F.expr("try_to_date(d,'dd/MM/yyyy')")) IFNULL(DATEPARSE("yyyy-MM-dd",[d]), DATEPARSE("dd/MM/yyyy",[d]))
Epoch / 時區UTC → 香港 DateTimeAdd("1970-01-01",[epoch],"seconds") → DateTimeAdd([dt],8,"hours") F.from_utc_timestamp(F.timestamp_seconds("epoch"),"Asia/Hong_Kong") DATEADD('second',[epoch],#1970-01-01#),再 DATEADD('hour',8,…)
文字金額 → 數字HKD 1,200 / $1,200 ToNumber(REGEX_Replace([x],"[^0-9.]","")),type 揀 Double F.expr("try_cast(regexp_replace(x,'[^0-9.]','') AS DOUBLE)") FLOAT(REGEXP_REPLACE([x],"[^0-9.]",""))
Join + 搵孤兒FK 對唔上 Join → J / L / R;J + L = 左邊總數 how="inner";孤兒:how="left_anti" Relationship;孤兒用 Join(left)+ ISNULL([right_key])
Ratio 喺啱嘅 grainloss ratio、avg ticket 兩邊各自 Summarize 到同一層 → Join 兩邊各自 groupBy().agg() → join SUM([a]) / SUM([b]);relationship 唔會重複計 dim
Pivot 長 → 闊 Cross Tab(Group By、Header、Value、Sum) .groupBy("store").pivot("year").sum("sales") Dimension 放 Columns 就係 pivot
Unpivot 闊 → 長 Transpose(Key 欄 + Data 欄) df.unpivot(["store"], ["m1","m2"], "month", "sales")(Spark 3.4+) Data Source:揀欄 → 右掣 Pivot
上一行比較repeat within N 日 Sort → Multi-Row(Group By)DateTimeDiff([d],[Row-1:d],"days") F.datediff("d", F.lag("d").over(Window.partitionBy("id").orderBy("d"))) Table calc:DATEDIFF('day', LOOKUP(MIN([d]),-1), MIN([d])),Compute Using 設好
每組 Top N Summarize → Sort → Sample(First N,Group By) row_number() 或 dense_rank() over partition → filter(rn<=3) INDEX() 或 RANK() table calc ≤ 3;單層用 Filter → Top N
累計 / 移動平均 Running Total tool;Multi-Row 做 moving avg F.sum("x").over(w.rowsBetween(Window.unboundedPreceding, 0));rowsBetween(-6,0) Quick Table Calc → Running Total / Moving Average
YoY / MoM Cross Tab 按年 → Formula ([2025]-[2024])/[2024] F.lag("sales",12).over(w) 或 pivot 後相除 Quick Table Calc → Year over Year Growth
% of total Summarize total → Append Fields → Formula 相除 F.col("x") / F.sum("x").over(Window.partitionBy()) Quick Table Calc → Percent of Total
補 0 / 冇 data 嘅期間scaffold Generate Rows(月份)+ Append Fields(dim)→ Join → Union L → 補 0 sequence() + explode → crossJoin(dim) → left join → fillna(0) Show Missing Values;ZN(SUM([x]))
Schema 唔同嘅多個檔 分開 Input + Output File Name as Field → Union Auto Config by Name df1.unionByName(df2, allowMissingColumns=True);_metadata.file_path Union(Data Source),同名欄自動對齊
Nested JSONarray of structs Input(\0 delimiter)→ Record ID → JSON Parse → Text To Columns → Cross Tab spark.read.json() → F.explode("items") → col("item.sku") 直接連 JSON file,揀要嘅 schema level
SCD Type 2保留維度歷史 Sort → Multi-Row:end = [Row+1:start];is_current = IsNull([end]) lead("start").over(w) 做 end;增量用 Delta MERGE(見下) 用 is_current = TRUE filter 睇現況
Point-in-time join交易嗰刻嘅 tier / 價錢 Join(key)→ Filter [ts] >= [start] AND ([ts] < [end] OR IsNull([end])) join 條件加 ts >= start AND ts < coalesce(end, '9999-12-31') 喺上游做好;Tableau 做 range join 好慢
Order-level 指標avg ticket、attach rate Summarize 按 order(Sum、Max(is_food))→ 再 Summarize 整體 先 groupBy("order_id"),再 groupBy("store") {FIXED [order_id] : MAX(IIF([cat]="Food",1,0))}
繁忙時段weekday × hour DateTimeFormat([ts],"%a")、DateTimeHour([ts]) → Cross Tab F.date_format("ts","E")、F.hour("ts") → groupBy Rows WEEKDAY、Columns HOUR、Marks Square(heatmap)

Alteryx Designer

最常用 tool

Input Data
CSV / xlsx;Options → Output File Name as Field
Select
揀欄、改名、改 type、排次序
Filter
T / F 兩個 output
Formula
新欄記得揀 type(Double、Date)
Data Cleansing
Trim、case、Null → 0
Sort / Unique
先 Sort 再 Unique = 留最新
Join
J 對到、L 左剩、R 右剩
Union
Auto Config by Name
Summarize
Group By + Sum / Count / CountDistinct
Cross Tab / Transpose
長 ↔ 闊
Multi-Row Formula
[Row-1:x]、[Row+1:x],要 Group By
Sample
First N per group
Generate Rows
生日期序列
Append Fields
Cross join
JSON Parse
出 JSON_Name / JSON_ValueString
Text To Columns
按 delimiter 拆欄或拆行
Block Until Done
控制 output 次序
Output Data
file.xlsx|||Sheet;.hyper 俾 Tableau

Formula 函數

// 文字
Trim([x])  TitleCase([x])  Uppercase([x])
Contains([x],"HKD")  StartsWith([x],"P")
Left([x],10)  Substring([x],3,2)  Length([x])
REGEX_Replace([x],"[^0-9.]","")
REGEX_Match([x],"^CL\d{6}$")

// 數字 / Null
ToNumber([x])  ToString([n])  Round([n],0.01)
IsNull([x])  IsEmpty([x])  Null()
IIF([x] > 0, "Y", "N")

// 日期
DateTimeParse([d],"%d/%m/%Y")
DateTimeFormat([d],"%Y-%m")
DateTimeDiff([d2],[d1],"days")
DateTimeAdd([d],1,"months")
DateTimeYear([d])  DateTimeHour([d])
DateTimeToday()  DateTimeNow()

// 條件
IF [a] THEN x ELSEIF [b] THEN y ELSE z ENDIF
[x] IN ("A","B","C")

驗證習慣

  • 每步睇 record 數,寫落 Comment tool
  • Join:J + L = 左邊總數;J 大過左邊 = 右邊 key 重複
  • Unique:勾晒所有欄再做,D 數一樣 = exact duplicate
  • 清理後 Summarize 睇 distinct 值
  • 轉 type 後 Filter IsNull(),數要等於原本 blank
  • Results window 睇黃色 conversion warning
  • 用 Container 按步驟分組,Ctrl+S 勤啲存

Tableau Desktop

砌圖

Bar
Dimension → Rows,Measure → Columns,撳 sort icon
Line
日期右掣 → continuous Month;Color = category
Heatmap
WEEKDAY → Rows,HOUR → Columns,Marks = Square,Color = measure
KPI tile
Measure → Text,Format 放大字;每個 KPI 一張 sheet
Stacked %
Color = channel,Quick Table Calc → Percent of Total(Compute Using: Table Down)
Dual axis
第二個 measure 拉去右邊軸 → 右掣 Dual Axis → Synchronize
Map
欄位右掣 Geographic Role(香港地區未必認到,用 lat/long)

Calculated field

// Ratio:喺入面 aggregate
SUM([Sales]) / COUNTD([Order ID])        // avg ticket
SUM([Paid]) / SUM([Premium])             // loss ratio

// 條件
IF [Channel] = "Mobile" THEN "Digital" ELSE "Store" END
IIF([Sales] > 100, "High", "Low")
ZN(SUM([Sales]))      IFNULL([x], 0)

// 日期
DATETRUNC('month', [Order Date])
DATEPART('hour', [Order TS])
DATEDIFF('day', [Start], [End])
DATEPARSE("dd/MM/yyyy", [Raw Date])

// 文字
TRIM([x])  UPPER([x])  CONTAINS([x],"Latte")
SPLIT([x], "-", 1)

LOD expression

// FIXED:唔理 view,固定 grain
{FIXED [Order ID] : SUM([Sales])}               // order total
{FIXED [Customer] : MIN([Order Date])}          // 首次購買
{FIXED [Order ID] : MAX(IIF([Category]="Food",1,0))}
// → attach rate = SUM(上面) / COUNTD([Order ID])

// INCLUDE:加細 grain 再 aggregate
AVG({INCLUDE [Order ID] : SUM([Sales])})

// EXCLUDE:去走某個 dimension
SUM([Sales]) / SUM({EXCLUDE [Store] : SUM([Sales])})

FIXED 喺 dimension filter 之前計。要佢跟 filter,就將 filter 右掣 Add to Context。

Table calc / Top N / Parameter

Running total
RUNNING_SUM(SUM([Sales]))
Rank
RANK(SUM([Sales]));INDEX()
上一期
LOOKUP(SUM([Sales]), -1)
Growth
(ZN(SUM([S])) - LOOKUP(ZN(SUM([S])),-1)) / ABS(LOOKUP(ZN(SUM([S])),-1))
Compute Using
右掣 → Edit Table Calculation,揀方向(Table Across / Down / Specific Dimensions)
Top N
Parameter(Integer)→ 將 dimension 拉落 Filters → Top → By field → 揀 parameter → Show Parameter
Filter 次序
Extract → Data Source → Context → Dimension → Measure → Table Calc

Dashboard

  • Size 設 Fixed(1200×800)或 Automatic
  • 上面 KPI tile 一排,下面主圖 + 細圖
  • 揀 sheet → funnel icon = Use as Filter
  • Dashboard → Actions → Filter / Highlight action
  • Filter 右掣 → Apply to Worksheets → All Using This Data Source
  • 加 title 同一句 insight
  • 存 .twbx(連 data 一齊)

Data source

  • Relationship(預設,橙色線):按 view 需要先 join,唔會重複計 dim 嘅 measure
  • Join(雙擊入去 physical layer):真 join,one-to-many 會重複計
  • 欄位 type:撳 icon 改(# 數字、日曆 = 日期、Abc 文字)
  • Dimension(藍)= 分組,Measure(綠 = continuous)= 數值
  • .hyper 由 Alteryx Output Data 出,直接連

PySpark DataFrame API

讀 / 寫

from pyspark.sql import functions as F, Window
from pyspark.sql.types import *

df = (spark.read.option("header", True)
      .option("inferSchema", False)
      .csv("/path/pos/*.csv"))
js = spark.read.json("/path/mobile/")         # JSONL 一行一個
df = df.withColumn("_src", F.col("_metadata.file_path"))

# 明確 schema(production 做法)
schema = StructType([
  StructField("order_id", StringType()),
  StructField("amount",   StringType()),
])

(df.write.format("delta").mode("overwrite")
   .saveAsTable("silver.pos"))
df.write.mode("overwrite").partitionBy("dt").parquet("/out")

Transform

df = (df
  .withColumn("channel", F.initcap(F.trim("channel")))
  .withColumn("amt", F.expr(
      "try_cast(regexp_replace(amount,'[^0-9.]','') AS DOUBLE)"))
  .withColumn("ts", F.coalesce(
      F.expr("try_to_timestamp(ts_raw,'yyyy-MM-dd HH:mm:ss')"),
      F.expr("try_to_timestamp(ts_raw,'dd/MM/yyyy HH:mm')"),
      F.timestamp_seconds(F.expr("try_cast(ts_raw AS BIGINT)"))))
  .withColumn("ts_hk", F.from_utc_timestamp("ts", "Asia/Hong_Kong"))
  .withColumn("tier", F.when(F.col("amt") > 100, "High")
                        .otherwise("Low"))
  .filter(F.col("amt").isNotNull())
  .dropDuplicates(["order_id", "line_no"])
  .withColumnRenamed("sku", "product_id")
  .drop("amount"))

Aggregate / Join

agg = (df.groupBy("store_id", F.to_date("ts_hk").alias("dt"))
  .agg(F.sum("amt").alias("sales"),
       F.countDistinct("order_id").alias("orders"),
       F.avg("amt").alias("avg_line")))

f = fact.join(F.broadcast(dim_store), "store_id", "left")
orphans = fact.join(dim_store, "store_id", "left_anti")

# 唔同名 key
a.join(b, a.pid == b.policy_id, "inner")

# pivot
df.groupBy("store_id").pivot("channel").sum("amt")

Window function

w = Window.partitionBy("member_id").orderBy("ts")

df = (df
  .withColumn("prev_ts", F.lag("ts").over(w))
  .withColumn("next_ts", F.lead("ts").over(w))
  .withColumn("gap_days", F.datediff("ts", "prev_ts"))
  .withColumn("rn", F.row_number().over(w))
  .withColumn("running", F.sum("amt").over(
      w.rowsBetween(Window.unboundedPreceding, 0))))

# 每組 Top 3
wr = Window.partitionBy("region").orderBy(F.desc("sales"))
top3 = agg.withColumn("rk", F.dense_rank().over(wr)).filter("rk <= 3")

# 留最新版本
wl = Window.partitionBy("id").orderBy(F.desc("updated_at"))
latest = df.withColumn("rn", F.row_number().over(wl)).filter("rn = 1")

Nested JSON / array

lines = (js
  .select("order_id", "ts_utc", F.explode("items").alias("it"))
  .select("order_id", "ts_utc",
          F.col("it.sku").alias("sku"),
          F.col("it.qty").alias("qty"),
          F.col("it.price").alias("price")))

# explode_outer:空 array 都保留一行
# higher-order function(唔使 explode)
js = js.withColumn("order_total", F.aggregate(
        "items", F.lit(0.0), lambda acc, x: acc + x["qty"] * x["price"]))
js = js.withColumn("n_custom", F.size(F.filter(
        "items", lambda x: F.size(x["customizations"]) > 0)))

Scaffold / 補 0 / SQL

months = spark.sql("""
  SELECT explode(sequence(DATE'2025-01-01', DATE'2025-12-01',
                          INTERVAL 1 MONTH)) AS m""")
grid = months.crossJoin(stores.select("store_id"))
full = (grid.join(monthly, ["m", "store_id"], "left")
            .fillna(0, subset=["orders"]))

# 隨時轉 SQL
df.createOrReplaceTempView("pos")
spark.sql("""SELECT store_id, SUM(amt) sales
             FROM pos GROUP BY store_id ORDER BY sales DESC""")

df.explain()     # 睇 plan:BroadcastHashJoin?
df.count(); df.printSchema(); df.show(5, truncate=False)

Delta Lake / Databricks Medallion

Medallion 分層

Bronze
Raw 照入,加 _source_file、_ingest_ts,全部 string,唔清理
Silver
清理、type、時區統一、去重、DQ check、SCD2 dim
Gold
Business grain:fact_sales_line、日報、KPI view,俾 Tableau

SCD2 MERGE

MERGE INTO silver.dim_member t
USING (
  SELECT c.member_id AS mk, c.* FROM changes c
  UNION ALL
  SELECT NULL AS mk, c.* FROM changes c
  JOIN silver.dim_member d
    ON c.member_id = d.member_id
   AND d.is_current AND c.tier <> d.tier
) s
ON t.member_id = s.mk AND t.is_current
WHEN MATCHED AND t.tier <> s.tier THEN UPDATE
  SET is_current = false, end_ts = s.change_ts
WHEN NOT MATCHED THEN INSERT
  (member_id, tier, start_ts, end_ts, is_current)
  VALUES (s.member_id, s.tier, s.change_ts, NULL, true);

Upsert / idempotent

MERGE INTO silver.orders t
USING updates s ON t.order_id = s.order_id
WHEN MATCHED AND s.updated_at > t.updated_at
  THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;
# 覆寫某段分區,重跑唔重複
(df.write.format("delta").mode("overwrite")
   .option("replaceWhere", "dt >= '2025-10-01' AND dt < '2025-11-01'")
   .saveAsTable("silver.pos"))

Auto Loader(Databricks)

(spark.readStream.format("cloudFiles")
  .option("cloudFiles.format", "json")
  .option("cloudFiles.schemaLocation", CHK + "/schema")
  .load(RAW + "/mobile/")
  .withColumn("_src", F.col("_metadata.file_path"))
  .writeStream
  .option("checkpointLocation", CHK + "/mobile")
  .trigger(availableNow=True)
  .toTable("bronze.mobile"))

Colab / OSS Spark 冇 cloudFiles:改用 spark.readStream.schema(s).json(path),checkpoint 一樣記住讀過嘅檔。

Table 管理

DESCRIBE HISTORY silver.pos;
SELECT * FROM silver.pos VERSION AS OF 3;
SELECT * FROM silver.pos TIMESTAMP AS OF '2025-10-01';
RESTORE TABLE silver.pos TO VERSION AS OF 3;

ALTER TABLE silver.pos ADD CONSTRAINT amt_ok CHECK (amt >= 0);
ALTER TABLE silver.pos ALTER COLUMN order_id SET NOT NULL;

CREATE TABLE gold.fact_sales_line (...) CLUSTER BY (store_id, dt);
OPTIMIZE gold.fact_sales_line;

CREATE OR REPLACE VIEW gold.v_store_daily AS
SELECT store_id, dt, SUM(amt) sales, COUNT(DISTINCT order_id) orders
FROM gold.fact_sales_line GROUP BY store_id, dt;

Colab setup(練習用)

!pip install -q pyspark==3.5.3 delta-spark==3.2.1
from pyspark.sql import SparkSession
from delta import configure_spark_with_delta_pip
b = (SparkSession.builder
  .config("spark.sql.extensions",
          "io.delta.sql.DeltaSparkSessionExtension")
  .config("spark.sql.catalog.spark_catalog",
          "org.apache.spark.sql.delta.catalog.DeltaCatalog")
  .config("spark.sql.ansi.enabled", "true"))
spark = configure_spark_with_delta_pip(b).getOrCreate()

Databricks serverless 預設 ANSI:parse / cast 失敗會報錯,所以用 try_cast、try_to_timestamp。Serverless 冇 .cache()、RDD。

陷阱 面試官最想見你避開

One-to-many 重複計

Join 後 sum「一」嗰邊嘅欄(premium、store seats)會膨脹。先 aggregate 到同一 grain 再 join。

Null ≠ 0

唔知道嘅金額唔好填 0,會拉低 avg。保持 Null,報告註明 exclude 咗幾多行。只有「冇 event」嘅 count 先補 0。

dd/mm vs mm/dd

拆開兩部分,睇邊部分有 > 12 嘅值嚟證明。

UTC vs 本地時間

Mobile 係 UTC、POS 係本地。唔統一,heatmap 錯位 8 個鐘,日界線都會錯。

按位置 union

Wildcard input / union() 係按位置對欄。用 by name。

冇 Sort 就 Multi-Row / Unique

Row-1 同「第一行」都靠次序。先 Sort,再加 tie-breaker 欄。

重跑會重複

mode("append") 重跑兩次就兩倍。用 MERGE、replaceWhere 或 checkpoint。

AVG of ratio

AVG(a/b) ≠ SUM(a)/SUM(b)。KPI 通常要後者。

Count 錯 grain

Order 數用 countDistinct(order_id),唔係 line 行數。

靜靜雞刪 data

孤兒、重複、outlier 要 flag 同報數,唔好直接 filter 走。

考試流程 3 小時

時間分配

0:00–0:10
睇晒全部題目,標分數同難度
0:10–1:30
Databricks / PySpark(DE 職位最重)
1:30–2:15
Alteryx
2:15–2:45
Tableau dashboard
2:45–3:00
Summary:assumption、DQ 問題、insight、點 productionize

每一步都做

  • 寫低 input / output 行數
  • 三個工具嘅數要對得上
  • 卡住 10 分鐘就跳,寫低 assumption
  • 勤 save:.yxmd、notebook、.twbx

Summary 模板

  • Data quality:搵到咩問題、點處理、影響幾多行
  • Assumption:例如 dd/mm、blank = 未知
  • Insight:2–3 句有數字嘅結論
  • Production:incremental、idempotent、DQ check、schedule