duma

最近在做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)

打印出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)

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()

经过上述三个步骤的处理,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的结果

我这里只截取了一部分, 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来画不同职位类别的词云。完整的代码还在整理,需要的朋友可以给我留言。

欢迎公众号 渡码

相关文章: