最近在做
Python
職位分析的項目,做這件事的背景是因為接觸
Python
這麼久,還沒有對
Python
職位有一個全貌的了解。是以想通過本次分析了解
Python
相關的職位有哪些、在不同城市的需求量有何差異、薪資怎麼樣以及對工作經驗有什麼要求等等。分析的鍊路包括:
- 資料采集
- 資料清洗
- 異常的建立時間
- 異常的薪資水準
- 異常的工作經驗
- 統計分析
- 大盤資料
- 單次元分析
- 二維交叉分析
- 多元鑽取
- 文本分析
- 文本預處理
- 詞雲
- FP-Growth關聯分析
- 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)
列印出
jobs
的行列資訊
jobs.info()
一共讀取了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)
p1stCat
和
p2ndCat
是拉勾的标記,并不是我打的标。
資料上我們發現,需要
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_final
DataFrame進行後續的處理,先來看下職位正文
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()
經過上述三個步驟的處理,
p_text_clean
列已比較幹淨且可以用于後續分析。
3.1 FP-Growth挖掘關聯關系
做的第一個文本分析就是挖掘關聯關系,提到關聯分析大家都能想到的例子就是“啤酒和尿布”,這裡我也想借助這個思路,挖掘一下不同的
Python
職位,哪些詞具有比較強的相關性。挖掘算法使用
mlxtend
子產品的
FP-Growth
,
FP-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
的結果
我這裡隻截取了一部分,
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
和
算法
關聯度94%,這是符合預期的,畢竟篩選的是人工智能崗位。出現
python
的職位裡,出現
機器學習
和
深度學習
的機率差不多,都是 69%,出現
機器學習
的機率稍微高一些,将近70%,看來這兩崗位的需求沒有差的特别多。還有就是對
經驗
的要求看起來是挺硬性的,85%的機率會出現。
同樣的,我們看看
資料開發
崗位的關聯分析
明顯看到的一個差別是,人工智能的分類裡與
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]
的目的是為了歸一化。
可以看到第一個主題是自然語言相關的,第二個主題是語音相關的,第三個主題是金融量化投資,第四個主題是醫療相關的,第五個主題是機器學習算法相關,第六個主題是英文職位,第七個主題是計算機視覺,第八個主題是仿真、機器人相關。
感覺分的還可以, 起碼一些大的方向都能分出來。并且每個類之前也有明顯區分度。
同樣的,我們看看
資料開發
職位的主題,這裡分了6個主題
第一個主題是數倉、大資料技術相關,第二個主題是英文職位,第三個主題是資料庫、雲相關,第四個主題是算法相關,第五個主題是業務、分析相關,第六個主題是爬蟲,也還行。
這裡我比較感興趣的
人工智能
和
資料開發
的職位,之前我們關注的
測試
、
後端開發
也可以做,思路是一樣的。
至此,我們的文本分析就結束了,可以看到文本分析能夠挖掘出統計分析裡統計不到的資訊,後續的分析中我們會經常用。另外,詞雲這部分由于時間原因沒來得及做,這塊我們之前做過,不是很複雜,可以嘗試用
TF-IDF
來畫不同職位類别的詞雲。完整的代碼還在整理,需要的朋友可以給我留言。
歡迎公衆号 「渡碼」