7.11 案例:基于自动节点树的数据异常原因下探分析
7.11.1 案例背景
日报、周报、月报等常规性报告是各个公司的基础数据支持形式。在日常报告中,经常会出现很多异常波动的指标,需要分析师找到异常波动的影响因素。但在寻找主要因素时由于需要下探的层级较多,实施起来会非常费时费力。以大型公司的广告投放渠道为例,可能包括以下层级:
一级渠道包括SEM、AD、CPS、Social、导航等;
二级渠道以SEM为基准包括百度、谷歌、360等;
三级渠道以百度为基准包括关键字、网盟等;
四级渠道以关键字为基准包括不同的广告计划;
五级渠道以广告计划为基准可以细分到不同的广告组;
六级渠道以广告组为基础可以细分到不同的关键字。
本案例介绍了一个自动化细分找到主要影响因素的方法——基于自动节点树的数据异常原因下探分析方法,该方法的实施思路是:找到每个层级上影响最大的因素并依次做下一因素的细分,直至最后一个因素。具体过程如下:
步骤1 统计全站在一定周期内、特定指标下的数据环比变化量和环比变化率。
步骤2 指定要分析日期并获得该日期及其前1天的数据。
步骤3 以全站数据为基准,下探第一层级维度并对指定日期和其前1天的数据做分类汇总。
步骤4 计算第一层级维度下分类汇总后的两天数据的差值并得到环比变化量和环比变化率。
步骤5 对第一层级维度下的变化量排序,并分别获得环比变化量最大和最小情况下的维度名称、变化量和变化率。
步骤6 计算下一层级变化量与上一层级变化量的比值,变化量最大值和最小值的比例将被定义为正向贡献率和负向贡献率。
步骤7 循环上述步骤,直至所有层级都计算完成。
步骤8 使用树形图展示所有层级下的变化量最大和最小的维度信息包括维度名称、环比变化量、环比变化率、贡献率等信息。
由于数据的波动可能包括正向波动和负向波动,因此在数据存储和展示时的正向和负向是相对的概念。如果全站波动为负值(数据下降),那么正向的波动为最小值(一般为负值);如果全站波动为正值(数据增长),那么正向的波动为最大值(一般是正值)。
上述过程完成后会得到如图7-17所示的结果,有关该图的解释会在本节后面具体介绍。
本节案例的输入源数据advertising_data.csv和源代码chapter7_code1.py位于“附件-chapter7”中,默认工作目录为“附件-chapter7”(如果不是,请cd切换到该目录下,否则会报“IOError:File advertising_data.csv does not exist”)。程序的输出是直接打印图形并保存为图片。
7.11.2 案例主要应用技术
本案例用到的主要技术包括:
数据处理:找到每个节点的极值并计算极值贡献,该过程通过Python“手动”实现。
数据展示:基于GraphViz的节点树图展示。
图7-17 主要影响因素节点树形图
主要用到的库包括:sys、Numpy、Pandas、datetime、GraphViz,其中GraphViz是展示数据的核心。
从技术方法上讲,本案没有用到复杂的模型和算法,都是基于统计完成的。但是本案例的应用具有以下特点:
自动按照指定的日期找到影响的最大因素,并可层层分解找到对应层级的上一层和下一层关联影响因素以及对应的贡献量。
在节点树中除了关注正向影响,还增加了负向影响因素的信息,可以帮助分析师找到被整体波动埋藏的负向规律。
7.11.3 案例数据
案例数据是来自某企业的网站分析系统,其中的数据部分已经经过处理。以下是数据概况:
维度数:6。
数据记录数:10693。
是否有NA值:无。
是否有异常值:有。
以下是本数据集的6个维度的详细说明:
date:日期,格式是YYYY/MM/DD。
source:流量来源一级分类,来源于业务部门的定义。
site:流量来源二级分类,来源于业务部门的定义。
channel:流量来源三级分类,来源于业务部门的定义。
media:流量来源四级分类,来源于业务部门的定义。
visit:访问量。
上述的四个层级是企业对于流量来源类别的层次性划分,一级类别包含二级类别,二级类别包含三级类别,依次类推。
7.11.4 案例过程
步骤1 导入库。
import sys reload(sys) sys.setdefaultencoding('utf-8') import datetime import pandas as pd import numpy as np from graphviz import Digraph # 画图用库
本案例主要用到了以下库:
sys:系统库,用来将默认编码设置为UTF-8,目的是处理中文处理
datetime:Python内置标准库,用来做时间处理
Numpy:基本数据处理
Pandas:数据读取、审查、异常值处理等
GraphViz:画图用库,主要用来做树形图打印和输出
步骤2 读取数据,该步骤使用pandas的read_csv方法读取csv文件。
raw_data = pd.read_csv('advertising_data.csv')
步骤3 数据审查和校验,该步骤包含了数据概览、类型分布等。
print ('{:*^60}'.format('Data overview:')) print (raw_data.head(2)) # 数据概览 print ('{:*^60}'.format('Data dtypes:')) print (raw_data.dtypes) # 数据类型
该部分内容已经多次讲过,因此这里不具体讲解用法。上述代码执行后输出如下结果:
数据概览:从结果中看到数据没有乱码,也没有格式问题,数据录入也正常。
***********************Data overview:*********************** date source site channel media visit 0 2017/5/15 品牌营销_品牌词品牌词产品播放器播放标签 PC 17600 1 2017/5/15 手机_品牌营销_品牌词品牌词广告 15秒前贴片_app app 15865
数据类型:输入的数据中,第一列是date但默认读取为字符串,原因是没有做特定转换;而visit应该是数值型格式,但是却被读取为字符串格式,因此这里存在问题。经过查看原始数据,发现在visit中,部分字段的数据为“-”,这类数据需要在预处理阶段处理掉。
************************Data dtypes:************************ date object source object site object channel object media object visit object dtype: object
步骤4 数据预处理,根据上述的数据审车校验结果,需要处理的内容包括:
将date列转换为日期型,便于后期基于数据做筛选以及日期计算。
将visit列中的“-”进行转换。由于从网站分析工具导出时,“-”代表的是没有数据,因此这里转换为0。
raw_data['visit'] = raw_data['visit'].replace('-', 0).astype(np.int64) # 替换字符为0然后转换为整数型 raw_data['date'] = pd.to_datetime(raw_data['date'], format='%Y/%m/%d') # 将字符串转换为日期格式 print ('{:*^60}'.format('Data overview:')) print (raw_data.head(2)) print ('{:*^60}'.format('Data dtypes:')) print (raw_data.dtypes)
以下是主要实现过程:
通过数据框的字段选择方法,选取visit列并使用replace方法将“-”替换为0,再使用astype方法将数据类型转换为int64。
使用之前介绍过的pd.to_datetime方法将date列转换为datetime格式。
转换完成后,再次打印输出前2行数据以及数据类型如下:
***********************Data overview:*********************** date source site channel media visit 0 2017-05-15 品牌营销_品牌词品牌词产品播放器播放标签 PC 17600 1 2017-05-15 手机_品牌营销_品牌词品牌词广告 15秒前贴片_app app 15865 ************************Data dtypes:************************ date datetime64[ns] source object site object channel object media object visit int64 dtype: object
数据已经按照预期转换了格式,并且没有产生意外数据变更。
相关知识点:使用pandas的replace方法替换字符串
pandas中的replace方法是一个非常强大的字符串替换功能,它可以实现字符串、字典、列表、数字甚至正则表达式级别的转换。
语法:
replace(self, to_replace=None, value=None, inplace=False, limit=None, regex= False, method='pad', axis=None)
其中关键参数如下:
to_replace:要替换的对象。它可以是str、regex、list、dict、Series、numeric、None。
str:完全替换和匹配的字符串。
regex:匹配符合正则表达式模式的对象。
list:可以是字符串列表、正则表达式列表或数值列表。如果to_replace(原始对象)和value(替换后的对象)都是列表格式,那么列表长度必须一致;如果regex参数设置为True,那么两个列表中的所有字符串将被应用正则表达式。
dict:可以使用嵌套字典直接做匹配模式定义,例如嵌套字典({'a':{'b':nan}})将在列'a'中查找值'b',并将其替换为nan,嵌套中也可以使用正则表达式。
value:替换后的值。它也可以是scalar、dict、list、str、regex,其中dict对象可以将每个列指定为不同的替换值,例如{'a':1,'b':2}可以将列a替换为1,列b替换为2。
步骤5 计算整体波动量,这里先将每天的数据与其前1天的数据做环比变化统计。
day_summary = raw_data.ix[:, -1].groupby(raw_data.ix[:, 0]).sum() # 按天求和汇总 day_change_value = day_summary.diff(1).rename('change') # 通过差分求平移1天后的变化量 day_change_rate = (day_change_value / day_summary).round(3).rename('change_rate') # 求相对昨天的环比变化率 day_summary_total = pd.concat((day_summary, day_change_value, day_change_rate), axis=1) # 整合为完整数据框 print ('{:*^60}'.format('Data change summary:')) print (day_summary_total)
具体实现过程如下:
使用ix方法指定特定列,并使用groupby方法做分类汇总,目标是以第一列(date)为单位,对最后一列(visit)做求和(sum)汇总,得到每天的visit数据day_summary,这是一个series类型的数据。
对day_summary使用diff方法做一次差分然后使用rename方法将其名称改为change,得到每1天相对于前1天的变化量day_change_value。
在4.6节我们提到的ARIMA方法中,需要针对数据做差分处理,在当时的案例中,我们使用的是log方法做处理。除此以外还可以使用diff方法做差分,差分用到的方法diff方法。diff能够对数据做一定量的偏移计算,参数为使用periods=n指定的偏移量,默认n=1。
对day_change_value和day_summary求商得到环比变化率day_change_rate,对结果使用round(3)方法保留3位小数,并使用rename方法设置名称为change_rate。
由于在使用diff做差分时,默认的day_summary为float64,因此将day_change_value和day_summary直接做除法时可以得到浮点型值。如果day_change_value和day_summary的类型都为整数型,那么得到的结果是整数型。此时,需要将其中至少一个对象转换为float型才能得到浮点型结果,例如day_change_value/day_summary.astype(np.float64)。
使用之前介绍过的pd.concat方法将上述3个Series沿列合并为一个数据框,打印结果如下:
********************Data change summary:******************** visit change change_rate date 2017-05-15 117260 NaN NaN 2017-05-16 166124 48864.0 0.294 2017-05-17 157727 -8397.0 -0.053 2017-05-18 155805 -1922.0 -0.012 2017-05-19 115644 -40161.0 -0.347 2017-05-20 120833 5189.0 0.043 2017-05-21 123145 2312.0 0.019 2017-05-22 113624 -9521.0 -0.084 2017-05-23 131248 17624.0 0.134 2017-05-24 149783 18535.0 0.124 2017-05-25 112208 -37575.0 -0.335 2017-05-26 98556 -13652.0 -0.139 2017-05-27 125342 26786.0 0.214 2017-05-28 122626 -2716.0 -0.022 2017-05-29 134067 11441.0 0.085 2017-05-30 137391 3324.0 0.024 2017-05-31 150686 13295.0 0.088 2017-06-01 80334 -70352.0 -0.876 2017-06-02 90468 10134.0 0.112 2017-06-03 79892 -10576.0 -0.132 2017-06-04 91720 11828.0 0.129 2017-06-05 97115 5395.0 0.056 2017-06-06 97984 869.0 0.009 2017-06-07 79529 -18455.0 -0.232 2017-06-08 83676 4147.0 0.050 2017-06-09 74351 -9325.0 -0.125 2017-06-10 76256 1905.0 0.025 2017-06-11 78155 1899.0 0.024 2017-06-12 133994 55839.0 0.417 2017-06-13 77315 -56679.0 -0.733 2017-06-14 46273 -31042.0 -0.671
上述结果显示了每天的数据变化量以及变化率。由于数据是后一天相对于前一天做偏移计算,因此第一天(2017-05-15)的change和change_rate为空值。
基于上述数据,我们可以分析特定日期的变化原因。这里我们指定要分析的日期为2017-06-07,要找到到底哪些维度导致全站访问量的下降。
步骤6 指定日期自动下探分解。
获得指定日期、前1天以及各自对应的数据。
the_day = pd.datetime(2017, 6, 7) # 指定要分析的日期 previous_day = the_day - datetime.timedelta(1) # 自动获取前1天日期 the_data_tmp = raw_data[raw_data['date'] == the_day].rename(columns={'visit': the_day}) # 获得指定日期数据 previous_data_tmp = raw_data[raw_data['date'] == previous_day].rename(columns= {'visit': previous_day}) # 获得前1天日期数据
使用pd.datetime指定分析日期,参数格式为年、月、日。
使用datetime.timedelta方法做日期迁移,得到其前1天的日期值。
使用数据框的字段选择方法,选取date列中值等于the_day等记录并将该列重命名为the_day,目的是后面合并起来的数据能有效区分不同日期。
使用相同的方法获得前1天的数据。
定义要使用的变量。
dimension_list = ['source', 'site', 'channel', 'media'] # 指定要分析的维度:4个层级 split_node_list = ['全站'] # 每层分裂节点名称列表 change_list = list() # 每层分裂节点对应的总变化量 increase_node_list = [] # 每层最大增长贡献最大的1个维度 decrease_node_list = [] # 每层最小增长贡献最大的1个维度
dimension_list:指定分析的维度,这里使用了广告媒体的全部类别特征。
split_node_list:每个层级分裂节点的名称,其中全站为顶级节点,因此直接加入到列表。
change_list:每层分裂节点对应的数据变化总量,该列表的每个值会用来做分裂方向判断。
increase_node_list:每层最大增长贡献的维度,一般情况下列表中的值为正数。
decrease_node_list:每层最小增长贡献的维度,一般情况下列表中的值是负数。
接下来是整个分裂过程的计算,由于代码比较长,我们拆分为6个part单独讲解。最外层的for循环过程针对每个维度做循环,下面开始6个部分的内容。
1)part1计算指定维度下的数据。
for dimension in dimension_list: # 遍历每个维度 # part1 the_data_merge = the_data_tmp[[dimension, the_day]] # 获得指定日期的特定维度和访问量 previous_data_merge = previous_data_tmp[[dimension, previous_day]] # 获得指定日期前1天的特定维度和访问量 the_day_groupby = pd.DataFrame(the_data_merge.ix[:, -1].groupby(the_data_merge.ix[:, 0]).sum()) # 对指定日期特定维度汇总求和 previous_day_groupby = pd.DataFrame( previous_data_merge.ix[:, -1].groupby(previous_data_merge.ix[:, 0]).sum()) # 对指定日期前1天特定维度汇总求和
基于指定日期的数据,使用列名选择方法获得维度列(dimension)以及对应日期的访问量数据(the_day)。使用相同的方法获得指定日期前1天特定维度的数据。
对指定日期使用ix选择以最后一列为维度,对最后一列做汇总求和;使用相同的方法对前1天的数据做分类汇总。
经过上述步骤得到每个维度下各自特征的分类汇总数据。由于接下来我们需要使用数据框方法做合并,因此分类汇总后的数据使用pd.DataFram转换为数据框。
2)part2将两天的数据合并然后计算其变化量和变化率。
# part2 merge_data = pd.merge(the_day_groupby, previous_day_groupby, how='outer', left_index=True, right_index=True) # 合并2天的数据 merge_data = merge_data.fillna(0) # 将缺失值(没有匹配的值)替换为0 merge_data['change'] = merge_data[the_day] - merge_data[previous_day] # 计算环比变化量 merge_data['change_rate'] = merge_data['change'] / merge_data[previous_day] # 计算环比变化率 total_chage = merge_data['change'].sum() # 获得分裂节点的总变化值 change_list.append(total_chage) # 将每个节点的变化值加入列表
使用pd.merge方法做汇总后的两天数据做合并,其中参数如下:
the_day_groupby:第一个要合并的数据框。
previous_day_groupby:第一个要合并的数据框。
how='outer':合并方式为outer,相当于SQL中的full outer join,两个数据框中的全部数据都会形成结果列表。匹配不上的数据会以NA值显示。
left_index=True:指定以第一个数据框的index作为匹配对象。
right_index=True:指定以第二个数据框的index作为匹配对象。
使用merge_data.fillna(0)方法将合并后没有匹配到的NA数据替换为0。
按照跟计算日环比变化量和环比变化率的相同方法计算该维度下的数据环比变化量和变化率。
使用merge_data['change'].sum()对特定维度的变化量做求和。该值是分列节点变化量的总值,而非单独特定分裂节点的值。
使用change_list.append(total_chage)将分裂节点变化量的总值加入列表,该列表会在下面做节点图输出时用到。
3)part3计算当前维度下变化量最大值对应的各项信息。
# part3 merge_data = merge_data.sort_values(by='change') # 按环比变化量正向排序 max_increase_node = merge_data.ix[-1].name # 获得增长变化量最大值节点名称 max_value, max_rate = merge_data.ix[-1][2:4] # 获得最大值节点变化量以及变化比例 increase_node_list.append([max_increase_node, int(max_value), max_rate]) # 将最大值信息追加到列表
使用merge_data.sort_values(by='change')对merge_data按照change列做排序,排序结果将以变化量change为维度做正向排序,第一行的值为最小值,最后一行的值为最大值。
使用merge_data.ix[-1].name获得增长变化量最大值节点名称。
使用merge_data.ix[-1][2:4]获得最大值节点变化量以及变化率。
将变化量最大的节点名称、变化量和变化率数据以append方法追加到列表中。
4)part4计算当前维度下变化量最小值对应的各项信息。
# part4 min_increase_node = merge_data.ix[0].name # 获得增长变化量最小值节点名称 min_value, min_rate = merge_data.ix[0][2:4] # 获得最小值节点变化量以及变化比例 decrease_node_list.append([min_increase_node, int(min_value), min_rate]) # 将最小值信息追加到列表
该部分的计算方法跟part3相同,区别仅在于part3在使用ix方法选择数据时选择的是最后一行(值为-1),而part4选择的是第一行(值为0)。
5)part5针对增长趋势的数据做逐层数据过滤。
从这里开始分别针对增长和下降两种趋势做数据逐层过滤。由于在针对异常值的自动下探过程中,会面临数据增长与数据下降两种情况。因此,针对数据增长的情况需要以左侧最大值作为分裂节点的条件来选择数据;而针对数据下降的情况需要以右侧最小值作为分裂节点的条件来选择数据。
# part5 if total_chage >= 0: # 判断为增长方向 split_node_list.append(max_increase_node) # 将分裂节点定义为增长最大值节点 rules_len = len(split_node_list) # 通过分裂节点的个数判断所处分裂层级 if rules_len == 2: # 第二层source,第一层为全站整体 the_data_tmp = the_data_tmp[the_data_tmp['source'] == max_increase_node] # 以source为维度过滤出指定日期符合最大节点条件的数据 previous_data_tmp = previous_data_tmp[ previous_data_tmp['source'] == max_increase_node] # 以source为维度过滤出前1天符合最大节点条件的数据 elif rules_len == 3: # 第三层site the_data_tmp = the_data_tmp[the_data_tmp['site'] == max_increase_node] # 以site为维度过滤出指定日期符合最大节点条件的数据 previous_day_data_tmp = previous_data_tmp[ previous_data_tmp['site'] == max_increase_node] # 以site为维度过滤出前1天符合最大节点条件的数据 elif rules_len == 4: # 第四层channel the_data_tmp = the_data_tmp[the_data_tmp['channel'] == max_increase_node] # 以channel为维度过滤出指定日期符合最大节点条件的数据 previous_data_tmp = previous_data_tmp[ previous_data_tmp['channel'] == max_increase_node] # 以channel为维度过滤出前1天符合最大节点条件的数据 elif rules_len == 5: # 第五层media the_data_tmp = the_data_tmp[the_data_tmp['media'] == max_increase_node] # 以media为维度过滤出指定日期符合最大节点条件的数据 previous_data_tmp = previous_data_tmp[ previous_data_tmp['media'] == max_increase_node] # 以media为维度过滤出前1天符合最大节点条件的数据
当total_chage>=0时,数据变化量为正,因此判断为增长方向。此时需要将最大值节点max_increase_node追加到分裂节点列表split_node_list。
使用len(split_node_list)得到分裂节点的数量来判断当前循环所属的维度层级,由于split_node_list初始化时就已经具有一个顶层节点名称了,因此判断长度值从2开始:
rules_len为2时代表此时循环的是第二层,即第一个下探维度source。此时基于指定日期的数据将the_data_tmp中source列值为max_increase_node的记录过滤出来;同样,将指定日期前1天的数据previous_data_tmp中source列值为max_increase_node的记录过滤出来。
以此类推,当rules_len为3时代表此时循环的是第三层,即第二个下探维度site并将两天的数据过滤出来。由此可以得到每个循环下的过滤数据。
这里在使用过滤条件时,没有将上一层的条件加到下一层,原始是每一层过滤所使用的数据都是上一层过滤之后的数据,该数据已经继承了前个循环的条件。以rules_len==3为例,在rules_len==2时,已经从数据集中过滤出source等于对应层级的max_increase_node的数据;在第三层级时,自然已经基于上述过滤后的数据再针对第三层site等于对应层级的max_increase_node即可。
6)part6针对下降趋势的数据做逐层数据过滤。
# part6 else: # 判断为下降方向 split_node_list.append(min_increase_node) # 将分裂节点定义为增长最大值节点 rules_len = len(split_node_list) # 通过分裂节点的个数判断所处分裂层级 if rules_len == 2: # 第二层source the_data_tmp = the_data_tmp[the_data_tmp['source'] == min_increase_node] # 以source为维度过滤出指定日期符合最小节点条件的数据 previous_data_tmp = previous_data_tmp[ previous_data_tmp['source'] == min_increase_node] # 以source为维度过滤出前1天符合最小节点条件的数据 elif rules_len == 3: # 第三层site the_data_tmp = the_data_tmp[the_data_tmp['site'] == min_increase_node] # 以site为维度过滤出指定日期符合最大节点条件的数据 previous_day_data_tmp = previous_data_tmp[ previous_data_tmp['site'] == min_increase_node] # 以site为维度过滤出前1天符合最大节点条件的数据 elif rules_len == 4: # 第四层channel the_data_tmp = the_data_tmp[the_data_tmp['channel'] == min_increase_node] # 以channel为维度过滤出指定日期符合最大节点条件的数据 previous_data_tmp = previous_data_tmp[ previous_data_tmp['channel'] == min_increase_node] # 以channel为维度过滤出前1天符合最大节点条件的数据 elif rules_len == 5: # 第五层media the_data_tmp = the_data_tmp[the_data_tmp['media'] == min_increase_node] # 以media为维度过滤出指定日期符合最大节点条件的数据 previous_data_tmp = previous_data_tmp[ previous_data_tmp['media'] == min_increase_node] # 以media为维度过滤出前1天符合最大节点条件的数据
该部分的实现逻辑与part5相同,区别仅在于在每个层级应用过滤条件时,由于下降趋势中,需要针对变化量最小值的右侧节点做筛选,因此条件设置为min_increase_node(而非max_increase_node)。
至此,我们已经完成了整个下探工作并获得了4个关键列表:
split_node_list:每层分裂节点名称列表。
change_list:每层分裂节点对应的总变化量。
increase_node_list:每层最大增长贡献维度及其对应的变化量和变化率。
decrease_node_list:每层最小增长贡献维度及其对应的变化量和变化率。
步骤7 画图展示自动下探结果。以上4个列表对于数据结果的理解和展示效果并不直接,因此这里使用类似4.3.6节中决策树的图形展示结果。由于该过程也比较长,这里将其拆分为5个part。
1)part1定义节点树形图用到的属性和样式。
# patr1 node_style = {'fontname': "SimSun", 'shape': 'box'} # 定义node节点样式 edge_style = {'fontname': "SimHei", 'fontsize': '11'} # 定义edge节点样式 top_node_style = '<<table><tr><td bgcolor="black"><font color="white">{0}</font> </td></tr><tr><td>环比变化量:{1:d}</td></tr><tr><td>环比变化率:{2:.0%}</td> </tr></table>>' # 定义顶部node节点标签样式 left_node_style = '<<table><tr><td bgcolor="chartreuse"><font color="black">{0} </font></td></tr><tr><td>环比变化量:{1}</td></tr><tr><td>环比变化率:{2:.0%}</td> </tr></table>>' # 定义左侧node节点标签样式 right_node_style = '<<table><tr><td bgcolor="lightblue"><font color="black">{0} </font></td></tr><tr><td>环比变化量:{1}</td></tr><tr><td>环比变化率:{2:.0%}</td> </tr></table>>' # 定义右侧node节点标签样式 dot = Digraph(format='png', node_attr=node_style, edge_attr=edge_style) # 创建有向图
node_style定义的是node节点的样式,node节点指所有节点,包括分裂和非分裂节点。其中:
fontname:定义字体名称为宋体,由于数据中包含中文,因此必须选择一个系统中存在的可以正常显示的字体样式。在windows中常用的中文字体名称如表7-2所示。
表7-2 windows常用中文字体
shape:定义节点样式。graphviz支持的节点样式超过50种,常见的样式如图7-18所示。
图7-18 常见的节点样式定义
edge_style:定义的是有向边的样式。其中:
fontname:定义的是字体样式,跟node字体样式定义的用法相同。
fontsize:定义的是字体大小,这里定义为11,单位为点(point)。
top_node_style:定义的是顶级节点的样式。由于下面我们会采用str.format的方法动态填充值,因此对其中的字符串变量使用format中的占位符表示方法。有关format方法的更多知识,请具体参照4.4.6节中的信息。
{0}表示第一个对应变量的数据。
{1:d}表示第二个对应变量的数据,其填充的数据类型为整数型。
{2:.0%}表示第三个对应变量的数据,其填充的数据类型为百分比类型,保留0位小数。
在显示节点内容时,我们希望除了节点名称以外,还增加节点环比变化量以及环比变化率数据,并且以表格的形式展示出来。graphviz支持以类似HTML格式的语法显示对应效果。在top_node_style的字符串定义中,我们使用了类似于HTML的语法定义了一个3行1列的table,并且table的第一行底色为黑色,字为白色;其他两行的都是默认(底色为白色,字体为黑色)。
相关知识点:HTML中的表格
在HTML中定义表格通过<table>标签实现。一个表格中通常包含多个行或多个列,行是通过<tr>标签定义,列通过<td>或<th>标签定义。行和列的“交集”就是单元格,单元格中可以展示任何信息,比如文字、数字、图像、列表、直线以及嵌套表格等。
表格的定义一般是遵循先行后列的方法,如下代码定义了一个2行2列的表格:
<table border="1"> <tr> <td>1</td> <td>2</td> </tr> <tr> <td>3</td> <td>4</td> </tr> </table>
该表格的数据按照先行后列,从左到右依次是1/2/3/4,展示样式如下:
在表格定义过程中,我们会用到很多“样式”。常用样式属性如表7-3所示。
表7-3 table常用属性
例如<table width="600"border="0"align="center"cellpadding="0"cellspacing="0">定义了一个宽度为600像素,边框宽度为0(即不显示边框),对齐方式为水平居中,单元格边距为0,单元格间的边距为0的表格。
同样的,td标签也支持table中的属性,而tr则支持其中的部分属性。
left_node_style:定义的是左侧节点的样式。分别定义左侧两侧节点的样式是为了更好的区分。该部分的定义逻辑与top_node_style相同,仅仅在第一行的背景色和字体颜色上不同。这里的背景色定义为chartreuse(黄绿色),字体颜色为黑色。
right_node_style:定义的是右侧节点的样式。该部分与left_node_style定义类似,不同的是背景色定义为lightblue(浅蓝色)。
最后,使用Digraph(format='png',node_attr=node_style,edge_attr=edge_style)创建一个有向图,然后应用上述指定的样式。其中format指的是图像文件保存为png格式图片。
2)part2获得每一层分裂节点相关信息。
这里的for循环用来循环读取每一层数据,但总量是读取4次,而非5次。这是由于每个树形图结构由两层信息构成:第一层的分裂节点和第二层左右一侧的分支节点;而下个层级又会以上一个层级的某个节点作为分裂节点在做分裂。当4层全部读取完毕之后,形成的图形便是5层层级了。
例如,当i=0时,得到的树形节点如图7-19所示。
图7-19 第一层节点树
for i in range(4): # 循环读取每一层 # part2 node_name = split_node_list[i] # 获得分裂节点名称 node_left, max_value, max_rate = increase_node_list[i] # 获得增长最大值名称、变化量和变化率 node_right, min_value, min_rate = decrease_node_list[i] # 获得增长最小值名称、变化量和变化率 node_change = change_list[i] # 获得分裂节点的总变化量-非分裂节点变化量 node_label_left = left_node_style.format(node_left, max_value, max_rate) # 左侧节点显示的信息 node_label_right = right_node_style.format(node_right, min_value, min_rate) # 右侧节点显示的信息
该部分共相对简单,主要通过列表读取对应循环次数下的各个信息:
node_name:分裂节点名称。
node_left、max_value、max_rate:分别是增长最大值名称、变化量和变化率。
node_right、min_value、min_rate:分别是增长最小值名称、变化量和变化率。
node_change:分裂节点的总变化量。
node_label_left:左侧节点显示的信息,这里基于part1定义的left_node_style使用str.format方法定义数据。该数据不是节点本身的数据,而是相当于左侧节点的显示数据,该数据会与node方法中的label参数结合使用。
node_label_right:右侧节点显示的信息,该字段与node_label_left逻辑相同,也是右侧节点显示的数据。
3)part3定义顶级节点信息。由于顶级节点是全站数据,其变化量和变化率不在步骤5范围内,该数据需要从步骤4产生的每日汇总数据中获得。
# part3 if i == 0: # 如果是顶部节点,则单独增加顶部节点信息 day_data = day_summary_total[day_summary_total.index == the_day] # 获得顶部节点的数据 former_data = day_data.ix[0, 1] # 获得全站总变化量 node_lable = top_node_style.format(node_name, int(former_data), day_data.ix[0, 2]) # 分别获取顶部节点名称、变化量和变化率 dot.node(node_name, label=node_lable) # 增加顶部节点
当i为0时,单独增加顶部节点信息。
day_data为汇总数据的当日记录,包含visit、change、change_rate三列以及日期索引。
former_data为全站总变化量,用途有2个,一是便于在顶级节点中显示总变化量信息,二是作为下面计算变化量贡献的初始值。
node_lable为顶级节点显示的信息,基于part1定义的top_node_style使用str.format方法定义出顶级节点完整信息,包括节点名称、变化量和变化率。这里由于是全站级别的数据,因此可以直接使用node_change;也可以使用day_data.ix[0,1]获取。
通过dot.node(node_name,label=node_lable)方法单独增加顶级节点,重点是使用label方法将上面定义的顶级节点完整信息通过标签显示出来。
4)part4分别定义左侧和右侧边显示的贡献率信息。这里的贡献率定义为分裂下级节点的变化量与上级分裂节点的变化量的比值。正向的贡献率结果为正,负向的贡献率结果为负。
这里没有用到整个维度的总变化量(即node_change),而是计算关键节点直接的总占比。
# part4 contribution_rate_1 = float(max_value) / former_data # 获得左侧变化量贡献率 contribution_rate_2 = float(min_value) / former_data # 获得右侧变化量贡献率 if node_change >= 0: # 如果为增长,则左侧为正向 edge_lablel_left = '正向贡献率:{0:.0%}'.format(contribution_rate_1) # 左侧边的标签信息 edge_lablel_right = '反向贡献率:{0:.0%}'.format(contribution_rate_2) # 右侧边的标签信息 former_data = max_value # 获得上一层级变化量最大值 else: # 如果为下降,则右侧为正向 edge_lablel_left = '反向贡献率:{0:.0%}'.format(contribution_rate_1) # 左侧边的标签信息 edge_lablel_right = '正向贡献率:{0:.0%}'.format(contribution_rate_2) # 右侧边的标签信息 former_data = min_value # 获得上一层级变化量最大值
contribution_rate_1:定义左侧变化量贡献率。这里使用float(max_value)/former_data而非两个变量直接除的原因是这两个变量都是整数型,如果直接除的结果将无法显示浮点型数据。
contribution_rate_2:定义右侧变化量贡献率,该变量定义方法与contribution_rate_1类似,差异点在于分子使用的是min_value。
接下来通过node_change来判断正负方向,目的是确认分裂节点沿左侧还是右侧分裂。
当node_change>=0,则沿左侧分裂。此时通过str.format方法定义左侧边标签edge_lablel_left,同理定义出右侧边标签edge_lablel_right。
当node_chang<0,则沿右侧分裂。此时使用跟上述类似的方法定义左右两侧的边的标签信息。
至此,我们已经定义好了左侧节点、左侧边,右侧节点、右侧边的全部信息,接下来需要将节点、边都关联起来。
5)part5建立节点、边并输出图形。
# part5 dot.node(node_left, label=node_label_left) # 增加左侧节点 dot.node(node_right, label=node_label_right) # 增加右侧节点 dot.edge(node_name, node_left, label=edge_lablel_left, color='chartreuse') # 增加左侧边 dot.edge(node_name, node_right, label=edge_lablel_right, color='lightblue') # 增加右侧边 dot.view('change summary') # 展示图形结果
跟定义顶级节点的方法类似,使用dot.node定义左右两侧的节点,同时设置节点显示的label(标签)值。
使用dot.edge定义左侧边与右侧边,并设置label(标签)值和颜色值。为了对应,我们将边的颜色跟节点内使用HTML设置的颜色相同。
最后使用dot.view()方法显示最终图形,该方法与之前用到过的dot.render(view=True)的结果是一样的,可以看成是其缩写版本。另外,该方法支持的参数如下:
view(self, filename=None, directory=None, cleanup=False)
filename:展示文件的名称,本节中设置为change summary。
directory:生成展示结果对应的数据源和图像的路径,默认为空意味着是当前路径。
cleanup:是否清除生成展示结果对应的数据源文件。
上述代码完成后在当前Python工作路径下会产生2个文件:
change summary:该文件没有扩展名,是上述生成展示结果对应的数据源文件。
change summary.png:目标保存的图像文件,也是预览文件(7.10.1节图7-17所示图形),生成图像后系统会默认调用该对象的对应程序打开预览。
7.11.5 案例数据结论
本案例的核心在change summary.png中的信息,该图包含了5层分析维度,依次从全站、source、site、channel、media做层层细分。以第三层级到第四层级下探分支为例,如图7-20所示。
图7-20 第三层级向第四层级细分
图中包括四类信息体:
第一类信息体:分裂节点,如图中的①,分裂节点是影响上一层变化的正向贡献最大的节点,数据包含环比变化量和环比变化率。
第二类信息体:正向边和负向边信息,如图中的②,两个边都计算了下一层极变化量跟上一层次分裂节点变化量的比值。正向边为正值、负向边为负值,这两个值分别代表了下一个分支节点对于分裂节点的变化贡献率。
第三类信息体:负向贡献因子节点,如图中的③,该因子是在跟整体相反趋势或贡献最小的因子,数据包括环比变化量和环比变化率。
第四类信息体:正向贡献因子节点,如图中的④,该因子是导致分裂节点变化的主要贡献因素,数据包括环比变化量和环比变化率。
这里需要注意两个点:
一是右侧贡献节点的环比变化量与上一层级的环比变化量数据不一致,并且其贡献率不一是100%。
二是分裂节点左右两侧的变化量的总和不等于上层级的分裂节点的变化量的总和。
以上两个现象的产生原因是分裂节点下一层级的左右分支,仅仅是分裂节点信息的一部分,而非全部。以图7-20的第三层级分裂节点为例,在准会员的-17575的变化量中,电视是下降最主要的部分,除此以外还有其他的上升和下降量,例如商城、手机等维度。因此,我们会看到电视对准会员的正向贡献率会超过100%,原因是在反向节点(有增加值的节点)上会有正向值抵消一部分下降量;这部分抵消的信息因子,也是我们在做日常分析中需要从整体趋势中发掘的亮点或问题点。
参照7.10.1节图7-17的信息,我们得到如下结果:
全站访问量下降18455,下降比例达到23%,主要的source源是CRM,其下降量为17591,“贡献”了95%的主要因素;而导致CRM下降的主要site源是准会员,其下降量为17575,“贡献了”几乎100%的下降因素;再进一步细分,在影响准会员的channel中,电视源的下降量达到19090,“贡献”了109%的比例,而导致电视流量下降的主要media源是APP,其贡献了19024的下降流量,比例几乎是100%。与此同时,某些来源渠道的流量与全站的下降趋势相反,呈现良好的增长趋势,这在全站的下降主要因子中表现良好,包括source源中的公众号流量环比增长2444,增长率达到171%;s准会员中的商城部分的流量增长1378,增长率为29%。
在上述结论中,针对每个节点(含分裂节点和非分裂节点)我们都有两个方向的参照:
横向因子,即查询哪些特征对于上一层级的变化有主要的正向和负向贡献,这是贡献率的来源;
纵向因子,即查询每个节点本身相对前1天的环比变化率,这是其本身随着时间的变化特征,能有效了解其自身波动水平。
7.11.6 案例应用和部署
本案例是一个非常实用的应用,它已经部署到笔者之前所在公司的日常应用中,作为日常数据报告内容主要波动原因探查的主要途径和方法。
大多数情况下,我们会针对昨天的数据与前天数据做对比分析。因此,在应用部署时默认设置昨日的数据与前1日数据做对比,即在代码中的
the_day = pd.datetime(2017,6,7) # 指定要分析的日期替换为the_day = datetime.date.today()-datetime.timedelta(days=1)
这样系统就会默认指定昨天作为分析日期与前天的数据做对比。因此,每天早晨上班时只需跑一下程序即可。
7.11.7 案例注意点
本案例虽然思路简单,但在实现过程中仍然需要读者避开一些坑:
分裂节点的左右分支的数量总和不等于分裂节点的变化量,这点跟决策树规则得到的结果逻辑不同。这里查询的仅仅是主要因子的贡献度。
由于整体数据增长和整体数据下降需要不同的分裂因子以及沿不同边做下探,因此需要分开获取数据并展示。
对于案例中使用上一节点的变化量作为正向贡献率的分母,如果读者有疑问,可以使用上一节点对应的波动总值作为分母,其值存储在change_list中,只需在计算contribution_rate_1和contribution_rate_2时使用node_change替换former_data即可。但这样做的坏处是边显示的贡献度跟节点的数据不是一个维度,导致信息判断混乱,因此不建议读者这样应用。
在label标签定义时,我们使用了类似HTML的语法,之所以说是类似于,是因为graphviz不完全支持所有HTML格式的语法;并且定义时需要使用“<>”将HTML语法代码括起来,否则代码无法生效。
node_name和node_left、node_right在分裂时其实是部分重叠的,这也是其能够形成连续节点树的原因。因此node本身就取自于node_left(当整体增长时)或node_right(当整体下降时)。在显示不同节点的信息时,注意区分开node数据与node label数据。
7.11.8 案例引申思考
案例使用的graphviz是一个非常强大的用于展示复杂关系库,我们在4.3.6节和4.4.6节都有用到。该库还有很多我们可以应用到的复杂场景,例如:
基于网络转发的传播关系图;
个人关系联络图;
基于有时间序列的流程图;
网络拓扑关系图;
信息流和事件流图。
案例中分裂寻找的是变化量(也意味着变化率)最大的节点,如果有课题需要也可以将变化率或贡献度指定出来,在计算的时候按照指定的贡献率提取出一系列(而不是一个)因子。或者,可以依据业务手工分析的需求,沿着指定维度做层层下探,其实只是在维度循环时从预设维度变为根据系统选择传值而已。整个的实现思路,已经跟决策树的实现思想比较接近了,如果读者兴趣可以参照决策树的实现和优化思路。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论