小白資料分析——Python職位全鏈路分析

渡碼發表於2020-12-22

最近在做Python職位分析的專案,做這件事的背景是因為接觸Python這麼久,還沒有對Python職位有一個全貌的瞭解。所以想通過本次分析瞭解Python相關的職位有哪些、在不同城市的需求量有何差異、薪資怎麼樣以及對工作經驗有什麼要求等等。分析的鏈路包括:

  • 資料採集
  • 資料清洗
  1. 異常的建立時間
  2. 異常的薪資水平
  3. 異常的工作經驗
  • 統計分析
  1. 大盤資料
  2. 單維度分析
  3. 二維交叉分析
  4. 多維鑽取
  • 文字分析
  1. 文字預處理
  2. 詞雲
  3. FP-Growth關聯分析
  4. LDA主題模型分析

分為上下兩篇文章。上篇介紹前三部分內容,下篇重點介紹文字分析。

0. 資料採集

巧婦難為無米之炊,我們做資料分析大部分情況是用公司的業務資料,因此就不需要關心資料採集的問題。然而我們自己業餘時間做的一些資料探索更多的需要自己採集資料,常用的資料採集技術就是爬蟲

本次分享所用的資料是我從拉勾網爬取的,主要分為三部分,確定如何抓取資料、編寫爬蟲抓取資料、將抓取的資料格式化並儲存至MongoDB。關於資料採集這部分內容我之前有一篇文章單獨介紹過,原始碼也開放了,這裡我就不再贅述了,想了解的朋友可以翻看之前那篇文章《Python爬職位》

1. 資料清洗

有了資料後,先不要著急分析。我們需要對資料先有個大概的瞭解,並在這個過程中剔除一些異常的記錄,防止它們影響後續的統計結果。

舉個例子,假設有101個職位,其中100個的薪資是正常值10k,而另外一個薪資是異常值1000k,如果算上異常值計算的平均薪資是29.7k,而剔除異常值計算的平均薪資是10k,二者差了將近3倍。

所以我們在作分析前要關注資料質量,尤其資料量比較少的情況。本次分析的職位數有1w條左右,屬於比較小的資料量,所以在資料清洗這一步花了比較多的時間。

下面我們就從資料清洗開始,進入編碼階段

1.0 篩選python相關的職位

匯入常用庫

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from pymongo import MongoClient

from pylab import mpl
mpl.rcParams['font.sans-serif'] = ['SimHei']  #解決seaborn中文字型顯示問題
%matplotlib inline

MongoDB讀取資料

mongoConn = MongoClient(host='192.168.29.132', port=27017)
db = mongoConn.get_database('lagou')
mon_data = db.py_positions.find()
# json轉DataFrame
jobs = pd.json_normalize([record for record in mon_data])

預覽資料

jobs.head(4)
小白資料分析——Python職位全鏈路分析

列印出jobs的行列資訊

jobs.info()
小白資料分析——Python職位全鏈路分析
一共讀取了1.9w個崗位,但這些崗位裡並不都是跟Python相關的。所以我們首先要做的就是篩選Python相關的職位,採用的規則是職位標題或正文包含python字串
# 抽取職位名稱或者職位正文裡包含 python 的
py_jobs = jobs[(jobs['pName'].str.lower().str.contains("python")) | (jobs['pDetail'].str.lower().str.contains("python"))]

py_jobs.info()

篩選後,只剩下10705個崗位,我們繼續對這部分崗位進行清洗。

1.1 按照建立時間清洗異常值

對 “職位建立時間” 維度清洗主要是為了防止有些建立時間特別離譜的崗位混進來,比如:出現了2000年招聘的崗位。

# 建立一個函式將職位建立時間戳轉為月份
import time
def timestamp_to_date(ts):
    ts = ts / 1000
    time_local = time.localtime(ts)
    return time.strftime("%Y-%m", time_local)
    
# 增加'職位建立月份'一列
py_jobs['createMon'] = py_jobs['createTime'].map(timestamp_to_date)

# 按照職位id、建立月份分組計數
py_jobs[['pId', 'createMon']].groupby('createMon').count()
圖片
不同月的職位

