重构Python的一个案例研究(详细指南)

106 阅读15分钟

在过去2年半的Python旅程中,我学到了很多东西。在2020年的COVID-19锁定期间,开始是一种爱好,现在已经成为我专业工作的一个主要组成部分。这篇文章旨在强调迭代过程的重要性:写一些代码->学习新东西->审查一些代码->重构。我将分享我在2020年4月和5月写的一些代码(大约在学习Python两个月后),我当时的思考过程,我是如何决定重构的,最后是性能比较。

在这篇特定的文章中,我不会提到的一个关键因素是测试。是的,我应该有一些测试来针对我重构的代码运行,以确保我没有偏离方向。我确实手动确认了重构后的代码产生了与原始代码相似的结果。由于原代码中的不准确之处,有一些细微的差别,这将在后面讨论。然而,这段代码只是为了让我使用,而不是用于生产。我的意思是,当我第一次写这段代码时,我甚至不知道什么是测试。相反,我将把重点放在设计决策和令人惊讶的,甚至是附带的性能影响上。下面显示的所有代码都可以在Github repo中找到。

背景介绍

我最近获得了课程与教学的硕士学位。在这个项目中,有一堂课是以一篇激发了一些强烈意见的文章为中心。我把这篇文章理解为对教育者的指责,并且完全不欣赏它。因此,我的一篇论文(Specious Solutions)的主题是拆解他们关于比例代表制(PR)的论点。在学校里,至少在美国加州这里,我们对学生有不同的分类。如果一个学生需要额外的学术或情感支持,或有某种残疾,他们可以接受特殊教育服务,随后被归类为特殊教育(SPED)。如果学生来自贫困家庭,按照联邦标准的指数,他们被归类为低社会经济地位(SES)。如果你没有读到链接的论文,这里是让我如此激动的论点的一部分。

因为比例代表制是公平审计的基础(如上所述),公平审计表要求数据收集包括分数和百分比,以便能够衡量比例代表制。例如,在100名残疾学生中,如果有70名学生接受免费和减价午餐,那么这个数据的分数是70/100,百分比是70%。然后,这个数据可以与学校中接受免费和减价午餐的学生百分比进行比较,在这个例子中,600名学生中有210名学生(210/600 = 35%)。因此,在这个例子中,在这所学校,我们知道来自低收入家庭的学生被贴上特殊教育标签的可能性是两倍,因此,在特殊教育中的比例过高。来自低收入家庭的学生在特殊教育中的比例应该是35%或以下。

在不深入研究的情况下(如果你喜欢深入研究,就去读这篇论文),他们的论点是假设SPED和低SES在人口中是独立和随机分布的。但即使是这样--我在论文中引用了很可能不是这样的证据--我也不相信PR有可能自然发生。鉴于过去的我一直在学习Python,好像有两个月了,我决定创建一个模拟。

原罪代码

我的想法是模拟一所学校,使用文本中给出的概率,随机地将学生标记为SPED或Low SES。该程序将计算有多少学生被标记为SPED、低SES或两者都是,并计算低SES学生的整体比例和SPED中的低SES学生比例。如果这两部分完全吻合,学校就会有PR。它将重复这个过程,进行大量的试验,看看它实际发生的频率。

在我们讨论代码之前,还有一个细节,虽然文本声称SPED中低SES学生的PR应该是35%或更少,但这实际上是他们论点的第一个缺陷。由于各种标签的学生比例加起来必须达到学生总数的100%,如果低SES的学生在SPED中的代表性不足,那就必须伴随着另一种标签的学生的过度代表性。因此,社会经济地位低的学生在SPED中的代表性不足,会导致他们所谴责的同样的不公平现象。不过,我还是给了他们一些宽容,也包括了那些在他们设定的上限2%以内的学校。

