封装-数据科学家必须了解以成功为目标的软件工程概念
封装:数据科学家必须了解以成功为目标的软件工程概念

你为什么应该阅读这篇文章
数据科学吸引了来自不同背景的人。根据我的专业经验,我曾与曾是以下同事共事:
-
核物理学家
-
博士后研究引力波
-
博士生在计算生物学
-
语言学专家
只是为了列举一些。
能够遇到这样多样化的背景真是令人愉快,我看到了各种思维方式推动创意和有效的数据科学功能的增长。
然而,我也看到了这种多样性的一个重大缺点:
每个人对关键软件工程概念的了解程度不同,导致编程技能参差不齐。
因此,我见过一些数据科学家所做的出色工作,但它们是:
-
不可读的——你不知道他们试图做什么。
-
不稳定的——一旦有人尝试运行它就会出错。
-
不可维护的——代码很快就会过时或容易出错。
-
无法扩展的——代码是单次使用,其行为无法扩展。
…这最终会削弱他们的工作影响力,并导致一系列问题。
-
不可读的代码意味着错误被忽视,
-
未注意到的错误会破坏模型和/或数据管道,
-
同时,由于代码不可读,可能需要几天时间才能找到错误。
-
而代码的原始作者已经离开公司,因此你无法获得帮助,
-
导致模型需要从头开始重新训练,数据管道需要重新编写,整个项目需要重新做。

图片由 David Pupăză 在 Unsplash 提供
因此,我计划在一系列文章中概述一些核心软件工程概念,这些概念是我针对数据科学家量身定制的。
这些概念很简单,但了解它们与不了解它们之间的区别,明显区分了业余和专业人士。
如果你打算进入数据科学领域,无论是研究生还是寻找职业转变的专业人士,或者是负责建立最佳实践的管理者,这篇文章适合你。
今天的概念:封装

Laura Gariglio 在Unsplash上的照片
互联网上有许多令人困惑和抽象的定义,它们使一个简单的概念变得过于复杂。
从本质上讲,封装就是隐藏对将要阅读或使用你代码的人来说不重要的复杂细节。
你只想揭示它做什么以及某人如何使用它。我在学习这个时记得的一个类比是:
所有驾驶员都知道如何使用方向盘。但大多数人不知道它如何或为什么工作,而且他们不需要知道。
封装也等同于信息/数据隐藏。
封装的有效使用应导致你的代码:
-
更容易阅读。
-
更容易维护(即诊断和修复错误)。
-
更容易扩展(即添加或更改功能)。
你已经见过封装了
scikit-learn的估计器套件是封装的理想例子。
我们都知道KMeans是如何工作的以及它应该做什么。我们也知道如何使用scikit-learn对KMeans的实现——所有这些都记录在 docstrings 中。
from sklearn.cluster import KMeans
model = KMeans() # initialise a model
model.fit(X_train) # fit the model
model.predict(X_test) # predict on new data
但我们是否需要确切地知道fit和predict方法是如何实现的?我是否需要阅读fit中的所有 126 行代码来了解如何使用它?
不。我们只需要知道它为我们给出的数据输出一些聚类。
即使是新手数据科学家也能使用这个界面并在一定程度上理解它。这就是封装的点。

Dan Crile 在Unsplash上的照片
作为用户,我们不需要知道引擎盖下发生的事情的细节。所有这些细节都整齐地藏在名为fit的有意义的功能之下。
我们需要知道的就是fit的预期输入和输出是什么,并适当地使用它。
现在,让我们通过一个 Jupyter Notebook 中的实际例子来看看这如何应用于典型数据科学家的日常工作中。
实际例子
让我们从以下示例任务开始:
建立一个模型来预测 Medium 博客文章的收入。
首先,你需要数据来做这件事。为了简单起见,让我们假设一些数据已经以以下 CSV 文件的形式提供给你。
文章生命周期统计
这个数据集通过一个唯一的 ID 列“文章”识别文章,它们的各种统计数据以及截至 2024 年 12 月的收入。