建立timestamp_to_date 函式將“職位建立時間”轉為“職位建立月份”,然後按“職位建立月份”分組計數。從結果上看,職位建立的時間沒有特別離譜的,也就是說沒有異常值。即便如此,我仍然對職位建立時間進行了篩選,只保留了10、11、12三個月的資料,因為這三個月的職位佔了大頭,並且我只想關注新職位。

# 只看近三個月的職位
py_jobs_mon = py_jobs[py_jobs['createMon'] > '2020-09']

1.2 按照薪資清洗異常值

對薪資進行清洗主要是防止某些職位的薪資特別離譜。這塊主要考察3個特徵:薪資高的離群點、薪資低的離群點和薪資跨度較大的。

首先,列出所有的薪資

py_jobs_mon[['pId', 'salary']].groupby('salary').count().index.values

以薪資高的離群點為例,觀察是否有異常值

# 薪資高的離群值
py_jobs_mon[py_jobs_mon['salary'].isin(['150k-200k', '100k-150k'])]

 圖片

果然發現了一個異常崗位,一個應屆實習生居然給150k-200k,很明顯需要將其清洗掉。

同樣地,我們也能發現其他特徵的異常職位

圖片

1.3 小節要介紹的按照工作經驗清洗異常值也與之類似,為了避免篇幅過長我這裡就不貼程式碼了。總之,按照這3個屬性清洗完之後,還剩 9715 個職位。

完成資料清洗後,我們就正式進入分析的環節了,分析分為兩部分,統計分析和文字分析,前者是對數值型指標做統計,後者是對文字進行分析。我們平時接觸到最多是前者,它可以讓我們從巨集觀的角度去了解被分析的物件。文字分析也有不可替代的價值,我們下篇重點介紹。

2. 統計分析

我們做統計分析除了要清楚分析的目外,還需要了解分析結果面向的物件是誰。本次分析中,我假想面向的是在校學生,因為他們是真正想要了解Python職位的人。因此,我們的分析思路就要按照他們所想看的去展開,而不能沒有章法的亂堆資料。

2.0 大盤資料

統計分析的資料一般都是按照資料粒度由粗到細展開的,粒度最粗的資料就是不加任何過濾條件、不按照任何維度拆分的數字。在我們的專案裡其實就是總職位數,上面我們也看到了 9715 個。如果跟Java、PHP職位去對比,或許我們能得出一些結論,然而單純看這個總數顯然是沒有實際參考價值的。

所以接下來我們需要按照維度來進行細粒度的拆分。

2.1 單維度分析

我們由粗到細,先來按照單維度進行分析。對於一個在校生來說,他最迫切想了解的資料是什麼?我覺得是不同城市之間職位數量的分佈。因為對於學生來說考慮工作的首要問題是考慮在哪個城市,考慮哪個城市需要參考的一點就是職位的數量,職位越多,前景自然更好。

# 城市
fig = plt.figure(dpi=85)
py_jobs_final['city'].value_counts(ascending=True).plot.barh()

 

圖片
分城市的職位數量

北京的崗位是最多的,比第二名上海還要高出一倍。廣州的崗位最少,少於深圳。

確定了在哪個城市發展後,再進一步需要考慮的就是從事什麼崗位。我們都知道Python的應用面很廣,自然就想看看不同類別的Python職位的分佈

# 按照p1stCat(一級分類)、p2ndCat(二級分類)分組計數

tmp_df = py_jobs_final.groupby(['p1stCat', 'p2ndCat']).count()[['_id']].sort_values(by='_id')
tmp_df = tmp_df.rename(columns={'_id':'job_num'})
tmp_df = tmp_df[tmp_df['job_num'] > 10]

tmp_df.plot.barh(figsize=(12,8), fontsize=12)

p1stCatp2ndCat是拉勾的標記,並不是我打的標。

圖片

資料上我們發現,需要Python技能的職位裡,測試是最多的,資料開發排第二,後端開發比較少,這也符合我們的認知。

這裡我們看的指標是職位數量,當然你也可以看平均薪資。

