几乎所有的程序语言都能处理数据,但有些过于通用,缺乏专业的结构化计算函数,用于数据处理时代码比较繁琐,比如C++、JAVA,这些语言更适合去实现大型专业的项目。还有专业的数学计算语言工具,如MATLAB和R,也有部分函数适合处理数据,但还是专业性过强,不适合日常数据处理。我们在这里挑选了四种适合桌面业务的轻量级数据处理语言来介绍和讨论:以MySQL为代表的轻量小型数据库、Excel VBA、Python pandas、esProc。
下面,就让我们深入了解这些程序语言,看看它们的真实能力到底如何。
MySQL
小型数据库比如HSQLDB、DerbyDB、SQLite、MySQL等,都可以在桌面轻松运行。这里以最常见的MySQL为例,讨论这种数据处理脚本的特点。
安装配置方面,MySQL因为有绿色版的存在,所以安装配置非常方便。偶尔遇到权限目录之类的环境问题,虽然只能用安装版来解决,但MySQL的安装向导非常友好,过程相当轻松。
交互界面方面,MySQL自带的命令行工具可以正常执行SQL,但有点简陋,所以很多人会用第三方工具来代替(比如Navicat、Toad)。这样看来,MySQL自带的交互界面算不上成功。
当然,对于一款程序语言来讲,数据处理能力才是核心。MySQL的数据处理能力,本质上也就是 SQL的能力。
经过半个多世纪的迭代改进,SQL语句已经在其模型框架内接近极致,几乎每种基本算法都能找到对应的SQL表达式,这便极大地降低了数据处理的门槛。近两年,MySQL又开始支持窗口函数、with子句、存储过程,其SQL能力已可比肩大型数据库。比如下面的基本算法:
/*过滤,表emp存储各部门员工信息*/ select eid, name, deptid, salary from emp where salary between 8000 and 10000 and hireday>='2010-01-01'
/*排序*/ select * from emp order by salary
/*去重*/ select distinct deptid from emp
/*分组汇总,表share存储某支股票每天的收盘价*/ select year(sDate),month(sDate),max(price) from share group by year(sDate),month(sDate)
/*关联,表dept存储部门信息*/ select e.name, e.salary, d.deptname from emp e inner join dept d on e.deptid=d.deptid
/*开窗,按工资对各部门内的员工进行排名*/ select eid, name, deptid, salary, rank()over( partition by deptid order by salary desc) rk from emp |
在基本运算方面,MySQL可以说是无可挑剔。但日常数据处理中还存在不少复杂情况,这时候MySQL(其实也就是SQL)并不擅长。
多步骤的过程性算法是SQL的弱项,比如根据员工表emp,计算出人数最多和最少的两个部门。直观思考的话,这个问题应该分2步,先把emp表按部门分组,计算出每部门人数,然后按人数逆序排序,头尾两个就是人数最多和最少的部门。但用SQL计算的时候,你会发现必须把算法复杂化,变成4步才能计算出结果。第一步不变,计算出各部门人数。第二步:用max求出最多的人数,然后用嵌套查询或关联查询反推出最多人数对应的部门。第三步:用类似第二步的算法,再计算出人数最少的部门。第四步:用union合并第二步和第三步的计算结果:
with tmp as ( select dept, count(*) total from employee group by dept), deptmax as( select dept from tmp where total=( select max(total) from tmp) ), deptmin as( select dept from tmp where total=( select min(total) from tmp) ) select dept from deptmax union select dept from deptmin |
这个代码写出来就有些冗长且费解了。
SQL发明于大约50年前,考虑欠缺在所难免,除了多步骤过程性算法,还有很多日常数据处理中的复杂情况都是SQL不擅长的,其中就包括有序运算,比如根据股价表share,计算该股票最长连续上涨了多少交易日。使用SQL计算时,你会发现很难直接表达连续上涨这个概念,只能转变思路来回绕弯,解题过程非常痛苦,具体是:先计算从初始时刻开始累计的不涨日数,不涨日数相同的交易日即是连续上涨,针对连续上涨分组可计算出最大连涨区间。事实上,此类算法对SQL专家都是难题,即使专家写出正确的代码,普通用户也难以真正读懂。
select max(consecutive_days) from (select count(*) consecutive_days from (select sum(updown_flag) over(order by sdate) no_up_days from (select sDate, case when price>LAG(price) over(order by sDate) then 0 else 1 end updown_flag from share) ) group by no_up_days) |
这已经是使用了有窗口函数的最新版本,如果是早期版本就会更麻烦。
再比如按指定的集合成员对齐:表orders存储订单记录,需要按星期日到星期六的顺序,统计大订单的当日金额,当日无订单则显示空值。其中,大订单指金额超过15000的订单。SQL解决此类问题,只能用拼假表的技巧把星期列表变成记录集合,再用该假表左关联被对齐的表,实现过程非常繁琐:
with std as( select 1 No,'Sun.' name from dual union select 2 ,'Mon.' from dual union select 3 ,'Tues.' from dual union select 4 ,'Wed.' from dual union select 5 ,'Thur' from dual union select 6 ,'Fri.' from dual union select 7 ,'Sat.' from dual ) select std.No,std.name,data.total from std left join ( select DAYOFWEEK(orders.orderdate) No,sum(amount) total from orders where amount>15000 group by DAYOFWEEK(orders.birthday) ) data on std.No=data.No order by std.No |
类似的例子还有很多,其根本原因还是SQL太古老,设计之初就没考虑现代需求的复杂性,后来虽然用with语句、存储过程、窗口函数打了很多补丁,有一些改善,但始终无法全面突破最初的限制。
SQL还有个封闭性的缺点, 即只能计算内部库表,却很难读写外部数据源。这倒不是SQL这种脚本的问题,而是数据库都实现成这样了。
众所众知,数据处理的第一步是读取源数据,最后一步是输出成目标数据格式。对外部数据源种类支持的多少,是判断一款数据处理脚本优劣的重要标准。遗憾的是,MySQL只能读取(无法写)一种外部数据源,即文本文件(CSV)。这也就罢了,问题是操作步骤还很麻烦,比如将标准格式的emp.csv导入数据库,须经过以下四步:
/*切换库、建表、入库,最后建主键索引,可大幅提升导入速度*/ mysql>use testdb; mysql>create table emp ( -> empid int(10) not null, -> name varchar(50), -> deptid int(10), -> salary float, -> sex varchar(1), -> birthday date, -> hireday date)CHARSET = utf8; mysql>LOAD DATA INFILE 'd:\data\emp.csv' INTO TABLE emp ->CHARACTER SET utf8 ->FIELDS TERMINATED BY ',' ->LINES TERMINATED BY '\r\n' ->IGNORE 1 LINES; mysql>ALTER TABLE emp ADD PRIMARY KEY (empid); |
上面的实现过程之所以异常繁琐,还是因为SQL封闭,设计之初没考虑读取文本文件,后来才硬加了这种额外操作而已。
使用Navicat等第三方工具可以让MySQL支持更多的数据源,但本质上是先将其他数据源转为文本文件,再将文本文件导入MySQL。这种非原生的、补丁摞补丁的方式毛病很多,其中老旧格式虽然支持得最好,但现在几乎没人用了,比如DBase、Paradox;新一点的Excel虽然表面上也支持,但实际要求很苛刻,成功导入的概率低;最近几年开始支持json,但仅限特殊的二维格式;至于现代数据处理中常用的数据源,Navicat基本上都不支持。
SQL还有个调试困难的问题,会严重制约代码的编写效率。
对于只存在于教科书中的简单算法,区区三五行而已,不需要调试,但现在数据处理过程越来越复杂,经常用到上百行带着嵌套的SQL, 不能调试就意味着难以理解难以维护,也意味着效率的严重降低。
SQL虽然擅长对库内数据进行一般性处理,但对于众多的外部数据源以及现代复杂算法,确实是心有余而力不足。好在新脚本总会顺势而生,下面,就让我们了解下PC时代的桌面轻量级数据处理脚本。
Excel VBA
随着30年前PC的崛起,计算机用户的主角由科学家变成了普通人,针对普通人的特点,一款无需编程也能处理数据的桌面工具顺势而生,这就是闻名世界的Excel。最近几年,Excel又加入了Power Query等组件,不仅极大地丰富了数据源的支持种类,同时也大幅增强了数据处理的能力。
毫不夸张地说,Excel是当前非编程者最强大的数据处理工具。
但不能编程会严重限制Excel的处理能力,所以VBA也很快诞生。VBA的目的很明确:通过编程增强Excel的灵活性,使Excel的数据处理能力不再受限。
问题来了,VBA的目的达到了吗?可以说达到了,也可以说没达到。
作为可编程的数据处理脚本,VBA几乎无所不能,理论上的确可以让Excel的数据处理能力不再受限,尤其在过程性算法、可调试方面,对比SQL可以说是翻天覆地的增强。从这一点来讲,VBA的目的达到了。但实际上, VBA仍然属于通用开发语言(访问单元格时有专用函数),并没有专门的结构化计算函数,所以用VBA处理数据是件繁琐且痛苦的事情,很多时候远不如SQL方便。
比如最基本的读文件:orders.csv是逗号分隔的文本,首行是列名,将列名和数据读入到当前sheet。代码就有这么长:
Const Title As String = "IMPORT CSV TEST" Sub fMain()
|
上面例子中,数据格式已经很规整,但读取的代码依旧麻烦,如果遇到不太规范的脏数据,比如跳过空白行、特殊分隔符、一条记录对应多行等,可以想象处理起来只会更加痛苦。
需要指出的是,虽然Power Query支持的数据源种类非常多,但只有用向导界面读取固定数据时才会方便,万一要用VBA动态读取,那又是噩梦一场。除此之外,Power Query只支持读取,不支持写出,如果需要把计算结果写出到目标数据源,基本只能用VBA的方式(CSV除外,可直接导出)。
不仅基本的读数据源很麻烦,就连基本的结构化算法也很难实现。比如最简单的分组汇总:对sheet1的A列分组,对B列求和。算法中已经省略了读取源数据的步骤,但仍然需要写出大片难懂的代码:
Public Sub test()
Dim Arr Dim MyRng As Range Dim i As Long Dim Dic As Object Set MyRng = Range("A1").CurrentRegion Set MyRng = MyRng.Offset(1).Resize(MyRng.Rows.Count - 1, 2) Set Dic = CreateObject("Scripting.dictionary") Arr = MyRng For i = 1 To UBound(Arr) If Not Dic.exists(Arr(i, 1)) Then Dic.Add Arr(i, 1), Arr(i, 2) Else Dic.Item(Arr(i, 1)) = Dic.Item(Arr(i, 1)) + Arr(i, 2) End If Next i Sheet2.Range("A1") = "subject" Sheet2.Range("A2").Resize(Dic.Count) = Application.WorksheetFunction.Transpose(Dic.keys) Sheet2.Range("B1") = "subtotal" Sheet2.Range("B2").Resize(Dic.Count) = Application.WorksheetFunction.Transpose(Dic.items) Set Dic = Nothing End Sub |
Excel对非编程者而言,的确是个强大的数据处理工具,但VBA并未显著增强Excel的能力,对编程者尤其是桌面分析师而言如同鸡肋。之所以出现这样尴尬的局面,是因为VBA虽然在过程性方面有进步,但在结构化算法方面并未赶上SQL。
下面,就让我们看看互联网时代的数据处理脚本,能否在结构化算法方面有所改进。
Python Pandas
Python的历史比VBA更长,但直到互联时代来临,Python得以利用开源社区的力量,不断扩充各类第三方函数库,人们才开始注意到它的存在。在众多明星级函数库中,就包括用于数据处理的Pandas。
Python的初衷是易读易写,Pandas在函数级别上很好地保持了这种风格,每一个函数都是接口清晰、简单易用、功能强大的典范。比如基本的结构化计算函数:
df.query('salary>8000 and salary<10000') #过滤,df是DataFrame类型 df.sort_values(by="salary",ascending = True) #排序 df.groupby("deptid")['salary'].agg([len, np.sum, np.mean]) #分组汇总 |
由于开源社区廉价而高效的生产力,Pandas所包含的函数非常之多,基本涵盖了常用的结构化算法。由于继承了Python的语法风格,调用Pandas函数的方法也非常简单。两个因素加在一起,让Pandas可以快速而轻松地完成基本的数据处理任务。
在结构化计算函数方面,Pandas不比SQL差,在外部数据支持程度方面,Pandas则比SQL强大太多了。比如用于读取csv/txt的函数read_csv,用法如下:
import pandas as pd df=pd.read_csv('D:/emp.csv') #return as a DataFrame |
上述是读取标准csv的用法,对于其他常规格式,比如首行非列名、跳过N行读取,该函数都可通过设定参数实现,用法同样简单。
除了csv,Pandas还可以读取数据库、json文件、Excel、网页等等几乎所有外部数据源,同样只需简单的函数引用。值得一提的是,Pandas不仅读取源数据很方便,写出到目标数据源同样方便。这些函数接口清晰、调用方便,充分体现了Python易读易写的特点。
作为标准的程序性语言,Pandas还有一个明显强于SQL的优点:Pandas(实际是Python)支持断点、单步、跳入、跳出等标准调试手段,可以快速排除代码中的错误,可以轻松维护复杂算法,因此开发效率要远高于SQL。
Pandas的主要优势在于丰富而易用的库函数,包括各类结构化计算函数和多种外部数据源访问函数,这对初学者很有吸引力力。但如果深入学习Pandas的话,就会发现一个严重的问题:单独使用每个基本函数时,的确简单易用;一旦要用多个函数配合,完成实际的日常算法时,代码就变得复杂难用起来。
比如:split_field.csv是tab分隔的文本文件,处理前有两个字段,ID和ANOMOALIES,需要将ANOMOALIES字段按空格拆分为多个字符串,使每个字符串和原ID字段形成新的记录,示意如下:
处理前的数据(split_field.csv)
ID | ANOMALIES |
1 | A1 B1 C1 D1 |
2 | A2 |
3 | A3 B3 C3 |
4 | A3 B4 D4 |
… | … |
处理后的数据
ID | ANOMOLIES |
1 | A1 |
1 | B1 |
1 | C1 |
1 | D1 |
2 | A2 |
… | … |
实现上述算法的代码如下:
import pandas as pd import numpy as np split_field = pd.read_csv( 'C:\\split_field.csv',sep= '\t') split_dict = split_field.set_index( 'ID').T.to_dict( 'list') split_list = [] for key,value in split_dict.items(): anomalies = value[0].split( ' ') key_array = np.tile(key,len(anomalies)) split_df = pd.DataFrame(np.array([key_array,anomalies]).T,columns=[ 'ID', 'ANOMALIES']) split_list.append(split_df) split_field = pd.concat(split_list,ignore_index=True) print(split_field) |
上面例子属于字符串拆解,虽然是Pandas的长项,但代码还是有点复杂,如果遇到Pandas不那么擅长的有序计算,代码只会更加难懂。比如:duty.csv记录着每日值班情况,一个人通常会持续值班几个工作日,之后再换人,现在请根据duty.csv依次计算出每个人连续的值班情况,示意如下:
处理前(duty.csv)
Date | name |
2018-03-01 | Emily |
2018-03-02 | Emily |
2018-03-04 | Emily |
2018-03-04 | Johnson |
2018-04-05 | Ashley |
2018-03-06 | Emily |
2018-03-07 | Emily |
… | … |
处理后
name | begin | end |
Emily | 2018-03-01 | 2018-03-03 |
Johnson | 2018-03-04 | 2018-03-04 |
Ashley | 2018-03-05 | 2018-03-05 |
Emily | 2018-03-06 | 2018-03-07 |
… | … | … |
|
|
实现上述算法的pandas代码如下:
import pandas as pd import numpy as np duty = pd.read_csv('C:\\duty.csv',sep='\t') name_rec = '' start = 0 duty_list = [] for i in range(len(duty)): if name_rec == '': name_rec = duty['name'][i] if name_rec != duty['name'][i]: begin = duty['date'].loc[start:i-1].values[0] end = duty['date'].loc[start:i-1].values[-1] duty_list.append([name_rec,begin,end]) start = i name_rec = duty['name'][i] begin = duty['date'].loc[start:i].values[0] end = duty['date'].loc[start:i].values[-1] duty_list.append([name_rec,begin,end]) duty_b_e = pd.DataFrame(duty_list,columns=['name','begin','end']) print(duty_b_e) |
从上面的例子可以看出,Pandas只有基本函数才具有易读易写的特点,一旦遇到实际工作中的日常算法,就会变得难读难写。但实际工作中,源数据格式不可能总是标准的,数据处理算法也不可能只是最基本的过滤和排序。实际工作中,我们会遇到各种各样未知的情况,我们必须用基本函数自由组合,让数据按照一定的规则去清洗、转换、计算,才能获得我们需要的处理结果。
Pandas实现日常算法时之所以变得难读难写,主要还是因为Python和开源社区之间的关系过于松散。Pandas只是众多开源社区之一,其权力只限于Pandas函数内部,Python不可能让一个社区去改进函数之间的调用语法。面对五花八门的函数库,Python团队也没有精力去一一了解,所以很难从整体上改进语法,让函数之间的配合更简单方便。
Pandas处理超出内存的数据也很困难。
如果数据量较大(指超出内存,而不是Big Data),一般要用循环的办法来处理,即:每次读取并计算少量数据,再保留本次计算的中间计算结果,循环结束后合并多个中间计算结果(比如过滤),或对合并结果做二次计算(比如分组汇总),如此形成最终计算结果。可以看到,即使是基本结构化算法,数据量大时也很繁琐,更别说涉及多种数据的关联、归并、集合等算法,或组合多种基本算法的实际日常算法。
为了简化大数据量的计算,数据处理脚本应当在底层提供某种机制,向下自动实现内外存交换,向上隐藏繁琐的计算细节,允许桌面分析师用类似处理小数据量的语法,直观地处理较大的数据量。但遗憾的是,Python没有为Pandas提供这种底层支持,这就导致Pandas实现较大数据量的计算时,必须由桌面分析师自己实现底层逻辑,代码因此异常繁琐。
比如: orders.csv记录着电商的订单数据,需要计算每个销售员销售额最大的3笔订单。Pandas代码如下:
import pandas as pd import numpy as np chunksize=1000000 order_data = pd.read_csv(d:\\orders.csv',iterator=True,chunksize=chunksize) group_list = [] for chunk in order_data: for_inter_list = [] top_n = chunk.groupby(by='sellerid',as_index=False) for index,group in top_n: group = group.sort_values(by='amount',ascending=False).iloc[:3] for_inter_list.append(group) for_inter_df = pd.concat(for_inter_list,ignore_index=True) group_list.append(for_inter_df) top_n_gr = pd.concat(group_list,ignore_index=True).groupby(by='sellerid',as_index=False) top_n_list=[] for index,group in top_n_gr: group = group.sort_values(by='amount',ascending=False).iloc[:3] top_n_list.append(group) top_3 = pd.concat(top_n_list) print(top_3) |
实现大数据量计算时,优秀的数据处理脚本不仅会提高代码的表达效率,也会想办法提高代码的执行效率,比如压缩分段、多线程并行等。这些底层优化手段应当由Python统一提供,才能保证各类三方库函数的统一和稳定,但Python没这样做,只能由开源社区自己解决,比如joblib等开源社区就实现了多线程并行。
第三方多线程库函数终于出现了,Pandas应该跑得更快吧?
错!Python和开源社区的关系很松散,开源社区之间的关系更松散,所以Pandas很难和第三方多线程库函数配合,也就谈不上更快。比如上面的例子,理论上虽然能改成多线程,但实际上很难实现。
Python和开源社区的关系过于松散,还会导致Pandas访问数据源的困难。
前面提到过,Pandas支持绝大多数数据源,之所以如此方便,是因为每种数据源都有对应的开源社区和第三方函数库。但问题在于:每种数据源对应的开源社区不止一个,第三方函数库也不止一个。比如MySQL数据库,常见的函数库就有三种:sqlalchemy、MySQLdb、PyMySQL。另外,oracle、MSSQL、HSQLDB…几乎每种数据库都有多个开源社区的多种函数库可用,而且每种函数库的使用方法都不一样!
对于专业程序员来说,多种选择也许是件好事,但对于桌面分析师而言,没人想用复杂的pip命令去寻找并部署不同的函数库,也没人想去测试这些函数库的区别,我们只想要这样的桌面轻量级数据处理脚本:可以用简洁且统一的语法访问不同的数据源,然后迅速处理数据。
Pandas的优点很明显:函数种类丰富,但缺点也很明显:日常算法编写复杂、大数据量算法编写繁琐、对桌面分析师不友好。那么,有没有一种桌面轻量级数据处理脚本,不仅具备专业而丰富的结构化计算函数,还能弥补Pandas的缺点?下面就会讲到这样的脚本语言。
esProc
与Pandas类似,esProc也具有丰富的结构化计算函数,比如:
|
A | B |
1 | =file("d:/emp.csv").impor@tc() | /从文件读入员工,序表类型 |
2 | =A1.select(salary>8000 && salary<10000) | /过滤 |
3 | =A1.sort(-salary) | /逆序排序 |
4 | =A1.groups(deptid;sum(salary),avg(salary)) | /分组汇总 |
5 | =A1.group(deptid).conj(~.sort(salary)) | /开窗,按工资对各部门内的员工进行排名 |
6 | =connect@l("mssql").query("select * from dept") | /从数据库读入部门,序表类型 |
7 | =A1.join@i(deptid,A6:deptid,deptname) | /内关联,文件-数据库 |
上述代码A1中,import是读取标准csv的用法,对于其他常规格式,比如首行非列名、跳过N行读取,该函数都可通过设定参数实现,用法同样简单。
与Pandas类似,esProc还可以读取数据库(上述代码A6)、json文件、Excel、网页等等几乎所有外部数据源,且写出到目标数据源同样方便。
作为标准的程序性语言,esProc同样支持断点、单步、跳入、跳出等标准调试手段,从而提高开发效率。
除了上述相同之处, esProc和Pandas也有很多不同,其中最主要的区别在于, esProc是由独立团队维护的闭源软件,而不是开源社区的第三方库函数,也不存在一个松散的上级组织。esProc可以毫无束缚地从全局角度设计语法,使函数之间以最大的灵活度搭配组合,从而快捷方便地解决日常工作中遇到的问题。
比如规范化特殊的数据格式:将split_field.csv的 ANOMOALIES字段按空格拆分为多个字符串,从而使一行记录变多行。代码非常简单,如下:
|
A |
1 | =file("C:\\split_field.csv").import@t() |
2 | =A1.news(ANOMALIES.split(" ");ID,~:ANOMALIES) |
再比如,根据duty.csv依次计算出每个人连续的值班情况,代码依然简单:
|
A |
1 | =file("C:\\duty.csv").import@t() |
2 | =A1.group@o(name) |
3 | =A2.new(name,~.m(1).date:begin,~.m(-1).date:end) |
可以看到,同样的日常算法, esProc要比Python简单许多,称得上是真正的易读易写。
有了统一的设计,esProc可以从底层提供游标机制,允许桌面分析师用类似处理小数据量的语法,直观地处理较大的数据量。比如,根据orders.csv计算每个销售员销售额最大的3笔订单。代码如下:
|
A |
1 | =file("d:\\orders.csv").cursor@tc() |
2 | =A1.groups(sellerid;top(3; -amount):top3) |
3 | =A2.conj(top3) |
有了统一的设计,esProc很容易从底层支持多线程并行,改动代码提升性能很方便。比如把前面的代码改为多线程并行:
|
A | B |
1 | =file("E:\\orders.csv").cursor@tmc(;4) | /4线程并行 |
2 | =A2.groups(sellerid;top(3; -amount):top3) |
|
3 | =A3.conj(top3) |
|
有了统一的设计,esProc可以用设计一致的接口访问数据库,而不是像Pandas那样用不同的第三放函数库。另外,对于任意种类的数据源,esProc可以返回统一的数据类型(序表),从而直接进行交互运算,而不是像Pandas那样,只能对个别数据源用统一数据类型(dataframe),其他数据源只能先写成csv文件,再读成dataframe。对于桌面分析师来讲,这意味着简单易用和快速开发,不必像Python那样到处下载并比较第三方库函数。
经过前面的比较,我们可以看到esProc是一款对桌面分析师非常友好的轻量级数据处理脚本,不仅具备丰富的结构化函数,还可以轻松实现日常工作中较复杂的算法,并简化大数据量算法的编写。