5.2 为 CSV 文件中数据的任意数目分类计算统计量
在很多商业分析中,需要为一个特定时间段内的未知数目的分类计算统计量。举例来说,假设我们销售 5 种不同种类的产品,你想计算一下在某一年中对于所有客户的按产品种类分类的总销售额。因为客户具有不同的品味和偏好,他们在一年中购买的产品也是不同的。有些客户购买了所有 5 种产品,有些客户则只购买了一种产品。在这种客户购买习惯之下,与每个客户相关的产品分类数目都是不同的。
如果想简单处理的话,你可以为每个客户都分配全部 5 种产品分类,将所有产品分类的初始总销售额都设为 0,然后只将每个客户实际购买的产品计算到总销售额中。但是,我们已经知道了很多客户只购买一种或两种产品,而且你也只关心客户实际购买的产品的总销售额。为所有客户计算全部 5 种产品分类的销售额不仅没有必要,还是一种干扰,同时也是对内存、计算能力和存储空间的浪费。基于以上原因,我们应该只处理必要的数据,为每个客户计算他们购买的产品以及每种产品分类的总销售额。
再举个例子,假如客户对不同的产品或服务包的购买力会随着时间的推移而有所提高。例如,你向客户提供了铜牌、银牌和金牌 3 种类型的服务包。针对这 3 种类型,有些客户首选了铜牌服务包,有些客户首选了银牌服务包,也有些客户首选了金牌服务包。对于那些首选铜牌和银牌服务包的客户,随着时间的推移,他们可能会购买价值更高的产品与服务包。
现在,你非常想计算出你的客户在他们购买的每个服务包类别上花费的总时间(以月计算)。例如,如果你的一个客户 Tony Shephard 在 2014 年 2 月 15 日购买了铜牌服务包,在 2014 年 6 月 15 日购买了银牌服务包,在 2014 年 9 月 15 日购买了金牌服务包,那么关于 Tony Shephard 的计算结果就是:“铜牌服务包:4 个月”“银牌服务包:3 个月”“金牌服务包:从 2014 年 9 月 15 日至今”。如果另一个客户 Mollie Adler 只购买了银牌服务包和金牌服务包,那么关于 Mollie Adler 的计算结果中就不会包含铜牌服务包的任何信息。
如果你的客户数据集非常小,那么你可以打开文件,计算出日期之间的差额,然后按照服务包类别和客户名称将它们累加起来。但是,这种手工处理方法不但浪费时间,还容易出错。而且,当文件太大难以打开时,就不好办了。这就是使用 Python 的绝好机会。 Python 可以处理因体积太大而难以打开的文件,它的计算速度非常快,而且能够减少人为出错的机会。
为了在一个客户购买服务包的数据集上执行计算,需要先创建一个 CSV 数据文件。
(1) 打开 Excel,输入图 5-8 所示的数据。
(2) 将文件保存为 customer_category_history.csv。
图 5-8:customer_category_history.csv 中的示例数据,显示在 Excel 工作表中
你可以看出,这个数据集包括 4 列数据:Customer Name、Category、Price 和 Date。还包括 6 个客户:John Smith、Mary Yu、Wayne Thompson、Bruce Johnson、Annie Lee 和 Priya Patel。同时包括 3 个服务包分类:铜牌、银牌和金牌。数据是以先按照客户姓名,再按照日期的形式升序排列的。
现在我们已经有了数据集,其中包括客户在过去一年中购买的服务包,还有服务包的购买日期或更新日期。接下来要做的就是编写 Python 代码来执行计算。
要完成这个操作,在文本编辑器中输入下列代码,然后将文件保存为 2calculate_statistic_by_category.py:
1 #!/usr/bin/env python3 2 import csv 3 import sys 4 from datetime import date, datetime 5 6 def date_diff(date1, date2): 7 try: 8 diff = str(datetime.strptime(date1, '%m/%d/%Y') - \ 9 datetime.strptime(date2, '%m/%d/%Y')).split()[0] 10 except: 11 diff = 0 12 if diff == '0:00:00': 13 diff = 0 14 return diff 15 input_file = sys.argv[1] 16 output_file = sys.argv[2] 17 packages = { } 18 previous_name = 'N/A' 19 previous_package = 'N/A' 20 previous_package_date = 'N/A' 21 first_row = True 22 today = date.today().strftime('%m/%d/%Y') 23 with open(input_file, 'r', newline='') as input_csv_file: 24 filereader = csv.reader(input_csv_file) 25 header = next(filereader) 26 for row in filereader: 27 current_name = row[0] 28 current_package = row[1] 29 current_package_date = row[3] 30 if current_name not in packages: 31 packages[current_name] = { } 32 if current_package not in packages[current_name]: 33 packages[current_name][current_package] = 0 34 if current_name != previous_name: 35 if first_row: 36 first_row = False 37 else: 38 diff = date_diff(today, previous_package_date) 39 if previous_package not in packages[previous_name]: 40 packages[previous_name][previous_package] = int(diff) 41 else: 42 packages[previous_name][previous_package] += int(diff) 43 else: 44 diff = date_diff(current_package_date, previous_package_date) 45 packages[previous_name][previous_package] += int(diff) 46 previous_name = current_name 47 previous_package = current_package 48 previous_package_date = current_package_date 49 header = ['Customer Name', 'Category', 'Total Time (in Days)'] 50 with open(output_file, 'w', newline='') as output_csv_file: 51 filewriter = csv.writer(output_csv_file) 52 filewriter.writerow(header) 53 for customer_name, customer_name_value in packages.items(): 54 for package_category, package_category_value \ 55 in packages[customer_name].items(): 56 row_of_output = [ ] 57 print(customer_name, package_category, package_category_value) 58 row_of_output.append(customer_name) 59 row_of_output.append(package_category) 60 row_of_output.append(package_category_value) 61 filewriter.writerow(row_of_output)
脚本中的代码完成了计算任务,同时也很有值得学习的意义。这是本书中第一个使用 Python 字典数据结构来组织和保存计算结果的示例。实际上,这个脚本中的示例比普通的字典还要复杂,因为它是一个嵌套的字典,也就是字典的字典。这个示例展示了创建字典和使用键-值对填充字典的便捷性。在这个示例中,外部字典的名称为 packages。外部字典的键为客户名称,与这个键相对的值是另一个字典,其中的键为服务包类别的名称,值为一个整数,表示客户拥有这个服务包的天数。字典是一种方便易懂的数据结构,因为很多数据源和分析技术都支持这种键-值对结构。你可以回忆一下,在第 1 章中,字典是由花括号({})创建的,字典中的键是唯一的字符串,键-值对中的键和值由冒号分隔,每个键-值对之间由逗号分隔。例如,costs={'people':3640, 'hardware':3975}。
其次,这个脚本还演示了如何对一个具体类别中的第一行数据进行特别的处理,这种处理与这个类别中其余各行是不同的,因为我们要在两行之差的基础上计算统计量。举例来说,在脚本中,在外层 if 语句 if current_name != previous_name 中的所有代码仅用来处理一个新客户的第一行数据,其余各行客户数据都使用外层的 else 语句进行处理。
最后,这个脚本演示了如何定义和使用用户自定义函数。这个脚本中的函数 date_diff 计算并返回两个日期之间间隔的天数。这个函数在第 6~14 行代码中定义,在第 38 和 44 行代码中被调用。如果我们没有定义函数,那么函数中的代码就会在脚本中重复输入两次,第一次在第 38 行,第二次在第 44 行。通过定义函数,你只需要将这些代码输入一次,这样不但可以减少脚本中的代码行数,还可以简化第 38 行和第 44 行代码。正如第 1 章中提到的那样,只要发现在脚本中有重复的代码,就可以考虑将这些代码打包成一个函数,并使用这个函数来简化和缩短脚本中的代码。
我们已经介绍了脚本中需要注意的几个方面,下面来逐行讨论代码。第 2~5 行代码导入读取和处理数据所需的模块和方法。我们导入了 csv、datetime、string 和 sys 模块,来分别读取和写入 CSV 文件、处理日期变量、处理字符串变量和在命令行中输入参数。我们从 datetime 模块导入 date 和 datetime 方法,来访问当前日期和计算日期之间的间隔。
第 6~14 行代码定义了一个用户自定义函数 date_diff。第 6 行代码包含了函数定义语句,它指定了函数名称,并说明这个函数具有两个参数 date1 和 date2。第 7~11 行代码包含了一个 try-except 异常处理语句。try 代码块试图使用 datetime.strptime() 函数按照日期字符串创建 datetime 对象,并用第一个日期减去第二个日期,使用 str() 函数将相减的结果转换成一个字符串,然后将结果字符串使用 split() 函数按照空格进行分割,最后保留字符串分割后最左边的部分(索引值为 [0] 的字符串),并将其赋给变量 diff。如果 try 代码块遇到异常,则执行 except 代码块。如果被执行,except 代码块就将 diff 的值设为整数 0。同样,第 12 和 13 行代码是一个 if 语句,处理当两个日期相等,二者的差为 0(被格式化为 '0:00:00')的情况。如果二者的差为 0(请注意用两个等号表示相等),那么 if 语句就将 diff 的值设为整数 0。最后,在第 14 行代码中,函数返回包含在变量 diff 中的整数值。
第 15 和 16 行代码读入我们在命令行中提供的两个参数:CSV 输入文件的路径名和 CSV 输出文件的路径名。CSV 输入文件中包含客户数据,CSV 输出文件中包含客户相关信息,以及他们拥有具体服务包的时间。这两个输入参数被分别赋给两个变量 input_file 和 output_file。
第 17 行代码创建了一个空字典 packages,用来保存我们需要的信息。第 18、19 和 20 行代码创建了 3 个变量:previous_name、previous_package 和 previous_package_date,并将一个字符串 'N/A' 赋给了每个变量。我们将 'N/A' 赋给这些变量有个假设前提,那就是字符串 'N/A' 不会出现在输入文件的客户姓名、服务包类别或服务包日期这 3 列中。如果你想根据自己的分析来修改代码,并且想让作为字典键的列中包括字符串 'N/A',那么就应该将 'N/A' 修改为一个不会出现在输入文件各列中的字符串,比如 'QQQQQ' 或其他有特点的又没有实际意义的字符串。
第 21 行代码创建了一个布尔变量 first_row,并赋值为 True。我们使用这个变量来确定是否在处理输入文件中的第一行数据。如果正在处理第一行数据,那么就使用某个代码块来进行处理。如果正在处理的不是第一行数据,那么就使用另一个代码块来进行处理。
第 22 行代码创建了一个变量 today,包含当前日期,形式为 %m/%d/%Y。在这种日期形式下,2014 年 10 月 21 日显示为 10/21/2014。
第 23 和 24 行代码使用 with 语句和 csv 模块的 reader 方法打开 CSV 输入文件,并创建一个 filereader 对象来读取文件中的数据。第 25 行代码对 filereader 对象使用 next 方法,从输入文件中读出第一行数据,并将其赋给变量 header。
第 26 行代码创建了一个 for 循环,在输入文件的其余数据行之间循环。第 27 行代码取出第一列的值 row[0],并将其赋给变量 current_name。第 28 行代码取出第二列的值 row[1],并将其赋给变量 current_package。第 29 行代码取出第四列的值 row[3],并将其赋给变量 current_package_date。第一个数据行中包含的值为 John Smith、Bronze 和 1/22/2014,所以这些值被分别赋给变量 current_name、current_package 和 current_package_date。
第 30 行代码创建了一个 if 语句,用来检验 current_name 变量中的值是否还不是字典 packages 中的一个键。如果不是,那么第 31 行代码将 current_name 中的值作为字典键加入到字典 packages 中,并将与这个键对应的值设为一个空字典。这两行代码使用键-值对来填充字典 packages。
同样,第 32 行代码也创建了一个 if 语句,用来检验 current_package 变量中的值是否还不是内部字典中的一个键,这个内部字典是 packages 字典的一个值,对应的键为 current_name。如果不是,就使用第 33 行代码将 current_package 作为字典键添加到内部字典中,并将与其对应的值设为整数 0。这两行代码使用键-值对来填充与每个用户名称相关的内部字典。
举例来说,在第 31 行代码中,John Smith 成为 packages 字典中的一个键,与之对应的值为一个空字典。在第 33 行代码中,第一个与 John Smith 相关的服务包类别(就是铜牌服务包 Bronze)成为内部字典的键,与 Bronze 键对应的值被设为 0。这时,字典 packages 的内容就是 {'John Smith':{'Bronze':0}}。
第 34 行代码创建了一个 if 语句,检验变量 current_name 中的值是否不等于变量 previous_name 中的值。当第一次执行这行代码时,current_name 中的值为输入文件中第一个客户名称(也就是 John Smith)。previous_name 中的值为 'N/A'。因为 John Smith 不等于 'N/A',所以我们进入 if 代码块。
第 35 行代码创建了一个 if 语句,用来检验代码是否正在处理输入文件的第一行数据。因为 first_row 变量的当前值为 True,所以执行第 36 行代码,将 first_row 的值设为 False。
然后,代码来到第 46、47 和 48 行,将 current_name、current_package 和 current_package_date 3 个变量的值分别赋给 previous_name、previous_package 和 previous_package_date 3 个变量。因此,previous_name 现在包含的值为 John Smith,previous_package 现在包含的值为 Bronze,previous_package_date 现在包含的值为 1/22/2014。
至此,脚本已经结束了对输入文件第一行数据的处理,所以脚本回到第 26 行代码,处理文件中的下一行数据。对于这行数据,第 27、28 和 29 行代码分别将行中第一、第二和第四列数据赋给变量 current_name、current_package 和 current_package_date。因为第二行数据包含 John Smith、Bronze 和 3/15/2014,所以这些就是变量 current_name、current_package 和 current_package_date 中的值。
第 30 行代码再次检验 current_name 中的值是否还不是字典 packages 中的一个键。因为 John Smith 已经是字典中的键,所以第 31 行代码不被执行。同样,第 32 行代码再次检验变量 current_package 中的值是否还不是内部字典中的一个键。Bronze 已经是内部字典中的一个键,所以第 33 行代码不被执行。
此后,第 34 行代码检验 current_name 中的值是否等于 previous_name 中的值。current_name 中的值是 John Smith,previous_name 中的值也是 John Smith。因为这两个变量中的值相等,所以第 35~42 行代码被跳过,我们来到开始于第 43 行代码的 else 代码块。
第 44 行代码调用用户自定义函数 date_diff 来计算 current_package_date 变量值减去 previous_package_date 变量值的差,并将这个差(单位为天)赋给变量 diff。我们正在处理输入文件中的第二行数据,所以 current_package_date 中的值为 3/15/2014。在前一次循环中,我们将值 1/22/2014 赋给了变量 previous_package_date。因此,变量 diff 中的值就是 3/15/2014 减去 1/22/2014,也就是 52 天。
第 45 行代码将某个用户拥有某种服务包的时间加上变量 diff 的值。例如,循环到现在,previous_name 的值为 John Smith,previous_package 的值为 Bronze。因此,我们将 John Smith 拥有铜牌服务包的时间从 0 增加到 52 天。这时候,packages 字典中的内容就是:{'John Smith':{'Bronze':52}},请注意其中的数值从 0 增加到了 52。
最后,第 46、47 和 48 行代码将 current_name、current_package 和 current_package_date 3 个变量的值分别赋给 previous_name、previous_package 和 previous_package_date 3 个变量。
为了确保你可以理解代码的工作方式,这里再讨论一次循环迭代。在前面一段中,我们知道 3 个 current_* 变量被赋给了 3 个 previous_* 变量。所以 previous_name、previous_package 和 previous_package_date 中的值分别为 John Smith、Bronze 和 3/15/2014。在循环的下一次迭代中,第 27、28 和 29 行代码将输入文件第三行数据中的值赋给 3 个 current_* 变量。在这次赋值之后,current_name、current_package 和 current_package_date 3 个变量中的值分别为 John Smith、Silver 和 4/2/2014。
第 30 行代码再次检验 current_name 中的值是否还不是字典 packages 中的一个键。因为 John Smith 已经是字典中的键,所以第 31 行代码不被执行。
第 32 行代码检验变量 current_package 中的值是否还不是内部字典中的一个键。这一次, current_package 中的值 Silver 是一个新值,还不是内部字典中的一个键,所以第 33 行代码将 Silver 作为内部字典的一个键,并将与 Silver 键对应的值初始化为 0。这个时候,packages 字典中的内容为 {'John Smith':{'Silver':0, 'Bronze':52}}。
此后,第 34 行代码检验 current_name 中的值是否等于 previous_name 中的值。current_name 中的值是 John Smith,previous_name 中的值也是 John Smith。因为这两个变量中的值相等,所以第 35~42 行代码被跳过,我们来到开始于第 43 行代码的 else 代码块。
第 44 行代码调用用户自定义函数 date_diff 来计算 current_package_date 变量值减去 previous_package_date 变量值的差,并将这个差(单位为天)赋给变量 diff。因为我们正在处理输入文件中的第三行数据,所以 current_package_date 中的值为 4/2/2014。在前一次循环中,我们将值 3/15/2014 赋给了变量 previous_package_date。因此,变量 diff 中的值就是 4/2/2014 减去 3/15/2014,也就是 18 天。
第 45 行代码将某个用户拥有某种服务包的时间加上变量 diff 的值。例如,循环到现在,previous_name 的值为 John Smith,previous_package 的值为 Bronze。因此,我们将 John Smith 拥有铜牌服务包的时间从 52 天增加到 70 天。这时候,packages 字典中的内容就是:{'John Smith':{'Silver':0,'Bronze':70}}。
同样,第 46、47 和 48 行代码将 current_name、current_package 和 current_package_date 3 个变量的值分别赋给 previous_name、previous_package 和 previous_package_date 3 个变量。
当 for 循环处理完输入文件中所有行的时候,第 49~61 行代码将标题行和嵌套字典中的内容写入输出文件。第 49 行代码创建了一个列表变量 header,其中包含 3 个字符串: Customer Name、Category 和 Total Time (in Days),这 3 个字符串将作为输出文件中 3 个列的标题。
第 50 和 51 行代码分别打开一个输出文件供脚本写入数据和创建一个写入对象来写入输出文件。第 52 行代码将 header 中的内容(也就是标题行)写入输出文件。
第 53 和 54 行代码是两个 for 循环,分别在外部字典和内部字典的键和值之间循环。外部字典的键是客户名称,与每个客户名称对应的值是另一个字典。内部字典的键是客户购买的服务包类别,内部字典的值是用户拥有的每个服务包的总时间(以天计)。
第 56 行代码创建了一个空列表 row_of_output,用来保存要写入输出文件的 3 个值。第 57 行代码将要输出的这 3 个值打印出来,这样我们就可以看到要写入输出文件中的内容了。当你确定脚本正确工作后,可以将这行代码删除。第 58~60 行代码将要输出的 3 个值追加到列表 row_of_output 中。最后,对于嵌套字典中的每一个客户名称和与之对应的服务包类别,第 61 行代码将我们需要的 3 个值以逗号分隔的形式写入输出文件。
我们已经完成了 Python 脚本,现在可以使用这个脚本计算每个顾客拥有不同服务类别的总时间,并将结果写入 CSV 输出文件了。要完成这个操作,在命令行中输入以下命令,然后按回车键:
python 2calculate_statistic_by_category.py customer_category_history.csv\ output_files\2app_output.csv
你可以看到如图 5-9 所示的输出被打印在命令行窗口或终端窗口中(每人的实际数字会有不同,因为在脚本中使用的是当前日期)。
图 5-9:在 CSV 文件 customer_category_history.csv 上运行 2calculate_statistic_by_category.py 的结果
命令行窗口中的输出与写在 2app_output.csv 中的输出是一样的,这个文件中的内容如图 5-10 所示,它展示了每个客户拥有一个特定服务包的总天数。
图 5-10:保存在 CSV 文件 2app_output.csv 中的脚本 2calculate_statistic_by_category.py 的输出(每个客户拥有一个特定服务包的总天数),显示在 Excel 工作表中
你可以看到,脚本先将标题行写入输出文件,然后写入了来自于输入文件的经过处理的信息,每行信息都以客户名称和服务包类别作为关键字。例如,标题行下面的前两个数据行说明了 Wayne Thompson 拥有铜牌服务包 167 天,银牌服务包 469 天。最后三行数据说明了 John Smith 拥有铜牌服务包 70 天,银牌服务包 39 天,金牌服务包 518 天。其余各行数据显示了其他客户的计算结果。对输入文件中的原始数据进行了处理和累加之后,你可以使用这些数据计算其他统计量,或者以各种方式进行摘要统计和数据可视化,或者将这些数据与其他数据结合起来进行更深入的分析。
还需要注意的一点是,对于如何处理每个客户的最后一种服务包类别,我们需要做出实际决策(或者说假设)。如果我们的输入数据是准确的而且是最新的,那么客户很可能还继续拥有上面的最后一个服务包。例如,Wayne Thompson 的最后一个服务包类别是银牌服务包,购买于 2014 年 6 月 29 日。因为 Wayne Thompson 很可能仍然拥有这种服务包,所以这个服务包的总时间应该是从 2014 年 6 月 29 日到今天为止的总时间,这说明每个客户拥有最后一个服务包的总时间取决于脚本在何时运行。
实现这个计算和累加的代码位于第 34 行下面的缩进部分。第 35 行代码保证在处理第一行数据时不进行减法计算,因为我们不能使用一个日期做减法。在处理完第一行数据之后,第 36 行代码将 first_row 设为 False。这样,对于所有其余的数据行,第 35 行代码都是 False,第 36 行代码不会被执行。对于每次从一个客户转换到另一个客户,第 38~42 行代码都要执行。第 38 行代码计算当前日期与 previous_package_date 之间的差值(以天计),并将其赋给变量 diff。然后,如果 previous_package 中的值还不是内部字典的键,第 40 行代码就将其作为内部字典的一个键,并将与之对应的值设为变量 diff 中的值。否则,如果 previous_package 中的值已经是内部字典的键,那么第 42 行代码就将 diff 中的值加在内部字典中与该键对应的字典值上面。
让我们回到 Wayne Thompson 的例子,Wayne Thompson 的最后一个服务包类别是银牌服务包,购买日期为 2014 年 6 月 29 日。下一行输入数据是另一个客户 Bruce Johnson,所以第 34 行下面的代码被执行。代码执行的时间就是我写这一章的时间,即 2015 年 10 月 11 日,所以 diff 中的值就是 10/11/2015 减去 6/29/2014 的差,也就是 469 天。在命令行窗口和输出文件中你都可以看到,Wayne Thompson 拥有银牌服务包的总时间是 469 天。
如果你在另一个时间运行了这个脚本,那么这行输出的总时间就会发生变化(应该是个更大的数字),同样,每个用户拥有最后服务包的时间也会发生变化。如何处理每个用户的最后一行数据要根据实际情况作出决策。你完全可以修改这个示例中的代码,以此来满足实际情况中的数据处理需求。
这个应用程序综合运用我们在第 1 章中学习的几种技术(例如创建用户自定义函数和填充字典)来解决一个常见的实际问题。商业分析师们会经常遇到这种需要计算输入数据中两行之间差值的问题。在很多情况下,需要以不同方式处理成千上万条数据,手动计算某些数据之间差值的想法简直是疯了(即使能够完成任务)。
这一节演示了一种可扩展的计算方法,可以用来计算行间的差值,并将这些差值按照输入文件中其他列中的值累加起来。为了使这个示例尽量简单,我们仅仅使用了少量客户记录。但是,这种方法具有很好的扩展性,你可以在大量记录中使用这种方法执行计算,也可以修改一下代码,来处理多个输入文件中的数据。
至此,我们已经解决了为未知数目的类别计算统计量的问题。下面开始解决通过分析纯文本文件找出关键数据的问题。这个目标此刻听起来有点抽象,我们将在下一节对这个问题进行详细讨论,并给出解决方法。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论