我不确定如何完成模拟,但我知道我可以用Python生成随机数字。因此,我开始在网上搜索(当然我用的是duck duck go,但不是每个人都叫它googling吗?)"根据概率选择随机数 "的内容。在互联网的某个地方,我偶然发现了numpy.random.binomial。它根据作为参数提供的概率返回0或1。我分别用sped和low SES的概率运行了两次,把整数转换为字符串,这样我就可以把它们连接起来。然后将它们转换为整数,结果是10代表一个学生被标记为SPED,1代表一个学生被标记为低SES,而11被标记为两者。(为什么我不直接比较字符串呢?我不确定。也许当时我认为我只能比较数值?我真的不记得了)。我必须警告你,你将要看到的代码很粗糙。下面是我的原始脚本的全部内容:

import numpy as np
import random

n = 1
p_sped = (1/6)
p_lowin = (7/20)

runs = 10000
pop = 600

PR_exact = 0
PR_twoper = 0
for i in range(runs):
    sped = 0
    lowin = 0
    both = 0
    sample_school = []
    for s in range(pop):
        student = str(np.random.binomial(n,p_sped)) + str(np.random.binomial(n,p_lowin))
        if int(student) == 10:
            sped += 1
        elif int(student) == 1:
            lowin += 1
        elif int(student) == 11:
            both += 1
        sample_school.append(student)
    per_lowsped = both/(sped + both)
    per_lowpop = (lowin+both)/(pop)
    if 0<(per_lowsped-per_lowpop)<= 0.02:
        PR_twoper += 1
    elif per_lowsped==per_lowpop:
        PR_exact +=1

prob_PR_exact = (PR_exact/runs)*100
prob_PR_twoper = (PR_twoper/runs)*100
print('The probability of having exact proportional representation in ' +str(runs) + ' trials is: '
      + str(prob_PR_exact) + '%')
print('The probability of having proportional representation within 2% in ' +str(runs) + ' trials is: '
      + str(prob_PR_twoper) + '%')

我知道,我知道......让我们找出一些问题:

  • 格式化:不一致的间距和零星的、不必要的括号。我显然还没有听说过黑色
  • 命名惯例:客观上很糟糕。得了吧,我写完这个还在为PR_twoper 。我已经忘记了我的许多变量名称代表什么,直到我回去读了原始论文。
  • 未使用的导入:我为什么要导入随机?我甚至都没有使用过它!
  • 效率低下:我正在使用numpy(它应该是超级快的!),但随后转换为字符串,因为我需要两个标签,然后再转换回整数用于条件反射。我还创建了一个样本学校列表,并将学生附加到其中,但后来我从未使用过它。什么?
  • 不准确:我没有意识到浮点数的不精确性,我没有包括任何形式的四舍五入。此外,在重构过程中,我意识到我应该在 "2%以内 "的计算中也包括准确的比例表示。这只是一个很小的区别,并没有改变论文的分析,但仍然需要解决。

如果出于某种原因--对普通人来说是不可理解的--你想用原始代码工作,它可以在repo里找到。

重构

在这一节中,我将描述我对原始代码进行重构的思考过程,使其更具有可读性。我的目标是将负责特定行为的大块代码抽象成函数。我并没有试图一下子想出所有的函数。我与你分享的最终代码是渐进式的。

我做的第一件事是对着黑子运行代码。虽然仅此一项就使它更容易阅读,但我知道这并不是最大的问题(不想错过大猩猩)。接下来,我知道我需要更好的变量名称。最终,我决定让它在我创建函数时有机地发生。作为额外的奖励,将行为抽象到函数中允许我添加文档字符串类型提示。这样一来,如果我或其他人最终再次回顾这段代码,我就会知道过去的我在想什么。

创建一个学校

第一个让我眼前一亮的结构问题是嵌套的for 循环。从内循环开始,我试图用简单明了的语言描述正在发生的事情。我正在创建一所学校并计算每个标签的学生人数。这导致了辅助函数_create_trial_school()