從城市、職位分類這倆維度,我們對Python職位有了一個大概的認知了。那其他的維度還需要看嗎,比如:薪資、工作經驗,並且這倆維度也是大家比較關心的。我認為,從單維度來看,城市和職位分類就夠了,其他都沒有實際參考價值。因為薪資一定是跟某一類崗位相關的,人工智慧職位工資自然偏高;同樣地,工作經驗也是跟崗位類別相關,大資料剛起步的時候,職位的工作經驗自然就偏低。所以這倆維度從單維度上看沒有參考價值,一定是需要限定了某類職位後去看才有意義。我們在做統計分析時不要亂堆資料,要想清楚資料背後的邏輯,以及對決策人是否有價值。

2.1 二維交叉分析

對於一個學生來說,當他確定了自己工作的城市,也瞭解了不同的職位分佈,接下來我們需要給他展示什麼樣的資料能為他提供擇業的決策呢?

對於想去北京發展的學生來說,他想了解北京的不同型別的職位分佈、薪資情況、工作經驗的要求、什麼樣的公司在招聘。同樣的,想去上海、深圳、廣州的同學也有類似的需求。這樣,我們就確定了我們需要分析的維度和指標了,維度是城市、職位類別,且需要二者交叉。指標是職位數量、平均薪資、工作經驗和公司,前三個好說,但第四個需要找一個量化指標去刻畫,這裡我選的是公司規模。

維度已經有了,我們要做需要是準備指標,比如:在我們的資料集裡,薪資(salary)這一列是15k-20k這樣的文字,我們需要處理成數值型別。以薪資為例,編寫函式將其轉為數字

# 薪資轉為數字
def get_salary_number(salary):
    salary = salary.lower().replace('k', '')
    salary_lu = salary.split('-')
    lower = int(salary_lu[0])
    if len(salary_lu) == 1:
        return lower
    upper = int(salary_lu[1])
    
    return (lower + upper) / 2

工作經驗和公司規模也用類似邏輯處理,為了節省篇幅我就補貼程式碼了。

# 將3個文字列轉為數字
py_jobs_final['salary_no'] = py_jobs_final['salary'].map(get_salary_number)
py_jobs_final['work_year_no'] = py_jobs_final['workYear'].map(get_work_year_number)
py_jobs_final['csize_no'] = py_jobs_final['cSize'].map(get_csize_number)

有了維度和指標,我們如何展示資料呢?我們平時展示的資料大部分是二維的,橫座標是維度,縱座標是指標。既然要展示二維交叉的指標,自然就要用3維圖形展示。這裡我們使用Axes3D來繪製

# 只選擇 開發|測試|運維類 一級分類下,測試、資料開發、人工智慧、運維、後端開發 二級分類
job_arr = ['測試', '資料開發', '人工智慧', '運維', '後端開發']
py_jobs_2ndcat = py_jobs_final[(py_jobs_final['p1stCat'] == '開發|測試|運維類') & (py_jobs_final['p2ndCat'].isin(job_arr))]
%matplotlib notebook

import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D  
# 畫3d柱狀圖
city_map = {'北京': 0, '上海': 1, '廣州': 2, '深圳': 3} # 將城市轉為數字,在座標軸上顯示
idx_map = {'pId': '職位數', 'salary_no': '薪資(單位:k)', 'work_year_no': '工作經驗(單位:年)', 'csize_no': '公司規模(單位:人)'}

fig = plt.figure()
for i,col in enumerate(idx_map.keys()):
    if col == 'pId':
        aggfunc = 'count'
    else:
        aggfunc = 'mean'
    jobs_pivot = py_jobs_2ndcat.pivot_table(index='p2ndCat', columns='city', values=col, aggfunc=aggfunc)
    
    ax = fig.add_subplot(2, 2, i+1, projection='3d')
    for c, city in zip(['r', 'g', 'b', 'y'], city_map.keys()):
        ys = [jobs_pivot[city][job_name] for job_name in job_arr]
        cs = [c] * len(job_arr)
        
        ax.bar(job_arr, ys, zs=city_map[city], zdir='y', color=cs)
    
    ax.set_ylabel('城市')
    ax.set_zlabel(idx_map[col])
    ax.legend(city_map.keys())

plt.show()

首先我只選了top5的職位類別,然後迴圈計算每個指標,計算指標使用DataFrame中的透檢視(pivot_table),它很容易將二維的指標聚合出來,並且得到我們想要的資料,最後將維度和指標展示在3d柱狀圖中。

圖片

