代码详解:用Python清理、分析和可视化调查数据
如何利用panda、matplotlib和seaborn来分析脏数据,并且从中有所领悟呢?通过本文,你一定会有所了解。
调查猴子(SurveyMonkey)是最受欢迎的数据调查平台之一。它导出数据的方式并不完善,不一定支持数据导出后即刻进入分析,但这两个环节的间隔时间已经非常短了。本文将展示一些你可能想要询问的关于调查数据的问题示例,以及如何快速提取这些答案。
我们将使用panda、matplotlib和seaborn来了解数据的整体情况。作者曾经使用Mockaroo来生成这些数据:具体来说,对于调查问题领域,以前可以用“自定义列表”来进入适当领域。而现在,你通过使用random模块里的 random.choice 也可以达到同样的效果,但利用Mockaroo来处理这些工作更容易。我们对Excel中的数据进行微调整,使其反映调查猴子导出数据的结构。
importpafy import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns %matplotlib inline sns.set_style('ticks') import warnings warnings.filterwarnings('ignore') survey_data = pd.read_csv('MOCK_DATA.csv') survey_data.head()
列名没有正确读入,这里有大量的NaNs,而不是像0/1或1/2/3/4/5这样的数字表示,我们在每个单元格中都有有效的文本内容…我们真的可以用分层(MultiIndex)读入这些吗?
在本文中,我们将忽略分层。(反正没人喜欢用它们)
第一件事:我们需要根据年龄层来找出这些问题的答案。但是我们没有针对不同年龄段的列表。幸运的是,我们很容易通过定义函数来创建年龄列表。
def age_group(age): """Creates an age bucket for each participant using the age variable. Meant to be used on a DataFrame with .apply().""" # Convert to an int, in case the data is read in as an "object" (aka string) age = int(age) if age < 30: bucket = '<30' # Age 30 to 39 ('range' excludes upper bound) if age in range(30, 40): bucket = '30-39' if age in range(40, 50): bucket = '40-49' if age in range(50, 60): bucket = '50-59' if age >= 60: bucket = '60+' return bucket
但是如果我们这样运行它,会出现错误。这是因为第一行对于年龄的值是单词“age”而不是数字。由于第一步是将每个年龄转换为int,结果就会失败。
按理说需要从分布式数据集(DataFrame)中删除这一行,但是这一行会对之后重命名列时很有用,所以先将它作为一个单独的变量保留下来。
# Save it as headers, and then later we can access it via slices like a list headers = survey_data.loc[0] # .drop() defaults to axis=0, which refers to dropping items row-wise survey_data = survey_data.drop(0) survey_data.head(3)
注意,自从删除了headers之后,单独查看调查数据会丢失了一些信息。理想情况下,你会有一个问题列表和一些调查中提及的选项,这都是一些想要数据分析的人提供给你的。如果没有,你应该在文档中保留一个单独的方法来引用这些信息,或者把这些引用信息记下来,以便工作的时候查看。
好的,现在让我们应用 age_group 函数来获取age_group列。
survey_data['age_group'] = survey_data['What is your age?'].apply(age_group) survey_data['age_group'].head(3)
太好了。接下来,将数据子集集中在第一个问题上。根据年龄的不同,第一个问题的答案如何不同?
# Subset the columns from when the question "What was the most..." is asked, # through to all the available answers. Easiest to use .iloc for this survey_data.iloc[:5, 3:7]
# Next, assign it to a separate variable corresponding to your question important_consideration = survey_data.iloc[:, 3:7]
太好了。现在得到的答案是一个变量。但绘制这些数据时,由于列名错误,它看起来不是很好。因此需要写一个快速函数,使重命名列变得简单:
def rename_columns(df, new_names_list): """Takes a DataFrame that needs to be renamed and a list of the new column names, and returns the renamed DataFrame. Make sure the number of columns in the df matches the list length exactly, or function will not work as intended.""" rename_dict = dict(zip(df.columns, new_names_list)) df = df.rename(mapper=rename_dict, axis=1) return df
还记得前面的headers吗?我们可以使用它来重新命名new_names_list。
headers[3:7].values
它已经是一个数组了,所以我以直接把它传输进来,或者为了可读性,可以先重命名它。
ic_col_names = headers[3:7].values important_consideration = rename_columns(important_consideration, ic_col_names) # Now tack on age_group from the original DataFrame so we can use .groupby # (You could also use pd.concat, but I find this easier) important_consideration['age_group'] = survey_data['age_group'] important_consideration.head(3)
看起来是不是好多了?别担心,我们几乎得到了想要的答案。
consideration_grouped = important_consideration.groupby('age_group').agg('count') consideration_grouped
注意groupby和其他聚合函数如何自动忽略NaNs的。这大大简化了生活。
目前不分析30岁以下的消费者,所以只绘制其他年龄组。
consideration_grouped[:-1].sort_index(ascending=False).plot( kind='barh', figsize=(10, 10), cmap='rocket', edgecolor='black', fontsize=14, title='Most Important Consideration By Age Group' ).yaxis.label.set_visible(False)
好吧,这些都很好,但是60岁以上的人比其他组的人多,所以很难公平地去做比较。该怎么办?我们可以在一个单独的图中绘制每个年龄组,然后比较它们的分布情况。
“但等等,”你可能会想,“我真的不想为4个不同的绘制题编写代码。”
非常不想!谁有时间再写一个函数来解决这个问题?
def plot_counts_by_age_group(groupby_count_obj, age_group, ax=None): """Takes a count-aggregated groupby object, an age group, and an (optional) AxesSubplot, and draws a barplot for that group.""" sort_order = groupby_count_obj.loc[age_group].sort_index().index sns.barplot(y = groupby_count_obj.loc[age_group].index, x = groupby_count_obj.loc[age_group].values, order = sort_order, palette = 'rocket', edgecolor = 'black', ax = ax ).set_title("Age {}".format(age_group))
珍妮·布莱恩 Jenny Bryan在她精彩的演说《代码的气味和感觉》(Code smell and feel)中已经率先提醒我们:
如果你发现自己正在复制和粘贴代码,并且只更改了几个值,那么你真的应该做的是编写函数。有一个经验法则是,如果你复制和粘贴超过3次,就可以编写一个函数。
除了方便外,这种方法还有其他好处,例如:
· 减少出错的可能性(复制和粘贴时,容易不小心忘记更改值)
· 使代码更具可读性
· 建立个人的函数工具箱
· 促使你在更高的抽象层次上思考
(所有这些都提高了你的编程技能,并使需要阅读你代码的人更快乐!)
# Setup for the 2x2 subplot grid # Note we don't want to share the x axis since we have counts fig, ax = plt.subplots(nrows=2, ncols=2, figsize=(8, 6), sharey=True) # ax.flatten() avoids having to explicitly reference a subplot index in ax # Use consideration_grouped.index[:-1] because we're not plotting the under-30s for subplot, age_group in zip(ax.flatten(), list(consideration_grouped.index)[:-1]): plot_counts_by_age_group(consideration_grouped, age_group, ax=subplot) plt.tight_layout()
当然,这是从均匀分布中生成的数据,因此我们不希望看到各组之间有任何明显不同。希望你自己的调查数据会更有趣。
接下来,让我们讨论另一种形式的问题。在这个例子中,需要了解每个年龄段的人对给予其的好处有多大的兴趣。令人高兴的是,这些问题实际上比前种类型更易处理。让我们来看看:
benefits = survey_data.iloc[:, 7:] benefits.head(3)
看,因为这是一个小型的分布式数据集,并且之前已经添加了 age_group ,所以这次不需要再添加它了。
ben_col_names = headers[7:].values benefits = rename_columns(benefits, ben_col_names) benefits.head(3)
现在进展还不错,我们有了子集数据,但我们不能像处理另一个问题一样,通过计时来整合这些数据。因为在上一个问题中,NaNs可以被排除,这才使最后回复数量的计算结果正确无误。但是在这个问题中,我们只能得到给每个年龄段的问题回复数量:
benefits.groupby('age_group').agg('count')
这绝对不是我们想要的!问题的关键是要了解不同年龄段的人对好处和福利的兴趣程度,我们需要保存这些信息。所有这些告诉我们,每个年龄段有多少人回答了这个问题。
我们该怎么做呢?一种方法是用数字重新编码这些答案。但是,如果我们想在更细化的水平上保持这种关系呢?如果用数字编码,可以取每个年龄组兴趣水平的中值和平均值。但如果我们真正感兴趣的是每个年龄段选择每个兴趣水平的人的具体百分比呢?在保留文本的情况下,在barplot中传递信息会更容易。
这就是接下来要做的,是时候写另一个函数了。
order = ['Not Interested at all', 'Somewhat uninterested', 'Neutral', 'Somewhat interested', 'Very interested'] def plot_benefit_question(df, col_name, age_group, order=order, palette='Spectral', ax=None): """Takes a relevant DataFrame, the name of the column (benefit) we want info on, and an age group, and returns a plot of the answers to that benefit question.""" reduced_df = df[[col_name, 'age_group']] # Gets the relative frequencies (percentages) for "this-age-group" only data_to_plot = reduced_df[reduced_df['age_group'] == age_group][col_name].value_counts(normalize=True) sns.barplot(y = data_to_plot.index, x = data_to_plot.values, order = order, ax = ax, palette = palette, edgecolor = 'black' ).set_title('Age {}: {}'.format(age_group, col_name))
给新学习者的一些提示:可视化通常是如何实现的?一般来说,这是一个高度迭代的过程,即使是最有经验的数据科学家也不会马上把所有这些规格都写下来。
一般来讲,你从.plot(kind='bar')或类似的图表开始,这取决于你想要什么了,然后改变大小、色图,按顺序给组正确排序=指定标签是否应该旋转,使x或y轴标签不可见,更多的是,这取决于你认为什么是对使用可视化的人是最好的。
所以,当人们绘制图表时,不要被你看到的长代码块吓倒。它们通常是在几分钟内创建的,期间还要测试不同的规格,而不是一次性编写的完美代码。
现在,我们可以绘制另一个2x2,表示按年龄组划分的每个好处。但为了这四个好处,我们必须这么做。我们可以循环使用每个好处,以及每个好处中的每个年龄组。如果你有兴趣,并且碰巧你有很多这样类似的问题,建议你把它重构成一个函数。
# Exclude age_group from the list of benefits all_benefits = list(benefits.columns[:-1]) # Exclude under-30s buckets_except_under30 = [group for group in benefits['age_group'].unique() if group != '<30'] for benefit in all_benefits: fig, ax = plt.subplots(nrows=2, ncols=2, figsize=(8, 6), sharey=True, sharex=True) for a, age_group in zip(ax.flatten(), buckets_except_under30): plot_benefit_question(benefits, benefit, age_group=age_group, ax=a) # Keeps x-axis tick labels for each group of plots a.xaxis.set_tick_params(which='both', labelbottom=True) # Suppresses displaying the question along the y-axis a.yaxis.label.set_visible(False) plt.savefig('sdbp_photos/{}_interest_by_age.png'.format(benefit)) plt.tight_layout()
成功!如果想导出每一组单独的图表,只需添加下面这一行:plt.savefig('{}_interest_by_age. png'.format(benefit)),然后 matplotlib 将自动保存每一组绘图,这些图表真是又美观又清楚。
为了方便其他团队借鉴你的发现,你可以简单地将它们导出到一个图表文件夹,人们可以浏览这些图像,并能够将它们直接拖放到PowerPoint演示文稿或其他报告中。
这些可以使用微量填充,所以如果想再做一次,可以稍微增加图形的可增高度。
让我们再举一个例子:如前所述,用数字编码的好处。然后可以生成一个热图(heatmap)来表示不同好处之间的兴趣。
def encode_interest(interest): """Takes a string indicating interest and encodes it to an ordinal (numerical) variable.""" if interest == 'Not Interested at all': x = 1 if interest == 'Somewhat uninterested': x = 2 if interest == 'Neutral': x = 3 if interest == 'Somewhat interested': x = 4 if interest == 'Very interested': x = 5 return x benefits_encoded = benefits.iloc[:, :-1].copy() # Map the ordinal variable for column in benefits.iloc[:, :-1].columns: benefits_encoded[column] = benefits[column].map(encode_interest) benefits_encoded.head(3)
最后,将生成相关矩阵并绘制相关关系。
# Use Spearman instead of default Pearson, since these # are ordinal variables! corr_matrix = benefits_encoded.corr(method='spearman') # Setup fig, ax = plt.subplots(figsize=(8, 6)) # vmin and vmax control the range of the colormap sns.heatmap(corr_matrix, cmap='RdBu', annot=True, fmt='.2f', vmin=-1, vmax=1) plt.title("Correlations Between Desired Benefits") # Add tight_layout to ensure the labels don't get cut off plt.tight_layout() plt.show()
同样,由于数据是随机生成的,预计相关性很小甚至没有,事实证明,这是对的。(有趣的是,SQL教程与拖放特性之间存在轻微的负相关关系,而这正是我们可能在实际数据中希望看到的。)
让我们做最后一种类型的图表,一种与热图密切相关的图表:聚类图(clustermap)。在分析调查结果时,它使相关性具有信息性,因为它们使用层次聚类(在本例中)根据它们之间的密切关系将好处组在一起。因此,不要盯着热图看哪些个别好处之间存在积极或消极的联系,因为当你有10个以上的好处时,你会有点抓狂的,这个图表将被分割成集群,使你更容易看到。
如果你熟悉层次聚类的数学细节,还可以轻松更改计算中使用的链接类型。一些可用的选项是‘最短距离’(single),‘平均距离’(average)和‘最小方差法’(ward)——‘最小方差法’通常是开始时一个万无一失的选择。
sns.clustermap(corr_matrix, method='ward', cmap='RdBu', annot=True, vmin=-1, vmax=1, figsize=(14,12)) plt.title("Correlations Between Desired Benefits") plt.tight_layout() plt.show()
长标签通常需要稍作调整,因此建议在使用聚类图之前将好处重命名为更短的名称。
对这一点进行了简单的评估,聚类算法具有拖放特性,并且会把现成的公式聚集在一起,而客户仪表盘模版和SQL教程会形成另一个集群。由于相关性很小,你可以看到当好处联系在一起形成集群时,这个集群的“高度”是非常高的。(这意味着你可能不应该基于此发现做出任何业务决策。)希望这个例子能说明问题,尽管关系很小。
留言 点赞 关注
我们一起分享AI学习与发展的干货
欢迎关注全平台AI垂类自媒体 “读芯术”