def _create_trial_school(
    population: int, prob_sped: float, prob_low_ses: float
) -> Counter:
    """Creates a counter of students with one of four possible labels: 'sped low',
    'sped high', 'gen_ed low' 'gen_ed high'.

    :population: int The number of students in the school.
    :prob_sped: float The probability that a student is labeled as sped.
    :prob_low_ses: float The probability that a student labeled as low ses.
    :returns: Counter The number of students with each label.
    """

    school = Counter()
    for s in range(population):
        student = (
            choices(
                population=["sped ", "gen_ed "],
                weights=[prob_sped, 1 - prob_sped],
            )[0]
            + choices(
                population=["low", "high"],
                weights=[prob_low_ses, 1 - prob_low_ses],
            )[0]
        )
        school.update([student])
    return school

电流知道计数器的存在,而且很厉害。所以我使用了一个计数器对象,而不是用if/else 语句手动计数。接下来,我需要解决numpy.random.binomial() 的可怕使用问题。利用我最喜欢的工具--搜索,直到找到答案--我决定使用内置随机模块的choices()

使用choices() ,我可以分配一个概率并从一个字符串列表中选择,而不是生成整数。这意味着,我可以摆脱尴尬的int ->str ->int 的转换。(说实话,如果我们不再谈论这个问题,我会很感激。提前感谢)。由于我是从一连串的字符串中选择的,我也需要创建我的标签的赞美。SPED的褒义词是普通教育(gen_ed),低SES的褒义词是高SES(高)。(从技术上讲,低SES的赞美是中SES和高SES的结合,但为了简单起见,我采用了低和高的二分法。)我将这些标签连接起来,产生了四种可能性:"sped low"、"sped high"、"gen_ed low"、"gen_ed high"。学校计数器随每个学生更新并由函数返回。原始代码和重构后的代码的区别如下。我用椭圆(...)表示还有其他代码没有在这里显示:

# This original code
...
    sped = 0
    lowin = 0
    both = 0
    sample_school = []
    for s in range(pop):
        student = str(np.random.binomial(n,p_sped)) + str(np.random.binomial(n,p_lowin))
        if int(student) == 10:
            sped += 1
        elif int(student) == 1:
            lowin += 1
        elif int(student) == 11:
            both += 1
        sample_school.append(student)
...

# Becomes this refactored code
...
    school = _create_trial_school(population, prob_sped, prob_low_ses)
...

在模拟的整体流程中,我能够用一行调用函数的代码取代13行代码。更重要的是,原来的13行所包含的逻辑已经被抽象为一个能清楚描述它的名字。

计数器的比例表示法

接下来,是另一个计数器我确定了一个具体的行为,并用通俗的语言描述了它。我正在计算我新创建的试验学校中,有多少学校真正具有比例代表制。然而,这一次,我需要这个计数器在各个学校之间持续存在。我的解决方案是在原始代码的第一个for循环之外创建一个Counter() 对象,然后创建一个辅助函数_update_pr_counts() ,在每所试验学校创建之后更新该计数器:

def _update_pr_counts(
    pr_counter: Counter,
    percent_low_ses_overall: float,
    percent_low_ses_in_sped: float,
) -> None:
    """Updates the number of schools that have proportional representation.

    :pr_counter: Counter The proportional representation counter to be updated.
    :percent_low_ses_overall: float The percentage of students at the school who are
        labeled low income.
    :percent_low_ses_in_sped: float The percentage of students who are labeled both as
        low income and sped.
    :returns: Counter The proportional representation counter updated if the school has
        proportional representation.
    """

    pr_delta = percent_low_ses_overall - percent_low_ses_in_sped
    if pr_delta == 0:
        pr_counter.update(["exact"])
    if 0 <= pr_delta <= 0.02:
        pr_counter.update(["within range"])
    return pr_counter

这就解决了四舍五入的误差和不包括2%范围内的精确PR的问题。它还提供了更具描述性的变量名称。让_create_trial_school() 返回一个计数器,稍微改变了我必须统计和计算SPED中低SES学生的部分,以及低SES的整体。我把它分成两部分。首先,计算计数,然后直接将这些部分作为_update_pr_counts() 的参数传递:

# This original code
...
for i in range(runs):
    ...
    per_lowsped = both / (sped + both)
    per_lowpop = (lowin + both) / (pop)
    if 0 < (per_lowsped - per_lowpop) <= 0.02:
        PR_twoper += 1
    elif per_lowsped == per_lowpop:
	   PR_exact += 1