以北京為例,可以看到,人工智慧職位的薪資最高,資料開發和後端開發差不多,測試和運維偏低的。人工智慧對工作經驗的要求普遍比其他崗位低,畢竟是新興的崗位,這也符合我們的認知。招聘人工智慧職位的公司平均規模比其他崗位小,說明新興起的AI創業公司比較多,而測試和資料開發公司規模就大一些,畢竟小公司幾乎不用測試,小公司也沒有那麼大體量的資料。

有一點需要提醒大家一下,除了職位數外,其他指標絕對值是有偏的,這是因為我們處理邏輯的原因。但不同職位使用的處理方式是相同的,所以不同職位之間指標是可比的,也就是說絕對值沒有意義,但不同職位的偏序關係是有意義的。

2.3 多維鑽取

當一個學生確定了城市、確定了崗位後,他還想了解的什麼呢?比如他可能想了解在北京、人工智慧崗位、在不同行業裡薪資、工作經驗要求、公司規模怎麼樣,或者北京、人工智慧崗位、在不同規模的公司裡薪資、工作經驗要求怎麼樣。

圖片

這就涉及三個維度的交叉。理論上我們可以按照任何維度進行交叉分析,但維度越多我們視野就越小,關注的點就越聚焦。這種情況下,我們往往會固定某幾個維度取值,去分析另外幾個維度的情況。

以北京為例,我們看看不同崗位、不同工作經驗要求下的薪資分佈

tmp_df = py_jobs_2ndcat[(py_jobs_2ndcat['city'] == '北京')]
tmp_df = tmp_df.pivot_table(index='workYear', columns='p2ndCat', values='salary_no', aggfunc='mean').sort_values(by='人工智慧')
tmp_df
圖片

為了更直觀的看資料,我們畫一個二維散點圖,點的大小程式碼薪資的多少的

[plt.scatter(job_name, wy, c='darkred', s=tmp_df[job_name][wy]*5) for wy in tmp_df.index.values for job_name in job_arr]
圖片

這個資料我們既可以橫向對比,也可以縱向對比。橫向對比,我們可以看到,同樣的工作經驗,人工智慧的薪資水平普遍比其他崗位要高;縱向對比,我們可以看到,人工智慧崗位的薪資隨著工作年限的增加薪資增幅比其他崗位要高很多(圓圈變得比其他更大)。

所以,入什麼行很重要。

當然,你如果覺得不夠聚焦,還可以繼續鑽取。比如,想看北京、人工智慧崗位、電商行業、不同公司規模的薪資情況,處理邏輯上面講的是一樣。

我們繼續介紹如何用文字挖掘的方式對Python職位進行分析。會包含一些資料探勘演算法,但我希望這篇文章面向的是演算法小白,裡面不會涉及演算法原理,會用,能解決業務問題即可。

3.0 文字預處理

文字預處理的目的跟上篇介紹的資料清洗一樣,都是為了將資料處理成我們需要的,這一步主要包含分詞、去除停用詞兩步。

我們基於上篇處理好的py_jobs_finalDataFrame進行後續的處理,先來看下職位正文

py_jobs_final[['pId', 'pDetail']].head(2)
圖片

職位正文是pDetail列,內容就是我們經常看到的“崗位職責”和“崗位要求”。上圖我們發現職位要求裡包含了html標籤,如:<br>,這是因為pDetail本來是需要顯示在網頁上的,所以裡面會有html標籤,還好我們有爬蟲的基礎,使用BeautifulSoup模組就很容易處理掉了

from bs4 import BeautifulSoup
# 使用BeautifulSoup 去掉html標籤, 只保留正文內容,並轉小寫

py_jobs_final['p_text'] = py_jobs_final['pDetail'].map(lambda x: BeautifulSoup(x, 'lxml').get_text().lower())py_jobs_final[['pId', 'pDetail', 'p_text']].head(2)
圖片

去除html標籤後,再用jieba模組對正文分詞。jieba提供了三種模式進行分詞,全模式、精確模式和搜尋引擎模式。具體差異我們看一個例子就明白了。

import jieba
job_req = '熟悉物件導向程式設計,掌握java/c++/python/php中的至少一門語言;'

# 全模式
seg_list = jieba.cut(job_req, cut_all=True)
# 精確模式
seg_list = jieba.cut(job_req, cut_all=False)
# 搜尋引擎模式
seg_list = jieba.cut_for_search(job_req)

