背景:笔者在近期的业务部会议中,发现某些区域及用户一直被反复提及。根据自身对数据的敏感度,做了个日报看板,发现只要产品线同期下降30%,这样的数据反应在折线图上,感官是非常明显的,可以说看上去几乎下降了一半。在强烈数据波动的影响下,笔者为了更好地了解各流失地区、产品线,及相关用户的发展走势,故有了这次数据探索。
技术工具如下:
地图框架:使用Pyecharts的Timeline、Tab模块,对湖南省各个县区数据进行可视化;定义城市的类模块进行各个县区的封装,避免自定义过多的城市;使用Plotly的Scattermapbox模块下钻至各区域具体用户的历年业务状况;通过Beautifulsoup解析渲染出来的html文件,并对多个html文件的拼接。
数据处理核心点:使用melt方法对透视表进行转置;通过cat.reorder_categories方法进行自定义排序,这类似Excel中的自定义排序功能。
1 导入库与数据集
import pandas as pd
import numpy as np
df = pd.read_csv(r'alldata.csv',low_memory=False)
df
约160万行的数据集。
1.1选择2021年有业务,但2022年没有业务的地区、产品线。
df_hunan = df[df['省份']=='湖南']
cross_hunan = pd.crosstab([df_hunan['城市'],df_hunan['县区'],df_hunan['产品']],df_hunan['年度']).reset_index()
cross_result = cross_hunan[(cross_hunan['2022']==0)&(cross_hunan['2021']!=0)].reset_index(drop=True).sort_values(by=['城市','县区','产品'])
cross_result['2022']=0.1
cross_result
cross_result这张表很关键,后面会反复涉及。至于为什么将2022年的那一列设为0.1,是为了避免后续展现分页选项卡地图的过程中,当鼠标点至2022年的区域时,内容显示为空白。
1.2 将没有数据的城市、县区、产品作为一个组合,去找对应的用户。
理论上用左连接、右连接等方法可以找到对应的用户,但试了好几次,最后的结果都不正确,所以只能写循环去找用户了。
result_to_crossresult = pd.crosstab([df['用户'],df['城市'],df['县区'],df['产品']],df['年度']).reset_index()
DT = []
for i in range(len(cross_result[['县区','产品']])):
DT.append(result_to_crossresult[(result_to_crossresult['县区']==list(cross_result[['县区','产品']].values[i])[0])
&(result_to_crossresult['产品']==list(cross_result[['县区','产品']].values[i])[1])])
Res = pd.concat(DT).reset_index(drop=True)
Res
1.3 将2019-2022年数量和排序,从而得到地区流失程度由大到小的列表
cityname_list = list((Res.assign(总值 = lambda x:x.loc[:,"2019":"2022"].apply(sum,axis=1))#将近三年总数最多的进行排序
.sort_values(by='总值',ascending=False)
)['城市'].unique()
)
cityname_list
2.处理城市
2.1先看一个城市+时间轴的板块
from pyecharts.charts import Bar,Funnel,Kline,Gauge,WordCloud,Map,Grid
from pyecharts import options as opts
from pyecharts.charts import Page,Pie,Scatter
from pyecharts.charts import Map, Timeline
def time_line_one() -> Timeline:
t1 = Timeline(init_opts=opts.InitOpts(width="1000px"))
for year in range(2014, 2023):
#citylist = []
for i in range(2014, 2023):
cityty = '衡阳市'#【查城市】,修改名称
area_df = cross_result.replace({'宁乡市':'宁乡县','邵东市':'邵东县','城步县':'城步苗族自治县',
'通道县':'通道侗族自治县','靖州县':'靖州苗族侗族自治县','新晃县':'新晃侗族自治县',
'芷江县':'芷江侗族自治县','麻阳县':'麻阳苗族自治县','江华县':'江华瑶族自治县'})
area_list = list(area_df['县区'])
area_count = list(area_df['{}'.format(i)])
map_ = Map()
map_.add("{}".format(cityty)
,[list(z) for z in zip(area_list, area_count)]
, "{}".format(list(pd.Series(cityty).str.replace('市','',regex=True).unique())[0])
,is_roam=True
)
map_.set_global_opts(title_opts=opts.TitleOpts(title="{}{}年数据".format(cityty,i),pos_left='left'),
visualmap_opts=opts.VisualMapOpts(min_=0, max_=max(area_count)+1
,range_color=['#F5F5DC', '#DC143C']
,pos_left='right'
,is_piecewise=False),
legend_opts=opts.LegendOpts(is_show=False)
)
t1.add(map_, "{}年".format(i))
return t1
time_line_one().render_notebook()
cross_result有个replace的操作,原因在于要将县区字段改成pyecharts的官方名单,否则数据无法显示。此外,还需注意pd.Series(cityty).str.replace('市','',regex=True)的操作,目的是将城市中带有“市”字段的内容替换为空。
2.2 解读pyecharts官网的分页选项卡
https://gallery.pyecharts.org/#/Tab/tab_base
根据官网的例子,需要几个类别就要def几次。然而,我们有12个城市,就需要定义12次,如果有100个城市,就需要定义100次。那么代码会变得相当冗长,且不易维护。
2.2处理城市-定义城市的类模块
from pyecharts import options as opts
from pyecharts.charts import Map, Timeline,Tab
class City():
def __init__(self,citycity):
self.citycity = citycity
def time_line_one(self) -> Timeline:
t1 = Timeline(init_opts=opts.InitOpts(width="1000px"))#init_opts=opts.InitOpts(width="1000px")
for i in range(2015, 2023):
cityty = self.citycity#【查城市】,修改名称
area_df = cross_result.replace({'宁乡市':'宁乡县','邵东市':'邵东县',
'城步县':'城步苗族自治县','通道县':'通道侗族自治县',
'靖州县':'靖州苗族侗族自治县','新晃县':'新晃侗族自治县',
'芷江县':'芷江侗族自治县','麻阳县':'麻阳苗族自治县',
'江华县':'江华瑶族自治县'})
area_list = list(area_df['县区'])
area_count = list(area_df['{}'.format(i)])
map_ = Map()
map_.add("{}".format(cityty)
,[list(z) for z in zip(area_list, area_count)]
, "{}".format(list(pd.Series(cityty).str.replace('市','',regex=True).unique())[0])
,is_roam=True
)
map_.set_global_opts(title_opts=opts.TitleOpts(title="""今年未发生业务区域在{}年的业务状况"""
.format(i)
,subtitle=""" {}在2021年发生,但今年未发生业务的县区:{}\n\n项目类型为:{}"""
.format(cityty
,"、".join(list(cross_result
[cross_result['城市']==
self.citycity]['县区']
.unique()
))# 将列表转为字符串
,"、".join(list(cross_result
[cross_result['城市']==
self.citycity]['产品']
.unique()
)))
,subtitle_textstyle_opts={'color':'#696969',
'font_size':100
#'font_style':'italic',
#'font_weight':'bolder'
}
,pos_left='left'
),
visualmap_opts=opts.VisualMapOpts(min_=0, max_= max(area_count)+1
#,range_color=['#D7E3EF', '#163A69']
,range_color=['#F5F5DC', '#DC143C']
,pos_left='right'
,is_piecewise=False),
legend_opts=opts.LegendOpts(is_show=False)
)
t1.add(map_, "{}年".format(i))
return t1
2.1 渲染带有时间轴(Timeline)的市级地图
tab = Tab()
for i in range(len(cityname_list)):
#tab = Tab()
tab.add(City(citycity=cityname_list[i]).time_line_one(), cityname_list[i])
#tab.render_notebook()
tab.render('市级地图(阉割).html')
选项卡的顺序就是cityname_list的列表顺序
2.2 渲染表格
from pyecharts.components import Table
cross_result.drop(labels='2014',axis=1,inplace=True)#如果二次运行,这里会报错,需要注释掉
tab1 = Tab()
for i in range(len(cityname_list)):
headers = list(cross_result.columns)
cross_result.replace(0.1,0,inplace=True)
cross_result['2022'] = cross_result['2022'].astype(int)
rows = cross_result.query("城市=='{}'".format(cityname_list[i])).values
attributes = {"font-size":"0.1px"}
table = (
Table()
.add(headers, rows)
.set_global_opts({
"title_style":"style='color:red'",
"subtitle_style":"style='color:green'"
})
)
tab1.add(table,cityname_list[i])
#tab1.render_notebook()
tab1.render('市级数据表格(阉割).html')
2.3渲染Plotly的地图-气泡图(Scattermapbox)
先将数据处理成plotly可读的结构。
Res_ = pd.melt(Res,id_vars=list(Res.columns[:4])).rename(columns = {'value':'申请次数','variable':'年度'})
Res_
将Res_表格排序,排序规则为以永州市为首的cityname_list列表顺序。
import pandas as pd
import plotly.express as px
import plotly
import datetime
from datetime import *
import numpy as np
Res_['城市'] = Res_['城市'].astype('category')
Res_['城市'].cat.reorder_categories(cityname_list, inplace=True)
Res_['年度'] = Res_['年度'].astype(int)
Res_.sort_values(by=['城市','年度'],inplace=True)
Res_
df_lon_lat = pd.read_excel(r'经纬度.xlsx')#读取经纬度
df_lon_lat.drop_duplicates(inplace=True)
plotly_df = (pd.merge(Res_.drop(index= Res_[Res_['年度']==2014].index)
,df_lon_lat,how='left')
.assign(申请次数 = lambda x:np.where(x['申请次数']==0,0.1,x['申请次数'])
)
)
px.set_mapbox_access_token("pk.eyJ1IjoidGlua2VyaXNtIiwiYSI6ImNrdDg4czh1djA2eGcyb3A5YXYyMmc3Z2EifQ.vnlyVgi0NCi1c1TcpwVH-g")
title = '今年未产生数据的县区中,用户的历年业务状况'
fig = px.scatter_mapbox(plotly_df
,title = title
,text = '用户'
,hover_name= '产品'
,lat="纬度",lon="经度"
,color="城市"
,size="申请次数"
, size_max=50
, zoom=10
,animation_frame = '年度'
)
fig.show()
plotly.offline.plot(fig, filename=title+".html")
丢弃2014年的数据是因为,这一年中相关用户及对应的产品线均无数据。将每年申请次数为0的改为0.1,原因在于,若数据为0则图例(legend)中的圆圈会变成一个点,不便于查看。
有些经纬度没对上,回头修改一下。
3 使用Beautifulsoup解析html文件,最终完成html的拼接
目前我们已经生成了3个html文件,现在将它们拼起来。
from bs4 import BeautifulSoup
text = open('市级地图(阉割).html','r',encoding='utf8')
soup_map = BeautifulSoup(text,'lxml')#'lxml'
text2 = open('市级数据表格(阉割).html','r',encoding='utf8')
soup_table = BeautifulSoup(text2,'lxml')
text3 = open('今年未产生数据的县区中,用户的历年业务状况.html','r',encoding='utf8')
soup_bigmap = BeautifulSoup(text3,'lxml')
test_text = """<html>
<body>
{}{}{}
</body>
</html>
"""
text_merge = test_text.format(soup_map,soup_table,soup_bigmap)
file = open(r'2022年未发生业务地区所对应的用户历年数据.html','w',encoding='utf-8')
file.write(text_merge)
file.close()