最近在作Python
职位分析的项目,作这件事的背景是由于接触Python
这么久,尚未对Python
职位有一个全貌的了解。因此想经过本次分析了解Python
相关的职位有哪些、在不一样城市的需求量有何差别、薪资怎么样以及对工做经验有什么要求等等。分析的链路包括:php
数据采集html
数据清洗java
分为上下两篇文章。上篇介绍前三部份内容,下篇重点介绍文本分析。python
巧妇难为无米之炊,咱们作数据分析大部分状况是用公司的业务数据,所以就不须要关心数据采集的问题。然而咱们本身业余时间作的一些数据探索更多的须要本身采集数据,经常使用的数据采集技术就是爬虫
。c++
本次分享所用的数据是我从拉勾网爬取的,主要分为三部分,肯定如何抓取数据、编写爬虫抓取数据、将抓取的数据格式化并保存至MongoDB
。关于数据采集这部份内容我以前有一篇文章单独介绍过,源码也开放了,这里我就再也不赘述了,想了解的朋友能够翻看以前那篇文章《Python爬职位》。算法
有了数据后,先不要着急分析。咱们须要对数据先有个大概的了解,并在这个过程当中剔除一些异常的记录,防止它们影响后续的统计结果。数据库
举个例子,假设有101个职位,其中100个的薪资是正常值10k,而另一个薪资是异常值1000k,若是算上异常值计算的平均薪资是29.7k,而剔除异常值计算的平均薪资是10k,两者差了将近3倍。编程
因此咱们在做分析前要关注数据质量,尤为数据量比较少的状况。本次分析的职位数有1w条左右,属于比较小的数据量,因此在数据清洗这一步花了比较多的时间。json
下面咱们就从数据清洗开始,进入编码阶段后端
导入经常使用库
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个岗位,咱们继续对这部分岗位进行清洗。
对 “职位建立时间” 维度清洗主要是为了防止有些建立时间特别离谱的岗位混进来,好比:出现了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 函数将“职位建立时间”转为“职位建立月份”,而后按“职位建立月份”分组计数。从结果上看,职位建立的时间没有特别离谱的,也就是说没有异常值。即使如此,我仍然对职位建立时间进行了筛选,只保留了十、十一、12三个月的数据,由于这三个月的职位占了大头,而且我只想关注新职位。
# 只看近三个月的职位
py_jobs_mon = py_jobs[py_jobs['createMon'] > '2020-09']
复制代码
对薪资进行清洗主要是防止某些职位的薪资特别离谱。这块主要考察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 个职位。
完成数据清洗后,咱们就正式进入分析的环节了,分析分为两部分,统计分析和文本分析,前者是对数值型指标作统计,后者是对文本进行分析。咱们平时接触到最可能是前者,它可让咱们从宏观的角度去了解被分析的对象。文本分析也有不可替代的价值,咱们下篇重点介绍。
咱们作统计分析除了要清楚分析的目外,还须要了解分析结果面向的对象是谁。本次分析中,我假想面向的是在校学生,由于他们是真正想要了解Python
职位的人。所以,咱们的分析思路就要按照他们所想看的去展开,而不能没有章法的乱堆数据。
统计分析的数据通常都是按照数据粒度由粗到细展开的,粒度最粗的数据就是不加任何过滤条件、不按照任何维度拆分的数字。在咱们的项目里其实就是总职位数,上面咱们也看到了 9715 个。若是跟Java、PHP职位去对比,或许咱们能得出一些结论,然而单纯看这个总数显然是没有实际参考价值的。
因此接下来咱们须要按照维度来进行细粒度的拆分。
咱们由粗到细,先来按照单维度进行分析。对于一个在校生来讲,他最迫切想了解的数据是什么?我以为是不一样城市之间职位数量的分布。由于对于学生来讲考虑工做的首要问题是考虑在哪一个城市,考虑哪一个城市须要参考的一点就是职位的数量,职位越多,前景天然更好。
# 城市
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
职位有了一个大概的认知了。那其余的维度还须要看吗,好比:薪资、工做经验,而且这俩维度也是你们比较关心的。我认为,从单维度来看,城市和职位分类就够了,其余都没有实际参考价值。由于薪资必定是跟某一类岗位相关的,人工智能职位工资天然偏高;一样地,工做经验也是跟岗位类别相关,大数据刚起步的时候,职位的工做经验天然就偏低。因此这俩维度从单维度上看没有参考价值,必定是须要限定了某类职位后去看才有意义。咱们在作统计分析时不要乱堆数据,要想清楚数据背后的逻辑,以及对决策人是否有价值。
对于一个学生来讲,当他肯定了本身工做的城市,也了解了不一样的职位分布,接下来咱们须要给他展现什么样的数据能为他提供择业的决策呢?
对于想去北京发展的学生来讲,他想了解北京的不一样类型的职位分布、薪资状况、工做经验的要求、什么样的公司在招聘。一样的,想去上海、深圳、广州的同窗也有相似的需求。这样,咱们就肯定了咱们须要分析的维度和指标了,维度是城市、职位类别,且须要两者交叉。指标是职位数量、平均薪资、工做经验和公司,前三个好说,但第四个须要找一个量化指标去刻画,这里我选的是公司规模。
维度已经有了,咱们要作须要是准备指标,好比:在咱们的数据集里,薪资(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创业公司比较多,而测试和数据开发公司规模就大一些,毕竟小公司几乎不用测试,小公司也没有那么大致量的数据。
有一点须要提醒你们一下,除了职位数外,其余指标绝对值是有偏的,这是由于咱们处理逻辑的缘由。但不一样职位使用的处理方式是相同的,因此不一样职位之间指标是可比的,也就是说绝对值没有意义,但不一样职位的偏序关系是有意义的。
当一个学生肯定了城市、肯定了岗位后,他还想了解的什么呢?好比他可能想了解在北京、人工智能岗位、在不一样行业里薪资、工做经验要求、公司规模怎么样,或者北京、人工智能岗位、在不一样规模的公司里薪资、工做经验要求怎么样。
这就涉及三个维度的交叉。理论上咱们能够按照任何维度进行交叉分析,但维度越多咱们视野就越小,关注的点就越聚焦。这种状况下,咱们每每会固定某几个维度取值,去分析另外几个维度的状况。
以北京为例,咱们看看不一样岗位、不一样工做经验要求下的薪资分布
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
来画不一样职位类别的词云。完整的代码还在整理,须要的朋友能够给我留言。