圖片

全模式
圖片
精確模式
圖片
搜尋引擎模式

區別一目瞭然,對於本次分析,我採用的是精確模式。

py_jobs_final['p_text_cut'] = py_jobs_final['p_text'].map(lambda x: list(jieba.cut(x, cut_all=False)))

py_jobs_final[['pId', 'p_text', 'p_text_cut']].head()
圖片

分詞後,我們發現裡面包含很多標點符號和和一些沒有意義的虛詞,這些對我們的分析沒有幫助,所以接下來我們要做的就是去除停用詞。

# stop_words.txt裡包含1208個停用詞
stop_words = [line.strip() for line in open('stop_words.txt',encoding='UTF-8').readlines()]

# 新增換行符
stop_words.append('\n')

# 去停用詞
def remove_stop_word(p_text):
    if not p_text:
        return p_text
    
    new_p_txt = []
    for word in p_text:
        if word not in stop_words:
            new_p_txt.append(word)
    
    return new_p_txt
py_jobs_final['p_text_clean'] = py_jobs_final['p_text_cut'].map(remove_stop_word)
py_jobs_final[['pId', 'p_text_cut', 'p_text_clean']].head()

小白資料分析——Python職位全鏈路分析

經過上述三個步驟的處理,p_text_clean列已比較乾淨且可以用於後續分析。

3.1 FP-Growth挖掘關聯關係

做的第一個文字分析就是挖掘關聯關係,提到關聯分析大家都能想到的例子就是“啤酒和尿布”,這裡我也想借助這個思路,挖掘一下不同的Python職位,哪些詞具有比較強的相關性。挖掘演算法使用mlxtend模組的FP-GrowthFP-Growth實現關聯規則的挖掘比Apriori更快。

from mlxtend.preprocessing import TransactionEncoder
from mlxtend.frequent_patterns import fpgrowth

# 構造fp-growth需要的輸入資料
def get_fpgrowth_input_df(dataset):
    te = TransactionEncoder()
    te_ary = te.fit(dataset).transform(dataset)
    return pd.DataFrame(te_ary, columns=te.columns_)

我們先來挖掘“人工智慧”類別

ai_jobs = py_jobs_final[(py_jobs_final['p1stCat'] == '開發|測試|運維類') & (py_jobs_final['p2ndCat'] == '人工智慧')]

ai_fpg_in_df = get_fpgrowth_input_df(ai_jobs['p_text_clean'].values)

ai_fpg_df = fpgrowth(ai_fpg_in_df, min_support=0.6, use_colnames=True)

min_support引數是用來設定最小支援度,也保留頻率大於該值的頻繁項集。比如,在100份購物訂單裡,包含“啤酒”的訂單有70個,“尿布”的訂單75個,“蘋果”的訂單1個,在min_support=0.6的情況下,“啤酒”和“尿布”會留下,“蘋果”就會丟掉,因為1/100 < 0.6

看下ai_fpg_df的結果

小白資料分析——Python職位全鏈路分析

我這裡只擷取了一部分, itemsets列就是頻繁項集,frozenset型別,它包含1個或多個元素。support是頻繁項集出現的頻率,這裡都是大於0.6的。第0行(python)代表99.6%的職位裡出現了python這個詞,第16行代表93.8%的職位裡python演算法同時出現。

有了這些我們就可以根據貝葉斯公式計算相關性了,比如:我看到有c++,那麼我就想看看出現python的職位裡有多大的概率還要求會c++,根據條件概率公式p(c++|python) = p(c++,python) / p(python)進行以下計算

# python概率
p_python = ai_fpg_df[ai_fpg_df['itemsets'] == frozenset(['python'])]['support'].values[0]

# c++ 和 python 聯合概率
p_python_cpp = ai_fpg_df[ai_fpg_df['itemsets'] == frozenset(['python', 'c++'])]['support'].values[0]

# 出現python的條件下,出現c++的概率
print('p(c++|python) = %f' % (p_python_cpp / p_python))

結果是64%。也就是人工智慧職位裡要求使用python的職位,有64%的概率還需要用c++。同理我們還可以看python跟其他詞的關聯關係

