最近几年,基本已经不做应用系统的开发了,主要做一些数据分析和机器学习相关的应用(业务复杂度不高),因此,对于以前熟悉的各种软件模式也逐渐生疏。
今天,偶尔又翻到保罗·格雷厄姆(Paul Graham)之前写过的一篇关于 Lisp 和编程本质的文章《自下而上的编程》,感觉这种源于黑客文化的"自下而上"的哲学,似乎也可以拯救陷入"意大利面条式代码"泥潭的数据分析师和机器学习工程师的。
在软件开发的世界里,最常见的错误就是过早地决定了程序的形状。而在数据科学领域,这个错误被放大了很多倍。
1. 现状:大多数人是如何做数据分析的?
如果不加干预,大部分初学者(甚至很多资深从业者)在处理数据时,采取的都是严格的 **"自上而下" **模式。
想象一下,老板交给你一个任务:"分析一下上个季度25岁以下用户的复购率。"
你的大脑立刻开始将其拆解:
- 读取
orders.csv。 - 过滤出
date在上个季度的行。 - 过滤出
age < 25的用户。 - 按用户
ID分组,计算购买次数。 - 输出结果。
于是,你打开了一个 Jupyter Notebook,从第一个 Cell 写到第十个 Cell。
代码像瀑布一样流下来,任务完成了,你很开心。
但这有什么问题?
问题在于,这是一种一次性的编程。如果老板明天说:"再看看30岁以上,最近半年的流失率。"你会怎么做?
你会把昨天的 Notebook 复制一份,然后把里面的数字改一改。
久而久之,我们的电脑里充满了 analysis_v1.ipynb, analysis_final.ipynb, analysis_final_really.ipynb。我们的代码变成了为了解决特定单一问题而存在的"脚本"。
这不是在 "开发软件" ,只是在 "操作计算器"。
2. 什么是"自下而上"的设计?
格雷厄姆在《自下而上的编程》中提到过:"与其为了解决问题而设计程序,不如设计一种语言来描述这个问题。"
在数据分析和机器学习中, **"自下而上" **意味着你先忽略具体的业务目标(比如计算复购率),转而先构建一套能让你轻松处理数据的 "基础词汇"。
当我们采用自上而下的方法时,就像是在用大石头堆砌金字塔,每一块石头的位置都是固定的。
当我们采用自下而上的方法时,则是在通过造积木(或者说创造新的操作符)来提升基础语言的能力。当语言的能力提升到一定程度,解决具体问题就变得像说话一样简单。
在 Python 或 R 中,这通常意味着利用函数式编程的思想,将通用的数据转换逻辑抽象成独立的小工具。
3. 场景模拟:自上而下 vs 自下而上
让我们通过一个具体的机器学习预处理场景来看看两者的区别。
假设我们要处理一份电商数据,目的是为模型准备特征。
场景:处理用户行为日志。我们需要做三件事:
- 去除异常值(比如购买金额为负数)。
- 填充缺失的年龄数据。
- 对分类数据(如城市)进行编码。
3.1. 模式 A:传统的自上而下(脚本式)
这是典型的"面条代码":
python
import pandas as pd
# 读取数据
df = pd.read_csv("data.csv")
# 1. 业务逻辑直接写死在流程里
df = df[df['amount'] > 0]
# 2. 处理缺失值
mean_age = df['age'].mean()
df['age'] = df['age'].fillna(mean_age)
# 3. 独热编码
df = pd.get_dummies(df, columns=['city'])
# 喂给模型
model.fit(df)
这段代码完全是为了"这一份数据"服务的。
如果换了一份数据,或者你想尝试"用中位数填充"而不是"均值填充",你就得侵入这段代码去修改它。
这使得实验变得极其痛苦。
3.2. 模式 B:自下而上的设计(语言构建式)
在这种模式下,我们先不看具体的数据,而是问自己:我经常需要做什么?我需要过滤、填充、编码。
于是,我们要为此创造一些"动词"。
python
# 第一层:构建基础词汇(这些是通用的积木)
def remove_outliers(col_name, threshold=0):
"""返回一个过滤函数"""
def _filter(df):
return df[df[col_name] > threshold]
return _filter
def fill_missing(col_name, strategy='mean'):
"""返回一个填充函数"""
def _filler(df):
data = df.copy()
if strategy == 'mean':
val = data[col_name].mean()
elif strategy == 'median':
val = data[col_name].median()
data[col_name] = data[col_name].fillna(val)
return data
return _filler
def encode_categorical(col_names):
"""返回一个编码函数"""
def _encoder(df):
return pd.get_dummies(df, columns=col_names)
return _encoder
# 第二层:组合积木(使用管道)
# 即使你是初学者,看到这里也能明白,我们创造了一种"新语言"
from functools import reduce
def apply_pipeline(df, functions):
return reduce(lambda d, f: f(d), functions, df)
# 第三层:解决具体问题
# 你看,现在的代码读起来就像是英语句子
pipeline = [
remove_outliers('amount', 0),
fill_missing('age', strategy='mean'),
encode_categorical(['city'])
]
clean_df = apply_pipeline(raw_data, pipeline)
哪怕你没学过高深的编程,也能看出模式 B 的美妙之处。
如果不想要均值填充了?只需要把 strategy='mean' 改成 'median'。
如果想给另一个数据集做同样的预处理?直接复用 pipeline 列表。
最重要的是,底层的函数(积木)一旦写好并测试通过,你就再也不用去动它们了。
你的思考层级从 **"如何写 Pandas 代码" **上升到了 "如何组合数据处理逻辑"。
4. 自下而上模式的优缺点
当然,自下而上的模式也不是万能的。
它的优点是:
- 代码会越来越短: 随着你积累的基础函数(积木)越来越多,解决新问题所需的代码行数会越来越少。就像 Lisp 程序员常说的,你在让语言向问题靠拢。
- 极强的复用性 : 你的
fill_missing函数可以在一百个不同的项目中使用,而不需要重写一行代码。 - 易于测试: 测试一个小的、独立的工具函数,比测试一个几百行的脚本要容易得多。
- 表达力: 顶层的代码读起来就是业务逻辑本身,而不是复杂的语法细节。
为此,它的的缺点是:
- 初期成本高 : 当你只想画一条线时,自下而上的方法要求你先造一支笔。对于非常简单、一次性的任务,这可能显得 "过度设计"。
- 思维门槛: 初学者往往习惯于线性思维(第一步做啥,第二步做啥)。要学会抽象出通用的操作符,需要一定的训练和对函数式编程的理解。
5. 总结
自下而上 的设计模式,核心在于抽象。
在数据分析和机器学习中,这意味着不要总是盯着眼前的这一行数据,而是去思考:"我该如何构建一套工具,让这类型的问题以后都不再是问题?"
当你开始这样思考时,就不仅是在做分析,而是在像黑客一样创造。