...

# Becomes this refactored code
...
    proportional_representation = Counter()
    for _ in range(trials):
        ...
        count_sped = school["sped low"] + school["sped high"]
        count_low_ses = school["sped low"] + school["gen_ed low"]
        proportional_representation = _update_pr_counts(
            proportional_representation,
            percent_low_ses_overall=round(count_low_ses / population, 3),
            percent_low_ses_in_sped=round(school["sped low"] / count_sped, 3),
        )
...

计算和打印结果

我需要解决的最后一个项目是计算结果。我决定采用字典理解法将计数转换为百分数。显然,过去的我还没有学会f-strings,所以我也利用f-strings清理了打印语句。最后,你会注意到一个小小的if 语句。这是一个标志,允许我决定是否要打印结果。这是在我决定比较性能之后才添加的。当试验次数较少时,有可能没有带PR的学校,所以我用dict方法.get() ,提供一个默认值:

# This original code
...
prob_PR_exact = (PR_exact/runs)*100
prob_PR_twoper = (PR_twoper/runs)*100
print('The probability of having exact proportional representation in ' +str(runs) + ' trials is: '
      + str(prob_PR_exact) + '%')
print('The probability of having proportional representation within 2% in ' +str(runs) + ' trials is: '
      + str(prob_PR_twoper) + '%')
...

# Becomes this refactored code
...
    results = {
        pr: round(count / trials * 100, 2)
        for pr, count in proportional_representation.items()
    }
    if print_results:
        print(
            f"The probability of having exact proportional representation in {trials:,} trials is: {results.get('exact', 0.0)}%"
        )
        print(
            f"The probability of being within 2% in {trials:,} trials is: {results.get('within range', 0.0)}%"
        )
...

我决定应该把整个模拟过程抽象成一个叫做run_trials() 的函数。这样我就可以利用if __name__ == "__main__":的成语来运行试验(这也是过去我根本不知道存在的东西)。在Twitter上Reuven Lerner互动后,我最近还了解了设置随机种子的含义。基本上它将产生同一组伪随机数,所以模拟的结果将是一致的。我相信由此产生的重构使人们更容易了解模拟中发生的事情,有更好的记录,并使运行不同参数的模拟更简单:

def run_trials(
    trials: int,
    population: int,
    prob_sped: float,
    prob_low_ses: float,
    print_results: bool = True,
) -> dict[str, float]:
    """Run trials to simulating a school with a given probabilities of students being
    labeled sped and low ses.

    :trials: int The number of trials to run.
    :population: int The number of students at the school.
    :prob_sped: float The probability that a student is labeled as sped.
    :prob_low_ses: float The probability that a student is labeled as low ses.
    :returns: dict Contains the percentages of schools that have proportional
        representation across all trials.
    """
    proportional_representation = Counter()
    for _ in range(trials):
        school = _create_trial_school(population, prob_sped, prob_low_ses)
        count_sped = school["sped low"] + school["sped high"]
        count_low_ses = school["sped low"] + school["gen_ed low"]
        proportional_representation = _update_pr_counts(
            proportional_representation,
            percent_low_ses_overall=round(count_low_ses / population, 3),
            percent_low_ses_in_sped=round(school["sped low"] / count_sped, 3),
        )
    results = {
        pr: round(count / trials * 100, 2)
        for pr, count in proportional_representation.items()
    }
    if print_results:
        print(
            f"The probability of having exact proportional representation in {trials:,} trials is: {results.get('exact', 0.0)}%"
        )
        print(
            f"The probability of being within 2% in {trials:,} trials is: {results.get('within range', 0.0)}%"
        )
    return results


if __name__ == "__main__":
    seed(1)
    trials = 10000
    results = run_trials(trials, 600, 0.166, 0.35)

整个重构后的代码可以在Github repo中找到。

级别提升