图片和数据由作者提供。CSV 文件“文章生命周期统计”的示例内容。
如你所见,这些数据存在各种问题:
-
published_date列有格式不佳的单元格和无效的日期。 -
claps有null值。 -
comments(或文章上的评论数量)有负值,这是不可能发生的,因此是无效的。 -
earnings列有一个无效的值为9999999999的单元格。
为了清理这些,假设你打开一个新的 Jupyter 笔记本,并在一个单元格中写下以下代码:
# can you figure out what's happening? How long did it take?
# I bet most of you didn't even bother to read the code.
# Why do you think that is?
df = pd.read_csv('~/Downloads/test.csv')
df.loc[df['published date'] == '2024-07-32', 'published date'] = '2024-08-01'
df['published date'] = pd.to_datetime(df['published date'], format='mixed')
df['claps'] = df['claps'].fillna(0)
df.loc[np.log10(df['earnings']) > 10, 'earnings'] = 0
这段代码对于编写它的数据科学家来说可能非常合理。但你的同事或经理将来阅读你的笔记本以了解你是如何构建预测模型的时候,他们会怎么想呢?
如果你正在度假,而你的工作需要重新运行以在另一组不同的数据上生成相同的输出,会怎样?
如果你是这段代码的编写者,被迫一年后重新审视这段代码,你确定你能记住并理解你写这段代码的每一个细节吗?
如果我阅读这段代码,我会想:
这里到底发生了什么?什么重要?为什么执行了这些操作?我需要运行整个笔记本并逐行阅读这段代码来理解它吗?!对每个单元格都要这样做吗?
封装在行动中
要在数据科学中有效地使用封装,你需要考虑以下因素:
-
你的受众是谁?
-
未来谁可能需要阅读这段代码?
-
他们为什么要阅读这段代码?
由于许多原因,你的代码可能需要被其他人阅读:
-
为了审计目的复制结果。
-
为了使用不同的参数重新运行代码。
-
为了调试影响下游流程的此笔记本的输出或结论中的问题。
根据你的判断,你需要决定:
-
你的代码中哪些部分是重要的,并且是打算直接供读者使用的?(参数、文件路径、常量)
-
你的代码中哪些部分可以隐藏起来?
隐藏起来应该会使你的代码更容易阅读和理解,通过将它们打包成可管理的和有意义的块。
现在,考虑以下使用封装原则重写的代码。
在你的 Jupyter 笔记本的第 1 个单元格中:
def clean_published_date_column(col:pd.Series) -> pd.Series:
"""Cleaning steps for `published date` column:
1\. One entry of '2024-07-32' which is a date that is out of range
for July. We replace this with 1st Aug.
2\. Dates are of type `str`, and in different formats. This is
handled using `pandas.to_datetime` with args `format='mixed'`.
Parameters
----------
col: pandas.Series
A Series containing article published date entries.
Returns
-------
col: pandas.Series
The cleaned `published date` column.
"""
jul_date_out_of_range_mask = (col == '2024-07-32')
jul_date_out_of_range_replacement = '2024-08-01'
col.loc[jul_date_out_of_range_mask] = jul_date_out_of_range_replacement
return pd.to_datetime(col, format='mixed')
def clean_earnings_column(col:pd.Series):
"""Cleaning steps for `earnings` column:
1\. One entry where the value is 999999999999\. We replace
any values exceeding 10 figures with zero.
Parameters
----------
col: pandas.Series
A Series containing article lifetime earnings data.
Returns
-------
col: pandas.Series
The cleaned `earnings` column.
"""
earnings_too_big_mask = (np.log10(col) > 10)
earnings_too_big_replacement = 0
col.loc[earnings_too_big_mask] = earnings_too_big_replacement
return col
def clean_claps_column(col:pd.Series):
"""Cleaning steps for `claps` column:
1\. Fill null values with zero.
Parameters
----------
col: pandas.Series
A Series containing number of claps per article.
Returns
-------
pandas.Series
The cleaned `claps` column.
"""
return col.fillna(0)
在第 2 个单元格中:
df = pd.read_csv('~/Downloads/test.csv')
# I bet you didn't read the functions above.
# Well, that's the point of encapsualtion. You shouldn't need to to understand
# what's going on here.
df['published date'] = clean_published_date_column(df['published date'])
df['claps'] = clean_claps_column(df['claps'])
df['earnings'] = clean_earnings_column(df['earnings'])
这很简单,我们只是把现有的代码放入它们各自的功能中,给它们起有意义的名字,并添加文档字符串来指导读者如何使用该函数以及它的预期行为。
你可能会想
“多么浪费时间,这篇文章只是告诉我把东西放入函数中,这有什么大不了的。”
嗯,是的,部分如此。
将代码放入任何函数中总会达到一定程度的封装。但有效的封装不仅仅是把所有东西都扔进函数里。
这种差异类似于
- 整理你的房间,
与之相比
- 把你所有的衣服和垃圾都扫到床下。
def clean(df):
df.loc[df['published date'] == '2024-07-32', 'published date'] = '2024-08-01'
df['published date'] = pd.to_datetime(df['published date'], format='mixed')
df['claps'] = df['claps'].fillna(0)
df.loc[np.log10(df['earnings']) > 10, 'earnings'] = 0
return df
df = pd.read_csv('~/Downloads/test.csv')
df = clean(df) # the 'sweeping everything under the bed' equivalent.
让我们先检查有效的封装实现了什么,以及这是如何实现的。
封装实现了什么?
为了简洁起见,我们只通过两个与数据科学最相关的优势进行说明。
此外,请注意,关于如何做到这一点并没有正确答案。根据你的意图和假设,可能有多种应用封装的方法。以下是我的尝试,我将尝试解释为什么我这样设计,以及为什么这样做是有意义的。
最重要的是封装所产生的影响。
代码的可读性更好
首先,我们在信息层次上创建了信息,这些信息根据其详细程度和与读者的相关性。
df = pd.read_csv('~/Downloads/test.csv')
# data cleansing
df['published date'] = clean_published_date_column(df['published date'])
df['claps'] = clean_claps_column(df['claps'])
df['earnings'] = clean_earnings_column(df['earnings'])
我故意将“清理”这个数据集的细节封装到三个精心设计的函数中,这反过来又服务于以下目的:
-
它们向读者表明,对输入数据已执行了一些复杂操作,
-
而是否需要深入了解取决于读者的目标。
-
如果读者需要深入了解,他们可以自由地查看每个函数及其相应的文档字符串,并立即理解每个函数如何在可管理的、有意义的块中工作。
-
如果他们不需要细节,他们可以快速跳转到与他们相关的部分,至少在较高层次上了解上述代码的意图。
这种方法假设笔记本中还有其他更重要的部分,例如模型构建或特征探索。因此,我觉得数据清理部分不是这段代码的主要焦点。
因此,我将每个函数命名为clean_xxx_column格式,假设读者只需要知道“这里进行了一些清理以创建可工作的数据集”,并且如果他们的兴趣在于模型构建/特征探索,他们可以跳过细节。
最终,封装应该使你的代码更容易阅读,向读者展示恰好足够的信息,以便他们了解正在发生的事情,同时避免信息轰炸。
更好的可读性使得其他人更容易使用你的代码,理解它并维护它。
更好的可扩展性和关注点分离
假设现在你从相同的数据源接收到了更大的数据集。你使用与之前相同的代码将其加载进来:
df = pd.read_csv(f'~/Downloads/bigger_test.csv')
# data cleansing
df['published date'] = clean_published_date_column(df['published date'])
df['claps'] = clean_claps_column(df['claps'])
df['earnings'] = clean_earnings_column(df['earnings'])
然而,这次你发现需要额外的清理步骤;claps列与earnings列存在相同的问题,其中一些值非常大:9999999999
在这个例子中,封装显示了两个好处:
- 可扩展性:扩展我们现有的代码以处理新的数据问题很容易,甚至可以说是微不足道的。即使是新接触代码的人也会很容易知道他们需要修改哪个函数。
def clean_claps_column(col:pd.Series):
"""Cleaning steps for `claps` column:
1\. Fill null values with zero.
2\. Replace any values exceeding 10 figures with zero.
Parameters
----------
col: pandas.Series
A Series containing number of claps per article.
Returns
-------
pandas.Series
The cleaned `claps` column.
"""
# additional functionality added here.
# Note: since this code is repeated in `clean_earnings_column`,
# it could be put into its own function. For brevity we leave it
# as is for now.
too_big_mask = (np.log10(col) > 10)
too_big_value_replacement = 0
col.loc[too_big_mask] = too_big_value_replacement
# existing functionality
return col.fillna(0)
而不是必须检查每一行代码以了解在哪里添加更改:
df = pd.read_csv('~/Downloads/bigger_test.csv')
# I bet you won't even bother reading the below because it is so untidy.
df.loc[df['published date'] == '2024-07-32', 'published date'] = '2024-08-01'
df['published date'] = pd.to_datetime(df['published date'], format='mixed')
df['claps'] = df['claps'].fillna(0)
df.loc[np.log10(df['claps']) > 10, 'claps'] = 0
df.loc[np.log10(df['earnings']) > 10, 'earnings'] = 0
- 关注点分离:代码更改被整洁地包含在
clean_claps_column函数中,而所有其他代码保持不变。
df = pd.read_csv(f'~/Downloads/bigger_test.csv')
# Nothing changes here. Same as usual.
df['published date'] = clean_published_date_column(df['published date'])
df['claps'] = clean_claps_column(df['claps'])
df['earnings'] = clean_earnings_column(df['earnings'])
负责运行清理的代码部分不需要担心任何清理函数底层的操作,只要函数行为没有改变。
这使得代码更改更加整洁且易于管理。
这能实现什么?
封装的概念是从软件工程世界中数十年的试错经验中发展起来的。
最终目标是使代码更容易维护。

图片由Pandu Agus Wismoyo在Unsplash提供
编写不良的代码会迅速膨胀成一堆错误和浪费在维护上的工时,导致你宝贵的时间被浪费在修复错误上,而不是开发令人兴奋的新模型。
数据科学已经超越了纯粹以研究为重点的阶段,模型只能用于证明概念,在 Jupyter 笔记本中拼凑起来。
这个领域已经足够成熟,可以对现实世界产生影响,而数据科学团队现在才意识到他们实际上需要编写生产就绪的代码,更糟糕的是,还需要维护它们。
不幸的是,我仍然看到数据科学家完全依赖笔记本和其无序的代码来维护整个生产流程。当我看到这种情况时,我为这个行业的未来感到担忧。
结论
总结来说,封装是软件工程中的一个重要原则,可以显著提高代码的可读性、可维护性和可扩展性。
通过隐藏复杂细节并仅展示必要的部分,你使代码更加用户友好并最小化返工。
对于数据科学家来说,掌握封装不仅提高了你工作的质量,还使你成为一个能够构建健壮、生产就绪系统的专业人士。

浙公网安备 33010602011771号