小白資料分析——Python職位全鏈路分析

python演算法關聯度94%,這是符合預期的,畢竟篩選的是人工智慧崗位。出現python的職位裡,出現機器學習深度學習的概率差不多,都是 69%,出現機器學習的概率稍微高一些,將近70%,看來這兩崗位的需求沒有差的特別多。還有就是對經驗的要求看起來是挺硬性的,85%的概率會出現。

同樣的,我們看看資料開發崗位的關聯分析

小白資料分析——Python職位全鏈路分析

明顯看到的一個區別是,人工智慧的分類裡與python關聯度高的偏技術類,機器學習深度學習以及c++。而資料開發裡的詞明顯更偏業務,比如這裡的業務分析。也就說如果一個職位提到了python那麼有60%以上的概率會提到業務或者分析,畢竟做資料要緊貼業務。

關聯規則更多的是詞的粒度,有點太細了。接下來我們就將粒度上升的文件的分析。

3.2 主題模型分析

LDA(Latent Dirichlet Allocation)是一種文件主體生成模型。該模型假設文件的主題服從Dirichlet分佈,某個主題裡的詞也服從Dirichlet分佈,經過各種優化演算法來解出這兩個隱含的分佈。

這裡我們呼叫sklearn裡面的LDA演算法來完成

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation

def run_lda(corpus, k):
    cntvec = CountVectorizer(min_df=1, token_pattern='\w+')
    cnttf = cntvec.fit_transform(corpus)
    
    lda = LatentDirichletAllocation(n_components=k)
    docres = lda.fit_transform(cnttf)
    
    return cntvec, cnttf, docres, lda

這裡我們用CountVectorizer統計詞頻的方式生成詞向量,作為LDA的輸入。你也可以用深度學習的方式生成詞向量,好處是可以學到詞語詞之間的關係。

LDA設定的引數只有一個n_components,也就是需要將職位分為多少個主題。

我們先來對人工智慧職位分類,分為8個主題

cntvec, cnttf, docres, lda = run_lda(ai_jobs['p_corp'].values, 8)

呼叫lda.components_返回的是一個二維陣列,每行代表一個主題,每一行的陣列代表該主題下詞的分佈。我們需要再定義一個函式,將每個主題出現概率最高的幾個詞輸出出來

def get_topic_word(topics, words, topK=10):
    res = []
    
    for topic in topics:
        sorted_arr = np.argsort(-topic)[:topK]  # 逆序排取topK
        res.append(','.join(['%s:%.2f'% (words[i], topic[i]) for i in sorted_arr]))
        
    return '\n\n'.join(res)

輸出人工智慧主題下,各個主題以及top詞分佈

print(get_topic_word(lda.components_ / lda.components_.sum(axis=1)[:, np.newaxis], cntvec.get_feature_names(), 20))

lda.components_ / lda.components_.sum(axis=1)[:, np.newaxis]的目的是為了歸一化。

小白資料分析——Python職位全鏈路分析

可以看到第一個主題是自然語言相關的,第二個主題是語音相關的,第三個主題是金融量化投資,第四個主題是醫療相關的,第五個主題是機器學習演算法相關,第六個主題是英文職位,第七個主題是計算機視覺,第八個主題是模擬、機器人相關。

感覺分的還可以, 起碼一些大的方向都能分出來。並且每個類之前也有明顯區分度。

同樣的,我們看看資料開發職位的主題,這裡分了6個主題

小白資料分析——Python職位全鏈路分析

第一個主題是數倉、大資料技術相關,第二個主題是英文職位,第三個主題是資料庫、雲相關,第四個主題是演算法相關,第五個主題是業務、分析相關,第六個主題是爬蟲,也還行。

這裡我比較感興趣的人工智慧資料開發的職位,之前我們關注的測試後端開發也可以做,思路是一樣的。

至此,我們的文字分析就結束了,可以看到文字分析能夠挖掘出統計分析裡統計不到的資訊,後續的分析中我們會經常用。另外,詞雲這部分由於時間原因沒來得及做,這塊我們之前做過,不是很複雜,可以嘗試用TF-IDF來畫不同職位類別的詞雲。完整的程式碼還在整理,需要的朋友可以給我留言。

歡迎公眾號 渡碼

相關文章