在重构后的代码中,仍有一些东西困扰着我,即_create_trial_school() 。接下来的部分是为了说明将特定行为抽象为函数的效用。我现在可以把我所有的精神注册送68集中在_create_trial_school() ,而不用担心代码的其他部分。下面是这个函数的主体,以唤起你的记忆:

    ...
    school = Counter()
    for s in range(population):
        student = (
            choices(
                population=["sped ", "gen_ed "],
                weights=[prob_sped, 1 - prob_sped],
            )[0]
            + choices(
                population=["low", "high"],
                weights=[prob_low_ses, 1 - prob_low_ses],
            )[0]
        )
        school.update([student])
    return school

我不喜欢choices() ,它返回一个列表。在上述重构的形式中,我不得不使用索引检索标签,然后将它们串联起来。如果我只需为每个标签调用一次适当的概率的选择,那就容易多了。在做了更多的调查之后(实际上阅读了友好的手册),我看到choices() 实际上有一个k 参数,并返回 "一个k大小的从人口中选择的元素的列表,并进行替换"。这意味着我可以一下子就给整个学生群体贴上标签。但如何计算正确的概率呢?我不再有一个编码问题;我有一个数学问题。

我想了又想。我写了一些代码,结果是200%的低社会经济地位的学生在SPED(我真的不想谈这个问题)。我给一个同事发电子邮件,询问独立事件的概率。他非常有礼貌地告诉我,我的假设是完全错误的。然后我勉为其难地做了我也要求我的学生做的事。画一幅画。

好吧,我知道整个学生群体是整体(100%)。我的问题是二维的。第一个维度包含被贴上SPED(7/20)和普通教育(13/20)的概率。第二个维度包含被贴上低社会经济地位(1/6)和高社会经济地位(5/6)的概率。这就导致了图片的出现:

probability rectangle

这就把原来的面积分成了几个象限,每个象限的维度是我要找的四个标签。计算每个象限的面积就可以得出概率:

Oh my god

这真是太简单了。我清楚地知道该怎么做。再次重构_create_trial_school() ,得到了:

def _create_trial_school(
    population: int, prob_sped: float, prob_low_ses: float
) -> Counter:
    ...
    prob_gen_ed = 1 - prob_sped
    prob_high_ses = 1 - prob_low_ses
    labels = ("sped low", "sped high", "gen_ed low", "gen_ed high")
    probabilities = (
        round(prob_sped * prob_low_ses, 3),
        round(prob_sped * prob_high_ses, 3),
        round(prob_gen_ed * prob_low_ses, 3),
        round(prob_gen_ed * prob_high_ses, 3),
    )
    return Counter(choices(population=labels, weights=probabilities, k=population))

我决定明确地标明赞美的概率,这样当我以后创建probability 元组时,就很容易快速读出发生了什么。我还把标签做成了一个元组,而不是把它们串联起来。这样我就可以直接用choices() ,作为参数返回模拟学生群体的列表,直接返回计数器。改进后的模拟结果也可以在repo中找到。

比较性能

我做了所有这些重构,目的是使代码更容易阅读、理解和维护。性能是一个事后的想法。但现在我很好奇。所有这些改变对性能有什么影响吗?我决定对每个版本运行的时间进行计时,然后比较结果。

我回到了google,从Real Python中找到了一个定时器装饰器,并进行了修改以适应我的需要。为了方便比较每个版本运行的时间,我需要把我的原始代码移到一个函数中。我把它复制并粘贴到自己的模块中(original_func.py)。我没有改变任何会影响原始代码性能的东西。但我确实运行了black,并给了这个函数与重构后的代码相同的参数。这样我就可以用同样的参数运行所有三个版本。这使我能够从每个模块(original_func.pyrefactored.pyimproved.py)中导入run_trials函数:

from original_func import run_trials as original_run_trials
from refactored import run_trials as refactored_run_trials
from improved import run_trials as improved_run_trials

接下来,我创建了comparison_runner() ,以运行每个函数,同时跟踪所经过的时间并返回结果:

