在開始進行圖表製作前,我會先進行「可視化方法的概念驗證」,目的是確認資料是否具備可視化潛力,並釐清哪些圖表形式最能有效呈現想要傳達的資訊。
初期使用 Power BI 進行資料瀏覽與檢查,這可以讓我專注在資料內容本身,避免被繪圖語法干擾。等資料結構確認後,正式視覺化階段則改用 Python 的 Plotly + Panel
,以支援互動性與網頁嵌入需求。
完整程式碼 。
import pandas as pd
import sqlite3
import panel as pn # 用於建立互動式面板與數據更新
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots # 用於建立多圖子圖畫布
# 載入的資料集(簡要列出即可)
responses # 原始回應資料
responses_single_choice # 單選題資料(已處理)
kaggle_question_reference_table # 問題對照表(跨年份欄位對應)
salary_order # 薪資排序
country_area # 國家地區分類
coding_exp_years_order # 程式經驗年資排序
prog_lang_skill_group # 程式語言技能群組
資料處理
本段資料處理會依輸入的分類名稱,擷取該分類對應的所有欄位資料,並將其整理為長表格式。為了便於後續圖表繪製,會保留部分群組欄位(如職稱、薪資),只篩選 Data-related 職群的回應,並將長字串回答裁切為 short_label,以利呈現在圖表中。每題資料會存成 dict 的形式,方便之後個別取出繪圖。
# 依題目分類拆分回應資料
def data(c): # 輸入為分類名稱,例如 '基礎輪廓分析',函式會擷取對應題目的回答資料。
# 每題資料會存成 dict 的形式,方便之後個別取出繪圖。
contents_dict = {}
# 資料條件
col = kaggle_question_reference_table.loc[kaggle_question_reference_table['分類'] == c, 'col_eng']
# 讀取資料
df = responses[responses['question_index'].isin(col)]
# 合併群組
df = df.merge(responses_single_choice[['id', 'job_title_group', 'salary_group']], left_on='id', right_on='id', how='left')
# 僅保留特定群組資料(如 Data-related)
df = df[df['job_title_group']== 'Data-related']
# salary_group 僅適用 salary,所以只去除 salary 資料中的錯誤資訊。
df = df[~((df['salary_group']=='wrong_info') & (df['question_index']=='salary'))]
df = df.iloc[:,:-2].groupby(['response', 'surveyed_in', 'question_index']).count().reset_index().rename(columns={'id':'count'}).sort_values('count')
# 將回答切短為 short_label
df['short_label'] = df['response'].apply(lambda x: x[:10] + '...' if len(x) > 12 else x)
for i in kaggle_question_reference_table.loc[kaggle_question_reference_table['分類'] == c , 'col_eng']:
x = kaggle_question_reference_table.loc[kaggle_question_reference_table['col_eng'] == i, '欄位'].values[0]
contents_dict[x] = df[df['question_index'].isin([i])]
return contents_dict
根據 surveyed_in 欄位判斷資料是否包含多個年度。若為多年度資料,將回應統整後製作圓餅圖;若為單年度資料,則保留當年回應並使用橫條圖呈現。
# 資料處理
def data_process(d):
d = d.reset_index(drop=True)
len_d = len(d)
is_multi_year = len(d['surveyed_in'].drop_duplicates()) > 1
# 圓餅圖:若有多個年份的資料,聚合後處理 top 9 + other
if is_multi_year:
x = {}
d = d.groupby(['response','question_index','short_label']).sum().sort_values('count').reset_index()
for i in d.columns :
if (i == 'response') and ((len_d > 9) and (len(d['surveyed_in'].drop_duplicates()) > 1)):
x[i] = ['other']
elif i == 'count':
x[i] = [d[i][:-9].sum()]
else:
x[i] = [d[i][0]]
d = pd.concat([d[-9:], pd.DataFrame(x)], axis=0).reset_index(drop=True).sort_values('count')
# 計算百分比。
d['count_pct'] = round((d['count'] / d['count'].sum()) * 100, 2)
# 產生 text 文字,將資料轉為文字(str),例如:1000 人 (50.0%)
d['text'] = d.apply(lambda row: f"{row['count']}人 ({row['count_pct']:.1f} %)", axis=1)
return d.drop(columns='surveyed_in').reset_index(drop=True)
# 橫條圖:若只有單一年度,直接抓 top 9
else:
# 計算百分比。
d['count_pct'] = round((d['count'] / d['count'].sum()) * 100, 2)
# 產生 text 文字,將資料轉為文字(str),例如:1000 人 (50.0%)
d['text'] = d.apply(lambda row: f"{row['count']}人 ({row['count_pct']:.1f} %)", axis=1)
return d[-9:]
概念驗證 橫條圖
圖表 1:單年度長條圖
```python
# 載入資料 dict
df = data('基礎輪廓分析')
# 本圖示資料:使用 2021 年資料,針對「國家」欄位回應進行統計,並以橫條圖呈現前 9 名的回答分布情況。
df_country = df['國家']
df_2021 = df_country[df_country['surveyed_in'] == 2021]
process_df = data_process(df_2021)
# 建立圖表(橫條圖)
fig = px.bar(
process_df,
x='count',
y='response',
orientation='h', # 轉橫條圖
title='2021年', # 圖表名稱
width=800, # 畫布寬度
height=500 # 畫布高度
)
# update_traces:圖表中的「資料圖層」
fig.update_traces(
texttemplate=process_df['text'], # 顯示資料標籤
textposition='auto' # 自動決定文字顯示在柱內或柱外
)
# update_layout:圖表的整體版面、軸線、標題、圖例、尺寸
fig.update_layout(
xaxis_title=None, # 移除 x軸標籤
yaxis_title=None # 移除 y軸標籤
)
# 顯示圖片
fig.show()
# 輸出圖片
fig.write_html(f'data_scientists_toolbox/橫條圖.html', auto_open=True)
```
概念驗證 圓餅圖
將「國家」欄位的回應資料整合所有年度,進行總計後使用圓餅圖呈現整體分布概況。此圖能幫助觀察整體樣本中,來自各國的比例差異。
圖表 2:多年度總計圓餅圖(圓餅圖.html
# 對「國家」欄位的所有年份資料進行統整處理(多年度 → 圓餅圖邏輯會自動套用)
data_all = data_process(df['國家'])
fig = px.pie(data_all,
names='response',
values='count',
hole=.5, # 製作甜甜圈圖樣式,使視覺更清爽
width=800, # 畫布寬度
height=500 # 畫布高度
)
fig.update_traces(
texttemplate=data_all['text'], # 顯示格式為:1000人 (50.0%),由 data_process() 預先計算好 text 欄位
textposition='outside' # 決定文字顯示在柱內或柱外
)
# 顯示圖片
fig.show()
# 輸出圖片
fig.write_html(f'data_scientists_toolbox/圓餅圖.html', auto_open=True)
概念驗證 橫條圖 圓餅圖 合併
為同時呈現年度差異與整體結構,橫條圖聚焦於各年度 top 回答,而圓餅圖則顯示跨年度總計結果。 本圖使用 make_subplots()
建立 2x2 的子圖畫布,前 3 張為 2020–2022 各年度的橫條圖,右下角則為圓餅圖表示各年度合計分布狀況。搭配 subplot_titles
設定標題,使每張圖更具辨識性。
圖表 3:年度分圖 + 圓餅合併(多圖合併.html)
# 建立畫布
fig = make_subplots(
rows=2, cols=2,
specs=[[{'type': 'xy'}, {'type': 'xy'}],
[{'type': 'xy'}, {'type': 'domain'}]], # 每格子圖型別(xy 為折線、長條圖,domain 為圓餅圖)
subplot_titles=["2020", "2021", "2022", "總計"], # 子圖標題
horizontal_spacing = 0.1, # 子圖間水平距離(0 ~ 1)
vertical_spacing = 0.1 # 子圖間垂直距離(0 ~ 1)
)
'''
| type 值 | 說明 |
| ----------- | ----------------------- |
| `'xy'` | 折線圖、長條圖、散點圖等常見圖 |
| `'domain'` | 圓餅圖(Pie)、指標圖(Indicator) |
| `'scene'` | 3D 圖(散點、曲面圖) |
| `'polar'` | 極座標圖 |
| `'ternary'` | 三元圖 |
'''
# 加入長條圖
fig.add_trace(go.Bar(y=data_2020['response'],
x=data_2020['count'],
orientation='h',
text=data_2020['text'],
hovertemplate = ''),
row=1, col=1)
fig.add_trace(go.Bar(y=data_2021['response'],
x=data_2021['count'],
orientation='h',
text=data_2021['text'],
hovertemplate = ''),
row=1, col=2)
fig.add_trace(go.Bar(y=data_2022['response'],
x=data_2022['count'],
orientation='h',
text=data_2022['text'],
hovertemplate = ''),
row=2, col=1)
# 加入圓餅圖
fig.add_trace(go.Pie(labels=data_all['response'],
values=data_all['count'],
text=data_all['count'],
hole=.5, # 製作甜甜圈圖樣式,使視覺更清爽
hovertemplate = ''),
row=2, col=2)
fig.update_traces(
textposition='outside', # 設定圓餅圖文字顯示位置,將數值標示移至圖形外側
row=2, col=2
)
fig.update_layout(
xaxis_title=None, # 移除 x軸標籤
yaxis_title=None # 移除 y軸標籤
)
# 更新版面
fig.update_layout(title_text="年度資料分析互動圖表", title_font_size = 24)
# 顯示圖片
fig.show()
# 輸出圖片
fig.write_html(f'data_scientists_toolbox/多圖合併.html', auto_open=True)
資料更新測試
起初嘗試直接操作原始 dataframe(responses),但在 Panel 的互動過程中遇到更新失效問題。後來將資料預處理為 dict 格式後成功解決,這也讓我了解 Panel 對資料綁定的限制與最佳實踐方式。
圖表 4:互動式年度切換長條圖(interactive_dashboard.html
)
# 模擬資料
data_dict = {
'2020': pd.DataFrame({'category': ['A', 'B', 'C'], 'value': [100, 200, 300]}),
'2021': pd.DataFrame({'category': ['A', 'B', 'C'], 'value': [120, 180, 260]}),
'2022': pd.DataFrame({'category': ['A', 'B', 'C'], 'value': [90, 150, 240]})
}
# 下拉選單元件
year_selector = pn.widgets.Select(name='年份', options=list(data_dict.keys()), value='2020')
# 圖表顯示區
plot_pane = pn.pane.Plotly()
# Callback:當選單變更時,更新圖表內容
def update_plot(event):
df = data_dict[year_selector.value]
fig = px.bar(df, x='category', y='value', title=f"{year_selector.value} 年資料")
plot_pane.object = fig # 將新圖表指派給圖表面板
# 註冊 Callback(回呼函式)
year_selector.param.watch(update_plot, 'value') # 當 year_selector 的值,一旦變動就執行 update_plot()
# 初始化一次圖表
update_plot(None)
# 包裝頁面
app = pn.Column("# 年度資料分析互動圖表", year_selector, plot_pane)
app.show()
# 輸出圖片
app.save('練習專案三:資料科學家的工具箱/data_scientists_toolbox/interactive_dashboard.html', embed=True)
多變量
圖表 5:折線圖 (洲別_經驗_薪資.html
)
# 建立折線圖 go.Scatter
def go_scatter(line_df_group, file_path_name):
data1 = go.Scatter(
x = line_df_group['coding_exp_years'],
y = line_df_group['Asia'],
mode = "lines+markers+text",
name = 'Asia',
textposition = "top center",
line = dict(width=3),
text = line_df_group['Asia']
)
data2 = go.Scatter(
x = line_df_group['coding_exp_years'],
y = line_df_group['Europe'],
mode = "lines+markers+text",
name = 'Europe',
textposition = "top center",
line = dict(width=3),
text = line_df_group['Europe']
)
data3 = go.Scatter(
x = line_df_group['coding_exp_years'],
y = line_df_group['North America'],
mode = "lines+markers+text",
name = 'North America',
textposition = "top center",
line = dict(width=3),
text = line_df_group['North America']
)
data4 = go.Scatter(
x = line_df_group['coding_exp_years'],
y = line_df_group['Oceania'],
mode = "lines+markers+text",
name = 'Oceania',
textposition = "top center",
line = dict(width=3),
text = line_df_group['Oceania']
)
data5 = go.Scatter(
x = line_df_group['coding_exp_years'],
y = line_df_group['South America'],
mode = "lines+markers+text",
name = 'South America',
textposition = "top center",
line = dict(width=3),
text = line_df_group['South America']
)
layout = go.Layout(
title = '薪資 & 經驗 & 洲別',
title_font_size = 30,
xaxis = dict(title='程式經驗 (年)', tickfont=dict(size=10)),
yaxis = dict(title='年資(中位數)', tickfont=dict(size=10)),
margin = dict(l=50, r=50, t=60, b=60),
showlegend = True
)
fig = go.Figure(data = [data1, data2, data3, data4, data5], layout = layout)
fig.show()
fig.write_html(file_path_name, auto_open=True)
# 讀取資料
def line_data():
# 讀取資料
line_df = responses_single_choice[['id','surveyed_in', 'coding_exp_years','country', 'salary', 'salary_group', 'job_title_group']]
# 設定條件 salary_group = correct_info 、 job_title_group = Data-related。
col = (line_df['salary_group'] == 'correct_info') & (line_df['job_title_group'] == 'Data-related')
# 合併 salary_order
line_df = line_df[col].merge(salary_order, left_on='salary', right_on='salary', how='left').rename(columns={'rank':'salary_rank'})
# 合併 country_area
line_df = line_df.merge(country_area[['country', 'gdp_group', 'area']], left_on='country', right_on='country', how='left')
# 設定條件 gdp_group = ['高收入', '中高收入']
line_df = line_df[line_df['gdp_group'].isin(['高收入', '中高收入'])]
line_df = line_df.merge(coding_exp_years_order, left_on='coding_exp_years', right_on='coding_exp_years', how='left')
# 建立中位數
line_df_group = line_df[['rank', 'coding_exp_years', 'area', 'salary_mean']].groupby(['rank', 'coding_exp_years', 'area']).median().reset_index().rename(columns={'salary_mean':'salary_median'})
# 展開資料:每洲別一欄,用於繪製多條線
line_df_group = line_df_group.pivot(index=['rank', 'coding_exp_years'], columns='area', values='salary_median').reset_index()
return line_df_group
file_path_name = 'data_scientists_toolbox/洲別_經驗_薪資.html'
go_scatter(line_data(), file_path_name)
氣泡圖
本圖為多變量氣泡圖,用以視覺化職稱(job_title)在不同年薪區間中,對應的平均技能數量、年薪中位數與樣本數。
- X 軸:每個職稱平均會學習的程式語言數量(lang_count_mean)
- Y 軸:年薪中位數(salary_median)
- 氣泡大小:該群體樣本數(count)
- 顏色:職稱類型(job_title)
- 滑鼠提示:顯示平均年資資訊(coding_exp_year_mean)
本圖可以觀察是否技能數量較多的職稱會有更高的薪資,以及在不同職稱中薪資與年資、技能數的相對趨勢。
圖表 6: 氣泡圖 (年薪_技能(數量)_職稱.html
)
def px_scatter(scatter_df, file_path_name):
fig = px.scatter(
scatter_df, # 數據來源的 DataFrame
x="lang_count_mean", # 技能數量(平均)
y="salary_median", # 年薪(中位數)
size="count", # 設定氣泡大小對應 人數
color="job_title", # 各職稱來區分顏色
hover_name="text", # 滑鼠懸停時顯示 (經驗 = 3.5 year)
size_max=50, # 設定氣泡的最大大小
range_x=[1.5, 5.5], # 設定 x 軸的範圍
range_y=[0, 200000], # 設定 y 軸的範圍
log_x=True # 以對數尺度顯示 x 軸,有助於處理資料密集區的壓縮與分布差異
)
fig.update_layout(title_text='年薪 & 技能(數量) & 職稱',
title_font_size = 24,
xaxis=dict(
title=dict(text='學習程式語言數量 的平均'),
gridcolor='white',
type='log',
gridwidth=2,
),
yaxis_title=None) # 關閉 y 軸標籤
fig.show()
fig.write_html(file_path_name, auto_open=True) # 儲存檔案
def scatter_data():
# 讀取資料
scatter_df = responses_single_choice[['id','surveyed_in', 'coding_exp_years', 'job_title','country', 'salary', 'salary_group', 'job_title_group']]
# 設定條件 salary_group = correct_info 、 job_title_group = Data-related。
col = (scatter_df['salary_group'] == 'correct_info') & (scatter_df['job_title_group'] == 'Data-related')
# 合併 salary_order
scatter_df = scatter_df[col].merge(salary_order, left_on='salary', right_on='salary', how='left')
# 合併 country_area
scatter_df = scatter_df.merge(country_area[['country', 'gdp_group', 'area']], left_on='country', right_on='country', how='left')
# 設定條件 gdp_group = ['高收入', '中高收入']
scatter_df = scatter_df[scatter_df['gdp_group'].isin(['高收入', '中高收入'])]
# 合併 coding_exp_years_order
scatter_df = scatter_df.merge(coding_exp_years_order, left_on='coding_exp_years', right_on='coding_exp_years', how='left')
# 合併 prog_lang_skill_group
scatter_df = scatter_df.merge(prog_lang_skill_group, left_on='id', right_on='id', how='left')
# 提取資料
scatter_df = scatter_df[['id', 'salary','job_title','count', 'coding_exp_years_mean', 'salary_mean']]
# 移除錯誤資料,正常的中高收入國家的年薪不應該低於 5000 美金,感覺應該要設定 10000 美金比較正確。
scatter_df = scatter_df[scatter_df['salary_mean'] > 5000]
# 建立 人數、技能數量(平均)、程式經驗年(平均)、年薪(中位數)。
scatter_df = scatter_df.groupby(['job_title','salary']).agg(count = ('id', 'count'),
lang_count_mean=('count', 'mean'),
coding_exp_year_mean=('coding_exp_years_mean', 'mean'),
salary_median=('salary_mean', 'median')
).round(2).reset_index()
# 建立 文字顯示
scatter_df['text'] = '經驗 = ' + scatter_df['coding_exp_year_mean'].astype(str) + ' year'
return scatter_df
file_path_name = '練習專案三:資料科學家的工具箱/data_scientists_toolbox/年薪_技能(數量)_職稱.html'
px_scatter(scatter_data(), file_path_name)