def comparison_runner(funcs: dict, trials: int, trial_params: dict) -> dict[float]:
    """Run the three functions with the same number of trials and stats.

    :funcs: dict Keys are a printable name, values are the function itself.
    :trials: int The number of trials to run.
    :trial_params: dict Contains school population and probabilities of labels.
    """

    seed(1)
    comparison_results = {"Trials": trials}
    for i, (func_name, func) in enumerate(funcs.items()):
        func = timer(func)  # Since functions are imported, I can't use the @timer syntactic sugar  
        elapsed_time = func(trials, **trial_params)
        comparison_results[func_name] = elapsed_time
    return comparison_results

我以字典的形式传入这些函数,因为这些函数在其原始模块中都被称为run_trials() 。当我试图打印这些名称时,我不知道哪个是哪个。字典给了我一种方法来标记每个函数。试验需要独立于其他参数传递,所以我可以运行不同数量的试验。而且我终于能够利用**解包操作符(像个坏蛋)。comparison_runner() ,返回一个包含试验次数、函数名称和耗时的字典。

我还想显示比较的结果。我可能对丰富的东西有点着迷,这似乎是创建一个表格的最佳机会:

def display_table(comparison_results: list[dict]) -> None:
    """Display table that compares function performance across a range of trials.

    :comparison_results: list A list of results dictionaries from the comparison_runner.
    :returns: None
    """

    console = Console()
    table = Table(title="Comparison Results (in seconds)")
    for column in comparison_results[0].keys():
        table.add_column(column, justify="center")

    for results in comparison_results:
        refactored_ratio = round(results["Original"] / results["Refactored"], 1)
        improved_ratio = round(results["Original"] / results["Improved"], 1)
        last_trial_width = len(str(trial_sets[-1]))
        longest_time_width = len(str(comparison_results[-1]["Original"]))
        table.add_row(
            f"{results['Trials']:{last_trial_width}d}",
            f"{results['Original']:0{longest_time_width}.4f}",
            f"{results['Refactored']:0{longest_time_width}.4f} [green]({refactored_ratio}x)[/green]",
            f"{results['Improved']:0{longest_time_width}.4f} [green]({improved_ratio}x)[/green]",
        )
    console.print(table)

这就产生了下面的表格:

comparison results table

这有多华丽?提高可读性的目标导致了性能的提高。通过。A.地段。这是因为我是按照Python中提供的工具来使用的。有一些人,比我更有经验,他们为优化这些工具的目标付出了数小时的时间。我应该通过正确使用这些工具来尊重他们的工作。如果你对整个比较脚本感兴趣,它也在 repo 中。

这个故事的寓意

我希望这个故事能激励你去写代码,不断学习新的东西,然后应用这些知识,同时牢记以下两个公理:

  • 不完美的代码存在 > 完美的代码不存在
  • 将新的知识应用于旧的代码 > 等到你知道所有的东西

如果是在2020年,我不可能写出重构后的代码。我不知道计数器,不知道将行为抽象为函数,不知道什么是文档字符串,甚至不知道F字符串是一种东西。我不知道我不知道什么。我写了一些面条状的代码,然后把它扔到墙上,看看有什么可以粘住。

今天也没有什么不同。我仍然在尝试一些经常不成功的事情。事实上,我第一次尝试重构我与你分享的代码时就陷入了死胡同。我花了几个小时的时间,却发现我解决了一个错误的问题。今天和我刚开始的时候最大的区别可以归结为两点:知道如何获得帮助和在鞍的时间。

我找到了像PyBites这样的社区,在那里我可以在遇到困难时提出问题。我已经接触到了更多的词汇,所以我可以向社区和谷歌提出更好的问题。最重要的是,我写了很多代码,并考虑到了刻意练习的想法。套用伟大的Excel On Fire的说法,为了获得好成绩,你只需要看很多东西,总的来说是很长一段时间。在写这篇文章的时候,我已经在PyBites平台上解决了214个咬合。我已经为我的学生创建了课程。我写过解决我所面临的问题的工具。我并不是说这都是好的代码,但这是这个过程的一部分。如果你的代码看起来像我的原始代码,而且你也犯过类似的错误,那也没关系。你从哪里开始并不重要。明天要比今天做得更好。