PyCon-2020-会议笔记-全-

PyCon 2020 会议笔记(全)

002:编程基础入门 🐍

在本节课中,我们将要学习编程的基础概念,特别是如何通过Python语言与数据进行交互。我们将从最核心的“数据”概念开始,逐步了解编程的基本结构,并最终理解如何通过代码来操控信息。

概述

编程的核心是处理数据。数据可以是数字、文字、图像或任何能被计算机理解的信息。Python是一种强大且易学的编程语言,非常适合初学者用来探索数据世界。本节课程将引导你理解数据在编程中的角色,并掌握用Python表示和操作数据的基本方法。


数据:一切的基础 💾

数据是信息的载体。在编程中,我们首先需要理解如何表示数据。

上一节我们介绍了课程的整体目标,本节中我们来看看最基础的元素——数据。

数据的类型

数据有不同的类型,每种类型在计算机中都有特定的表示方式和操作规则。以下是几种基本的数据类型:

  • 整数:没有小数部分的数字,例如 1, -5, 100
  • 浮点数:包含小数部分的数字,例如 3.14, 2.0, -0.5
  • 字符串:由字符组成的文本,在Python中用单引号或双引号包围,例如 ‘你好’, “Python”
  • 布尔值:表示真或假,只有两个值:TrueFalse

在Python中,你可以直接为变量赋值来存储数据:

# 整数
age = 25
# 浮点数
price = 19.99
# 字符串
name = "查尔拉"
# 布尔值
is_learning = True

变量:数据的容器 📦

变量就像是一个贴有标签的盒子,用于存储数据。你可以通过变量名来访问或修改盒子里的数据。

上一节我们了解了数据的几种形式,本节中我们来看看如何用变量来存放它们。

变量的命名与使用

给变量起名需要遵循一些简单的规则:

  • 名称只能包含字母、数字和下划线。
  • 名称不能以数字开头。
  • 名称是区分大小写的(myVarmyvar 是两个不同的变量)。

以下是使用变量的基本操作:

# 将数据存入变量
message = “欢迎学习Python!”
# 打印变量中的数据
print(message)
# 修改变量中的数据
message = “数据很有趣!”
print(message)

基本操作:与数据交互 ⚙️

有了数据和变量,我们就可以对它们进行各种操作,比如数学计算、文本连接和逻辑判断。

上一节我们学会了如何用变量保存数据,本节中我们来看看如何操作这些数据。

常见的操作

以下是几种对数据的基本操作:

  • 算术运算:对数字进行加、减、乘、除等。
    a = 10 + 5  # 加法,结果是15
    b = 10 * 2  # 乘法,结果是20
    
  • 字符串连接:将多个字符串组合在一起。
    greeting = “你好,” + “世界!”  # 结果是“你好,世界!”
    
  • 比较运算:比较两个值,结果是布尔值(TrueFalse)。
    result = 10 > 5  # 判断10是否大于5,结果是True
    

控制流程:让程序做决定 🧭

程序并非总是直线执行。我们可以通过条件判断,让程序根据不同的情况选择执行不同的代码块。

上一节我们学习了如何操作数据,本节中我们引入“控制流程”的概念,让程序变得更智能。

条件判断:if语句

if语句是控制程序流程的基础工具。它的基本结构如下:

if 条件:
    # 如果条件为True,执行这里的代码
    执行操作
else:
    # 如果条件为False,执行这里的代码
    执行其他操作

例如,我们可以根据分数判断成绩等级:

score = 85
if score >= 90:
    print(“优秀”)
elif score >= 60:
    print(“及格”)
else:
    print(“不及格”)

总结

本节课中我们一起学习了编程的四个核心基础概念:

  1. 数据:信息的多种表示形式,如整数、字符串。
  2. 变量:用于存储和引用数据的命名容器。
  3. 基本操作:对数据进行计算、连接和比较的方法。
  4. 控制流程:使用 if 语句让程序根据条件做出不同反应。

理解这些概念是学习任何编程语言的第一步。它们就像积木,组合起来就能构建出功能丰富的程序。在接下来的学习中,你将运用这些基础去处理更复杂的数据和逻辑。

003:Asyncio集成实践 🚀

在本节课中,我们将学习如何在实际项目中集成和使用Python的asyncio库。我们将探讨异步编程的核心概念,并通过具体的场景理解如何避免常见问题,特别是与服务器通信和资源管理相关的挑战。


概述

asyncio是Python用于编写并发代码的库,使用async/await语法。它特别适合处理I/O密集型任务,如网络请求。然而,在集成到复杂系统(如微服务架构)时,需要特别注意资源管理和错误处理,否则可能导致性能问题或安全漏洞。

上一节我们介绍了asyncio的基础,本节中我们来看看如何将其安全、有效地集成到实际服务中。


核心挑战:资源与通信管理

在微服务或服务器环境中集成异步代码时,主要挑战来自于通信环境资源安全性。由于多个异步任务可能同时访问共享资源(如网络连接、内存),不当管理会导致数据竞争、内存泄漏或服务不可用。

以下是一个简单的异步服务器示例,展示了如何启动一个基础服务:

import asyncio

async def handle_client(reader, writer):
    data = await reader.read(100)
    message = data.decode()
    addr = writer.get_extra_info('peername')
    print(f"Received {message} from {addr}")
    writer.close()

async def main():
    server = await asyncio.start_server(handle_client, '127.0.0.1', 8888)
    async with server:
        await server.serve_forever()

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/a5bd61a95ab6e1bc2d339ba61d46b7fb_6.png)

asyncio.run(main())

关键实践原则

在集成asyncio时,必须遵循一些核心原则以确保系统的稳定和安全。以下是需要重点关注的事项列表:

  • 明确资源生命周期:每一个由服务器创建的资源(如连接、任务、客户端会话)都必须有明确的创建和销毁点。避免任务无限期挂起或资源未被释放。
  • 隔离任务上下文:确保异步任务之间不共享可变状态,或通过锁(asyncio.Lock)等机制安全地共享。这可以防止数据竞争。
  • 优雅处理取消:使用asyncio.CancelledError来妥善处理任务取消,确保取消时能清理占用的资源。
  • 限制并发度:使用信号量(asyncio.Semaphore)或池来限制同时运行的并发任务数量,防止系统过载。

避免常见陷阱

集成过程中,许多问题源于对异步操作生命周期的误解。我们必须确保代码的每一处都考虑到并发环境下的行为。

具体来说,在编写异步函数时,必须反复检查关键区域:

  • 我必须确保这不是一个可能引发竞态条件的地方。
  • 我必须确保这是一个可以安全执行I/O操作的地方。
  • 我必须确保在异常发生时,资源能被正确释放。
  • 我必须确保任务的取消信号能被正确传播和处理。
  • 我必须确保不会在未等待的情况下创建大量任务,导致内存耗尽。

这个检查清单需要应用于每一个异步交互点,从数据库连接到外部API调用。


总结

本节课中我们一起学习了asyncio集成到实际项目中的关键要点。我们了解到,成功集成异步编程不仅需要理解async/await语法,更需要严格管理任务生命周期共享资源,并时刻警惕在并发环境下可能出现的陷阱。通过遵循明确的实践原则和细致的代码审查,可以构建出既高效又可靠的异步应用。

记住,在异步世界里,“我必须确保这不是一个可以使用的地方” 这种严谨的思维模式,是保障代码质量与系统稳定性的基石。

004:调试实战与代码模拟 🐛

在本节课中,我们将学习Python调试中的高级技巧,特别是如何通过模拟和修补(Patching)来处理复杂的依赖关系和测试场景。我们将深入探讨如何理解代码执行流程,以及如何利用工具来隔离和解决问题。


概述

调试不仅仅是修复错误,更是理解代码行为、数据流和状态变化的过程。本节课程将从一个具体的调试故事出发,讲解如何分析问题、模拟外部依赖,并确保测试的独立性与准确性。


理解问题与依赖关系

上一节我们介绍了调试的基本概念,本节中我们来看看如何分析一个具体问题。首先,需要识别出问题的核心依赖。例如,用户可能拥有多个设备,这意味着代码可能需要以多种方式处理输入或状态。

关键的第一步是定位关键令牌(Token)或标识符所在的位置。例如,aicator的令牌可能存储在特定字段中。同时,外部服务(如Gocca)的网络可用性会影响数据的读取。如果Gocca的网络在过去未被正确读取,那么其数据可能存储在之前的流(Stream)中。

核心概念:依赖状态可能被缓存或存储在之前的执行上下文中。

# 示例:检查令牌和网络状态
token = get_field_token('aicator')
gocca_network_available = check_network('Gocca')
previous_stream_data = load_from_previous_stream('Gocca')

引入修补程序(Patching)

当外部依赖(如网络、数据库)不稳定或难以在测试中复现时,修补程序(Patching)是一种有效的模拟技术。它允许我们替换掉原有的函数或对象,从而控制其返回值和副作用。

以下是使用修补程序的基本步骤:

  1. 选择目标:确定需要模拟的函数或对象。
  2. 创建模拟响应:定义模拟对象应返回的数据或行为。
  3. 应用补丁:在测试执行期间,用模拟对象替换真实对象。
  4. 验证与断言:确保代码在模拟环境下按预期运行。

核心公式测试输出 = 函数(模拟输入)

通过修补,我们可以确保即使Gocca网络不可用,测试也能获得一个可控的、隐含的Gocca网络数据副本,从而专注于测试用户输入的处理逻辑。


处理测试数据与结构

在编写测试时,数据的结构和一致性至关重要。如果第二个测试是第一个测试的副本,那么必须确保输入是正确的,并且每个测试应该独立运行,一次只调用一次目标函数。

一个常见问题是测试间的数据污染。例如,第二个测试可能意外地返回了第一个测试的数据。为了避免这种情况,需要确保每个测试用例都有独立的上下文,例如,使用不同的用户名和城市数据进行比对。

核心概念:测试隔离。每个测试应设置自己的数据,并在完成后进行清理。


代码分析与函数模拟

现在,让我们深入代码层面。假设我们有一个函数调用链:主函数调用一个辅助函数。辅助函数返回一个字符串。我们的测试需要验证这个字符串是否符合预期。

测试档案(Test Suite)通常分为两类:Test A(设置类)和Test B(执行类)。Test A负责在运行前设置环境,例如创建目标对象(self.target)的副本。Test B则执行实际的测试逻辑。

代码示例

class TestMyFunction:
    def setup_method(self):
        # Test A: 设置阶段
        self.target = create_target_copy()  # 创建目标副本
        self.original_function = self.target.my_function  # 备份原始函数

    def test_my_function(self):
        # Test B: 执行阶段
        # 使用模拟器替换 my_function
        with patch('module.target.my_function', return_value='模拟字符串'):
            result = self.target.main_function()
            assert result == '期望的字符串'

在这种结构中,魔法模拟器(Mock)被用于替换第二个函数,使得第一个函数的执行结果可以被转移到Test B中进行验证。关键在于,我们需要在Test A中创建对原始函数的引用或备份,以便在测试后能恢复状态。


备份与状态恢复

在模拟过程中,直接修改self.target可能带来副作用。更安全的做法是创建一个指向原函数的备份引用。

我们可以这样做:

  1. 在设置阶段(setup),备份需要模拟的函数:backup = self.target.the_function
  2. 在测试执行时,应用模拟器。
  3. 在测试拆卸阶段(teardown),用备份恢复原函数:self.target.the_function = backup

这样能确保每个测试都从一个干净、一致的状态开始,避免测试间的相互影响。这个教训是:总是为可能被修改的依赖创建备份

核心概念函数备份 = 引用(原函数),用于状态恢复。


总结与反馈

本节课中我们一起学习了Python高级调试中的关键技巧。我们从理解复杂的依赖关系开始,引入了修补程序(Patching)作为模拟外部依赖的强大工具。我们探讨了如何构建隔离的测试,分析了测试类的结构(设置与执行),并强调了备份原始状态的重要性,以确保测试的独立性与可重复性。

调试的思维模式——识别模式、控制变量、模拟场景——是应对职业生涯中各种挑战的宝贵技能。技术社区在不断变化,持续学习和分享是进步的动力。

如果你有任何疑问或想分享自己的调试故事,欢迎通过评论进行交流。感谢你的学习。


005:如果没有文档记录,您的项目

概述 📖

在本节课中,我们将学习项目文档的重要性以及如何有效地编写和维护文档。一个没有文档的项目就像一本没有目录和索引的百科全书,难以被他人理解和维护。我们将探讨文档缺失的后果、优秀文档的核心要素以及实用的编写策略。


文档缺失的后果 🚨

上一节我们概述了课程目标,本节中我们来看看如果项目缺乏文档,会带来哪些具体问题。

没有文档记录的项目会面临一系列挑战。新成员加入团队时需要花费大量时间理解代码和业务逻辑。项目交接变得异常困难,知识仅存在于少数核心成员的头脑中。当出现问题时,排查和修复的效率会大大降低,因为缺乏对系统设计和历史决策的参考。

以下是文档缺失可能导致的主要问题:

  • 知识孤岛:关键信息仅由个别开发者掌握,形成单点故障风险。
  • 上手成本高:新成员需要逆向工程代码来理解项目,延长了产出时间。
  • 维护困难:随着时间推移,即使是原作者也可能忘记某些复杂逻辑的设计初衷。
  • 协作低效:团队成员对系统理解不一致,容易产生沟通误解和重复工作。
  • 项目风险:核心成员离职可能导致项目陷入停滞或需要推倒重来。


优秀文档的核心要素 ✨

了解了缺乏文档的危害后,我们来看看一份优秀的项目文档应该包含哪些核心内容。

优秀的文档不仅仅是代码注释的堆砌,它是一个多层次、面向不同读者的知识体系。它应该解释“为什么”要这样设计,而不仅仅是“是什么”和“怎么做”。

一份完整的项目文档通常包含以下几个层次:

  • 项目概述:用一两句话说明项目是做什么的,解决什么问题。
    • 公式项目价值 = 解决的问题 - 使用成本
  • 快速开始指南:让用户或新开发者能在几分钟内搭建环境并运行一个最简单的例子。
    • 代码示例
      git clone <项目地址>
      cd <项目目录>
      npm install
      npm start
      
  • 架构设计:描述系统的主要组件、模块划分以及它们之间的交互关系。
  • API 文档:清晰说明每个接口的用途、输入、输出和错误码。
  • 部署与运维手册:包含环境配置、构建步骤、发布流程和监控指标。
  • 常见问题与解决方案:记录在开发和使用过程中遇到的实际问题及其解决方法。


如何开始编写与维护文档 📝

上一节我们列出了优秀文档的要素,本节中我们来看看如何具体着手编写并长期维护这些文档。

开始编写文档的关键是克服“从零开始”的恐惧,并建立可持续的习惯。不要追求一次性写出完美的文档,而应采用迭代的方式。

以下是开始和维持文档工作的实用步骤:

  1. 从“README”文件开始:这是项目的门面,至少应包含项目概述和快速开始指南。
  2. 采用“文档即代码”的理念:将文档和代码放在同一个仓库管理,便于同步更新和版本控制。
  3. 在代码审查中包含文档:将相关文档的更新作为合并代码的前提条件。
  4. 设立文档“守护者”:可以轮流指定团队成员负责定期检查并更新文档。
  5. 使用合适的工具:根据项目类型,选择 Markdown、Swagger、Sphinx 或 MkDocs 等工具来降低编写成本。
  6. 保持简洁与准确:文档应直达要点,并确保与代码实际行为保持一致。过时文档比没有文档更具误导性。


总结 🎯

本节课中,我们一起学习了项目文档的关键作用。我们认识到,没有文档的项目会阻碍团队协作、增加维护成本并带来业务风险。我们探讨了优秀文档应具备的核心要素,包括从概述到运维的全方位内容。最后,我们学习了如何通过从小处着手、结合开发流程以及利用合适工具来启动并持续维护文档工作。

请记住,编写文档不仅是为了他人,也是为了未来的自己。它是项目长期健康发展的基石。开始为您负责的项目添加上第一份文档吧。

006:从零构建数据管道 📊

在本教程中,我们将学习如何使用 Microsoft Azure Functions 这一无服务器计算平台,构建一个自动化的数据管道。我们将创建一个定时触发的函数,从 Stack Exchange API 收集数据,处理后存储到云存储,并最终通过电子邮件发送每日数据摘要。

概述:什么是无服务器计算? ☁️

首先,我们来理解核心概念。无服务器计算并不意味着没有服务器,而是指开发者无需管理底层服务器、硬件或软件。云提供商(如 Microsoft Azure)负责所有基础设施的维护、配置和扩展。

主要优势

  • 托管服务:开发者专注于编写业务逻辑代码。
  • 按需付费:只为函数执行时消耗的资源付费,而非 24/7 运行的服务器。
  • 自动扩展:可根据负载自动横向扩展或收缩。

在本教程中,我们将使用的 Azure Functions 正是微软提供的无服务器平台。

Azure Functions 核心概念 ⚙️

上一节我们介绍了无服务器计算的优势,本节中我们来看看 Azure Functions 的具体特性。

Azure Functions 的核心是 函数,即一小段为特定任务编写的自包含代码。它也被称为 函数即服务

以下是函数的重要特性:

  • 事件触发:函数在特定事件发生时执行,例如 HTTP 请求、文件上传或定时器触发。
  • 服务集成:函数可以轻松与数据库、存储、机器学习服务等其他 Azure 服务集成。
  • 无状态与短生命周期:函数执行通常是短暂且无状态的。如需持久化输出,必须连接到外部存储。
  • 异步执行:函数被触发后即执行,通常不等待外部响应。

鉴于这些特点,函数非常适合处理 图像/视频处理物联网数据流数据管道 等场景。接下来,我们将构建一个数据管道。

准备工作 🛠️

在开始编码之前,我们需要准备好开发环境。以下是所需的工具和资源:

  • Python 3.6+:确保安装 Python 3.6、3.7 或 3.8 版本。
  • Visual Studio Code:一个开源的集成开发环境。
  • VS Code 扩展:安装 Python 扩展和 Azure Functions 扩展以优化工作流程。
  • Stack Exchange API 密钥:用于从 Stack Exchange 网站获取数据。
  • Azure 账户:可以注册获得免费额度和信用。

所有详细的安装步骤和资源链接都可以在配套教程网站找到。

第一步:创建定时触发函数 ⏰

我们将从创建一个由定时器触发的 Azure Function 开始。这个函数将作为我们数据管道的起点,定期从 API 拉取数据。

首先,在 VS Code 中打开 Azure Functions 扩展,点击“创建新项目”。选择项目目录、Python 语言和解释器版本(例如 3.7)。选择“Timer trigger”作为触发器类型,并为函数命名。

创建时,需要提供一个 CRON 表达式 来定义执行计划。例如,表达式 0 0 9 * * * 表示每天上午 9 点运行。如果不熟悉 CRON 表达式,可以使用在线工具生成和解释。

项目创建后,你会看到一些核心文件:

  • function.json:定义了函数的触发器和绑定配置。
  • __init__.py:包含函数的主逻辑代码。
  • local.settings.json:用于本地调试的环境变量(切勿提交到版本控制)。

现在,你可以按 F5 键在本地运行和调试这个基础函数。首次运行可能需要创建一个本地存储账户来模拟 Azure 环境。

第二步:部署你的第一个函数 🚀

上一节我们在本地创建并测试了函数,本节中我们将其部署到 Azure 云平台。

在 VS Code 的 Azure Functions 扩展中,点击“部署到函数应用”。你可以选择创建一个新的函数应用,并为其命名、选择 Python 版本和区域。

部署过程将在状态栏显示进度。完成后,你可以访问 Azure 门户,在“函数应用”下找到你刚部署的应用。在这里,你可以监控函数的执行、查看日志、管理配置(如环境变量)以及手动触发函数。

首次调用已部署的函数时,可能会遇到“冷启动”延迟,这是初始化新实例的正常现象。

第三步:连接 API 并处理数据 🔌

目前我们的函数只是记录日志。现在,我们将修改代码,使其能够从 Stack Exchange API 获取数据。

我们需要:

  1. 在函数目录下创建一个 utils 文件夹,用于存放辅助脚本。
  2. utils 中创建 stack.py,编写从 API 获取问题数据的方法。
  3. 修改主函数文件 __init__.py,调用 utils 中的方法,并传入搜索词(例如 “python azure functions”)来收集相关问题。
  4. 在项目根目录创建 .env 文件,安全地存储你的 API 密钥等机密信息,并在代码中通过 os.environ 读取。

修改完成后,再次在本地按 F5 运行并调试。通过 VS Code 扩展手动执行函数,你应该能在输出中看到收集到的新问题数量。

代码测试无误后,使用扩展的“部署”功能将更新后的函数重新部署到 Azure。别忘了在 Azure 门户中,为你的函数应用添加在 .env 文件中定义的环境变量。

第四步:添加输出绑定以存储数据 💾

我们已经能收集数据,但需要将其保存下来。这里我们将引入 绑定 的概念。绑定是一种声明式连接,使函数能够轻松地与输入/输出数据交互。

我们将添加一个 输出绑定,将数据保存到 Azure Blob Storage(一种云对象存储)。

在 VS Code 的 Azure Functions 扩展中,右键点击你的函数,选择“添加绑定”。选择“Azure Blob Storage”作为绑定类型,方向为“out”。你需要指定 Blob 的路径,例如 function-blob/{DateTime}.csv,这会将 CSV 文件以时间戳命名并存入 function-blob 容器。

添加绑定后,function.json 文件会自动更新。接着,修改 __init__.py 中的主函数,将收集到的数据转换为 CSV 格式,并通过输出绑定参数(例如 outputBlob)写入。

再次在本地运行测试。成功后,重新部署函数。现在,当你触发函数时,不仅会收集数据,还会在指定的 Azure Blob Storage 容器中生成一个 CSV 文件。你可以通过 Azure 门户查看这个存储账户和生成的文件。

第五步:构建完整管道:触发、处理与通知 📧

我们的管道已过半程。现在,我们希望每当新的 CSV 文件被添加到 Blob Storage 时,就触发另一个函数来处理它并发送摘要邮件。

我们将创建第二个函数,由 Blob 触发器 激活。

  1. 在同一个项目中,使用 Azure Functions 扩展添加新函数,选择“Azure Blob Storage trigger”。
  2. 配置它监视我们第一个函数输出 CSV 文件的路径(例如 function-blob/*.csv)。
  3. 这个新函数将读取 CSV 文件,分析数据(例如,统计最常用的标签、有答案/无答案的问题比例),并生成一个图表。

为了发送结果,我们需要为这个函数添加两个输出绑定:

  • SendGrid 绑定:用于发送包含分析摘要和图表的 HTML 电子邮件。
  • 另一个 Blob 输出绑定:用于将生成的图表图片保存到存储中。

在 Azure 门户中,你需要创建一个 SendGrid 资源来获取 API 密钥,并将其作为环境变量添加到函数应用中。

在本地更新代码、测试成功后,将整个项目重新部署。现在,你的数据管道就完整了:

  1. 定时器函数 每天运行,从 API 拉取数据并保存为 CSV。
  2. 新的 CSV 文件触发 Blob 处理函数
  3. Blob 处理函数 分析数据,保存图表,并发送摘要邮件到你的收件箱。

总结 🎉

在本教程中,我们一起学习了:

  1. 无服务器计算Azure Functions 的基本概念与优势。
  2. 如何使用 VS CodeAzure Functions 扩展 本地开发、调试和部署函数。
  3. 如何利用 定时器触发器Blob 触发器 来编排任务。
  4. 如何通过 输入/输出绑定 轻松集成 Azure Blob Storage 和 SendGrid 等服务,构建一个完整的数据处理管道。

你已成功构建了一个自动化的数据管道,它能够定期收集、处理数据并发送报告。你可以在此基础上扩展,例如连接数据库、添加更复杂的分析或集成其他服务。无服务器架构为你提供了强大的灵活性和可扩展性,同时让你能专注于代码逻辑本身。

007:教育峰会开场与演讲精选 🎓

在本节课中,我们将学习2020年Python教育峰会的开场致辞、核心演讲内容以及社区动态。我们将了解峰会的目标、演讲选择标准、如何提交有效提案,以及Python教育领域的最新发展。


欢迎致辞与会议须知

现在是上午11点。欢迎参加2020年Python教育峰会。我和委员会成员在这里,稍后会介绍他们。对于无法到场的委员会成员,请用虚拟掌声感谢他们。感谢大家的到来。这是一个困难时期,感谢您抽出时间参与。

虚拟会议并非任何人的原计划,但我们很高兴今年能以某种形式举办峰会。这对我们个人和职业来说都是一个非常重要的活动。

我们期待现在待在家中,以便明年能够再次团聚。

接下来,PSF工作人员将担任行为规范的监督员。首先,欢迎各位参加今天的Python在线教育峰会。希望大家在各自的地方都健康安全。我们要求大家遵守PSF的行为规范,以创造一个让大家感到安全的环境,便于参与和发言。

本次会议正在录制。除非您在发言,否则您的名字不会在录制中显示。

为了带宽和录制的目的,只有发言者应保持视频开启。如果您没有发言,请保持视频关闭并保持静音。请在聊天中保持对话。目前欢迎大家打个招呼并告诉我们您在哪里登录。一旦演讲者开始发言,请确保聊天内容具体而简洁。

在演讲者演讲时,请尽量避免讨论或评论。您可以在聊天中提问,我们会将其放入排队中。每个演讲结束后,我们会有大约五到十分钟的提问时间。


委员会介绍与峰会目标

自去年夏天以来,我一直参与教育峰会。我在前几年也参与了委员会的工作。我很高兴我们今年能够以在线虚拟峰会的形式进行。

我想向大家介绍一些参与峰会策划的委员会成员:

  • Minal:参与教育峰会多年,是一名教师,教授中学和高中学生编程,目前在谷歌全职工作。
  • Chalmer Lowe:教育峰会的长期创始成员之一,在Python数据科学和新闻领域工作。
  • Elizabeth:出色的主持人,过去几年一直参与教育峰会,在伊利诺伊大学任教,专注于Python。
  • Meg Ray:委员会成员,撰写了关于游戏编程的书籍,并在康奈尔科技担任驻校教师,启动了计算机科学教育项目。
  • Laura:今年新加入委员会,在外联工作中给予了很大帮助。

向这个委员会致敬。他们在过去一年里非常努力。

我们在2020年有几个主要目标:

  1. 增加参与:我们希望与教育工作者、学生和技术社区互动,了解Python在教学环境中的应用。
  2. 扩展内容形式:除了演讲,我们引入了闪电演讲(5分钟简短介绍)和小型冲刺会议(小组深入讨论)。
  3. 加强外联:我们计划了与当地学校合作的学生展示活动。

2020年,我们有104个虚拟峰会注册,收到53个提交(包括8个小型冲刺、18个闪电演讲和27个演讲)。我们鼓励大家注册并参与,共同在Python社区中发展这个渠道。


演讲日程与录制信息

虚拟峰会并非我们的原计划,特别感谢所有演讲者为此付出的努力。作为教育社区的一员,我们几乎每个人都付出了大量工作将材料转到线上。

今天早上我们有几场精彩的演讲。在欢迎环节后,我们会进行一场“元演讲”,讨论教育峰会的未来方向。接着,Zoe将介绍如何吸引数字学习者和数据科学实践。Jeffrey会谈论“食物和酒吧必须消亡”。Piper会谈论如何专门为教学构建API。最后还有几场闪电演讲。

部分演讲者无法参加现场环节,因此我们可能会提前结束。所有内容都会被录制并上传到PyCon 2020 YouTube频道。请关注Twitter和该频道以获取更新。


元演讲:教育峰会的“秘密配方” 🧪

上一节我们介绍了峰会的日程,本节中我们来看看峰会背后的组织理念和运作方式。

作为委员会成员,我们在思考如何塑造社区方向时,决定进行一次“元演讲”,谈谈峰会的“秘密配方”、如何提供帮助以及未来的成长方向。

委员会的角色与使命

委员会通常有主席和联合主席,但角色远不止于此。在实际运作中,我们承担多种职责:

  • 小型冲刺和闪电演讲组织者
  • 峰会当天的主持人和协调员
  • 公共关系和社交媒体负责人
  • 演讲提案审阅者

审阅提案可能是最耗时的任务。

教育峰会的核心使命是:让参与者带走能提升教学能力的东西,并获得能帮助学生成功应用Python解决问题的工具。这一理念驱动着委员会的所有决策,从联系演讲者到选择演讲主题。

与会者的期望与我们的交付

与会者通常期待:

  1. 高质量、可操作的演讲
  2. 良好的交流体验(尽管疫情下面临挑战)。
  3. 扩展人际网络的机会

八年来,我们尝试过多种形式:演讲、快速演讲、小型冲刺、非会议式讨论等。我们涵盖的主题广泛,从K-12到大学教育,从企业培训到数据科学、初学者内容、硬件应用、教学工具以及预算有限的教学方法。

演讲选择流程与标准

选择演讲比想象中更难。我们依据多项标准进行评估:

  1. 新颖性:主题对我们的观众来说是否新鲜?是否重复往年内容?
  2. 创新性:是否展示了创新的方法、工具或技术?
  3. 教学有效性:提案是否应用了有效的教学原则和方法?
  4. 普适性:主题是否具有广泛适用性?我们也会平衡一些小众主题,因为它们常能激发创新。
  5. 多样性与包容性:这是重中之重。我们努力确保演讲者背景和观点的多样性,并通过外联活动鼓励代表性不足的群体提交提案。我们也会审查术语和标题,确保其对所有观众友好。

我们收到的提案质量不一。高质量的提案令人兴奋,而过于简略的提案则难以让我们有信心选择。


如何提交一个成功的演讲提案 ✍️

上一节我们了解了委员会如何选择演讲,本节中我们来看看,如果您希望在未来峰会上发言,该如何提交一个有效的提案。

以下建议有助于提高您的提案被选中的几率:

  1. 创造良好的第一印象:您的提案需要让人感觉有教育意义、令人愉快且能吸引广泛听众。避免在书面描述中就显得乏味。
  2. 精心设计标题:标题是评审和与会者首先看到的。避免陈词滥调(如“为了乐趣和利益去做X”)或可能引发疏离感的政治化表述。选择欢迎、引人入胜的标题。
  3. 提供具体、现实的摘要和提纲
    • 摘要:应具体、切合实际、编辑得当。明确说明覆盖A、B、C点,而不覆盖F点。指明目标受众。
    • 提纲:分解子项目,显示您对时间安排的把握。例如:“用5分钟谈论X,再用4-5分钟覆盖Y。”
  4. 证明您的专业能力:通过清晰的提纲展示您对该主题的深入了解。提供过往演讲经验(如YouTube链接)很有帮助。如果您是首次演讲,请务必说明,我们一直在寻找新声音。
  5. 寻求他人帮助:在提交前,请朋友或同事帮忙审查提案,进行技术编辑,确保其引人注目。

核心公式:成功提案 = 吸引人的标题 + 具体现实的摘要/提纲 + 专业能力证明 + 外部审阅

页面上有两个资源链接,提供了更多关于撰写强力提案的指导。


如何参与及社区支持 🤝

在峰会结束时,我们通常会收到两类反馈:“我该如何帮助”和“我想要什么”。委员会工作量很大,我们非常欢迎新鲜血液加入。

如果您想提供帮助,以下建议能使过程更顺畅:

  • 具体说明:明确告知您想在哪方面帮助(如讲座评审、社交媒体等),并分享相关经验。
  • 耐心坚持:委员会成员都是志愿者。如果您未及时收到回复,请再次联系。

对于委员会,我们希望在接下来一年重点关注:

  1. 规范化最佳实践:记录我们的流程、角色、职责和时间承诺,便于新成员加入。
  2. 优化年度工作流程:打破PyCon之间的12个月间隔,使工作更连贯。
  3. 整理模板和指南:将沟通模板和演讲评审指南规范化,提高效率。

社区可以帮助我们:

  • 参与执行未来的峰会
  • 帮助简化流程
  • 增加思想和背景的多样性

Python教育领域近期动态与资源 🌟

上一节我们探讨了如何参与,本节中我们来看看过去一年Python教育领域的重要进展和可用资源。

Python软件基金会(PSF)教育资助

PSF去年颁发了第一轮教育资助:

  1. BeeWare项目:改善Android上的Python,对依赖移动设备的社区非常重要。
  2. Meg Ray的项目:创建“Python与教育”网站,收集教育资源(她将在闪电演讲中更新)。
  3. Friendly Traceback项目:使Python错误追踪信息更易于阅读。

鼓励大家在能力范围内考虑向PSF捐款,以支持此类项目。

Python教育工作者Slack群组

去年创建了Python教育工作者Slack群组,为K-12教育工作者提供资源。包含一般讨论、公告、招聘、资源推荐等频道。这是一个与同行保持联系的有效工具,链接将在聊天中发布。


演讲精选:Foo与Bar必须消亡——为初学者提供真实语境 🚫

现在,我们将进入一场精选演讲的核心内容,由Jeffrey主讲。他探讨了为何教学中常见的“foo”、“bar”等抽象例子对初学者有害,以及如何提供真实的语境。

问题的核心:错失语境机会

编程教学中充斥着像my_stringstrxmy_list这样的变量名。这错失了为示例添加上下文的机会。

不好的例子

my_list = [4, 5, 6]
print(my_list[0]) # 输出4,但缺乏直观意义

更好的例子

favorite_artists = ["Artist A", "Artist B", "Artist C"]
print(favorite_artists[0]) # 输出“Artist A”,语境清晰

问学生他们最喜欢的歌曲是什么,并用歌词作为字符串变量。如果字符串不用于重要事情,那又何妨?

伪语境(Pseudocontext)的陷阱

伪语境是指强加的不真实、不自然的任务或上下文。它让学生感到脱节。

  • 例子1(数学题):“在2000年1月,我儿子的年龄是我年龄的11倍加1...” 问题:谁会用这种方式思考?为什么关心?这迫使学生使用不合适的工具(方程组)解决一个无趣的任务。
  • 例子2(地理题):给出龙卷风警报区域的地图(呈平行四边形),问面积。问题:龙卷风警报是按平行四边形发布的吗?我为什么要计算这个面积?

面向对象编程中的语境缺失

考虑一个Dog类,属性是attribute1attribute2,有一个fun方法。问题:

  1. 狗在数字世界中不存在。
  2. 属性名无意义。
  3. 方法名fun不清晰。
  4. 缺失核心:为什么我要用计算机程序创建一只狗?如果是为了“狗狗交友网站”,那么Dog对象拥有profile_pictureadd_friend()方法就更真实。

给教育者的建议

  1. 赋予真实的任务:例如,User类比单个变量更具体,扩展性更好,且自带探索性。
  2. 提供真实的工具:在学生需要解决复杂问题、单个变量不堪重负时,再引入面向对象等更强大的工具。不要在只需求简单方案时强行介绍复杂工具。
  3. 提出真实的问题:通过预测和推理,向学生展示新工具如何更优雅地解决实际问题。

核心结论

  • 学生不需要简单的例子,他们需要清晰的例子
  • 先让学生理解事物的实用性,再赋予学术词汇。
  • 每一个变量名、每一个字符串都是添加上下文的机会。
  • 并非所有上下文都是真实上下文,需注意其包容性。

最后,编码是情感过山车。当学生在网上看到foobar却不理解时,那是作者的错,不是他们的错。感谢所有教育者,让初学者知道他们在这里有归属。


演讲精选:原则驱动的开发——以教育为中心的Python游戏引擎 🎮

接下来,我们进入Piper的演讲,了解她如何以明确的原则指导一个教育类游戏引擎PursuedPyBear的开发。

项目起源与原则的演变

PursuedPyBear(PPB)始于2016年,旨在减少Python游戏开发(如使用Pygame)中的模板代码。后来,在教育工作者的影响下,它转向教育焦点。

最初原则(混合了技术、文化理想):

  • 教育友好(后改为教育聚焦)
  • Pythonic(符合Python习惯)
  • 面向对象与事件驱动
  • 硬件库无关
  • 充满乐趣

这些原则在个人项目中可行,但随着团队壮大,出现了冲突:工程师想要酷炫功能,教育者需要易用性,缺乏成文原则导致决策困难。

确立核心原则

为解决问题,团队为PPB 0.8版本制定了成文的核心原则文档:

  1. 学生与学习者优先
    • 设计API时优先考虑学习者。
    • 渐进式揭示复杂性:让学习曲线平缓,从“Hello World”到第一个自制游戏。
    • 承认学习者不全是学生,也包括自学者。
  2. 无歉意:不为API设计道歉,如果必须道歉就重新设计。
  3. 无限创造力:唯一限制是Python本身和用户技能。
  4. 代码优先:PPB首先是学习Python的途径,反之亦然。
  5. 充满乐趣:鼓励轻松实验,成为尝试新事物的试验场。
  6. 社区驱动与激进接纳
    • 拥抱变化,愿意重构。
    • 寻求多样化的输入(贡献者多数为女性)。
    • 包容所有使用者。

原则在实践中的体现

  • 简化入门:将“Hello World”从多行代码简化为一个函数调用。
  • 最小可行游戏:一个物体追逐鼠标的游戏仅需12行代码。未来目标是提供默认精灵,进一步简化。
  • 项目治理:设立“监督”团队,决策时混合教育者、学生、核心开发者和业余爱好者的意见,确保方向平衡。

结论:PPB从一个个人项目发展为社区驱动项目。成文的原则帮助团队保持一致,做出艰难决定,并指导API设计朝着对学习者友好、有趣的方向发展。


闪电演讲:图书馆环境中的Python教学挑战与策略 📚

现在,我们进入闪电演讲环节。首先,由Matthew分享他在学术图书馆环境中进行Python教学的经验。

背景与挑战

Matthew是UNC教堂山大学的数据分析图书馆员,团队支持全校的数据科学需求(Python、GIS、Tableau)。受众极其广泛(教职员工、学生、公众),背景多样(从仅会用Excel到统计/计算机科学背景)。

目标:并非培养专家,而是让参与者能够开始自己的项目,并可通过一对一咨询获得后续支持。

挑战

  1. 参与者背景多样:难以因材施教。
  2. 维持动力:工作坊非学分课程,参与者容易中途退出。
  3. 支持能力有限:需要更多人手提供一对一帮助。

应对策略

  1. 多周工作坊与开放实验室模式
    • 前半部分正式教学,后半部分开放式练习,参与者可处理自己的项目。
    • 将基础内容剥离出来,开设更传统的“Python从零开始”工作坊。
  2. 建立“成长文化”与团队教学
    • 利用机构内的“实践社区”和研发时间,鼓励同事学习Python并参与教学。
    • 从三年前的独自教学,发展到目前有七人参与教学团队,引入了不同学科背景的视角。
  3. 未来方向:考虑流媒体直播工作坊,支持远程参与,并继续应对安装帮助、社区建设等持久挑战。

闪电演讲:Python教育中心——一个社区资源枢纽 🏗️

最后,由Meg Ray带来关于新建“Python教育中心”网站的闪电演讲。

项目概述

Meg获得了PSF的资助,用于创建一个Python教育资源中心,旨在整合资源、维持社区联系。

时间表

  • 2020年10月中旬:预告网站上线(迁移到Python 3 + Flask)。
  • 2021年PyCon:完整网站发布。

网站内容

  • 聚合边缘的Python教育资源。
  • 提供创建开放教育资源的模板。
  • 为不同教育者(K-12、高等教育、企业培训)提供入门指南。
  • 提供促进教育公平的工具包。
  • 作为全年活跃的社区建设空间。

目标受众

包括所有对Python教育感兴趣的人:全球社区、正式/非正式教育者、培训师、产品开发者、作者以及所有学习者。

当前如何帮助促进Python教育可及性

结合当前疫情,Meg提出几点建议:

  1. 提供免费资源:如果您有课程或产品,考虑提供免费版本。
  2. 关注移动端:开发支持在浏览器、平板电脑和手机上编码的工具。
  3. 增强项目教育支架:为开源项目添加指导说明,使其更易于教学。
  4. 解决实际问题:帮助教师用技术解决他们面临的具体问题。
  5. 捐赠技术:向学校或社区组织捐赠笔记本电脑或Chromebook。
  6. 共同反思:将此作为契机,思考如何系统性地改进教育。

呼吁参与

项目需要贡献者,尤其是在:

  1. 技术方面:从Python 2到3的迁移、数据库经验。
  2. 内容方面:帮助构建代表全球Python教育社区的内容。

参与方式:关注邮件列表(education@python.org)、Twitter标签#PythonEDU,或直接联系Meg。


总结与展望

本节课中,我们一起学习了2020年Python教育峰会的核心内容:

  1. 峰会的组织与使命:旨在连接社区、分享最佳实践、提升Python教学质量,并特别注重多样性与包容性。
  2. 演讲的艺术:了解了委员会如何选择演讲,以及如何通过清晰的标题、具体的提纲和专业的表现来提交成功的提案。
  3. 教学的核心:认识到为初学者提供真实、清晰的语境至关重要,应避免“foo/bar”式抽象和“伪语境”。
  4. 项目与工具:看到了像PursuedPyBear这样的项目如何通过明确的原则驱动,打造以教育为中心的工具。
  5. 社区与资源:了解了图书馆等机构的教学策略,并看到了新的Python教育中心如何致力于整合资源、支持全球教育者。
  6. 持续参与:我们都被鼓励以各种方式参与进来,无论是提交提案、加入委员会、贡献代码,还是简单地分享知识和资源。

教育峰会是一个充满活力、由社区驱动的活动。它成功的关键在于我们每个人的参与、分享和互助。让我们继续共同努力,让Python教育更加普及、有效和包容。

感谢您的参与!

008:培训师孵化器2020

概述

在本教程中,我们将学习如何成为一名成功的Python培训师。内容涵盖培训业务的建立、有效的现场编码教学技巧、应对会议教程的挑战,以及将面对面研讨会成功迁移到在线环境的策略。我们将通过具体的公式、代码示例和最佳实践,帮助初学者理解并应用这些概念。


培训业务:建立与运营

上一节我们概述了课程内容,本节中我们来看看如何将Python培训作为一项业务来建立和运营。

培训应被视为一种产品,而非单纯的服务。公司将其视为一项投资,旨在帮助员工提升技能,从而提高效率、减少成本并增强员工留任率。

核心公式:培训的价值

公司评估培训价值的一个简单方式是计算其投资回报。假设:

  • 一名工程师年薪为 $100,000
  • 培训使其效率提升 10%
    那么,公司每年可从该工程师身上节省:
    $100,000 * 10% = $10,000
    如果一场培训有20名工程师参加,潜在年节省额为:
    $10,000 * 20 = $200,000
    因此,公司愿意支付一笔可观的费用(例如 $24,000)来获取这项长期收益。

关键策略

以下是开展培训业务的关键步骤:

  1. 专业化定位:专注于特定领域(如Python数据科学、网络安全),能吸引更有针对性的客户。
  2. 建立权威:通过写博客、在技术会议演讲、参与开源项目来展示你的专业知识。
  3. 明确提案:向客户提交清晰的提案,需包含:
    • 目标受众
    • 课程大纲
    • 学员需具备的先验知识
    • 预期学习成果
    • 课程人数上限
    • 动手实践环节的比例(建议>30%)
    • 取消条款
  4. 定价策略:通常按天收费,并设定学员人数上限。价格因地区、专业领域和讲师声誉而异。
  5. 重视评估:课程结束后的评估分数对客户(培训经理)至关重要。高分和积极的书面反馈是获得回头客的关键。
  6. 增值销售:根据学员反馈,开发并推荐相关的高级或专题课程。

代码示例:简单的投资回报计算

def calculate_training_roi(engineer_salary, efficiency_gain_percent, num_engineers, training_cost):
    """
    计算培训的潜在投资回报。
    """
    yearly_saving_per_engineer = engineer_salary * (efficiency_gain_percent / 100)
    total_yearly_saving = yearly_saving_per_engineer * num_engineers
    net_gain = total_yearly_saving - training_cost
    return net_gain

# 示例计算
salary = 100000
gain = 10
engineers = 20
cost = 24000

net = calculate_training_roi(salary, gain, engineers, cost)
print(f"培训后公司年净收益: ${net}")
# 输出: 培训后公司年净收益: $176000


教学技巧:互动式现场编码

上一节我们介绍了培训的商业层面,本节中我们来看看一种高效的教学方法:互动式现场编码。

现场编码是指讲师在课堂上实时编写并解释代码,而非使用预先准备好的幻灯片。这种方法鼓励互动和主动学习。

教学设置

一个有效的设置是使用终端复用器(如Tmux)或IDE的分屏功能:

  • 屏幕A(讲师视图):包含教学笔记和辅助终端。
  • 屏幕B(学员视图):仅共享一个全屏终端或代码编辑器窗口,学员只看到编码过程。

核心技巧

  1. 持续提问:在每行代码执行前,询问学员预测结果。这促进回忆和思考。
    # 讲师问:“你们认为这段代码会输出什么?”
    result = 5 / 2
    print(result)  # 在Python 3中,输出 2.5
    
  2. 探索可能性:对于开放性问题,先引导学员列出所有可能的结果,再验证。
    numbers = [1, 2, 3, 4]
    letters = ['a', 'b', 'c']
    # 讲师问:“zip(numbers, letters) 可能返回什么?截断?报错?用None填充?”
    zipped = list(zip(numbers, letters))
    print(zipped)  # 输出: [(1, 'a'), (2, 'b'), (3, 'c')]
    # 然后讨论Python设计者为何选择“截断至最短序列”的行为。
    
  3. 重视错误答案:营造安全氛围,强调猜测和犯错是学习过程的一部分。
  4. 动手练习:安排学员结对编程完成练习。讲师在教室中巡视,使用“便利贴”系统(绿色=顺利,红色=需要帮助)来识别需要援助的学员。

在线教学调整

在线教学时,需调整互动方式:

  • 替代便利贴:使用Zoom的“举手”功能、聊天框输入特定符号(如?表示问题,表示完成)或表情符号。
  • 管理聊天:指定助教监控聊天频道,过滤问题,让讲师能专注于教学。
  • 分组讨论室:用于练习和社交,助教可以进入不同房间提供帮助。
  • 开启摄像头:鼓励但不强制,有助于营造课堂氛围。


挑战应对:会议教程实战

上一节我们探讨了理想的教学方法,本节中我们来看看在现实世界的Python大会上进行教程教学时会遇到的具体挑战。

会议教程通常是收费的、为期半天的密集型课程,学员背景差异巨大。

主要挑战

  1. 环境多样性:学员使用不同的操作系统(Windows, macOS, Linux各版本),安装软件(Python, 依赖包)的过程复杂且易出错。
  2. 技能水平差异:学员从初学者到专家都有,目标和子专业领域(数据科学、Web开发、运维等)也不同。
  3. 时间与经济效益:教程时间短(约3小时),讲师报酬固定,但需承担大量课前准备(编写材料、测试安装说明)和课后支持工作。

实用建议

以下是应对这些挑战的策略:

  1. 课前准备
    • 提供详尽、分操作系统的安装说明文档。
    • 举办一次“安装派对”,提前帮助学员搭建环境。
    • 准备云环境或虚拟机镜像作为备用方案,供安装失败的学员使用。
  2. 降低预期:明确告知学员,由于环境多样性,可能无法为所有人100%解决所有安装问题。
  3. 结对编程:鼓励学员结对,这样只需一半的电脑能成功安装即可进行实践。
  4. 简化内容:聚焦核心概念,避免涉及需要复杂环境配置的尖端工具。
  5. 管理沟通:与会议组织方明确责任,设定对学员支持范围的合理预期。

模式迁移:从线下到线上研讨会

上一节我们讨论了会议教程的挑战,本节中我们来看看如何将成熟的面对面研讨会模式(如Carpentries)系统性地迁移到在线环境。

在线教学不是简单地将线下内容搬到网上,需要重新设计互动、支持和社交环节。

核心原则

  1. 保持同步互动:尽管有异步选项,但初期建议保持实时视频教学,以维持互动性。
  2. 强化教学团队:在线教学更需要团队协作。明确角色:
    • 主讲师:专注教学。
    • 助教/共同讲师:监控聊天、解答技术问题、管理分组讨论室。
    • 主持人:管理会议设置(静音、分组)、应对突发状况(如讲师断线)。
  3. 刻意规划社交:安排固定的社交时间,利用分组讨论室进行破冰或主题讨论。

具体实施方案

以下是实施在线研讨会的关键点列表:

  • 技术选择:使用稳定的视频会议工具(如Zoom),并优先利用其内置功能(投票、非语言反馈、举手)。
  • 沟通渠道
    • 主聊天:用于学员提问和快速反馈。
    • 后台频道:教学团队使用Slack、Discord或Zoom私聊进行协调。
  • 替代便利贴
    • 要求学员在聊天中输入特定词(如“help”、“done”)。
    • 使用Zoom的“赞成/反对”非语言反馈图标。
    • 使用第三方互动工具(如Mentimeter)进行实时投票和问答。
  • 反馈收集:使用在线表单(如Google Forms)替代纸面反馈卡,进行匿名课后反馈。
  • 录制策略:如果录制,需提前告知所有参与者,并明确录制用途。注意录制可能抑制学员的参与意愿。

代码示例:使用Zoom API自动创建分组讨论(概念)

# 注意:此为概念性示例,实际需使用Zoom API SDK
import requests

def create_zoom_breakout_rooms(api_token, meeting_id, room_assignments):
    """
    通过Zoom API创建分组讨论室。
    room_assignments: 列表,包含每个房间的学员邮箱列表。
    """
    url = f"https://api.zoom.us/v2/meetings/{meeting_id}/breakout_rooms"
    headers = {"Authorization": f"Bearer {api_token}"}
    data = {
        "settings": {
            "enable": True,
            "rooms": [{"participants": participants} for participants in room_assignments]
        }
    }
    response = requests.put(url, headers=headers, json=data)
    return response.json()

# 在实际教学中,可能需要更动态的分组逻辑。


总结

在本教程中,我们一起学习了成为Python培训师的多个关键方面。我们从建立培训业务开始,理解了如何将培训产品化、定价并与客户沟通。接着,我们深入探讨了互动式现场编码教学技巧,强调了提问、探索和动手实践的重要性。然后,我们直面了在会议教程教学中遇到的环境多样性和时间限制等现实挑战,并给出了应对策略。最后,我们系统性地研究了如何将高质量的面对面研讨会模式成功迁移到在线环境,重点关注团队协作、互动设计和工具使用。

希望本教程为你提供了实用的见解和可操作的步骤,帮助你在Python培训与教育的道路上迈出坚实的一步。记住,成功的教学不仅在于深厚的专业知识,更在于理解学员需求、创造互动环境并持续反思与改进。

009:我们需要谈谈Windows

概述

在本节课中,我们将探讨Python在Windows平台上的构建过程,分析其与Linux和macOS构建的差异,并了解ActiveState平台如何致力于改进这一过程,以提供更可靠、可重复且易于维护的Python运行时。

背景:ActiveState平台简介

在深入探讨Windows构建的具体问题之前,我们先简要了解一下ActiveState及其平台。ActiveState在构建和分发开源语言运行时方面拥有超过20年的经验,是Python软件基金会的创始成员之一。其核心产品ActiveState平台旨在为开发者提供自定义的、跨平台的运行时环境。

该平台允许用户选择所需依赖项,并生成攻击面更小的轻量级分发版。它通过自动依赖解析、多层缓存和分布式构建系统,高效地处理构建工作。平台目前支持Python、Perl和Tcl,未来将通过API支持更多语言。

三大平台的Python构建对比

上一节我们介绍了ActiveState平台,本节中我们来看看在不同操作系统上构建Python核心的差异。我们将对比Linux、macOS和Windows的构建流程、复杂度和面临的挑战。

Linux上的Python构建

在Linux上构建Python相对简单直接。

以下是典型的Linux构建步骤:

  1. 确保系统已安装基本的开发构建工具(如gcc, make)。
  2. 运行配置脚本(./configure),可以使用默认选项或指定自定义路径(如OpenSSL库的位置)。
  3. 执行make命令进行编译。

构建过程可以概括为以下代码流程:

# 安装基础构建工具 (以Debian/Ubuntu为例)
sudo apt-get install build-essential

# 进入Python源代码目录
cd Python-3.x.x

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/5635b753cebce3913b253195e367fbcd_2.png)

# 配置构建参数
./configure --prefix=/usr/local

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/5635b753cebce3913b253195e367fbcd_4.png)

# 编译
make

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/5635b753cebce3913b253195e367fbcd_6.png)

# 安装 (可选)
sudo make install

系统上已安装的依赖库版本通常兼容,即使不兼容,也较容易通过包管理器安装或自行编译特定版本来解决。

macOS上的Python构建

在macOS上构建Python与Linux类似,但通常采用框架构建方式。

以下是macOS构建的关键点:

  1. 需要安装Xcode命令行工具。
  2. 使用--enable-framework选项运行配置脚本。
  3. 依赖管理可以通过Homebrew等工具方便地处理。

构建命令示例如下:

cd Python-3.x.x
./configure --enable-framework
make

与Linux类似,依赖库版本的选择和管理也相对灵活。

Windows上的Python构建

现在,我们需要重点讨论Windows。Windows上的Python构建过程与前两者有显著不同,它严重依赖于本地的Visual Studio构建系统。

以下是Windows构建的主要特点和挑战:

  1. 环境要求苛刻:需要特定版本的Visual Studio、特定的工作负载以及合适的Windows SDK组合。在未专门配置的机器上几乎不可能成功构建。
  2. 构建系统独特:Python使用一系列Visual C++项目文件(.vcxproj)和批处理文件进行构建。核心入口点是 PCbuild\build.bat
  3. 依赖管理僵化:C库依赖(如OpenSSL)的版本在Python发布时即被固定,无法轻易更新。这带来了安全风险和灵活性限制。
  4. 缺乏透明度和缓存能力:对于像ActiveState这样的外部构建系统,无法洞察或控制这些硬编码的依赖,导致无法为构建输出生成准确的哈希值(Merkle Tree),从而难以实现有效的缓存和构建复用。

一个典型的Windows构建命令如下:

cd Python-3.x.x\PCbuild
build.bat -p x64

此过程会通过批处理脚本自动获取外部依赖的源代码或二进制文件。

ActiveState的解决方案与未来计划

面对Windows构建的诸多挑战,ActiveState正在采取行动进行改进。

当前,为了能够更新关键依赖(如OpenSSL),ActiveState采用了一种临时方案:下载所需版本的依赖库,并将其解压到构建系统期望的目录结构中,替换掉旧的版本。但这并非长久之计。

因此,ActiveState计划开发一个新的、更灵活的Visual C++/MSBuild构建系统。该系统的核心目标是:

  • 支持预构建库作为依赖:允许使用像ActiveState平台这样的外部系统来提供依赖项,同时也兼容传统的从源码构建的方式。
  • 贡献给上游社区:ActiveState计划将此改进后的构建系统贡献给Python社区,希望被上游采纳。
  • 提升跨平台一致性:最终目标是使Windows上的Python构建能像在Linux和macOS上一样,实现可靠、可重复且易于升级的体验,让开发者能轻松获取安全、稳定的Python版本。

总结

本节课中我们一起学习了Python在不同操作系统上的构建过程。我们看到,在Linux和macOS上构建Python相对顺畅,而在Windows上则面临环境配置复杂、依赖管理僵化、缺乏构建缓存等挑战。ActiveState基于其多年的企业级运行时构建经验,不仅揭示了这些问题,还正着手通过开发一个更灵活的构建系统来从根本上改善Windows上的Python构建与分发体验,旨在为所有Python开发者提供更好的跨平台一致性。

010:类型提示让你的代码更清晰

概述

在本教程中,我们将学习Python类型提示。类型提示是一种为代码添加类型注解的语法,它本身不影响程序运行,但可以被外部工具(如mypy)用来检查代码中的类型错误,从而提高代码的清晰度和可靠性。我们将从基础概念开始,逐步深入到更高级的用法,并通过一个经典的“FizzBuzz”示例来实践。

1:环境搭建与工具介绍

为了开始使用类型提示,你需要准备相应的工具。本节将介绍如何设置工作环境。

你需要安装Python 3.7或更高版本。此外,为了检查类型提示,你需要安装mypy工具。

以下是安装mypy的命令:

python -m pip install mypy

mypy是一个静态类型检查器。它会读取你的代码并检查其中的类型注解,但它不是Python运行时的一部分。一个常见的做法是在运行单元测试的同时检查类型提示。

我个人喜欢在单元测试通过后再检查类型提示,这样可以先确保代码基本功能正确,再处理类型相关的问题。

运行mypy的方式如下:

mypy your_module.py

或者检查整个项目目录:

mypy src/

我建议将项目代码组织在一个src文件夹中,这样便于一次性检查所有代码。

2:一个简单的类型提示示例

上一节我们介绍了工具,本节中我们来看看如何编写最简单的类型提示。我们将从一个简单的问题开始。

这个问题是经典的“FizzBuzz”游戏。规则是:从1开始数数,遇到3的倍数说“Fizz”,5的倍数说“Buzz”,同时是3和5的倍数说“FizzBuzz”。

以下是一个没有类型提示的简单实现:

def fizzbuzz(n):
    if n % 3 == 0 and n % 5 == 0:
        return “FizzBuzz”
    elif n % 3 == 0:
        return “Fizz”
    elif n % 5 == 0:
        return “Buzz”
    else:
        return n

for i in range(1, 21):
    print(fizzbuzz(i))

现在,我们为这个函数添加类型提示。在参数后使用冒号指定类型,在函数定义末尾使用->箭头指定返回类型。

添加类型提示后的版本:

def fizzbuzz(n: int) -> str:
    if n % 3 == 0 and n % 5 == 0:
        return “FizzBuzz”
    elif n % 3 == 0:
        return “Fizz”
    elif n % 5 == 0:
        return “Buzz”
    else:
        return n  # 这里返回的是整数,与声明的返回类型`str`不符!

for i in range(1, 21):
    print(fizzbuzz(i))

运行mypy检查这段代码,它会报告第8行存在类型冲突:声明的返回类型是str,但实际可能返回int

这里我们遇到了一个关键决策点:是修改代码使其符合类型提示,还是修改类型提示使其符合代码?这取决于函数的设计初衷。如果fizzbuzz函数应该始终返回字符串,那么就需要修复代码(例如将return n改为return str(n))。如果它被设计为可以返回整数或字符串,那么类型提示就应该改为Union[int, str]

3:为内置数据结构添加类型提示

上一节我们看到了基础类型提示,本节中我们来看看如何为Python内置的复杂数据结构(如列表、集合、字典)添加类型提示。

Python的列表可以包含任何类型,但通常我们的应用程序中,列表只包含特定类型的对象。类型提示可以帮助我们明确这一点。

让我们看一个使用集合(Set)和字典(Dict)的“FizzBuzz”过度工程版。我们的目标是创建一个映射(字典),将整数映射到其对应的状态集合(包含“Fizz”、“Buzz”或两者)。

首先,我们定义目标数据结构:

from typing import Set, Dict

# 目标:一个将整数映射到字符串集合的字典
FizzBuzzMapping = Dict[int, Set[str]]

现在,我们重写函数来构建这个映射。以下是可能遇到复杂类型提示的情况:

def create_fizzbuzz_map(n: int) -> Dict[int, Set[str]]:
    result: Dict[int, Set[str]] = {}
    for i in range(1, n+1):
        status: Set[str] = set()
        if i % 3 == 0:
            status.add(“Fizz”)
        if i % 5 == 0:
            status.add(“Buzz”)
        result[i] = status
    return result

当类型提示变得冗长复杂时,一个好办法是将其分解,并为子结构赋予有意义的名称。

我们可以进行如下重构:

from typing import Set, Dict

# 为“状态”定义一个类型别名
FizzBuzzStatus = Set[str]
# 为整个映射定义一个类型别名
FizzBuzzMapping = Dict[int, FizzBuzzStatus]

def create_fizzbuzz_map(n: int) -> FizzBuzzMapping:
    result: FizzBuzzMapping = {}
    for i in range(1, n+1):
        status: FizzBuzzStatus = set()
        if i % 3 == 0:
            status.add(“Fizz”)
        if i % 5 == 0:
            status.add(“Buzz”)
        result[i] = status
    return result

这样,代码的意图就清晰多了。给复杂类型起一个好名字,就像为代码的未来留下一份路线图。

4:处理前向引用与循环依赖

在定义类或复杂函数时,有时会遇到循环依赖:A类的方法返回B类,而B类的方法又需要引用A类。本节我们来看看如何处理这种情况。

类型提示是按模块从上到下执行的。如果两个类相互引用,直接写可能会因为一个类尚未定义而报错。

例如:

class FBState:
    def get_mapping(self) -> “FBMap”:  # 注意这里的引号
        ...

class FBMap:
    def get_state(self, n: int) -> FBState:
        ...

注意FBState类中get_mapping方法的返回类型注解被写成了字符串“FBMap”。这被称为“前向引用”。mypy在分析时会把这个字符串解析为实际的类型名。

使用带引号的字符串作为类型名,可以让我们不必担心类定义的顺序问题,轻松处理循环引用。这是一个非常有用的技巧。

5:使用reveal_type进行调试

有时,mypy的报告会让人困惑,我们不清楚它为何认为某个变量是某种类型。本节介绍一个强大的调试工具。

reveal_type()是一个特殊的调试函数,它只在mypy检查时起作用,用于揭示mypy内部对某个变量类型的理解。

例如,当mypy抱怨某个变量缺少类型注解时,你可以这样做:

def __init__(self):
    self.fb = set()
    reveal_type(self.fb)  # 在运行mypy时,这会输出self.fb的类型

运行mypy后,你会看到类似这样的输出:

note: Revealed type is “builtins.set[Any]”

这表明mypy认为self.fb是一个可以包含任何类型(Any)的集合。这通常不是我们想要的,我们需要将其缩小为特定类型,例如Set[str]。于是我们可以添加明确的类型注解:

def __init__(self):
    self.fb: Set[str] = set()

当代码复杂、类型提示难以理清时,自由地使用reveal_type()吧。它可以帮助你理解mypy的“想法”,从而决定是修改代码还是调整类型提示。

总结

在本教程中,我们一起学习了Python类型提示的核心概念和应用。

我们首先搭建了环境,安装了mypy工具。然后,我们从一个简单的“FizzBuzz”函数开始,学习了如何为函数参数和返回值添加基本的类型提示,并理解了当代码与提示冲突时需要做出的设计决策。

接着,我们探索了如何为列表、集合、字典等内置数据结构添加精确的类型提示,并学会了通过定义类型别名来简化复杂的类型表达式,使代码意图更清晰。

我们还掌握了处理类之间循环依赖的技巧——使用字符串形式的前向引用。最后,我们介绍了reveal_type()这个强大的调试工具,它可以帮助我们在类型检查遇到困惑时洞察根本原因。

记住,类型提示是提升代码清晰度和可维护性的强大工具。当你发现难以写出简洁的类型提示时,这往往是一个信号,提示你可能需要重新思考或简化你的代码设计。拥抱类型提示,让它为你的代码带来更多的“清晰之音”。

011:使用Go构建无服务器Python应用

在本课程中,我们将学习如何构建一个松耦合、事件驱动的分布式无服务器系统。我们将通过创建一个编程竞赛评判系统作为示例,涵盖无服务器概念、架构设计以及使用Google Cloud Platform(GCP)的具体实现。课程内容分为三个主要部分:构建参赛者解决方案、构建评判组件以及构建系统管理核心。

概述

我们将创建一个管理编程竞赛的系统。参赛者需要编写一个能玩“猜数字”游戏的程序。评判系统会测试这些程序,并跟踪排名。整个系统将基于无服务器架构,使用多个通过消息传递松散连接的组件。

什么是无服务器计算?

无服务器计算并不意味着没有服务器,而是指开发者无需管理服务器基础设施。服务器由云平台提供商(如Google Cloud Platform)管理。开发者只需关注应用程序代码。

无服务器计算的主要优势包括:

  • 快速部署:从构思到部署运行速度很快。
  • 成本效益:通常可以缩减到零,只为实际使用的资源付费。
  • 免运维:无需配置、备份、监控或扩展服务器。

无服务器应用通常具有以下特征:

  • 无状态性:代码不应依赖内存或磁盘中的本地状态,任何需要持久化的信息必须存储在外部的数据存储中。
  • 松散耦合:系统由多个处理明确定义任务的独立组件组成。
  • 事件驱动:代码由特定事件(如HTTP请求、存储更新、消息到达)触发执行。
  • 异步通信:组件之间通常通过消息进行异步通信,发送者不等待即时响应。

问题描述:编程竞赛系统

传统编程竞赛中,参赛者提交源代码,评审需要在自己的环境中编译、运行并测试这些代码,过程繁琐且容易出错。

我们的解决方案是:让参赛者将自己的解决方案部署为Web服务(一个URL)。评审系统通过向该URL发送HTTP请求(包含游戏状态)来测试程序,并接收程序返回的响应(游戏操作)。这样,参赛者负责维护自己的运行环境,评审工作得以简化。

高层系统架构

  1. 参赛者:在本地编写游戏程序,并将其部署为无服务器Web服务(云函数),获得一个可公开访问的URL。
  2. 提交:参赛者通过网页表单将URL提交给评判系统。
  3. 评判:评判系统向该URL发送HTTP请求,模拟游戏过程,进行多轮测试。
  4. 排名:评判系统根据测试结果更新数据库,参赛者可以查看实时排名页面。

我们将使用的工具

  • Google Cloud Platform (GCP):作为云服务平台。
  • Google Cloud Functions:用于运行事件触发的无服务器代码(参赛者程序和部分评判组件)。
  • Google Cloud Pub/Sub:用于可靠的异步消息传递。
  • Google Cloud Firestore:无服务器NoSQL文档数据库,用于持久化存储。
  • Google App Engine:用于构建和托管Web应用程序(管理后台)。
  • Identity-Aware Proxy (IAP):用于为Web应用添加身份验证层。

准备工作:您只需要一台连接互联网的笔记本电脑、一个现代网页浏览器和一个Google账户。


第一部分:构建参赛者(玩家)程序

上一节我们介绍了无服务器的概念和整体系统设计,本节中我们来看看如何构建参赛者需要提交的游戏程序。

参赛者需要编写一个能玩“猜数字”游戏的程序,并将其部署为Web服务。游戏规则如下:评判系统会给出一个数字范围(最小值min和最大值max)以及之前的猜测历史。程序需要返回一个新的整数猜测。

游戏交互示例

第一次请求 (来自评判系统):

{
  "min": 1,
  "max": 10,
  "history": []
}

玩家响应:

5

第二次请求 (如果正确答案大于5):

{
  "min": 1,
  "max": 10,
  "history": [
    {"guess": 5, "result": "higher"}
  ]
}

玩家需要根据历史记录做出新的猜测。

我们将使用 Google Cloud Functions 来部署这个玩家程序。它是一个完全托管的无服务器执行环境。

动手步骤

以下是创建和部署玩家云函数的关键步骤:

  1. 创建GCP项目:在 Google Cloud Console 中创建一个新项目。

  2. 启用Cloud Functions API

  3. 创建函数

    • 在Cloud Console中,导航到 Cloud Functions
    • 点击 创建函数
    • 名称:player
    • 触发器:选择 HTTP
    • 认证:选择 允许未通过身份验证的调用(以便评判系统可以访问)。
    • 运行时:Python 3.7
    • 源代码:使用内联编辑器,粘贴以下Python代码:
    import json
    
    def make_guess(request):
        """HTTP Cloud Function. 处理猜数字请求。"""
        request_json = request.get_json()
    
        min_num = request_json['min']
        max_num = request_json['max']
        history = request_json['history']
    
        # 这是一个非常简单的玩家:总是猜最小值。
        # 在实际竞赛中,参赛者会在这里实现更智能的逻辑。
        guess = min_num
    
        # Cloud Functions 期望返回一个字符串、元组或Response对象
        return str(guess)
    
    • 入口点:填写 make_guess(与代码中的函数名一致)。
    • requirements.txt 留空,因为代码只使用了标准库 json
  4. 部署并测试:点击“部署”。部署完成后,您可以在“触发器”选项卡中找到函数的URL。您可以使用 curl 命令或云控制台内的测试功能来测试它。

代码说明

  • 函数 make_guess 由HTTP请求触发。
  • request.get_json() 解析请求中的JSON数据。
  • 程序从JSON中提取 min, max, history
  • 这个示例玩家总是返回最小值 min_num。参赛者可以在此处实现更复杂的算法(如二分查找)。
  • 函数返回一个字符串格式的数字。

关键点:玩家程序是无状态的。它不记得之前的请求。游戏状态完全由评判系统通过每次请求中的 history 字段来维护。


第二部分:构建评判组件(提问者)

现在我们已经有了参赛者的程序,本节我们来构建评判系统的核心组件之一——提问者。它的职责是与参赛者的程序进行完整的游戏对局。

提问者不应由评审系统同步调用而阻塞其进程。相反,我们将使用异步消息传递。评审系统发布一个“玩游戏”的任务消息,提问者订阅该消息并独立完成任务,最后将结果报告回去。

系统交互设计

  1. 触发:评审系统向一个Pub/Sub主题(例如 play-game)发布一条消息。消息体包含:
    {
      "player_url": "https://us-central1-your-project.cloudfunctions.net/player",
      "result_url": "https://us-central1-your-project.cloudfunctions.net/manager",
      "round_id": "unique-round-123",
      "secret": "a-random-secret-key"
    }
    
  2. 执行:一个或多个提问者(Cloud Functions)订阅了 play-game 主题。当消息到达时,它们被自动触发。
  3. 游戏过程:提问者读取 player_url,开始与玩家程序进行HTTP对话(发送游戏状态,接收猜测),直到游戏结束(猜对、超时、错误)。
  4. 报告结果:游戏结束后,提问者将结果(如步数、胜负)以HTTP POST请求的形式发送到 result_url,并附上 round_idsecret 以供验证。

动手步骤

以下是创建提问者云函数的关键步骤:

  1. 创建Pub/Sub主题
    • 在Cloud Console中,导航到 Pub/Sub > 主题
    • 点击 创建主题,名称填写 play-game
  2. 创建提问者云函数
    • 导航到 Cloud Functions,点击 创建函数
    • 名称:easy-questioner(例如,用于简单难度)。
    • 触发器:选择 Cloud Pub/Sub,并选择刚才创建的 play-game 主题。
    • 运行时:Python 3.7
    • 源代码:粘贴提问者逻辑代码。代码主要结构包括:
      • 从Pub/Sub消息中解码并提取 player_url, result_url, round_id, secret
      • 实现一个循环,通过 requests 库向 player_url 发送POST请求(包含游戏状态JSON),并解析响应。
      • 根据响应更新游戏状态(例如,根据“higher”/“lower”缩小范围)。
      • 当猜中或达到最大尝试次数时结束循环。
      • 将最终结果(提问者名称、步数、结果)POST到 result_url
    • 入口点:填写处理函数名(如 question_player)。
    • requirements.txt:需要添加 requests 库。
      requests>=2.20.0
      
  3. 测试:您可以在Pub/Sub主题页面手动发布一条测试消息,然后查看提问者函数的日志和结果URL的接收情况。

优势:这种设计允许多个提问者并行工作,处理不同的测试场景或大量提交,且与评审系统核心逻辑解耦。


第三部分:构建系统管理核心

在前两部分,我们构建了玩家和提问者。本节我们将完成系统的“大脑”——管理核心。它负责与用户交互、接收提交、触发评判以及展示排名。

我们将把管理核心拆分为两个部分,通过共享的Firestore数据库连接:

  1. Web应用 (Manager App):使用 App Engine 构建。提供网页表单供参赛者提交URL,并展示实时排名。
  2. 结果接收服务 (Manager Service):一个 Cloud Function,提供API端点供提问者上报游戏结果。

组件详解与动手步骤

1. 创建数据库 (Firestore)

  • 导航到 Firestore
  • 选择以原生模式创建数据库。
  • 选择一个位置(如多区域)。
  • 数据库将用于存储:
    • rounds 集合:每个文档代表一次提交(round_id),包含提交者、玩家URL、密钥(secret)等字段。
    • rounds/{round_id}/runs 子集合:存储该次提交对应的各个提问者的评判结果。

2. 创建结果接收服务 (Cloud Function)

  • 创建HTTP触发的Cloud Function,例如命名为 manager
  • 函数逻辑:
    • 接收提问者POST来的结果JSON。
    • 验证 round_idsecret 是否与数据库中记录匹配。
    • 将结果(提问者名、步数、结果)作为新文档存入对应 roundruns 子集合中。
  • requirements.txt 需要包含Firestore库:
    google-cloud-firestore>=2.0.0
    

3. 创建Web应用 (App Engine)

  • 准备应用代码:一个Python Flask应用。
    • main.py:包含路由处理。
      • GET /:从Firestore查询所有 rounds 及其 runs,计算排名,渲染主页。
      • GET /round:显示提交URL的表单。
      • POST /round:处理表单。将新的提交(用户、URL)存入Firestore的 rounds 集合,生成 round_idsecret,然后向 play-game Pub/Sub主题发布消息,触发提问者。
    • app.yaml:配置App Engine运行环境。
      runtime: python37
      
    • requirements.txt:列出依赖(如 flask, google-cloud-pubsub, google-cloud-firestore)。
  • 使用 gcloud app deploy 命令部署应用到App Engine。

4. 添加用户身份验证 (Identity-Aware Proxy - IAP)

  • 在Cloud Console中导航到 Security > Identity-Aware Proxy
  • 为您的App Engine应用启用IAP。
  • 配置OAuth同意屏幕。
  • 设置访问权限,例如添加“所有已登录用户”作为有权访问的成员。
  • 启用后,用户访问您的App Engine网址时,会被重定向到Google登录。登录后,IAP会在转发给应用的请求头部添加用户身份信息(如 X-Goog-Authenticated-User-Email)。
  • 您的应用代码可以利用这个头部来唯一标识用户,防止昵称冒充,实现更公平的竞赛。

完整工作流

  1. 参赛者访问受IAP保护的App Engine网站,登录。
  2. 在表单中输入自己部署的玩家云函数URL,并提交。
  3. App Engine后端:
    • 将此次提交信息存入Firestore。
    • 生成唯一 round_idsecret
    • play-game Pub/Sub主题发布消息。
  4. 订阅了该主题的提问者云函数被触发,开始与参赛者URL进行游戏。
  5. 提问者游戏结束后,将结果POST到结果接收服务(Manager Service)的URL。
  6. 结果接收服务验证 secret 后,将结果存入Firestore对应 roundruns 子集合。
  7. 参赛者刷新App Engine主页,应用从Firestore中读取所有结果,计算并显示最新排名。

总结

在本课程中,我们一起学习并实践了如何构建一个完整的分布式无服务器应用。我们以编程竞赛系统为例,实现了以下目标:

  1. 理解了无服务器计算的核心优势:免运维、弹性伸缩、按需付费和事件驱动。
  2. 设计了松耦合架构:将系统拆分为玩家、提问者、管理应用、管理服务等独立组件,通过HTTP和Pub/Sub进行通信。
  3. 实践了多种GCP无服务器服务
    • Cloud Functions:用于部署无状态、事件触发的业务逻辑(玩家、提问者、结果接收器)。
    • Cloud Pub/Sub:实现了组件间可靠的异步消息传递。
    • Cloud Firestore:作为共享的、无服务器的持久化存储。
    • App Engine:快速构建和部署了完整的Web应用。
    • Identity-Aware Proxy:轻松为Web应用添加了企业级身份验证。
  4. 掌握了关键设计模式:如事件驱动、异步处理、无状态设计,这些是构建可扩展、可维护云应用的基础。

通过这个项目,您不仅学会了具体工具的使用,更重要的是掌握了利用无服务器服务构建复杂分布式系统的设计思路。您可以将此模式应用到其他领域,如数据处理流水线、物联网后端、微服务架构等。

所有课程材料(幻灯片、代码实验室、源代码)均可在 serverlessworkshop.dev 获取。

012:从项目到生产化部署 🚀

在本教程中,我们将学习如何将一个Django项目部署到Heroku平台,使其从本地开发环境转变为可公开访问的生产级应用。我们将遵循“十二要素应用”原则,并完成从环境配置到最终部署的完整流程。


准备工作 🛠️

在开始之前,请确保您已满足以下先决条件。这不是一个Django入门教程,我们假设您对Django有基本了解。

以下是完成本教程所需的准备工作:

  1. 注册Heroku账户:访问Heroku官网注册一个免费账户,无需添加信用卡。
  2. 安装Heroku CLI:在您的机器上下载并安装Heroku命令行工具。
  3. 获取项目代码:克隆本教程提供的GitHub仓库,或使用您自己的Django项目。
  4. 准备开发环境:确保您可以在文本编辑器或IDE中打开项目代码。

准备工作完成后,我们就可以开始了。


第一步:更新 .gitignore 文件 📁

首先,我们需要确保不会将敏感信息或不必要的文件提交到Git仓库。这通过配置 .gitignore 文件来实现。

以下是需要添加到项目根目录下 .gitignore 文件中的内容:

# Python virtual environment
venv/

# SQLite database files
*.sqlite3
*.db

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/9037c989e72b3b2d6c2de8f1b77588d1_4.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/9037c989e72b3b2d6c2de8f1b77588d1_5.png)

# Django static files collected locally
staticfiles/

操作说明

  • 将上述文本复制到项目的 .gitignore 文件中。
  • 注意不要复制额外的引号或换行符。
  • 如果您本地使用了其他虚拟环境目录名(如 .venv),请相应修改。
  • 务必不要忽略Django的迁移文件(migrations/),它们需要被提交。

完成编辑后,保存文件并提交更改到Git。

git add .gitignore
git commit -m “Update .gitignore for Heroku deployment”

核心概念.gitignore 文件列出了Git不应跟踪的文件模式。这对于保护密钥和保持仓库清洁至关重要。


第二步:模块化Django设置 ⚙️

上一节我们配置了版本控制。本节中,我们将重构Django的设置,使其能适应不同环境(如开发、生产),这是实现持续交付的关键。

我们需要将单一的 settings.py 文件拆分为多个文件。

操作步骤

  1. 在您的Django项目目录(例如 start/)中,创建一个名为 settings 的新文件夹。
  2. 将原有的 settings.py 文件移动到这个新文件夹中,并将其重命名为 base.py。这个文件将包含所有环境的通用设置。
  3. 修复因移动文件而损坏的引用:
    • start/wsgi.py 文件中,将 ‘start.settings’ 修改为 ‘start.settings.base’
    • manage.py 文件中,进行同样的修改:os.environ.setdefault(‘DJANGO_SETTINGS_MODULE’, ‘start.settings.base’)

完成修改后,保存并提交更改。

核心概念:通过将设置模块化,我们可以轻松地为开发、测试、预发布和生产环境创建不同的配置文件(如 development.py, production.py),只需从 base.py 继承并覆盖特定变量即可。


第三步:配置WhiteNoise处理静态文件 🗂️

Django在生产环境中不直接提供静态文件(如CSS、JavaScript)。我们将使用WhiteNoise中间件来高效地服务这些文件。

首先,安装WhiteNoise。将以下依赖添加到 requirements.txt 文件:

whitenoise==6.2.0

然后,我们需要在Django设置中配置WhiteNoise。

操作步骤

  1. settings/base.py 文件中,找到 MIDDLEWARE 列表。将WhiteNoise的中间件添加到列表的第二个位置(紧接在安全中间件之后)。

    MIDDLEWARE = [
        ‘django.middleware.security.SecurityMiddleware’,
        ‘whitenoise.middleware.WhiteNoiseMiddleware’, # 添加这一行
        # ... 其他中间件
    ]
    
  2. settings/base.py 文件的末尾,添加静态文件存储设置。

    # Static files (CSS, JavaScript, Images)
    STATIC_URL = ‘static/’
    STATIC_ROOT = BASE_DIR / ‘staticfiles’
    STATICFILES_STORAGE = ‘whitenoise.storage.CompressedManifestStaticFilesStorage’
    

完成配置后,保存并提交更改。

核心概念WhiteNoise 是一个用于Python Web应用的静态文件服务库,它简单、高效,并且与Heroku的短暂性文件系统兼容。STATICFILES_STORAGE 配置项启用了文件压缩和长效缓存,提升性能。


第四步:创建Heroku专用设置文件 🌍

现在,我们将为Heroku生产环境创建一个特定的设置文件。这个文件将从 base.py 继承,并覆盖生产环境所需的配置。

settings/ 文件夹中创建一个新文件,命名为 heroku.py

将以下内容复制到 heroku.py 文件中:

import environ
from .base import *

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/9037c989e72b3b2d6c2de8f1b77588d1_19.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/9037c989e72b3b2d6c2de8f1b77588d1_21.png)

env = environ.Env()

# 从环境变量中读取密钥,确保生产环境密钥安全
SECRET_KEY = env(‘SECRET_KEY’)

# 从环境变量读取允许的主机名
ALLOWED_HOSTS = env.list(‘ALLOWED_HOSTS’, default=[‘.herokuapp.com’])

# 配置数据库,自动解析Heroku的DATABASE_URL环境变量
DATABASES = {
    ‘default’: env.db(‘DATABASE_URL’)
}

核心概念:我们使用 django-environ 库来管理环境变量。env.db() 方法能自动将Heroku提供的 DATABASE_URL 字符串解析为Django数据库配置字典。这避免了在代码中硬编码敏感信息。


第五步:准备部署配置文件 📄

我们的应用几乎准备好了。要成功部署到Heroku,还需要三个关键文件:requirements.txt, runtime.txtProcfile

1. 完善 requirements.txt

此文件列出了项目的所有Python依赖。确保它包含以下包:

Django==4.0
gunicorn==20.1.0
whitenoise==6.2.0
psycopg2-binary==2.9.3
django-environ==0.9.0
  • gunicorn:是Heroku推荐的Python WSGI HTTP服务器,用于运行Django应用。
  • psycopg2-binary:是PostgreSQL数据库的适配器,Heroku使用PostgreSQL作为生产数据库。

提示:对于正式项目,建议使用 pip freeze > requirements.txt 来锁定所有依赖的确切版本,确保构建的可重复性。

2. 创建 runtime.txt

此文件指定Heroku使用的Python版本。在项目根目录创建该文件,内容如下:

python-3.10.4

请根据您的项目需求调整版本号。

3. 创建 Procfile

Procfile 告诉Heroku在启动容器时运行哪些命令。在项目根目录创建名为 Procfile 的文件(注意首字母大写),内容如下:

release: python manage.py migrate --noinput
web: gunicorn start.wsgi:application --bind 0.0.0.0:$PORT --access-logfile -
  • release: 在应用部署后、新版本启动前运行的命令。这里用于执行数据库迁移。
  • web: 定义主要的Web进程。它使用gunicorn启动Django应用,并绑定到Heroku动态分配的端口($PORT)。

完成以上三个文件的创建和配置后,保存并提交所有更改到Git。


第六步:创建Heroku应用并配置环境变量 🔧

代码已准备就绪,现在我们需要在Heroku上创建应用实例并设置运行环境。

  1. 创建Heroku应用:在项目根目录打开终端,运行以下命令。

    heroku create
    

    此命令会:

    • 在Heroku上创建一个新的应用(并生成一个唯一名称和URL,如 https://神秘山-12345.herokuapp.com)。
    • 为您的本地Git仓库添加一个名为 heroku 的远程地址。
  2. 设置环境变量:我们需要将 settings/heroku.py 中使用的环境变量配置到Heroku应用中。

    • 设置Django配置模块:告诉Heroku使用我们创建的 heroku 设置。
      heroku config:set DJANGO_SETTINGS_MODULE=start.settings.heroku
      
    • 设置允许的主机:添加您的Heroku应用域名。
      heroku config:set ALLOWED_HOSTS=‘.herokuapp.com’
      
    • 设置安全密钥:生成一个全新的、复杂的密钥用于生产环境。
      heroku config:set SECRET_KEY=‘您生成的强随机字符串’
      
      切勿使用开发环境的密钥或将其提交到Git。
  3. 添加PostgreSQL数据库:Heroku为应用提供托管数据库服务。

    heroku addons:create heroku-postgresql:hobby-dev
    

    此命令会创建一个免费层的PostgreSQL数据库,并自动将连接URL设置为 DATABASE_URL 环境变量,我们的 django-environ 配置会自动识别它。


第七步:部署到Heroku 🚀

这是最后一步,也是最简单的一步——将代码推送到Heroku。

  1. 确保终端位于项目根目录,并且所有更改都已提交到Git。

  2. 运行部署命令:

    git push heroku main
    

    (如果您的主分支名为 master,请使用 git push heroku master

    您将在终端看到详细的构建日志,Heroku会自动安装依赖、执行 Procfile 中的 release 命令(运行迁移)。

  3. 启动Web Dyno:免费套餐下,Web进程默认是关闭的。需要手动启动一个。

    heroku ps:scale web=1
    

部署完成后,使用以下命令打开您的应用:

heroku open

恭喜!您的Django应用现在已经运行在互联网上了。


故障排除与日志查看 🔍

如果部署或访问遇到问题,可以按以下步骤排查:

  1. 检查构建日志:部署时的错误信息会直接显示在终端。仔细阅读。
  2. 查看运行日志
    heroku logs --tail
    
    此命令会实时显示应用日志,包括所有HTTP请求和错误信息,是调试的主要工具。
  3. 运行一次性管理命令:您可以在Heroku环境中执行命令,例如检查文件或启动Python Shell。
    heroku run bash  # 启动一个Bash shell
    heroku run python manage.py check  # 执行Django检查命令
    
  4. 验证配置:确保所有环境变量已正确设置。
    heroku config
    

总结 📝

在本教程中,我们一起完成了一个Django应用从本地项目到Heroku生产化部署的全过程。我们主要学习了:

  1. 使用 .gitignore 保护敏感信息。
  2. 模块化Django设置以支持多环境。
  3. 集成WhiteNoise来高效处理静态文件。
  4. 使用 django-environ 安全地管理环境变量。
  5. 准备 requirements.txtruntime.txtProcfile 这三个Heroku部署的关键文件。
  6. 通过Heroku CLI创建应用、配置环境变量、附加数据库。
  7. 使用 git push 完成最终部署,并学习如何查看日志和进行基本故障排除。

遵循“十二要素应用”的方法,不仅让部署到Heroku变得顺畅,也使您的应用更具可移植性、可扩展性和可维护性。祝您编码愉快!

013:使用 Python 工具推动 Salesforce.org 的独特开源模式

在本课程中,我们将学习 Salesforce.org 如何利用 Python 构建一套强大的工具,来支持其独特的开源模式。我们将了解这些工具如何帮助开发者、管理员和合作伙伴更高效地在 Salesforce 平台上进行开发、测试和部署,并最终学习如何将你自己的 Python Web 应用程序与 Salesforce 集成。


自我介绍与背景

我是 Jason Lantz,Salesforce.org 的高级发布工程总监。我的职业生涯始于 Unix 管理员,但很快转向了开源项目和 Python 开发。大约在 2003 年,我接触到了 Python 内容管理系统 Plone,并由此爱上了 Python 的优雅和强大。2013年,我加入 Salesforce.org,致力于将我的技术技能应用于帮助全球的非营利和教育组织。

在 Salesforce.org,我的工作重点是让 DevOps 对 Salesforce 社区变得更加容易。我的 Python 背景,特别是在开源项目和 Web 开发方面的经验,为我带来了独特的视角,帮助我为 Salesforce 社区创建了许多实用的工具。


关于 Salesforce 与 Salesforce.org

Salesforce 是一个客户关系管理(CRM)平台,它将公司与客户连接起来,为市场营销、销售、商务和服务等所有部门提供统一的客户视图。

Salesforce.org 是 Salesforce 内部的一个业务部门,专注于社会影响力。我们相信商业的目的应该是改善世界。我们为变革者提供强大技术的访问权限,帮助他们构建更美好的世界。我们为全球超过 44,000 个非营利和教育组织提供免费的或大幅折扣的 Salesforce 许可证,并开发适应其需求的产品。


Salesforce.org 的开源模式

Salesforce.org 的开源模式非常独特,它结合了三种主要类型的开源贡献:

  1. 开源产品:为非营利和教育领域提供共同的可信平台。
  2. 开源工具:解决 Salesforce 平台开发中常见的 DevOps 挑战。
  3. 开源社区:通过社区冲刺和项目孵化,促进协作和创新。

这种模式的核心优势在于,它将企业慈善与完全托管的软件实例相结合。我们每两周自动为所有客户升级产品,确保了大规模下的易用性和可维护性。


核心 Python 工具框架:CumulusCI

上一节我们介绍了 Salesforce.org 独特的开源模式,本节中我们来看看支撑这一切的核心技术框架:CumulusCI。

CumulusCI 是一个用于 Salesforce 项目的便携式自动化 Python 框架。它旨在解决 Salesforce 开发项目独特的 DevOps 需求,并被 Salesforce.org 的产品开发团队广泛使用。

CumulusCI 的核心组件

CumulusCI 的架构主要包含四个部分:

  1. Python 包 (cumulusci): 可通过 pip install cumulusci 安装。
  2. 配置文件 (cumulusci.yml): 一个 YAML 文件,用于定义项目的所有自动化配置。
  3. 任务 (Tasks): 自动化的基本工作单元,例如部署元数据或运行测试。
  4. 流程 (Flows): 一系列按顺序执行的任务,用于完成更复杂的工作流,如设置开发环境。

配置与可移植性

CumulusCI 采用基于配置的方法。每个项目都有一个 cumulusci.yml 文件,它可以继承并覆盖全局的默认配置。这种设计使得项目中所有的自动化需求都被抽象地定义出来,从而实现“可移植的自动化”。

这意味着,无论是通过命令行工具 cci,还是通过我们构建的 Web 应用程序,都可以执行同一套定义好的自动化流程。

关键技术栈

CumulusCI 充分利用了 Python 生态系统的强大功能:

  • 命令行接口: 使用 click 构建。
  • API 交互: 使用 requestssimple-salesforce
  • 测试: 使用 robotframework 进行浏览器自动化测试。
  • 数据生成: 使用 Faker 和自研的 Snowfakery 工具生成测试数据。
  • 模板: 使用 Jinja2 生成配置文件。

基于 CumulusCI 构建的 Web 应用

了解了 CumulusCI 这个核心框架后,我们来看看在其之上构建的三个关键 Web 应用程序,它们进一步扩展了自动化的能力,并降低了使用门槛。

以下是这三个应用的主要功能:

  • MetaCI: 一个运行在 Heroku 上的自定义持续集成(CI)应用,专门为使用 CumulusCI 的 Salesforce 项目设计,支持可扩展的构建流水线。
  • MetaDeploy: 一个面向客户的安装程序和自动化工具。用户可以通过简单的 Web 界面,将项目(如产品包)安装到他们自己的 Salesforce 实例中。
  • MetaGo: 一个旨在赋能 Salesforce 管理员通过点击(而非代码)为开源项目做出贡献的工具。管理员无需了解 Git 或 Python,即可通过 Web 界面贡献配置更改。

这些应用都是使用 Django 框架构建,并部署在 Heroku 上,它们都作为 CumulusCI “可移植自动化”的客户端,执行项目中定义好的流程和任务。


社区与协作:开源共享

我们构建的工具不仅服务于内部开发,更致力于赋能整个社区。Salesforce.org 的“开源共享”计划包含两个主要部分:

  1. 社区冲刺: 定期举办的线上或线下活动,将客户、合作伙伴和员工聚集在一起,共同构建专注于解决非营利和教育领域挑战的开源解决方案。
  2. 可持续项目孵化: 为社区运营的开源项目提供流程、基础设施(如 MetaCI、MetaDeploy)和安全审核支持,帮助它们降低维护门槛,实现可持续发展。

通过赋能社区驱动的创新,我们能够产生指数级的影响力,解决远远超出我们自身团队能力的特定领域问题。


实战演示一:启动 Salesforce 开发项目

前面的章节介绍了理论框架和工具,现在让我们进入实战环节。在本演示中,我们将展示如何使用 CumulusCI 快速启动一个新的 Salesforce 开发项目。

作为 Python 开发者,你可能会需要与 Salesforce 集成。例如,如果你的 Web 应用用户在使用 Salesforce,你可能需要在 Salesforce 中创建特定的数据架构来建立集成管道。本演示将展示如何创建和打包这样的架构。

初始化项目

首先,我们使用 CumulusCI 的命令行工具 cci 来初始化一个新项目。

# 创建项目目录并初始化Git仓库
mkdir pycon-demo && cd pycon-demo
git init

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/80d398914e0f602867ed182ef02f1adf_15.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/80d398914e0f602867ed182ef02f1adf_17.png)

# 运行项目初始化向导
cci project init

在初始化过程中,你需要提供项目名、包名等信息,并可以选择将项目设置为托管包。

探索自动化

初始化后,项目会包含一个 cumulusci.yml 文件。你可以使用 cci 命令列出所有可用的任务和流程。

# 列出所有任务
cci task list

# 查看“dev_org”流程的步骤
cci flow info dev_org

创建开发环境

运行以下命令,CumulusCI 会自动创建一个临时的 Salesforce Scratch Org,并执行 dev_org 流程中的所有步骤(如安装依赖、部署元数据),为你准备好一个完整的开发环境。

cci flow run dev_org --org dev

完成后,你可以用浏览器打开这个组织:

cci org browser --org dev

进行更改并捕获

在 Salesforce 的网页界面中,你可以通过点击方式创建自定义对象和字段(例如,为食品银行创建一个“配送”对象)。完成更改后,使用 CumulusCI 将这些更改捕获到版本控制中。

# 列出在组织中检测到的更改
cci task run list_changes --org dev

# 将更改检索到本地项目文件中
cci task run retrieve_changes --org dev

现在,你可以使用 git addgit commit 将这些元数据更改提交到代码库。这套流程使得声明式配置(点击配置)能够与基于版本控制的开发完美结合。


实战演示二:使用 Web 工具协作

在第一个演示中,我们通过命令行操作了项目。现在,我们来看看如何通过基于 Web 的工具(MetaGo、MetaCI、MetaDeploy)来实现团队协作和持续交付,这对非开发者尤其友好。

假设我们需要为之前创建的“配送”对象添加一个外部 ID 字段,用于与 Django 应用集成。

1. 使用 MetaGo 进行无代码贡献

  • 管理员登录 MetaGo,选择项目并创建一个新的“任务”(例如:“添加外部 ID 字段”)。
  • 管理员被分配为“开发者”,点击“创建组织”。MetaGo 在后台调用 CumulusCI,创建一个新的 Scratch Org。
  • 管理员在网页界面中登录这个新组织,使用熟悉的 Salesforce 点击界面添加所需字段。
  • 完成后,在 MetaGo 中点击“捕获更改”。MetaGo 会询问更改应归属到哪个功能分支,然后自动从组织中检索元数据更改,并提交到一个新的 Git 分支中。
  • 管理员提交任务进行测试。MetaGo 会自动创建一个 GitHub Pull Request。

2. 使用 MetaCI 进行自动化构建与测试

  • Pull Request 创建后,MetaCI 会自动检测并启动构建。
  • MetaCI 运行项目中定义的 CI 流程(通常是 ci_feature):创建一个干净的 Scratch Org,部署该分支的代码,并运行自动化测试(如 Robot Framework 测试)。
  • 测试结果和日志会直接显示在 MetaCI 的 Web 界面和 GitHub Pull Request 状态中。
  • “测试人员”可以登录 MetaCI 提供的测试环境进行验证,并在 MetaGo 或 GitHub 上批准合并。

3. 使用 MetaDeploy 进行发布与安装

  • 代码合并到主分支后,MetaCI 会触发发布流程,自动将项目打包为新版本,并在 GitHub 上创建发布。
  • 项目维护者使用 cci 命令将新版本发布到 MetaDeploy 站点。
  • 最终用户(客户)可以访问 MetaDeploy 站点,连接他们的 Salesforce 组织,并通过简单的点击操作,将最新版本的产品包安装到自己的实例中。

这个演示展示了从功能开发、代码审查、自动化测试到最终交付给客户的完整、流畅的协作链条,所有这些都建立在 CumulusCI 的便携式自动化之上。


实战演示三:集成 Python Web 应用与 Salesforce

现在,我们将把所学的一切结合起来,演示如何作为一个 Python Web 应用开发者,将你的应用与 Salesforce 集成。我们将以一个帮助食品银行跟踪配送的 Django 应用为例。

1. 建立 OAuth 连接

首先,需要在你的 Django 应用中实现 Salesforce OAuth 登录。我们使用了开源包 sfdo-template-helpers 来简化流程,它处理了 Salesforce 生产环境和沙箱环境的不同登录端点。

关键配置步骤包括:

  • 在 Salesforce 中创建一个“连接的应用程序”,获取客户端 ID 和密钥。
  • 在 Django 配置中设置 OAuth 回调 URL、作用域和加密密钥。
  • 在 Django 视图中提供“连接至 Salesforce”的按钮,引导用户完成 OAuth 授权流程。

2. 安装 Salesforce 包架构

用户通过 OAuth 连接他们的 Salesforce 组织后,你的 Django 应用需要将之前创建的数据包(包含“配送”对象和外部 ID 字段)安装到用户的组织中。

我们编写了一个 Django 管理命令或后台任务,利用 CumulusCI 的 Python API 来执行安装:

# 示例代码结构
from cumulusci.core.config import ServiceConfig
from cumulusci.core.runtime import BaseCumulusCI
from cumulusci.tasks.salesforce import UpdateDependencies

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/80d398914e0f602867ed182ef02f1adf_47.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/80d398914e0f602867ed182ef02f1adf_49.png)

def install_package(social_account, repo_url):
    # 1. 从数据库解密获取 OAuth 令牌
    credentials = get_salesforce_credentials(social_account)
    
    # 2. 配置 CumulusCI 运行时和组织连接
    cci = BaseCumulusCI()
    org_config = {
        ‘instance_url‘: credentials[‘instance_url‘],
        ‘access_token‘: credentials[‘access_token‘],
        ‘refresh_token‘: credentials[‘refresh_token‘],
    }
    
    # 3. 配置服务和任务选项
    cci.keychain.set_service(‘connected_app‘, ServiceConfig({‘client_id‘: CLIENT_ID, ‘client_secret‘: CLIENT_SECRET}))
    task_config = {‘options‘: {‘dependencies‘: [{‘github‘: repo_url}]}}
    
    # 4. 初始化并运行“更新依赖”任务
    task = UpdateDependencies(cci.project_config, task_config, org_config)
    task()

这段代码的核心是初始化 CumulusCI 并运行 UpdateDependencies 任务,该任务会从 GitHub 仓库获取指定的包并安装到用户的 Salesforce 组织中。

3. 同步数据

安装好架构后,就可以在 Django 模型保存时,将数据同步到 Salesforce。

# 在 Django 模型的 save() 方法或信号中
def sync_delivery_to_salesforce(delivery_id):
    delivery = Delivery.objects.get(id=delivery_id)
    if not delivery.sf_org:
        return
    
    # 获取 Simple Salesforce API 客户端
    sf = get_simple_salesforce_connection(delivery.sf_org)
    
    # 查找或创建关联的 Account(供应商)
    accounts = sf.query(f"SELECT Id FROM Account WHERE Name = ‘{escape_single_quotes(delivery.supplier_name)}‘")
    if accounts[‘totalSize‘] == 0:
        account_id = sf.Account.create({‘Name‘: delivery.supplier_name})[‘id‘]
    else:
        account_id = accounts[‘records‘][0][‘Id‘]
    
    # 创建或更新 Delivery 记录
    delivery_data = {
        ‘CCIDemo1__Supplier__c‘: account_id,
        ‘CCIDemo1__Status__c‘: delivery.status,
        ‘CCIDemo1__DjangoAppId__c‘: delivery.id, # 外部ID
    }
    if delivery.sf_id:
        sf.CCIDemo1__Delivery__c.update(delivery.sf_id, delivery_data)
    else:
        result = sf.CCIDemo1__Delivery__c.create(delivery_data)
        delivery.sf_id = result[‘id‘]
        delivery.save(update_fields=[‘sf_id‘])

通过这样的集成,食品银行既可以使用你量身定制的 Django 应用,又能将所有数据无缝同步到他们强大的 Salesforce CRM 中,用于高级报告、关系管理和业务流程自动化。


总结与资源

本节课中,我们一起学习了 Salesforce.org 如何利用 Python 生态系统构建了一套强大的工具链(CumulusCI, MetaCI, MetaDeploy, MetaGo),来驱动其独特的开源模式。我们从理论框架入手,逐步深入到三个实战演示:

  1. 使用 CumulusCI 命令行快速启动和开发 Salesforce 项目。
  2. 利用基于 Web 的协作工具实现团队开发和持续交付。
  3. 将 Python Django Web 应用程序与 Salesforce 进行深度集成。

进一步学习资源

如果你想深入了解或开始使用这些工具:

  • CumulusCI 学习路径: 在 Salesforce 的免费在线学习平台 Trailhead 上搜索 “CumulusCI”,完成互动式学习模块。
  • 官方文档: 访问 cumulusci.readthedocs.io 获取详细参考文档。
  • GitHub 组织:

感谢 Python 社区构建的这一切,它使我们能够创造出这些工具,从而最大化我们为世界带来的积极影响。希望本教程能启发你利用 Python 和 Salesforce 的强大能力,构建出令人惊叹的集成解决方案。

014:RAPIDS生态系统全解析 🚀

在本教程中,我们将全面学习NVIDIA的RAPIDS开源数据科学生态系统。RAPIDS旨在利用GPU的强大算力,加速从数据准备、特征工程到模型训练和可视化的整个数据科学工作流程。我们将逐一解析其核心组件,并通过代码示例展示其易用性和高性能。

概述:为什么需要RAPIDS? 📈

传统的数据处理流程,尤其是在CPU集群上,涉及大量的数据读写和移动(例如,从磁盘到内存,从CPU到GPU),这成为性能瓶颈。RAPIDS的核心思想是最小化数据移动,让数据尽可能驻留在GPU内存中,从而获得高达50倍至100倍的端到端性能提升。这种速度优势使得数据科学家能够快速迭代,尝试不同的特征工程和模型,极大地提升了工作效率。

1:RAPIDS核心架构与设计理念 🏗️

上一节我们概述了RAPIDS的目标,本节中我们来看看其整体架构和设计理念。

RAPIDS并非一个单一工具,而是一个封装了整个数据科学管道的生态系统。它建立在CUDA和C++之上,但为数据科学家提供了熟悉的Python API,实现了高性能与易用性的结合。

其核心设计理念包括:

  • GPU内存驻留:减少CPU与GPU之间的数据拷贝。
  • Pythonic API:提供与Pandas、Scikit-learn等库相似的接口,降低学习成本。
  • 端到端加速:覆盖ETL、分析、机器学习和可视化。

2:cuDF - GPU加速的数据帧处理 📊

数据准备和特征工程(ETL)占据了数据科学家大量时间。RAPIDS中的cuDF库正是为此而生,它提供了类似Pandas的GPU数据帧操作。

cuDF的技术栈底层是libcudf C++库,它使用优化的CUDA内核处理字符串、时间戳、数值类型等。上层的Python cudf库则提供了我们熟悉的API。

以下是使用cuDF读取CSV文件并进行简单操作的示例:

import cudf

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/d6b8cc5571a6fab4b0a120210f8ecc8d_15.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/d6b8cc5571a6fab4b0a120210f8ecc8d_17.png)

# 从CSV文件创建GPU数据帧,速度远超CPU读取
gdf = cudf.read_csv(‘my_data.csv’)

# 操作与Pandas高度相似
gdf[‘new_column’] = gdf[‘old_column’] * 2
filtered_gdf = gdf[gdf[‘value’] > 100]
grouped_gdf = gdf.groupby(‘category’).mean()

关键优势:

  • 高性能I/O:支持快速读写CSV、Parquet、ORC、JSON、Avro等格式。
  • 强大的字符串操作:完全在GPU上支持正则表达式、拆分、连接、排序等。
  • 无缝扩展:可与Dask结合,进行多GPU或多节点分布式数据处理。

3:cuML - GPU加速的机器学习算法 🤖

在高效完成数据准备后,下一步就是模型训练。cuML库提供了与Scikit-learn兼容的GPU加速机器学习算法。

cuML同样构建在CUDA加速的C++原语之上,并通过Python绑定提供易用接口。其算法覆盖广泛,从0.2版本仅有的几个算法,发展到如今包含:

  • 分类与回归:随机森林、梯度提升树、逻辑回归、支持向量机(SVM)、k-最近邻(KNN)。
  • 聚类:K-Means、DBSCAN。
  • 降维:主成分分析(PCA)、均匀流形近似与投影(UMAP)、t-SNE。
  • 时间序列、模型评估与超参数调优等。

使用cuML与使用Scikit-learn的代码差异极小:

# Scikit-learn (CPU) 版本
from sklearn.cluster import DBSCAN
cpu_dbscan = DBSCAN(eps=0.3, min_samples=10)
cpu_labels = cpu_dbscan.fit_predict(cpu_data)

# cuML (GPU) 版本
from cuml.cluster import DBSCAN
gpu_dbscan = DBSCAN(eps=0.3, min_samples=10)
gpu_labels = gpu_dbscan.fit_predict(gpu_data) # gpu_data 是一个 cuDF DataFrame 或数组

演示:DBSCAN聚类加速
在一个随机生成的数据集上,cuML的DBSCAN仅需约3毫秒,而Scikit-learn的CPU版本需要约30毫秒,实现了近10倍的加速。这使得数据科学家能够以“思考的速度”进行模型迭代。

4:cuGraph - GPU加速的图分析 🕸️

对于图数据(如社交网络、网络安全日志、推荐系统),cuGraph提供了高效的GPU加速图分析能力。

cuGraphcuDF数据帧解释为图结构,并在此之上执行算法。它支持存储和分析数亿甚至数十亿条边。

可用的算法包括:

  • 社区检测:Louvain、K-Truss。
  • 链接分析:PageRank、个性化PageRank。
  • 遍历与路径查找:广度优先搜索(BFS)、单源最短路径(SSSP)。
  • 中心性度量:度中心性、接近中心性、介数中心性。
  • 连通分量:弱连通分量、强连通分量。

使用示例极其简单:

import cugraph

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/d6b8cc5571a6fab4b0a120210f8ecc8d_27.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/d6b8cc5571a6fab4b0a120210f8ecc8d_29.png)

# 从边列表创建图
G = cugraph.Graph()
G.from_cudf_edgelist(edges_df, source=‘src’, destination=‘dst’)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/d6b8cc5571a6fab4b0a120210f8ecc8d_31.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/d6b8cc5571a6fab4b0a120210f8ecc8d_33.png)

# 执行PageRank算法
pagerank_df = cugraph.pagerank(G)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/d6b8cc5571a6fab4b0a120210f8ecc8d_35.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/d6b8cc5571a6fab4b0a120210f8ecc8d_36.png)

# 查找弱连通分量
components_df = cugraph.weakly_connected_components(G)

性能基准:在一个拥有1.6亿顶点和160亿边的巨型图上,使用16个V100 GPU进行3次PageRank迭代仅需318秒,而使用108个CPU节点(800个vCPU)的Apache Spark GraphX则需要约96分钟(5760秒),性能提升超过18倍。

5:其他核心组件与生态系统 🌐

除了上述三大核心库,RAPIDS生态系统还包括其他专注于特定领域的库:

  • cuSpatial:用于GPU加速的地理空间数据处理,如距离计算、范围查询、轨迹分析,相比CPU库可提升数千倍性能。
  • cuSignal:用于GPU加速的信号处理,提供卷积、滤波、重采样、频谱分析(如周期图、谱图)等功能,完全在Python中利用CUDA内核开发。
  • CLX (Cybersecurity Learning eXchange):面向网络安全工作流程的库和示例集合,集成了RAPIDS、PyTorch等,用于日志解析、动态网络映射、威胁警报分析等任务。

6:如何开始与社区参与 🚪

RAPIDS是开源项目,拥有活跃的社区。你可以通过以下方式快速开始并参与其中:

  1. 快速安装:访问 RAPIDS官网,使用“RAPIDS Release Selector”选择适合你环境(Docker、Conda、Google Colab)的安装命令。对于新手,推荐使用Docker镜像,它包含了所有示例。
    # 示例:拉取带有示例的Docker镜像
    docker pull rapidsai/rapidsai-core:latest
    
  2. 运行示例:所有演示教程(Jupyter Notebook)都包含在安装包或Docker镜像中,你可以边学边练。
  3. 获取帮助与贡献
    • GitHub:提交问题(Issue)或功能请求(Feature Request)。
    • 社区:加入RAPIDS的Slack频道或Google Groups参与讨论。
    • Stack Overflow:使用[rapids]标签提问。
    • 贡献:欢迎提交代码(Pull Request)、撰写博客分享使用经验。

总结 🎯

本节课中我们一起学习了NVIDIA RAPIDS生态系统。我们从其减少数据移动、提升迭代速度的设计理念出发,深入探讨了其四大核心组件:

  • cuDF 用于GPU加速的数据准备与ETL。
  • cuML 提供了与Scikit-learn兼容的GPU加速机器学习算法。
  • cuGraph 专为大规模图分析设计。
  • cuSpatialcuSignalCLX 等库则服务于更专业的领域。

RAPIDS通过提供熟悉的Python API,将GPU的强大计算能力无缝融入现有数据科学工作流,使数据科学家无需深入CUDA编程即可获得巨大性能提升。无论是单GPU开发还是通过Dask进行多GPU/多节点扩展,RAPIDS都旨在让数据科学工作更快、更高效。现在,你可以访问其官网和GitHub仓库,开始你的GPU加速数据科学之旅。

015:用Postgres优化Python和Django应用

在本教程中,我们将学习如何优化你的Python和Django应用程序,特别是利用Postgres数据库的强大功能。我们将探讨导致性能问题的常见原因、识别问题的工具,并通过四种典型场景来学习具体的优化策略。

性能问题的根源与识别工具

上一节我们介绍了课程概述,本节中我们来看看为什么会出现性能问题以及如何发现它们。

使用ORM(对象关系映射)可以快速构建应用程序,但也可能导致性能问题。ORM可能执行你未预料到的查询。如果你不检查日志,就不会知道这些查询的存在。查询可能未经优化。如果你只查看Python代码,你不会知道数据库层面发生了什么。开发环境中的快速查询,在数据量巨大或高负载的生产环境中可能表现迥异。

那么如何防止这种情况呢?Postgres的超能力如何帮助你?

以下是识别性能问题的三个核心工具:

  1. pg_stat_statements
    这是Postgres的一个扩展,用于跟踪服务器上执行查询的统计信息。它将查询保存到一个表中,并告诉你查询执行的次数和速度。建议在生产环境和开发环境中都启用它。你可以通过PostgreSQL配置文件来调整要跟踪的查询数量。在云服务(如Azure Database for PostgreSQL)中,此扩展通常默认安装。

    使用示例:你可以执行类似 SELECT * FROM pg_stat_statements ORDER BY total_time DESC LIMIT 100; 的查询来找出最慢的100个查询。Azure的“查询性能洞察”工具就基于此扩展,它能提供创建索引或删除冗余索引等性能建议。

  2. Django Debug Toolbar
    这个工具可以列出在模板和视图中执行的所有查询,并提供查询的执行计划(EXPLAIN)。它还能帮助追踪查询在代码中的来源。注意: 应仅在调试模式(DEBUG = True)下使用,切勿在生产环境中启用。

  3. Postgres 日志
    当你的环境复杂(如多语言应用)或使用AJAX/React调用API时,Django Debug Toolbar可能无法捕获所有查询,此时Postgres日志更为有用。

    配置Postgres日志需要修改以下设置:

    log_statement = 'all'
    log_duration = 0
    logging_collector = on
    

    注意: 在开发环境中可以记录所有查询,但在生产环境中应谨慎使用,避免日志文件过大。

场景一:避免N+1查询循环 🔄

上一节我们介绍了识别问题的工具,本节中我们来看看第一个常见性能陷阱:循环导致的N+1查询问题。

循环通常是性能问题的根源。我们将通过一个示例应用来演示。该应用包含公司(Company)、员工(Employee)、活动(Campaign)和广告(Ad)等模型。员工可以查看其所属公司的活动列表。

以下是活动列表视图的初始代码:

# 初始视图,存在N+1问题
def campaign_list_view(request):
    campaigns = Campaign.objects.filter(company=request.user.employee.company)
    return render(request, 'campaign_list.html', {'campaigns': campaigns})

对应的模板中,循环遍历每个活动并显示其广告:

{% for campaign in campaigns %}
    <h2>{{ campaign.name }}</h2>
    {% for ad in campaign.ad_set.all %} <!-- 这里每次循环都会产生一次数据库查询 -->
        <a href="{{ ad.url }}">{{ ad.name }}</a>
    {% endfor %}
{% endfor %}

使用Django Debug Toolbar检查,会发现执行了96个类似的查询(假设有96个活动)。这是因为在模板循环中,campaign.ad_set.all 为每个活动都执行了一次独立的数据库查询。

解决方案:使用 prefetch_related
prefetch_related 用于优化“一对多”或“多对多”关系的查询。它通过单独的查询预先获取相关对象,然后在Python中进行“连接”,从而将多次查询减少为两次。

优化后的视图代码如下:

# 优化后的视图,使用prefetch_related
def campaign_list_view(request):
    campaigns = Campaign.objects.filter(
        company=request.user.employee.company
    ).prefetch_related('ad_set') # 预先获取所有相关广告
    return render(request, 'campaign_list.html', {'campaigns': campaigns})

优化后,查询数量从约100个减少到6个。在生产环境中,循环问题会非常严重,务必在开发阶段通过检查查询来避免。

场景二:限制查询字段 📊

上一节我们解决了循环查询问题,本节中我们来看看如何通过限制查询字段来进一步提升性能。

Django ORM默认会查询所有列(SELECT *)。但在许多情况下,我们只需要部分字段。查询不必要的列(尤其是大文本字段)会拖慢查询速度并消耗更多内存。

在之前的例子中,即使我们只需要广告的idname,查询仍然获取了所有列。我们可以使用only()defer()方法来限制字段,但更优雅的方式是在使用prefetch_related时配合Prefetch对象进行精细控制。

优化示例如下:

from django.db.models import Prefetch

def campaign_list_view(request):
    # 使用Prefetch对象,指定只获取广告的id和name字段
    ads_prefetch = Prefetch(
        'ad_set',
        queryset=Ad.objects.only('id', 'name', 'campaign_id')
    )
    campaigns = Campaign.objects.filter(
        company=request.user.employee.company
    ).prefetch_related(ads_prefetch)
    return render(request, 'campaign_list.html', {'campaigns': campaigns})

这样,查询广告的SQL语句将变为 SELECT id, name, campaign_id FROM ads WHERE ...,从而减少了数据传输量。

场景三:高效分页 📄

上一节我们优化了单次查询,本节中我们来看看当数据量巨大时,如何通过高效分页来提升页面加载速度。

当公司拥有上百万个活动时,一次性加载所有数据会导致页面极其缓慢。分页将数据分成多个页面加载,是解决此问题的关键。

Django内置了Paginator类。基本用法如下:

from django.core.paginator import Paginator

def campaign_list_view(request):
    campaign_list = Campaign.objects.filter(company=request.user.employee.company).order_by('id')
    paginator = Paginator(campaign_list, 25) # 每页25条
    page_number = request.GET.get('page')
    page_obj = paginator.get_page(page_number)
    return render(request, 'campaign_list.html', {'page_obj': page_obj})

然而,传统的OFFSET/LIMIT分页(Paginator默认使用)存在两个问题:

  1. COUNT(*) 查询慢:为了计算总页数,Paginator会执行COUNT(*),在数据量大时非常慢。
  2. OFFSET 效率低OFFSET 1000000 LIMIT 20 意味着数据库需要先扫描并跳过前100万行,再返回20行。页码越靠后,速度越慢。

解决方案:键集分页(Keyset Pagination)
键集分页不依赖OFFSET,而是利用索引字段(如id)进行过滤。例如,获取第一页后,记录最后一行的id为20,那么第二页的查询条件就是 WHERE id > 20 LIMIT 20。这种方式效率极高。

在Django中,可以使用第三方库 django-keyset-pagination 来实现:

  1. 安装:pip install django-keyset-pagination
  2. 在视图中使用:
from keyset_pagination import KeysetPaginator

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/bb31743488f5c60a3d9c6d9df3e85d64_24.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/bb31743488f5c60a3d9c6d9df3e85d64_26.png)

def campaign_list_view(request):
    campaign_list = Campaign.objects.filter(company=request.user.employee.company).order_by('id')
    paginator = KeysetPaginator(campaign_list, per_page=25)
    page_obj = paginator.get_page(request)
    return render(request, 'campaign_list.html', {'page_obj': page_obj})

使用键集分页后,不再需要COUNT查询,并且翻页速度稳定,不受页码影响。

场景四:分析与优化慢查询 🐌

上一节我们解决了大数据集的分页问题,本节中我们来看看如何分析和优化具体的慢查询。

我们可以使用pg_stat_statements找到慢查询。例如,发现一个查询:SELECT * FROM campaigns WHERE archived = FALSE AND company_id = ? 执行缓慢。

使用Postgres的EXPLAIN命令分析该查询:

EXPLAIN ANALYZE SELECT * FROM campaigns WHERE archived = FALSE AND company_id = 1;

分析结果可能显示“Seq Scan”(顺序扫描,即全表扫描),这表明缺少合适的索引。

创建部分索引(Partial Index)
如果archived=FALSE是常用过滤条件,可以为其创建部分索引,只索引需要的行,效率更高。
在Django中可以通过迁移文件创建:

# 在Campaign模型的Meta类中
class Meta:
    indexes = [
        models.Index(
            fields=['company', 'archived'],
            condition=models.Q(archived=False),
            name='idx_campaign_active_for_company'
        )
    ]

或者使用原始SQL:

CREATE INDEX idx_campaign_active_for_company ON campaigns (company_id) WHERE archived = FALSE;

创建索引后,查询速度会显著提升。

何时考虑扩展硬件(Scaling Up)
如果代码和索引都已优化,但性能仍然随着数据量或流量增长而下降,可能是硬件资源(CPU、内存、磁盘)达到瓶颈。此时需要考虑垂直扩展(升级服务器硬件)。

何时考虑水平分片(Scaling Out)
当单个数据库节点无法容纳数据(如TB/PB级)时,需要考虑水平扩展。Citus 是PostgreSQL的一个开源扩展,它可以将数据和查询分布到多个节点(分片)上。对于多租户应用尤其有效。使用Citus后,应用层几乎无需修改,只需连接至Citus协调器节点即可。

总结与核心要点 🎯

本节课中,我们一起学习了利用Postgres优化Python和Django应用的全过程。以下是需要记住的八个核心要点:

  1. 使用 pg_stat_statements:在生产环境中启用,用于发现慢查询和高频查询。
  2. 善用 Django Debug Toolbar:在开发时检查查询数量和来源,但注意其局限性。
  3. 配置 Postgres 日志:始终在开发环境中打开查询日志,全面审视所有数据库交互。
  4. 警惕 N+1 查询循环:务必使用 select_relatedprefetch_related 来优化关联查询。
  5. 主动限制查询字段:不要依赖ORM的默认SELECT *,使用 only()defer()Prefetch 对象精确控制。
  6. 采用高效的键集分页:对于大数据集,放弃传统的 OFFSET/LIMIT 分页,改用键集分页以获得稳定性能。
  7. 深入理解并创建合适索引:学习Postgres的索引类型(B-tree, GiST, SP-GiST, GIN, BRIN),并考虑使用部分索引等高级特性。
  8. 适时进行扩展:优化达到瓶颈后,考虑垂直扩展(升级硬件)或水平扩展(使用如Citus这样的分片方案)。

通过应用这些策略,你可以显著提升应用的数据库性能,为用户提供更流畅的体验。

016:使用Crypten进行安全多方计算

概述

在本节课中,我们将学习如何使用加密技术在多方之间安全地训练机器学习模型,而无需共享任何一方的原始敏感数据。我们将重点介绍一个名为Crypten的框架,它基于安全多方计算技术,允许我们在加密数据上执行计算。


P16:1:机器学习与数据隐私挑战

机器学习模型通过从大量示例中学习模式来工作。例如,一个区分猫和狗的模型会查看许多带有标签的图片,并调整其内部参数以识别特征。

为了训练一个好的模型,通常需要大量数据。可以想象,如果许多宠物爱好者聚集在一起贡献猫狗图片,就能训练出优秀的模型。

然而,在涉及敏感数据的场景中,这种方法效果不佳。例如,三家医院希望联合训练一个模型来更好地理解某种疾病。但每家医院都拥有不能与其他医院共享的敏感患者数据,这使得联合训练一个好的模型变得困难。

幸运的是,密码学中有一个名为安全多方计算的子领域,专门研究如何在多个参与方之间计算一个函数,同时保证各方原始输入数据的安全。

安全多方计算示例
假设三个参与方拥有数字 342。安全多方计算允许我们计算它们的和 9,而无需向任何一方透露其他方的原始输入(342)。

这为解决医院系统联合训练模型而不暴露患者数据的问题提供了可能。


P16:2:Crypten框架介绍 🛡️

得益于相关研究人员的辛勤工作,我们现在有了Crypten这样一个框架来实现加密计算。该框架旨在连接机器学习和密码学两个社区,因此它需要具备易用的语法。

我们希望框架的语法与流行的机器学习库(如PyTorch)相似,以降低学习成本。同时,框架需要支持简单的调试和实验,因此它采用Python编写并强调强执行力。此外,框架需要模拟真实场景中各方之间的通信方式。


P16:3:Crypten基础:加密张量

现在让我们开始实际操作。在Crypten的世界里,核心概念是加密张量

创建一个加密张量非常简单。以下代码展示了基本操作:

import crypten

# 初始化通信(模拟多方设置)
crypten.init()

# 创建加密张量
x = crypten.cryptensor([1, 2, 3])
print(“加密张量 x:”, x) # 输出是加密的随机数,不是原始值

# 解密以查看原始输入
plain_text_x = x.get_plain_text()
print(“解密后的 x:”, plain_text_x) # 输出: [1, 2, 3]

如你所见,语法与PyTorch的torch.tensor非常相似。当你打印加密张量x时,看到的是一堆随机数,而不是原始的[1, 2, 3]。只有调用.get_plain_text()方法时,才能获取解密后的原始值。

让我们创建另一个加密张量并进行运算:

y = crypten.cryptensor([4, 5, 6])

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/1d18793bcda110852d2376c7a853eada_10.png)

# 加法运算
result_add = x + 2
print(“x + 2 的解密结果:”, result_add.get_plain_text()) # 输出: [3, 4, 5]

# 两个加密张量相加
result_sum = x + y
print(“x + y 的解密结果:”, result_sum.get_plain_text()) # 输出: [5, 7, 9]

在整个计算过程中,所有值都保持加密状态,直到我们显式地调用解密方法。

除了加法,Crypten还支持更多复杂操作,如点积、对数、softmax等。框架在很大程度上模仿了PyTorch的API。

计算示例:均方误差损失

# 计算加密数据上的均方误差
diff = y - x
squared = diff ** 2
mse_loss = squared.mean()
print(“加密计算出的MSE损失:”, mse_loss.get_plain_text()) # 输出: 9.0

# 与纯文本PyTorch计算对比
import torch
x_plain = torch.tensor([1., 2., 3.])
y_plain = torch.tensor([4., 5., 6.])
mse_plain = ((y_plain - x_plain) ** 2).mean()
print(“纯文本计算出的MSE损失:”, mse_plain) # 输出: tensor(9.)

可以看到,加密计算与纯文本计算的结果完全一致。


P16:4:安全多方计算原理简述

上一节我们使用了加密张量,现在我们来简单了解其背后的安全多方计算原理。

假设有两个参与方:Alice拥有数字 7,Bob拥有数字 10。他们想计算总和,但不想让对方知道自己的具体数字。

加密过程(以Alice的数字7为例)

  1. Alice生成两个随机数,例如 24
  2. 她将这两个随机数分别秘密发送给Bob和另一个假设的第三方Charlie。
  3. Alice自己保留剩余部分:7 - (2 + 4) = 1
  4. 现在,Alice持有份额 1,Bob持有份额 2,Charlie持有份额 4。单独看每个份额,都无法得知原始数字 7

Bob用同样的流程加密他的数字 10

安全求和过程
当需要计算 7 + 10 时,各方只需将自己持有的对应份额相加:

  • Alice计算:1 + (Bob加密10时Alice得到的份额)
  • Bob计算:2 + (Alice加密7时Bob得到的份额)
  • Charlie计算:4 + (双方加密时Charlie得到的份额)

然后他们将这三个结果公开相加,得到最终和 17。在整个过程中,没有任何一方直接暴露自己的原始输入 710

乘法运算则使用一种名为 Beaver Triple 的协议,原理类似但更复杂一些。


P16:5:在Crypten中定义与训练模型

了解了基础原理后,我们来看看如何在Crypten中定义和训练模型。其步骤与PyTorch非常相似。

以下是定义一个简单的加密模型(逻辑回归)的示例:

import crypten.nn as cnn

class EncryptedLR(cnn.Module):
    def __init__(self):
        super(EncryptedLR, self).__init__()
        # 使用Crypten的线性层,而非torch.nn.Linear
        self.fc = cnn.Linear(28*28, 10) # 输入28x28图像,输出10个类别

    def forward(self, x):
        x = x.view(-1, 28*28) # 展平图像
        x = self.fc(x)
        return x

接下来,我们将通过一个交互式教程,学习如何在加密的MNIST手写数字数据集上训练模型。

准备工作:加载并加密数据

# ... 加载MNIST数据,获得图像和标签 ...
# 假设 `images` 和 `labels` 已加载

# 加密数据
encrypted_images = crypten.cryptensor(images)
encrypted_labels = crypten.cryptensor(labels, src=0) # 指定标签来源

print(“加密图像(看到的都是随机数):”, encrypted_images)

训练循环
训练循环的结构与PyTorch几乎相同:

model = EncryptedLR()
model.encrypt() # 加密模型参数

criterion = cnn.CrossEntropyLoss()
optimizer = crypten.optim.SGD(model.parameters(), lr=0.01)

for epoch in range(10):
    optimizer.zero_grad()
    output = model(encrypted_images)
    loss = criterion(output, encrypted_labels)

    loss.backward()
    optimizer.step()

    # 监控损失(需要解密)
    print(f”Epoch {epoch}, Loss: {loss.get_plain_text().item()}”)

训练完成后,我们可以使用模型进行加密预测,并在需要时解密查看结果。


P16:6:多方联合训练实战

现在,让我们回到最初的挑战场景:数据分布在多方。例如,Alice拥有所有图像,Bob拥有所有对应的标签。他们希望联合训练一个模型,但Alice不能看到标签,Bob也不能看到图像。

以下是模拟这一过程的步骤:

步骤1:各方保存加密数据

import crypten
from crypten import mpc

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/1d18793bcda110852d2376c7a853eada_18.png)

@mpc.run_multiprocess(world_size=2)
def save_encrypted_data():
    # 进程0代表Alice
    if crypten.comm.get().get_rank() == 0:
        alice_data = torch.tensor([1, 2, 3])
        crypten.save(alice_data, ‘alice_data.pth’, src=0) # src=0 表示数据归Alice所有
    # 进程1代表Bob
    else:
        bob_data = torch.tensor([4, 5, 6])
        crypten.save(bob_data, ‘bob_data.pth’, src=1) # src=1 表示数据归Bob所有

save_encrypted_data()

保存后,alice_data.pthbob_data.pth 文件中存储的是加密后的数据。

步骤2:加载加密数据进行联合训练
各方加载属于自己的加密数据,然后共同执行训练脚本。训练过程中,加密的图像和加密的标签在加密状态下进行计算,模型参数也保持加密更新。任何一方都无法从中间数据推断出另一方的原始信息。

Crypten的GitHub仓库中提供了完整的端到端示例脚本,展示了如何在模拟的或多台真实机器上运行这种联合训练。


总结

在本节课中,我们一起学习了如何使用Crypten框架进行安全的加密机器学习计算。

我们首先了解了在隐私敏感场景下联合训练模型所面临的挑战。接着,我们介绍了安全多方计算这一密码学解决方案的核心思想。然后,我们深入探讨了Crypten框架,学习了如何创建和操作加密张量,其语法与PyTorch高度相似。

我们通过实例演示了如何在加密数据上定义模型、执行训练循环并进行预测。最后,我们模拟了数据分布在Alice和Bob两方时,如何进行联合训练,确保在整个过程中,各方的原始数据始终保持加密和私密状态。

通过Crypten,我们能够在保护数据隐私的前提下,利用多方数据的力量来构建更强大的机器学习模型。

017:Sponsor Workshop Xilinx, Inc. Patrick Lysaght

在本教程中,我们将学习如何利用开源框架Pynq,将Python生态系统扩展到Xilinx的可编程平台。我们将探讨Pynq如何让Python开发者、数据科学家和硬件工程师能够轻松地在集成了ARM处理器和FPGA的Zynq系统级芯片上进行开发与创新。

1:平台演进与动机 🚀

上一节我们介绍了本教程的主题。本节中,我们来看看推动Pynq框架发展的平台演进趋势。

本次回顾并非全面,但旨在突出过去十年中的一些主要趋势。最大的趋势之一是树莓派的兴起。树莓派是典型的功能强大的ARM微处理器,部署在小型嵌入式开发板上。其新颖之处在于微处理器现已足够强大,可以运行完整的桌面Linux系统和庞大的软件栈。这意味着我们可以在目标设备上进行开发,无需交叉编译或交叉调试,因此开发过程更简单、更快速。

相比之下,Arduino是另一种现象,它针对更底层的软件栈,部署更接近硬件。Arduino通常用于控制传感器或读取传感器数据,以及控制电机或执行器。Arduino的微控制器侧重于底层操作,可被描述为“位敲击”设备。

我们考虑的第三类是FPGA。FPGA增加了一个新的维度。FPGA不是冯·诺依曼架构。FPGA代表现场可编程门阵列,意思是用户或客户可编程的逻辑门集合。其区别在于用户可以针对目标应用定义所需的架构。这允许开发出高度并行、高度优化的快速解决方案,并提供了另一种程度的创新。

所有这些平台经常被单独使用,但也越来越多地结合在一起。对于更复杂、更具挑战性的项目,拥有多个平台供你使用是非常有利的。

为了展示这一趋势的一个例子,这里有一个树莓派。在底层是树莓派板,拥有强大的ARM微处理器,可以运行完整的Linux和庞大的软件栈。在最上层,通过HAT扩展板,我集成了一个FPGA,可以在其中设计定制解决方案来解决我关注的问题。这可以带来巨大的差异化、高性能和独特性,使我的项目或产品脱颖而出。因此,这些不同平台的结合使用越来越普遍,这体现在更高性能和更具创新性的解决方案中。

在进一步讨论之前,我将花一点时间回顾FPGA的工作原理,因为并非所有Python程序员都同样熟悉它们。这张图从概念上很好地表达了FPGA的工作原理。本质上,FPGA有两个“平面”。上层是配置存储器,下面的平面是逻辑电路。如果上层平面未初始化,那么下面的电路实际上没有配置,不可用。然而,如果我们对配置存储器进行编程,并在其中放入一个比特流,我们就可以确定逻辑平面上提供的电路。在这种情况下,你可以看到这种确定性模式:我们配置了一个电路,逻辑平面上的电路就准备就绪。这就是FPGA的美妙之处:通过简单地改变SRAM存储器中的位,我们现在可以在逻辑平面上实现不同的电路。从前这些都是小电路,但现在你可以在逻辑平面上拥有数百万个门,因此可以在FPGA上构建非常复杂的电路,这只会进一步增强其能力。

例如,在许多计算机体系结构课程中,学生可以更深入地了解流水线或架构的工作原理,并首次体验构建自己的处理器、编写自己的汇编代码和更高级语言代码的过程。

现在我们已经讨论了三种环境:Arduino、树莓派和FPGA。想象一下,如果我们能拥有世界上最好的部分。想象一下,如果我们能把所有这些设备组合成一个系统级芯片。这就是我们在左边所描绘的。这张图显示了Arduino、树莓派模型和FPGA集成在一个设备中。除此之外,还有可编程的输入和输出,以及两个子系统之间的高速连接。这就是系统级芯片带给我们的能力。

事实上,这种能力现在可以在Xilinx的Zynq可编程平台上使用。这些Zynq设备集成了ARM微处理器、Xilinx FPGA作为高速可编程逻辑、许多软核微控制器以及组件与输入/输出之间的快速互连。我们称这个产品为Xilinx的Zynq-7000系列。现在它不是我们唯一的Zynq系列,这个家族有更大的兄弟Zynq UltraScale+,但为了简单起见,我们将关注Zynq-7000本身。

为了以略有不同的方式强调Zynq可编程平台的优势,这个对照表显示了Zynq可编程平台的本质:它具备树莓派的所有特性、Arduino的能力以及集成的FPGA功能。当然,因为它是一个系统级芯片,所有东西都集成到单一设备中,你还能获得所有额外的好处,例如更低的功耗、更小的尺寸等。

到目前为止,Zynq在市场上非常成功,被用于一系列非常创新的产品中。例如自动驾驶系统和高级驾驶辅助系统、过程控制(尤其是在要求高精度的场景)、高性能无人机、视觉处理和人工智能应用、视频处理、医疗仪器、独特的仪器以及一个非常令人兴奋的领域——远程机器人手术。

看看这些并在此总结一下,例如精密机器人技术。你可以想象,如果你在进行远程机器人手术,你不会想用Arduino,因为你可能会有点紧张。你会希望有专门的电路来确保缝合或其他手术过程精确进行,因为你希望它们是实时的。高分辨率视频处理是另一个主要领域。此外,许多研究小组使用FPGA,因为他们可以设计全新的架构,而无需自己制造芯片。FPGA在教学中也应用广泛,尤其是Zynq,允许在一个设备中教授一系列主题,例如逻辑设计、计算机体系结构、信号处理、数字控制。当然,对于毕业设计项目来说也是巨大的。

随着越来越多的嵌入式应用向边缘计算过渡,出现了更多的Python开发人员和数据科学家,以及硬件和嵌入式软件工程师。这些数据科学家和Python开发人员中的许多人可以从可编程平台中受益,但大多数人对这项技术不熟悉。Pynq框架的第一个目标是让更多的人能够使用Xilinx可编程平台,并利用这项技术,尤其是在边缘设备等应用中。

我们还希望为硬件设计师创造一种利用Python的更高生产力的方法。我们希望这些硬件设计师能更容易地与更多人分享他们的设计。我们希望消除不同利益相关者之间的一些人为障碍或层级,同时鼓励一个更敏捷的环境,在所有利益相关者之间开辟新的渠道,这样每个人都可以直接互动,并通过早期反馈来改进他们的设计。

2:从开源社区汲取灵感 💡

上一节我们探讨了平台演进的动机。本节中,我们来看看设计Pynq时从开源社区,特别是Python和Jupyter项目中汲取的灵感。

在设计Pynq时,我们向开源社区寻求灵感。例如,我们借鉴了Python的一些重要思想和最佳实践。

过去十年最大的趋势之一是Python语言在开源社区中越来越受欢迎,尤其是在学术界的广泛使用。第二个趋势是将Python用于嵌入式或边缘系统。现在,随着越来越多的嵌入式系统连接到云端,在物联网领域,我们将这些系统称为边缘系统。这张图表由IEEE于2018年7月制作,是他们首次注意到Python被列为嵌入式语言。嵌入式语言通常是C和C++,这些语言几十年来一直主导着嵌入式系统。因此,这是一个重要的认可:Python开始被用于嵌入式和边缘系统。这反映了树莓派的影响,以及连接回云端的边缘系统所需的额外智能。

在边缘和嵌入式系统中使用Python有相当大的好处。据估计,目前全球活跃的Python开发者多达800万。你可以在目标设备上进行开发,正如我们之前提到的,这意味着你不必像使用C和C++那样进行交叉编译和交叉调试。这让设计感觉更简单、更有生产力,结果是更快的迭代周期。Python拥有一个巨大的生态系统和一个非常活跃的社区。Python可以非常有效地与C和C++互操作,以便在嵌入式系统中与它们共存。这为我们带来了敏捷的软硬件协同设计流程的好处。最后,有很多可移植的代码可以在桌面、嵌入式和边缘系统之间重用,这是因为Python在虚拟机上执行。

这就是Python社区的力量以及第三方生态系统。一位Python核心开发者说过一句名言:“我为语言而来,但我为社区而留下。”这些类别中的每一个都有大约40%或更高的复合年增长率。因此,除了Python附带的标准库,PyPI上还有大量的第三方库。所以,你真的可以说,普通的Python开发者站在一个庞大社区的肩膀上。一个巨大的Python社区就像艾萨克·牛顿说的那样:“我看得更远,因为我站在巨人的肩膀上。”

它们实际上并不都是用Python写的,其中许多是用其他语言写的,例如C、C++、Java、Fortran。这是非常值得注意的。Python的主要优点之一是它与其他语言编写的代码接口的效果和频率。这并不完全令人惊讶,既然我们说的是Python,最受欢迎的Python版本当然是用C写的。但这仍然是一个重要的优势。

我们看到Python与其他语言一起使用的例子。通常,Python调用用其他语言编写的代码有两个原因:第一个是重用其他代码,只需将其封装在Python API中;第二个是加速Python代码,其性能通常需要通过使用预编译的C代码来改进。例如,SciPy实际上是用Python和C/Fortran混合编写的。当程序之间共享数据时,尤其是出于性能原因,重要的是避免不必要的复制。Python依赖于它的缓冲区协议,NumPy和数组可以有效地交换数据。这些都是我们在设计Pynq框架时借鉴和重用的想法。

3:Jupyter项目的核心作用 📓

上一节我们了解了Python生态系统的力量。本节中,我们来看看过去十年最激动人心的Python项目之一——Jupyter项目,以及它如何成为Pynq架构的核心部分。

正如你将在下一张幻灯片中看到的,Pynq的架构广泛使用了Jupyter。

IPython是交互式Python的简称,是一个项目,最初是为了创造一个更好的REPL(读取-求值-打印循环)。然而,在Mathematica和Sage Mathematics等浏览器界面的影响下,IPython演变成了IPython Notebook。笔记本是网络文档,以及所有其他由现代浏览器支持的富媒体。设计背后的一个关键动机是为了解决科学论文缺乏可重复性的问题,通过提供一种新的可执行、因此可复制的文档。我发现笔记本被证明是一个巨大的成功,并被许多人、许多其他编程语言所采用。因此,它的名字从IPython改为了Jupyter,是Julia、Python和R三种编程语言的缩写。据估计,目前GitHub上大约有700万本笔记本,且使用量呈指数级增长。这项技术每年被教授给成千上万的大学生。

也就是说,许多开发者真的很喜欢Jupyter Notebook,但他们也想要更多他们在常规集成开发环境中熟悉的工具。这促使了JupyterLab的建立。JupyterLab是基于IDE的下一代浏览器界面,由学术界和工业界合作开发。JupyterLab成功的关键之一是开源库,它支持可调整大小的子窗口或窗格。如果我们看左手边的图表,代码编辑器、外壳终端,JupyterLab从设计之初就是可扩展的,每个窗口只是一个插件的实例。因此,开发人员可以在使用更熟悉的IDE工具的同时使用笔记本,使整体价值主张更有说服力。

Jupyter真正的天才在于它的系统架构。它由客户端、服务器和语言内核组成,如图所示。块之间的接口定义良好,并基于开放标准。因为它使用浏览器界面,所以不需要支持很多不同的窗口系统。相反,通过使用浏览器,它受益于可能是世界上最先进和广泛使用的计算机接口。例如,今天Jupyter支持多达100种语言。Jupyter项目被授予2017年ACM软件系统奖,有点像软件系统的诺贝尔奖,TCP/IP、Java和Eclipse也曾获此奖,所以这是一家非常好的公司。

这促使我们思考:如果我们能在可编程平台上运行Jupyter会怎么样?使其成为Pynq框架架构中不可或缺的一部分。消费级和专业产品多年来一直使用内部网络服务器,主要用于托管嵌入式配置门户。网络路由器、激光打印机就是很好的例子,可以通过这些内部网络服务器进行配置。相比之下,JupyterLab通常运行在桌面或服务器类机器上。这里的想法是直接在可编程平台上托管JupyterLab IDE的Web服务器和语言内核。然后,我们可以从任何联网计算机的浏览器在目标设备上进行开发。

这里显示的板子是Pynq-Z2,尤其是Pynq项目。Pynq-Z2具有Zynq可编程平台,它是Jupyter的主机。当然,如你所见,我们为Pynq框架设计了一个Pynq板,为了让开发更有趣。这张幻灯片显示了在Zynq可编程平台上运行的Pynq系统架构。我们在ARM处理器上运行的Linux下运行JupyterLab和IPython内核。然后,我们在可编程逻辑上创建覆盖电路。覆盖层本质上是FPGA上高度可参数化的设计,在概念上与软件库相似。接下来,我们为覆盖创建C驱动程序,并将它们封装在Python API中,就像处理任何其他库一样。注意,所有浏览器都托管在通过网络连接的外部机器上。这种分区还具有将任何图形渲染卸载到更强大的网络计算机的优点。

该体系结构允许直接对目标进行软件开发,多亏了Pynq框架,而且不需要在客户端机器上安装特殊软件,一个现代浏览器就是全部所需。

现在是时候看看Pynq的实际行动了。为了这次演示,我们有一个Pynq-Z2板,其上的Zynq可编程平台通过网络连接到一个带有浏览器界面的客户端。在本片中,我们首先通过网络从浏览器访问Pynq-Z2板。板子的响应是启动JupyterLab页面。这和我们如果在桌面上运行JupyterLab看到的着陆页完全一样。我们要在文件浏览器中选择一个文件夹,我们可以在笔记本上滚动并检查它的输出,就像我们接下来在台式机上一样。

我们打开Linux shell和CPU信息,以确认我们确实是在Zynq设备中的ARM嵌入式微处理器上运行。然后,我们可以执行一个tree命令,来检查目标系统上的一些文件。

离开终端shell,我们可以查看matplotlib的在线帮助,找到一个雷达图的例子。我们现在可以将示例notebook和Python脚本下载到外部主机(在这种情况下是一台电脑)。我们可以确认我们拿到了新的笔记本和脚本。我们可以简单地将下载的文件拖放到Pynq可编程平台上,在目标上重新打开它们。为了验证我们有笔记本和脚本版本的雷达图库,最后,我们可以在目标上重新执行Python代码,无需编写任何额外的代码。如你所见,JupyterLab IDE在Zynq可编程平台上工作得非常好,已经了解JupyterLab的Python开发者可以立即开始研究Zynq。

接下来让我们更详细地看看Pynq框架。

4:Pynq框架详解与演示 🛠️

上一节我们看到了Jupyter在Pynq上的运行。本节中,我们将深入探讨Pynq框架的架构,并通过一个图像调整示例来演示其工作流程。

GitHub是一个优秀的Jupyter笔记本存储库,因为它会显示渲染过的笔记本,这很有帮助。我们把它作为我们Pynq框架的一部分。所以在最左边,旁边是一个在硬件中实现的电机控制示例。这是PID控制器电机控制算法。为了训练和推断,最后一个例子是一个OpenCV过滤器,也在硬件中实现。稍后我们将更仔细地观察图像调整器。我们只需一个pip install命令就可以做到这一点。这会部署设计到我们目标开发板上的硬件和软件元素。

在这张幻灯片中,我们会看得更详细。当我们开始使用刚从GitHub获取的笔记本时,我们从运行在ARM微处理器上的JupyterLab开始。注意在这一点上,可编程逻辑未配置。所以我们从打开笔记本开始。然后我们继续创建一个覆盖类的实例,参数化地指定要加载到可编程逻辑中的调整大小位流。一旦完成,我们现在就有了FPGA的硬件设计。当然,我们可以运行笔记本,让它控制软件和硬件。有很多细节,但这些是Python开发者或数据科学家需要知道的主要概念。

在这张幻灯片中,我们展示了图像调整器笔记本的两个部分。在左手边,笔记本上显示图像大小的部分是纯粹用软件完成的。而在右手边,调整大小的两个部分显示初始图像和调整大小后的图像。软件唯一的调整大小使用众所周知的Python图像库Pillow。在右手边,我们可以看到可编程平台设计的框图。硬件主要由一个图像调整块、一些缓冲器和一个DMA(直接内存访问)单元组成,用于有效地在DRAM和可编程逻辑之间移动数据。

在我们的第二次演示中,我们将显示图像调整大小正在执行,首先作为一个纯粹的Python程序,然后把结果与硬件加速版本进行对比。

在本片中,我们会下载、安装,然后运行图像调整器。我们首先检查设计是否已经下载。

然后我们打开一个新的浏览器窗口,转到Pynq的GitHub仓库。

在这里我们得到了pip install命令。注意,我们使用的硬件配置与第一个演示中使用的完全相同。为了节省时间,我们缩短了下载时间。我们打开文件夹取出Jupyter笔记本。

我们之前讨论过,尽管更详细,这种级别的细节完全是可选的。在这里我们可以看到笔记本单元格是相关的。所以我们运行所有的单元格。一旦我们这样做了,我们看到活动状态指示器显示“忙”,指示正在处理。现在我们可以看到调整大小的结果。首先我们看到软件调整大小,然后我们看到硬件调整大小。你可以看到结果是相同的。在这种情况下,我们可以利用timeit来测量它在硬件中花费的时间,这大约是四毫秒。对于这个相对简单的例子来说,相对于其软件等价物,它的速度大约提高了三到四倍。

在我们看下一步之前,让我们缩小一会儿来总结一下Pynq能带来什么。Pynq是一个开源框架,由软件和硬件组件组成。它基于Python、JupyterLab、NumPy以及许多其他的Python库。它可以在一系列不同的板子上运行,但我们应该注意到,Pynq本身并不是一块板子,它是管理兼具硬件和软件元素的设计的Python方法开源框架。Pynq还通过消除不同专业知识的开发人员之间协作的不必要障碍,鼓励更敏捷的开发。它允许数据科学家与硬件和嵌入式软件工程师在同一个平台上直接交流与互动。Pynq还通过向更多的Python开发人员提供可编程平台的好处,扩展了Python生态系统。

5:下一步与总结 🏁

这是演讲的最后一部分,让我们看看下一步,特别是如何开始使用Pynq。

要了解更多,我们建议你去Pynq官网,这是Pynq框架的主要位置。我们的Read the Docs站点上也有大量的文档。当然,所有我们在这次演讲中分享的硬件和软件设计都是开源的,可在我们的GitHub仓库中使用。我们有一个活跃的社区页面,托管在Discourse上,在那里你可以提交问题,看看社区里的趋势是什么。要获取受支持的开发板的完整列表,请看Pynq网站主页的相关部分。虽然Pynq始于Zynq设备,但它现在可以在一系列的板子上运行,其中一些使用不同类型的可编程平台。并不是所有支持的板子都是Pynq品牌的。在这张幻灯片上,我们在社区网页上展示了一张快照。它以来自世界各地的一系列令人兴奋和新颖的项目为特色,我们鼓励你去看看。

有一件事我们没有谈过,那就是如何设计FPGA电路。这是一个有意识的决定,因为这次演讲的重点不是逻辑设计。然而,有一点需要注意:在Pynq中,我们不是用Python合成电路。我们展示给你的所有FPGA设计都是用Xilinx Vivado工具创建的。如果你想了解更多关于设计可编程平台的知识,Xilinx使这些Vivado设计工具可用,对于Zynq板是免费的。这里的链接可以让你下载软件。也有很多很多好的资源在那里,可以帮助你理解你需要做什么才能成为一个成功的逻辑设计师。

这里有一些非常有用的Pynq资源的链接:Pynq的主页、Read the Docs页面、GitHub站点,以及你还可以去哪里买一个Pynq-Z2板。

总结一下,我们已经看到Python的流行,以及在嵌入式和边缘系统中的持续增加。我们已经展示了Zynq可编程平台如何在一个系统级芯片设备中实现微处理器、微控制器和FPGA的所有好处及更多方面。我们分享了开源Pynq框架如何让Python开发人员可以访问可编程平台,即使只有最少的硬件经验。我们还看到了Pynq是如何让硬件设计师更有效率的,使他们能够与更大的Python群体分享他们的设计。最后,Pynq是每个人的,我们邀请你去探索它。我们期待着你将来成为Pynq社区的贡献者。

感谢您今天与我们分享您的时间。我希望你觉得关于Pynq框架的讨论很有趣,对你很有价值。我代表Xilinx的所有同事,希望你和亲人平安、健康、快乐。


本节课中我们一起学习了:

  1. 平台演进:了解了从树莓派、Arduino到FPGA,再到集成所有功能的Zynq系统级芯片的发展趋势。
  2. 灵感来源:认识到Python生态系统的强大和Jupyter项目的革命性,它们是Pynq框架设计的思想基石。
  3. Pynq架构:掌握了Pynq如何在Zynq平台上集成JupyterLab,允许通过浏览器直接进行软硬件协同开发。
  4. 框架演示:通过图像调整器的例子,直观地看到了Pynq如何简化硬件加速功能的调用和对比。
  5. 入门指南:获得了开始使用Pynq所需的资源链接和下一步行动方向。

Pynq框架降低了硬件可编程平台的门槛,为Python开发者打开了通往高性能边缘计算和嵌入式创新的大门。

019:构建高效的工程师入职流程 🚀

在本节课中,我们将学习如何为你的团队设计和实施一个高效、人性化的工程师入职流程。我们将探讨如何从零开始构建一个框架,确保新成员能够快速融入团队,建立信心,并尽早开始创造价值。


概述

入职是每位新工程师加入团队的关键第一步。一个设计良好的流程不仅能加速新人的生产力,还能培养积极的团队文化。特别是在远程工作日益普遍的今天,传统的入职方法需要调整以适应新的挑战。本节内容基于多年的实践经验,旨在为你提供一个可操作的框架。


从个人经历到通用框架

上一节我们概述了课程目标,本节中我们来看看讲师分享的个人经历,这些经历揭示了糟糕的入职体验带来的问题,并最终促成了一个通用框架的形成。

讲师亚历山德拉·桑德兰分享了她作为新工程师时的艰难经历:在没有入职流程的情况下,她感到迷茫,不敢提问,花费大量时间自行搜索基础知识。这种体验是不可扩展的。

她作为导师的早期尝试也遇到了问题:例如,一次性灌输过多代码细节而缺乏业务背景,或者教授与多数新员工无关的通用技术课程。这些经历让她意识到,一个有效的流程需要同理心策略

核心教训是:永远不要假设新人已经了解对你而言显而易见的事情。无论新人的技术水平如何,他们都缺乏你团队的特定背景知识。重复一些他们可能知道的内容是无害的,关键在于提供他们寻找答案的路径。

在经历了更多错误并收集了大量反馈后,她提炼出了现在使用的入职框架。这个框架的核心支柱是:关系、知识、常见问题解答、目标、空间和反馈


第一步:建立关系 🤝

在介绍了框架的由来后,本节我们来看看第一步,也是最重要的一步——建立关系。这对于远程入职尤为关键。

入职最重要的部分是尽早建立正确的人际关系。这能帮助新人打开沟通渠道,让他们在遇到问题时知道该找谁。

以下是建立关系的几个关键行动:

  • 安排一对一会议:确保尽早与新成员进行第一次一对一交流,并保持规律周期。这为他们提供了讨论职业目标、想法和顾虑的安全空间。
  • 团队介绍:提前让团队知道有新成员加入,并鼓励大家表示欢迎。务必向新人介绍他们将密切合作的同事(如设计师、产品经理)。
  • 指定伙伴:为新人安排一位“伙伴”(Buddy)。这位伙伴是他们可以随时提问的专属联系人,能有效减轻新人在初期不敢打扰他人的压力。
  • 精心安排首次互动:对于远程入职,不要仅仅通过邮件介绍。安排一对一的视频通话,让新人能感受到对方的语气和沟通风格,减少误解。

注意:避免在第一天安排背靠背的视频会议,这会让新人(尤其是内向者)筋疲力尽。


第二步:沉淀与共享知识 📚

建立了初步的人际网络后,下一步是确保新成员能够获取到完成任务所需的知识。本节我们探讨如何将团队的“隐性知识”转化为“显性文档”。

你的团队拥有大量外人无法轻易获知的“隐性知识”,这构成了新人上手的瓶颈。目标是识别这些知识并将其记录下来。

以下是创建团队知识库的建议:

  • 记录特有流程:文档内容应专注于团队特有的内容,而非通用技术。例如:如何运行特定测试、如何设置本地环境、团队代码规范等。
  • 鼓励集体贡献:这是一个协作项目。鼓励每位团队成员在遇到问题并解决后,将方案记录下来。可以使用在线协作工具(如Notion、Confluence)或内部Wiki。
  • 建立团队FAQ:除了代码,还应记录非代码的团队惯例。例如:
    • 如何创建Pull Request?
    • 团队的Jira工作流程是怎样的?
    • 开发会议的礼仪是什么?
    • 如何申请休假?

公式团队知识 = 代码库文档 + 团队FAQ

这些文档对远程工作者尤其宝贵,因为他们无法通过“旁听”来获取背景信息。一个可搜索的FAQ能极大增强他们的自信和独立性。


第三步:设定清晰且可实现的目标 🎯

拥有了关系和知识库,新人还需要明确的方向感。本节我们来看看如何通过设定目标来管理期望并减少不确定性。

新人常常因不确定自己应该完成什么而感到压力,可能过度工作以证明自己。通过设定清晰、渐进的目标,你可以引导他们稳步成长。

以下是设定目标的方法:

  • 分解任务:将“搭建开发环境”这样的大任务分解成一系列可检查的小步骤。
  • 设定短期目标:例如,“在第一周内完成第一个小型工单并提交Pull Request”。
  • 规划长期路线图:为前三个月设定清晰的里程碑,明确在每个阶段结束时他们应掌握哪些知识和技能。
  • 确保目标可达成:目标应该是容易实现的,让新人在完成每个小目标时都能获得成就感,保持动力。

代码示例(一个简化的目标清单):

## 第一周目标
- [ ] 完成本地开发环境搭建
- [ ] 熟悉代码库结构
- [ ] 提交第一个文档修正的Pull Request
- [ ] 与团队成员完成一轮一对一交流

## 第一个月目标
- [ ] 独立完成一个简单的功能工单
- [ ] 参与一次代码评审
- [ ] 在团队会议上做一次简短分享

清晰地定义期望,能防止新人把自己累垮,并让他们看到明确的成长路径。


第四步:保障工作空间与硬件 💻

目标明确了,但实现目标需要工具。本节我们关注常被忽略但至关重要的一环:为新成员提供合适的工作条件。

在办公室,这可能意味着一张干净的桌子和一台电脑。远程工作时,你需要考虑更多。

以下是需要准备的方面:

  • 提前准备硬件:确保在新人入职日前,电脑和所需硬件(如显示器、键盘)已准备就绪并完成基本设置。
  • 远程配送:对于远程员工,制定将硬件安全配送到家的流程。
  • 支持家庭办公环境:考虑公司是否能为员工报销或提供基本的办公家具(如桌椅、显示器),这能显著提升长期工作的舒适度和效率。
  • 网络支持:确认员工家中有足够快速和稳定的网络连接,以支持视频会议和大型软件下载。

忽视这些物理条件,会直接影响到新人的工作效率和身心健康。


第五步:持续寻求与整合反馈 🔄

流程建立后,并非一成不变。本节是框架的闭环步骤——通过反馈来持续改进流程。

征求反馈是优化入职流程的关键。如果你不问,你可能会错过关于哪些环节无效的重要信息。

以下是实施反馈机制的方法:

  • 发送标准化调查:在新工程师工作满一周后,发送一份简单的反馈调查。包含评分问题和开放式问题。
    • 示例问题:“第一周,哪些部分对你最有帮助?”、“如果重来一遍,你希望如何调整第一周的安排?”
  • 跟踪指标:通过星级评分等问题,可以量化跟踪入职体验的改进趋势。
  • 开放修改渠道:鼓励新人(和所有团队成员)直接对团队文档和FAQ提出修改意见或提交更新。这不仅能保持信息最新,也能让新人立即感受到自己是团队的一份子。

核心思想:每一次入职都是对流程的一次测试。收集的每一条反馈都让下一位新人的体验更好。


实践模板与总结

在详细讲解了六个核心步骤后,本节我们将看到一个如何将它们整合到实际日程中的模板示例,并对全课内容进行总结。

以下是一个用于新人第一天的日程模板示例,它以一个全天日历事件的形式存在,整合了前述所有步骤:

**新人 [姓名] 入职第一天**
- **上午**
  - 9:00 与经理一对一(欢迎,了解背景)
  - 10:00 硬件设置与账户激活
  - 11:00 与伙伴(Buddy)视频通话
- **下午**
  - 13:00 团队欢迎午餐(视频会议)
  - 14:00 阅读“团队入门”文档 & FAQ
  - 15:00 开始“第一周目标”清单任务1:环境搭建
- **资源列表**
  - 团队文档链接:[链接]
  - 团队FAQ链接:[链接]
  - 同事联系方式列表
- **工程任务清单**(可勾选)
  - [ ] 克隆代码库
  - [ ] 安装依赖
  - [ ] 运行测试套件
  - [ ] ...

经理可以通过此共享日程轻松了解进展,无需频繁打扰新人。日程中穿插了关系建立、知识传递和目标执行。


总结与行动起点

本节课中,我们一起学习了构建高效工程师入职流程的六个核心步骤:建立关系 (🤝)、沉淀知识 (📚)、设定目标 (🎯)、保障空间 (💻)、寻求反馈 (🔄),并看到了如何用一份日程模板将它们实践出来。

无论你的团队规模大小,都可以立即开始行动:

  1. 从文档开始:为代码库创建基础文档,或建立一个团队FAQ。
  2. 收集内部反馈:询问现有团队成员他们入职时的体验和改善建议。
  3. 从小处着手:不必追求完美。从一页文档、一个伙伴制度开始,然后逐步迭代。

记住,入职流程的目标是创造公平的竞争环境,让每位新成员都能获得成功所需的支持、知识和信心。这是一个值得你精心设计和持续投入的过程。


本节课中我们一起学习了如何通过一个包含六个步骤的框架(关系、知识、目标、空间、反馈)来设计和实施一个有效的工程师入职流程,并了解了如何通过日程模板将其付诸实践,最终确保新成员能够顺利、自信地融入团队。

020:如果语句是一种代码异味 🚨

在本节课中,我们将学习如何识别代码中的“代码异味”,特别是与 if 语句相关的几种常见模式。我们将探讨这些模式为何会使代码难以理解和维护,并学习如何通过重构,例如使用多态性,来改善代码设计,使其更清晰、更易于测试和扩展。


概述

if 语句是编程语言的基本元素,它允许我们根据条件控制程序的执行流程。虽然功能强大,但过度或不当使用 if 语句会导致代码变得复杂、难以阅读和修改。这种复杂性被称为“代码异味”,它暗示着代码设计可能存在问题。本节课将介绍三种与 if 语句相关的代码异味,并提供重构策略。


什么是 if 语句?

if 语句是编程语言的元素,允许我们控制执行哪些语句。通常,程序从上到下逐行执行。if 语句让我们能够在条件为真时执行一段代码,条件为假时则跳过该段代码。

代码示例:

if today == user_birthday:
    print("Happy Birthday!")

如果 today 等于 user_birthday,则打印生日祝福;否则,跳过打印,继续执行后续代码。

通过串联一系列 if 语句,我们可以完成各种任务。然而,如果代码中包含过多的 if 语句,会使逻辑难以遵循,也更难修改。


代码异味:难以理解和修改的代码

难以理解的代码可能表现为“意大利面逻辑”,即需要上下滚动或跨多个模块查看才能理清执行流程。它也可能是冗长的函数,混杂了多种不同类型的操作,或者相关功能没有逻辑地分组在一起。糟糕的变量名、函数名,或过度使用线性流程而非高级抽象,都会导致代码难以理解。

难以修改的代码意味着,当我们需要进行更改时,必须触及代码库的许多不同部分。添加新功能时,可能需要修改大量现有代码。代码中散布的重复逻辑也使得修改时容易遗漏某些需要更新的地方。缺乏测试的代码尤其难以修改,因为我们无法确保更改不会破坏现有功能。

这种难以理解和修改的代码,我们称之为“代码异味”。它可能表明设计存在问题,但并非绝对。我们的目标是:如果逻辑难以理解,就尝试简化它;如果修改耗时过长,就调整设计以提高效率。


代码异味一:复杂的复合 if 语句

上一节我们介绍了代码异味的概念,本节中我们来看看第一种具体的异味:复杂的复合 if 语句。

单个 if 语句通常易于阅读。但当条件变得复杂,尤其是包含多个逻辑运算符(如 andor)时,理解其意图就变得困难。

重构建议:将复杂条件重构为具有描述性名称的布尔变量或函数。

例如,对于一个包含两个条件的 if 语句:

if user.is_active and user.has_valid_subscription:
    # do something

可以重构为:

is_eligible_user = user.is_active and user.has_valid_subscription
if is_eligible_user:
    # do something

如果这个复杂条件在代码中多次使用,可以将其重构为一个函数:

def is_eligible_user(user):
    return user.is_active and user.has_valid_subscription

if is_eligible_user(user):
    # do something

这样做提高了代码的可读性和可重用性。


代码异味二:嵌套的 if 语句(箭头代码)

接下来,我们探讨第二种代码异味:深层嵌套的 if 语句,它会使代码形成向右延伸的“箭头”形状。

箭头代码存在几个问题。首先,它具有很高的“圈复杂度”,即代码中不同执行路径的数量。高圈复杂度的代码难以理解,也更难测试。其次,深层嵌套会浪费大量缩进字符,限制每行代码的有效表达空间。

重构技巧:展平箭头代码,核心思想是尽早返回。

让我们通过一个案例来理解。假设我们有一个查询自行车共享站点信息的仪表板代码:

import requests

def get_bike_availability(station_id):
    response = requests.get(f"api_url/{station_id}")
    if response.status_code == 200:
        stations = response.json()
        for station in stations:
            if station[‘id‘] == station_id:
                if station[‘bikes‘] <= 3:
                    return "Bikes are low!"
                else:
                    return "Bikes are available."
        raise Exception("Station not found")
    else:
        raise Exception("API request failed")

这段代码嵌套了三层,形成了箭头形状。

以下是重构后的版本,使用“保护子句”尽早返回,从而展平了嵌套结构:

import requests

def get_bike_availability(station_id):
    response = requests.get(f"api_url/{station_id}")
    # 保护子句:尽早处理错误情况并返回
    if response.status_code != 200:
        raise Exception("API request failed")

    stations = response.json()
    for station in stations:
        if station[‘id‘] == station_id:
            # 另一个保护子句
            if station[‘bikes‘] <= 3:
                return "Bikes are low!"
            # 主逻辑变得清晰直接
            return "Bikes are available."
    # 最终的错误情况
    raise Exception("Station not found")

通过将正面检查转换为负面检查并提前返回,我们消除了深层嵌套,使主逻辑更加清晰。


代码异味三:散布各处的重复 if 语句

最后,我们来看第三种代码异味:相同的 if 语句检查遍布整个代码库。

前两种异味相对容易识别和修复。而这种异味虽然也容易发现,但在设计解决方案前,需要对问题有更深入的理解。如果这段代码永远不需要修改,问题可能不大。但如果需要阅读或修改它,探索一种不同的抽象(如多态性)可能更有意义。

案例研究:GitHub 活动摘要机器人

假设我们有一个为 GitHub 用户生成每日活动摘要的机器人。最初,我们只关心两种事件:推送代码和加星标仓库。

一个简单的实现可能包含一系列 if-elif 语句来处理不同事件类型:

def generate_summary(events):
    summary = ""
    for event in events:
        if event[‘type‘] == ‘PushEvent‘:
            summary += f"Pushed {len(event[‘payload‘][‘commits‘])} commits.\n"
        elif event[‘type‘] == ‘WatchEvent‘:
            summary += "Starred a repository.\n"
    return summary

当需要添加对新事件类型(如新开的拉取请求)的支持时,我们只需添加一个 elif 块。起初这很简单。

问题浮现:
然而,随着支持的事件类型增多,这个函数会变得越来越长,越来越难以阅读和维护。更严重的是,每次添加新功能时:

  1. 需要修改这个核心函数。
  2. 可能需要修改已有的测试,以确保新功能不会意外影响旧逻辑。
  3. 测试这个函数会变得复杂,因为它承担了过多不同类型的职责。

如果你发现添加新功能需要修改多个地方的代码或已有测试,那么你的代码中很可能存在这种异味。


重构策略:使用多态性替代条件逻辑

面对散布的重复条件逻辑,我们可以运用面向对象编程中的“多态性”来进行重构。

面向对象编程(OOP)简介:
OOP 是一种基于“对象”的编程范式。对象将数据和行为捆绑在一起。

  • :创建对象的模板(像饼干模具)。
  • 对象:类的实例(像压出的饼干)。
  • 四大支柱
    1. 封装:将数据和行为捆绑在对象内,隐藏内部细节。
    2. 抽象:隐藏对象内部的复杂性,只暴露简单的接口。
    3. 继承:子类可以继承父类的数据和行为,并可以覆盖或扩展它们,用于消除冗余代码。
    4. 多态性:为不同类型的对象提供相同的接口,但具体行为由对象类型决定。

多态性的力量:
在过程式编程中,我们使用 if 语句来选择执行哪段代码。在 OOP 中,我们可以将条件逻辑“嵌入”到对象的结构中。当代码运行时,对象的类型自动决定了执行哪种行为。

一个简单例子:

class Animal:
    def speak(self):
        raise NotImplementedError("Subclass must implement this method")

class Dog(Animal):
    def speak(self):
        return "Woof!"

class Cat(Animal):
    def speak(self):
        return "Meow!"

# 使用多态性
animals = [Dog(), Cat()]
for animal in animals:
    print(animal.speak()) # 输出: Woof! Meow!

在这里,animal.speak() 的调用根据具体对象类型(DogCat)产生不同行为,无需 if 语句检查类型。


将案例重构为多态性设计

让我们将 GitHub 摘要机器人的案例重构为使用多态性。

步骤 1:识别重复的条件块
在我们的函数中,条件块根据事件类型匹配事件并生成摘要文本。

步骤 2:创建基类来建模问题
我们创建一个 EventList 基类,它定义了接口。

class EventList:
    def __init__(self):
        self.events = []

    def add_event(self, event):
        self.events.append(event)

    def matches(self, event):
        """检查给定事件是否属于此类事件"""
        raise NotImplementedError

    def generate_summary(self):
        """为此类事件生成摘要文本"""
        raise NotImplementedError

步骤 3:为每种事件类型创建子类
每个子类实现具体的 matchesgenerate_summary 逻辑。

class PushEventList(EventList):
    def matches(self, event):
        return event[‘type‘] == ‘PushEvent‘

    def generate_summary(self):
        count = sum(len(e[‘payload‘][‘commits‘]) for e in self.events)
        return f"Pushed {count} commits."

class WatchEventList(EventList):
    def matches(self, event):
        return event[‘type‘] == ‘WatchEvent‘

    def generate_summary(self):
        return f"Starred {len(self.events)} repositories."

步骤 4:创建驱动类来协调流程
这个类负责将事件分类到各自的 EventList 子类中,并生成总摘要。

class GitHubSummaryGenerator:
    def __init__(self, event_types): # event_types 是 EventList 子类的列表
        self.event_types = event_types
        # 为每种类型初始化一个空列表
        for et in self.event_types:
            et.events = []

    def categorize_events(self, all_events):
        for event in all_events:
            for event_type in self.event_types:
                if event_type.matches(event): # 这里有一个 if 语句
                    event_type.add_event(event)
                    break # 找到匹配类型后跳出内层循环

    def generate_summary(self):
        summary = ""
        for event_type in self.event_types:
            if event_type.events: # 这里有一个 if 语句
                summary += event_type.generate_summary() + "\n"
        return summary.strip()

步骤 5:更新主函数使用新设计

def generate_github_summary(events):
    # 定义我们关心的事件类型
    event_handlers = [PushEventList(), WatchEventList()]
    generator = GitHubSummaryGenerator(event_handlers)
    generator.categorize_events(events)
    return generator.generate_summary()

添加新功能变得简单:
现在,要支持新的“拉取请求”事件,我们只需:

  1. 创建一个新的 PROpenedEventList 子类。
  2. 将其添加到 event_handlers 列表中。

无需修改现有的 PushEventListWatchEventList 类或 GitHubSummaryGenerator 的核心逻辑。测试也变得更容易,因为每个类职责单一,可以独立测试。


注意事项与权衡

虽然多态性是强大的工具,但并非银弹。引入类层次结构增加了设计的复杂性。新人需要理解这些类和它们的关系,而不是直接阅读线性逻辑。

何时重构?遵循“三次法则”:

  1. 第一次做某事:直接完成即可。
  2. 第二次做类似的事:感到重复的痛楚,可以复制一次代码,问题不大。
  3. 第三次做类似的事:是时候停下来,寻找一个更好的抽象了。

组合优于继承:
过度深的继承层次会带来耦合和僵化。很多时候,“组合”(即一个对象拥有其他对象的功能)比“继承”更灵活。有时,甚至复制粘贴代码也比使用一个错误的抽象要好。

重构的前提是拥有可靠的测试套件,以确保重构不会破坏现有功能。


总结

在本节课中,我们一起学习了 if 语句作为代码异味的几种常见形式:

  1. 复杂的复合条件:通过提取到布尔变量或函数来简化。
  2. 深层嵌套的箭头代码:通过使用保护子句尽早返回来展平。
  3. 散布的重复条件逻辑:通过使用面向对象的多态性进行重构,将条件逻辑嵌入到对象层次结构中。

记住,重构的目的是提高代码的清晰度和可维护性。在决定投入时间进行重大重构(如引入多态性)前,请权衡收益与成本,并始终在良好测试的保护下进行。现在,你已经掌握了识别这些异味和进行重构的工具,可以更有信心地让你的代码变得更加整洁。


本次课程内容基于 Ali Sivji 的演讲“If Statements are a Code Smell”整理。涉及的“忙碌海狸”机器人是芝加哥 Python 用户组的开源项目。

021:东西方交汇的Django应用翻译

概述

在本节课中,我们将学习如何为一个Django Web应用添加多语言支持。我们将跟随一位开发者的真实故事,了解他如何为家庭小生意开发的应用实现从英语到中文的翻译,并详细拆解Django框架提供的国际化与本地化工作流程。


背景故事:一个翻译需求

当你不理解他人语言时,会感到沮丧。语言障碍在今天不应成为问题。我将分享一个家庭如何克服语言障碍的故事。

我为家庭小生意开发了一个Django应用。我的妻子杰西卡曾用复杂的电子表格跟踪订单,耗时费力。我使用Django Web框架创建了数据模型,利用Django ORM管理订单和客户数据,并将Django管理后台作为前端来追踪订单。整个应用部署在Heroku上,图片存储在Amazon S3。

这个应用存在一个问题。杰西卡是家里唯一会说双语的人。我不会说中文,岳母不会说英语。我们日常最通用的语言是中式英语。因此,所有使用该应用的家庭成员都需要它从英语翻译成中文。

应用演示

我创建的应用以Django管理后台作为前端。应用包含客户和订单的数据模型。

订单页面是Django记录的标准视图,包含字段、操作、过滤器和标题等标准组件。需要注意页面右上角,我添加了语言切换部件。

整个页面已被翻译成中文,包括中文标题、操作、数据标签和过滤器。打开一个订单详情,可以看到记录表单及其按钮也已完全转换。

点击切换,页面可以改回英文。

核心问题

如何将应用在英语和中文之间进行翻译?Django框架内置了国际化支持,让这一切变得容易。


Django翻译工作流程 🛠️

在Django应用中进行翻译包含四个步骤。前两步简单,第三步工作量较大,第四步是编译。

  1. 添加应用设置以启用翻译。
  2. 配置URL路由以支持语言前缀。
  3. 标记所有可见字符串以便翻译。
  4. 提供翻译并编译成语言文件。

Django官方文档详细记录了这些步骤。在深入之前,我们先定义关键术语。

关键术语定义

  • 字符串:指需要在应用中进行翻译的文本,可位于Python代码或Django模板中。
  • 翻译:指将字符串从一种语言映射到另一种语言的过程。
  • 国际化:缩写为 i18n。指使应用程序适应不同地区的开发工作和工具。Django的翻译框架属于国际化范畴。
  • 本地化:缩写为 l10n。指国际化过程的产出物,即针对特定地区(如中文)的语言文件。

工作流的前三步(设置、路由、标记)属于国际化。第四步(语言文件)属于本地化


第一步:应用设置 ⚙️

配置位于 settings.py 文件。

以下是需要添加或检查的设置:

1. 添加中间件
LocaleMiddleware 添加到 MIDDLEWARE 列表。它使Django能自动检测语言偏好。顺序很重要:需在 SessionMiddleware 之后,CommonMiddleware 之前。

MIDDLEWARE = [
    # ...
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.locale.LocaleMiddleware',  # 添加此行
    'django.middleware.common.CommonMiddleware',
    # ...
]

2. 设置默认语言
LANGUAGE_CODE 设置应用的默认语言,格式需符合HTTP Accept-Language头代码。

LANGUAGE_CODE = 'en-us'  # 美式英语

3. 启用翻译和本地化格式
USE_I18NUSE_L10N 应设为 True 以启用翻译和本地化日期/数字格式。

USE_I18N = True
USE_L10N = True

4. 指定支持的语言
LANGUAGES 列表限定了可供翻译的语言。这很重要,可以防止用户请求未支持的语言时出现错误或显示英文占位符。

from django.utils.translation import gettext_lazy as _

LANGUAGES = [
    ('en', _('English')),
    ('zh-hans', _('Simplified Chinese')),
]

5. 设置语言文件路径
LOCALE_PATHS 指定Django查找翻译文件(.mo文件)的目录列表。建议在项目根目录下创建 locale 文件夹集中管理。

import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/52d64aeb3f3a21f8623d9c3a2aa85fc9_12.png)

LOCALE_PATHS = [
    os.path.join(BASE_DIR, 'locale'),
]

第二步:URL路由 🌐

URL中的语言前缀可以直接指示加载页面所需的语言。

例如:

  • /en/orders/ 加载英文订单页面。
  • /zh-hans/orders/ 加载简体中文订单页面。
  • /orders/ 无前缀,将加载默认语言(英语)页面。

urls.py 中配置非常简单:

from django.conf.urls.i18n import i18n_patterns
from django.urls import path, include
from django.views.generic import TemplateView

urlpatterns = [
    # 非国际化URL(如API)可以放在这里
]

# 使用 i18n_patterns 包装需要国际化的URL
urlpatterns += i18n_patterns(
    path('orders/', include('orders.urls')),
    path('admin/', admin.site.urls),
    # ... 其他需要翻译的视图
    prefix_default_language=False, # 重要:不为默认语言添加前缀
)

设置 prefix_default_language=False 后,访问默认语言(如英语)页面时无需URL前缀。


第三步:标记字符串 🏷️

这是工作量最大的一步,需要标记所有面向用户的字符串。

在Python代码中标记

使用 django.utils.translation 模块的函数。

导入约定

from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _

通常别名为下划线 _,因为输入更短。

即时翻译 gettext
用于在视图等执行时即需翻译的字符串。

from django.http import HttpResponse
from django.utils.translation import gettext as _

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/52d64aeb3f3a21f8623d9c3a2aa85fc9_18.png)

def my_view(request):
    output = _("Welcome to my site.") # 字符串被标记
    return HttpResponse(output)

惰性翻译 gettext_lazy
用于在模型等定义时不需要,但在访问(如展示)时才需要翻译的场景。对模型字段必须使用惰性翻译

from django.db import models
from django.utils.translation import gettext_lazy as _

class MyThing(models.Model):
    name = models.CharField(_('name'), help_text=_('This is the help text')) # 使用惰性翻译

    class Meta:
        verbose_name = _('my thing')
        verbose_name_plural = _('my things')

在Django模板中标记

翻译单个字符串
使用 {% trans %} 标签。

<!-- 翻译字面量 -->
<title>{% trans "My Homepage" %}</title>

<!-- 翻译变量 (谨慎使用,可能不会被makemessages自动提取) -->
<p>{% trans my_variable %}</p>

<!-- 未来翻译占位符 -->
<p>{% trans "Future translation" as noop %}{{ noop }}</p>

翻译文本块
使用 {% blocktrans %} 标签,支持占位符。

{% blocktrans with user_name=user.username %}
Hello, {{ user_name }}! You have {{ count }} message.
{% plural %}
Hello, {{ user_name }}! You have {{ count }} messages.
{% endblocktrans %}

翻译Django管理后台

管理后台的许多字符串已有翻译。在自定义时需注意:

  1. 为模型翻译 verbose_nameverbose_name_plural,而非 name 字段。
  2. settings.py 中为管理后台标题提供翻译:
    from django.utils.translation import gettext_lazy as _
    ADMIN_SITE_INDEX_TITLE = _('My Admin Home')
    ADMIN_SITE_HEADER = _('My Administration')
    ADMIN_SITE_TITLE = _('My Site Admin')
    

标记字符串的建议

  • 默认使用惰性翻译:使用 gettext_lazy 通常无害,且能避免在模型等地方出错。
  • 仔细检查管理后台:许多模板已有翻译,注意不要重复工作或冲突。
  • 保持界面整洁:确保所有标题、按钮文本都被标记。

第四步:提供翻译并编译 📦

此步骤涉及创建翻译文件、填入译文,并编译供Django使用。

1. 创建消息文件 (.po)

在命令行中运行以下命令,为特定语言创建或更新消息文件:

django-admin makemessages -l zh_Hans
  • -l 后跟的是地区代码,不是语言代码。例如,简体中文的语言代码是 zh-hans,但地区代码是 zh_Hans
  • 此命令会扫描项目中的所有Python文件和模板,提取被标记的字符串。
  • LOCALE_PATHS 指定的目录下(如 locale/zh_Hans/LC_MESSAGES/)生成 .po 文件。

.po 文件示例:

#: orders/models.py:12
msgid "name"
msgstr "名称"

#: orders/templates/orders/detail.html:5
#, fuzzy
msgid "Order Details"
msgstr "订单详情"
  • msgid: 源字符串(英文)。
  • msgstr: 目标语言翻译。
  • #, fuzzy: 表示Django找到了可能的翻译建议,但需人工确认。
  • 如果 msgstr 为空,Django将保留字符串不翻译。

2. 填写翻译

打开生成的 .po 文件,为每个 msgidmsgstr 中填入准确的翻译。这通常需要人工或借助专业翻译服务完成。

3. 编译消息文件 (.mo)

翻译填写完成后,运行编译命令:

django-admin compilemessages

此命令将 locale 目录下所有 .po 文件编译成Django运行时使用的 .mo 文件。.mo 文件是二进制格式,效率更高。

重要:编译后的 .mo 文件需要像其他静态文件(CSS, JS)一样被部署到服务器。


总结与思考 💭

本节课我们一起学习了为Django应用添加多语言支持的完整四步工作流程:配置设置、路由、标记字符串、编译翻译。Django的国际化框架使这个过程标准化且相对直接。

为何翻译至关重要

  1. 连接人群:翻译能弥合语言鸿沟,让信息无障碍流通,对于家庭、社区乃至全球用户都至关重要。
  2. 易于实现:借助Django等框架,实现翻译虽有工作量,但并无难以克服的技术挑战。
  3. 早期规划:在应用开发初期就规划国际化,远比后期添加更容易。
  4. 人工智能辅助:AI翻译工具可提高效率,但仍需人工校对以确保准确性,尤其是处理俚语或特定语境时。
  5. 即时翻译的局限:浏览器即时翻译(如Chrome的翻译功能)并非完美,可能曲解意思。对于需要精确性的应用(如管理后台、法律、医疗),预设的静态翻译更可靠。
  6. 开发者的道德责任:翻译的真实性和准确性至关重要。错误的翻译,尤其是故意的误译,会误导用户、传播错误信息。作为开发者,我们有责任确保技术(包括翻译)被合乎道德地使用。

通过本教程,希望你能掌握Django国际化的核心技能,并理解其背后的重要意义。现在,你可以开始为你自己的Django应用打破语言壁垒了。

022:为什么Python慢

在本节课中,我们将深入探讨一个经典话题:为什么Python(特指CPython)在某些场景下运行速度较慢。我们将从编译原理、解释器架构、即时编译(JIT)技术、内存管理等多个角度,科学地分析其性能瓶颈,并了解现有的优化方案和替代选择。


1. 🚀 性能对比:一个直观的例子

上一节我们提出了核心问题,本节中我们来看看一个具体的性能对比案例。

当我们谈论“Python慢”时,通常指的是官方的CPython解释器。让我们通过一个CPU密集型基准测试——模拟木星、土星、天王星和海王星轨道的N体问题——来直观感受一下。

  • C语言实现:执行时间约为 7秒
  • CPython实现:执行相同算法需要 14分钟

这个差距是数量级的。虽然C是强类型编译语言,而Python是动态类型解释语言,直接比较可能不公平。但同为动态类型语言的Node.js(使用V8 JavaScript引擎)在该基准测试中也比CPython快得多。这引出了我们的核心疑问:为什么?


2. ⚙️ CPython如何运行代码

要理解性能差异,我们需要先了解CPython执行代码的流程。上一节我们看到了性能差距,本节我们来剖析CPython的内部工作机制。

Python代码是跨平台的。当执行一个.py文件时,解释器会按以下步骤工作:

  1. 解析:将源代码解析成抽象语法树
  2. 编译:编译器遍历AST,生成控制流图,然后将其转换为连续的字节码指令。
  3. 执行求值循环(一个巨大的switch语句)逐条获取并执行字节码指令。每个函数调用会创建一个栈帧,用于管理局部变量、返回地址等信息。

为了提升效率,编译后的字节码会被缓存(存储在__pycache__目录中),下次运行相同代码时可直接加载。

关键点:CPython是一个提前编译器。代码在执行前已被完全编译为字节码。执行时,解释器循环(ceval.c中的主循环)负责解释这些字节码,而许多字节码操作最终会调用已编译的C函数。


3. 🔁 性能瓶颈:紧密循环问题

既然很多工作由高效的C代码完成,为什么CPython还是慢?线索就在那个“求值循环”里。上一节我们了解了执行流程,本节我们来看看其中的开销。

这个循环的每次迭代都不是免费的。对于每个简单的字节码操作(例如加法),解释器都需要:

  • 取指(获取下一条字节码)。
  • 解码(通过switch判断执行哪个分支)。
  • 执行操作。

在N体测试这类紧密循环中,代码包含数百万次简单的算术运算。CPython解释器将大量时间花费在循环开销上,而不是实际计算上。

公式表示

总执行时间 ≈ (实际计算时间) + (循环迭代次数 × 单次循环开销)

当循环迭代次数极大且单次操作很简单时,循环开销就占据了主导地位。


4. ⚡ JIT编译器的威力

那么,如何解决紧密循环的开销问题?答案是即时编译器。上一节我们看到了AOT编译的瓶颈,本节中我们来看看JIT如何优化。

与CPython的AOT编译不同,JIT编译器在程序运行时进行编译。它能监控代码执行,识别“热点”(被频繁执行的代码段,如循环),并将其动态编译成更高效的机器码。

PyPy是一个用Python编写的Python解释器,它内置了JIT编译器。在N体测试中,PyPy比CPython快约650%。这证明了JIT在优化重复性任务上的巨大潜力。

核心概念JIT编译器能够分析数据流,在运行时改变代码的编译和执行方式,将多个操作“融合”在一起,极大减少了循环开销。

另一个例子是Numba,它是一个针对数值计算的JIT编译器,可以作为CPython的扩展使用。通过一个简单的装饰器,就能让函数运行速度大幅提升。

from numba import jit

@jit(nopython=True)
def fast_function(x):
    # 你的数值计算代码
    return result

5. 🧠 中间表示与优化

为什么V8(Node.js)和PyPy的JIT如此高效?关键在于它们使用的中间表示。上一节我们介绍了JIT的概念,本节我们深入其优化原理。

CPython的中间表示是线性的字节码序列,执行顺序是固定的。

而现代JIT编译器(如V8的Turbofan、PyPy)使用静态单赋值形式的中间表示。在这种表示中,所有变量和操作都成为一张巨大数据流图的节点,边表示数据依赖关系。

优势

  • 编译器可以清晰地看到数据如何流动。
  • 能够轻松识别并消除死代码(不影响结果的代码)。
  • 可以更智能地调度指令,让CPU以最高效的方式执行。

这种基于数据流的优化能力,是V8和PyPy在动态语言上也能实现高性能的重要原因。


6. 🚫 为什么CPython没有内置JIT?

既然JIT这么好,为什么CPython不加入一个呢?这主要有几个原因:

  1. 与C扩展的兼容性:CPython生态的核心优势之一是能无缝调用C扩展模块。JIT编译器需要分析代码结构和数据流来进行优化,但编译好的C扩展模块对JIT来说是一个“黑盒”,无法优化。这会导致性能收益大打折扣。
  2. 设计哲学与复杂度:CPython编译器的设计强调简单通用。引入JIT会极大增加复杂性,违背其设计原则。JIT本身也有内存开销和启动时间成本。
  3. 开发资源:开发并维护一个高质量的JIT编译器需要巨大的、持续的工程投入(参考由大型公司支持的V8团队)。CPython主要由志愿者在业余时间维护,资源有限。

7. 🗃️ 内存管理与垃圾回收

性能不仅仅是CPU速度,内存管理也至关重要。上一节讨论了执行优化,本节我们看看内存方面的表现。

CPython使用引用计数作为主要内存管理机制。每个对象都有一个计数器,记录引用它的变量数量。当计数归零时,对象立即被销毁。

但是,循环引用(例如,两个对象互相引用)会导致引用计数无法归零。为此,CPython配备了分代垃圾回收器,它会定期扫描并清理存在循环引用的对象。

问题:CPython的GC是“停止世界”的。在GC运行时,所有其他操作都会暂停。虽然停顿时间很短,但在某些场景下仍可能产生影响。

相比之下,V8等引擎的GC使用了更复杂的策略(如并行标记),可以进一步减少主线程的暂停时间。但这同样意味着更高的实现复杂度。


8. 🧵 并行计算:GIL与解决方案

全局解释器锁是CPython中另一个著名的“性能杀手”。它确保同一时刻只有一个线程执行Python字节码,这限制了多核CPU的利用。

对于CPU密集型并行任务,有以下解决方案:

  • multiprocessing 模块:创建多个进程,每个进程有独立的解释器和内存空间,绕过GIL。适用于任务可拆分且进程启动开销相对较小的场景。
  • 使用替代解释器:如PyPy(也有GIL)或Jython/IronPython(无GIL)。
  • 将关键部分移至C扩展:在C扩展中释放GIL。
  • 异步I/O:对于I/O密集型并发任务,asyncio是更轻量、高效的方案。
  • 子解释器:Python 3.9+引入了实验性的子解释器功能,允许在同一个进程内运行多个隔离的解释器,比多进程更轻量,是未来解决并行问题的一个有潜力的方向。


9. ✅ 总结与选择

本节课中,我们一起深入探讨了“为什么Python慢”这个问题。

核心总结

  1. 紧密循环开销:CPython的解释器循环在大量简单操作上开销显著,这是其处理数值计算时慢的主要原因。
  2. JIT的缺失:由于兼容性、复杂度和资源限制,CPython没有内置JIT编译器,而JIT正是V8、PyPy等实现高性能的关键。
  3. 通用性设计:CPython的设计目标是简单和通用,而非针对特定场景(如数值计算)进行极致优化。
  4. 优化有代价:所有优化(JIT、高级GC、无GIL)都伴随着复杂性、内存开销或开发成本的增加。

给开发者的建议:“Python慢”这个说法需要细化。CPython在它设计的目标场景(如脚本、文本处理、胶水逻辑)中是非常有效的。当你遇到性能瓶颈时,应首先分析问题类型:

  • CPU密集型/紧密循环:考虑使用PyPyNumba或将核心部分用C/C++编写。
  • 并行计算:使用multiprocessingconcurrent.futures或关注子解释器的未来发展。
  • I/O密集型:使用asyncio进行异步编程。

Python生态系统提供了多种解释器和工具,选择最适合你问题领域的工具,而不是期望一个通用解释器在所有场景下都表现最佳。理解这些底层原理,能帮助我们做出更明智的技术选型和优化决策。

023:与布莱恩·K·奥肯谈论 - 用参数化测试提升你的测试有效性

在本节课中,我们将学习如何使用参数化测试来提升测试的有效性和效率。我们将探讨参数化的基本概念、三种不同的实现方式,以及一些高级技巧。通过本教程,你将能够编写更简洁、更易于维护的测试代码。

概述

自动化测试对于构建可靠的软件至关重要。它能增强你对代码的信心,让你更自豪地交付工作,并促进团队间的信任。如果测试能快速编写且易于维护,其价值会更高。本节课的核心就是介绍如何利用参数化测试来实现这一目标。

我们将介绍三种参数化方法:使用固定的参数化函数、使用参数化装饰器,以及使用 pytest_generate_tests 钩子函数生成测试。此外,我们还将学习如何运行参数化的子集,并简要了解 pytest.paramindirect 这两个高级概念。

澄清与准备

在深入之前,需要澄清一个拼写细节。参数化的英文 “parameterize” 在英式英语和美式英语中有两种拼写(“parameterise” 和 “parameterize”)。在 pytest 框架中,使用的是英式拼写,即 t 和 r 之间没有 e

我们将编写测试来验证一个名为 triangle_type 的函数。该函数接收三个角度值,并返回三角形的类型:right(直角三角形)、obtuse(钝角三角形)、acute(锐角三角形)或 invalid(无效三角形)。为了演示,代码中故意包含了一个错误。

为了便于演示,我们在 pytest.ini 配置文件中进行了一些设置:

  • addopts = -v:显示详细的测试名称。
  • addopts = --tb=no:关闭错误回溯信息,使输出更清晰。

从冗余测试到参数化

初始的冗余测试方法

要测试 triangle_type 函数,我们至少需要四个测试用例来覆盖所有可能的输出。一种直接的方法是编写四个独立的测试函数。

def test_right_triangle():
    assert triangle_type(90, 45, 45) == ‘right‘

def test_obtuse_triangle():
    assert triangle_type(100, 40, 40) == ‘obtuse‘

def test_acute_triangle():
    assert triangle_type(60, 60, 60) == ‘acute‘

def test_invalid_triangle():
    assert triangle_type(0, 0, 180) == ‘invalid‘

这种方法虽然简单,但存在明显的冗余。随着测试逻辑变复杂,这种冗余会变得更加难以维护。更重要的是,如果其中一个测试失败,pytest 默认只会报告整个测试函数失败,而不会明确指出是哪个具体的用例出了问题,除非我们查看详细回溯。

引入参数化测试

参数化测试允许我们将多个测试用例合并到一个测试函数中,同时保持每个用例的独立性。pytest 会为每个参数组合生成一个独立的测试节点,这样当某个用例失败时,我们能清晰地看到是哪一个。

上一节我们看到了冗余测试的缺点,本节中我们来看看如何使用 @pytest.mark.parametrize 装饰器来改进它。

以下是参数化测试的基本语法:

import pytest

# 定义测试数据和期望结果
test_cases = [
    (90, 45, 45, ‘right‘),
    (100, 40, 40, ‘obtuse‘),
    (60, 60, 60, ‘acute‘),
    (0, 0, 180, ‘invalid‘),
]

# 使用装饰器进行参数化
@pytest.mark.parametrize(‘a, b, c, expected‘, test_cases)
def test_triangle_type(a, b, c, expected):
    assert triangle_type(a, b, c) == expected

运行这个测试,pytest 会输出四个独立的测试结果,每个都有清晰的名称(例如 test_triangle_type[90-45-45-right])。这样,任何一个用例失败,我们都能立刻定位到它。

我们可以进一步优化,将测试用例列表提取出来,使装饰器更简洁:

triangle_test_cases = [
    (90, 45, 45, ‘right‘),
    (100, 40, 40, ‘obtuse‘),
    (60, 60, 60, ‘acute‘),
    (0, 0, 180, ‘invalid‘),
]

@pytest.mark.parametrize(‘a, b, c, expected‘, triangle_test_cases)
def test_triangle_type_parametrized(a, b, c, expected):
    assert triangle_type(a, b, c) == expected

动态生成测试参数

参数化的强大之处在于,测试数据源可以非常灵活。它不一定是一个静态列表。

上一节我们使用了固定的参数列表,本节中我们来看看如何动态生成这些参数。

使用函数生成参数

我们可以将一个函数传递给 @pytest.mark.parametrize 装饰器。这个函数在测试收集阶段被调用,并返回一个参数列表。

def generate_triangle_cases():
    # 可以在这里进行复杂的逻辑计算或数据筛选
    cases = [
        (90, 45, 45, ‘right‘),
        (100, 40, 40, ‘obtuse‘),
        (60, 60, 60, ‘acute‘),
        (0, 0, 180, ‘invalid‘),
    ]
    # 例如,可以过滤掉某些用例
    return [case for case in cases if case[3] != ‘invalid‘] # 不测试无效用例

@pytest.mark.parametrize(‘a, b, c, expected‘, generate_triangle_cases())
def test_triangle_type_from_func(a, b, c, expected):
    assert triangle_type(a, b, c) == expected

使用生成器或读取文件

参数生成函数也可以是一个生成器,这对于处理大型数据集(如从文件读取)非常有用,因为它可以惰性地产生数据。

def triangle_cases_from_file():
    # 假设有一个包含测试数据的文件
    with open(‘test_data.csv‘, ‘r‘) as f:
        for line in f:
            a, b, c, expected = line.strip().split(‘,‘)
            yield int(a), int(b), int(c), expected

@pytest.mark.parametrize(‘a, b, c, expected‘, triangle_cases_from_file())
def test_triangle_type_from_file(a, b, c, expected):
    assert triangle_type(a, b, c) == expected

注意:虽然使用了生成器,但 pytest 会在测试收集阶段将其耗尽,将所有数据加载到内存中。对于非常大的文件,需要考虑其他策略。

运行特定的参数化测试用例

当参数化测试很多时,我们可能只想运行其中的一部分,例如只运行上次失败的,或只运行某个特定类型的测试。

以下是运行参数化测试子集的几种方法:

  • 运行上次失败的测试:使用 pytest --lf 命令。
  • 运行指定的测试用例:使用 pytest -k 进行关键字过滤。例如,pytest -k “60“ 会运行所有名称中包含 “60” 的测试。
  • 运行完整的节点ID:可以复制粘贴 pytest 输出的完整测试节点 ID 来运行单个用例。例如:pytest test_file.py::test_triangle_type[60-60-60-acute]

许多现代 IDE(如 PyCharm、VS Code)也提供了图形化界面来运行单个参数化测试用例,这非常方便。

固件(Fixture)参数化

除了测试函数,pytest 的固件(Fixture)也可以被参数化。这允许你为每个测试用例提供不同的固件数据。

以下是固件参数化的基本语法:

import pytest

# 定义参数化固件
@pytest.fixture(params=[(90, 45, 45), (100, 40, 40), (60, 60, 60)])
def triangle_angles(request):
    # request.param 包含了来自 params 列表的当前值
    return request.param

# 在测试中使用参数化固件
def test_with_fixture_param(triangle_angles):
    a, b, c = triangle_angles
    # 这里可以调用 triangle_type,但需要额外处理期望值
    result = triangle_type(a, b, c)
    # ... 进行断言

然而,默认的固件参数化生成的测试名称不友好(如 triangle_angles[0])。我们可以通过 ids 参数来改善:

@pytest.fixture(params=[(90, 45, 45, ‘right‘),
                        (100, 40, 40, ‘obtuse‘),
                        (60, 60, 60, ‘acute‘)],
                ids=lambda val: val[3]) # 使用期望结果作为ID
def triangle_case(request):
    return request.param

def test_with_named_fixture_param(triangle_case):
    a, b, c, expected = triangle_case
    assert triangle_type(a, b, c) == expected

ids 可以是一个字符串列表,也可以是一个接收参数值并返回字符串的函数。好的测试 ID 应该足够简短,能唯一标识测试用例。

使用 pytest_generate_tests 钩子

对于更高级的动态测试生成需求,可以使用 pytest_generate_tests 钩子函数。pytest 会在测试收集时调用这个钩子。

一个常见的用途是基于自定义命令行参数来动态生成或过滤测试。

def pytest_addoption(parser):
    # 添加一个自定义命令行选项
    parser.addoption(“--test-mode“, action=“store“, default=“all“,
                     help=“运行模式: all, smoke, quick“)

def pytest_generate_tests(metafunc):
    # 检查测试函数是否需要 ‘angle_data‘ 参数
    if “angle_data“ in metafunc.fixturenames:
        # 获取命令行选项
        test_mode = metafunc.config.getoption(“test_mode“)
        all_cases = [...]
        if test_mode == “smoke“:
            selected_cases = [case for case in all_cases if case[3] == ‘right‘]
        elif test_mode == “quick“:
            selected_cases = all_cases[:2]
        else:
            selected_cases = all_cases
        # 对测试函数进行参数化
        metafunc.parametrize(“angle_data“, selected_cases)

这样,你就可以通过 pytest --test-mode=smoke 来只运行冒烟测试用例。

高级概念简介

最后,我们简要介绍两个你可能不会立即用到,但值得了解的高级概念。

pytest.param

pytest.param 用于在参数化中为单个测试用例添加额外的元数据,比如标记(marks)或自定义ID。

import pytest

@pytest.mark.parametrize(“a, b, c, expected“, [
    pytest.param(90, 45, 45, ‘right‘, marks=pytest.mark.smoke),
    pytest.param(100, 40, 40, ‘obtuse‘, id=“obtuse_case“),
    (60, 60, 60, ‘acute‘),
    pytest.param(0, 0, 180, ‘invalid‘, marks=[pytest.mark.slow, pytest.mark.xfail]),
])
def test_with_param(a, b, c, expected):
    assert triangle_type(a, b, c) == expected

然后可以用 pytest -m smoke 来运行标记为 smoke 的测试。

indirect 参数

indirect 参数允许你将参数化中提供的值,先传递给一个同名的固件进行处理,再将处理结果注入测试函数。这适用于参数需要预处理的情况。

import pytest

@pytest.fixture
def processed_angle(request):
    # 固件接收原始参数值
    raw_value = request.param
    # 进行一些处理,例如转换为整数
    return int(raw_value)

@pytest.mark.parametrize(“processed_angle“, [“90“, “100“, “60“], indirect=True)
def test_with_indirect(processed_angle):
    # processed_angle 在这里已经是整数了
    print(f“Testing with angle: {processed_angle} (type: {type(processed_angle)})“)
    # ... 进行测试

indirect=True 表示所有参数都间接处理,也可以指定一个参数名列表 indirect=[“param1“]

总结与优势

本节课中我们一起学习了参数化测试的核心概念和多种实现方式。

让我们回到最初的例子。通过参数化,我们可以轻松地将测试用例从4个扩展到11个甚至更多,而代码结构依然清晰易读:

triangle_test_cases = [
    # 直角三角形
    (90, 45, 45, ‘right‘),
    (90, 30, 60, ‘right‘),
    # 钝角三角形
    (100, 40, 40, ‘obtuse‘),
    (120, 30, 30, ‘obtuse‘),
    # 锐角三角形
    (60, 60, 60, ‘acute‘),
    (70, 55, 55, ‘acute‘),
    (80, 50, 50, ‘acute‘),
    # 无效三角形
    (0, 0, 180, ‘invalid‘),
    (90, 90, 0, ‘invalid‘),
    (200, 10, -10, ‘invalid‘),
    (10, 20, 30, ‘invalid‘), # 和不等于180
]

@pytest.mark.parametrize(‘a, b, c, expected‘, triangle_test_cases)
def test_all_triangles(a, b, c, expected):
    assert triangle_type(a, b, c) == expected

参数化测试的主要优势在于:

  1. 减少冗余:消除重复的测试代码。
  2. 提高可维护性:添加新测试用例只需在数据列表中添加一行。
  3. 清晰的测试报告:每个用例独立报告成功或失败。
  4. 支持复杂数据源:可以从函数、生成器、文件等动态生成测试数据。
  5. 便于针对性运行:可以轻松运行测试的子集。

你可以混合使用这些技术(如多个 @pytest.mark.parametrize 装饰器会产生参数组合的笛卡尔积),但要注意避免生成过于庞大的测试用例集。

如果你想深入学习 pytest,推荐阅读《Python Testing with pytest》一书。此外,播客 “Test & Code“ 和 “Python Bytes“ 也是很好的学习资源。

024:教程

概述

在本教程中,我们将学习如何在 Django 项目中实现有限状态机。有限状态机是一种用于管理对象状态和状态间转换的强大模式,特别适用于建模业务流程和工作流。我们将从基本概念开始,逐步深入到如何在 Django 中应用它,包括定义状态、创建转换、设置权限以及生成可视化图表。


什么是工作流?

工作流是一系列为完成特定任务而进行的交互。它们在现实生活和软件应用中无处不在。例如,申请带薪休假时,你会提交请求,该请求随后被批准或拒绝,从而进入新的状态。

我们已经非常熟悉现实生活中的工作流,但需要学习如何在应用程序中建模这些相同类型的工作流。这就是有限状态机发挥作用的地方。


什么是有限状态机?

有限状态机是一种用于设计计算机程序行为的简单状态机模型。具体来说,它由有限数量的状态以及连接这些状态的转换组成。转换是关键,它定义了一系列从一个状态开始、到另一个(或同一个)状态结束的动作。

让我们从一个简单的例子开始。考虑一个最简单的发布模型,例如一个联系人管理系统的发布流程。它可能包含两个状态:private(私有)和published(已发布)。状态之间的转换由带箭头的线表示,例如“发布”或“撤回”。

在这个简单的例子中,我们没有显示谁被允许执行什么操作。在软件中,这一点变得非常重要,因为你希望设置条件守卫,只允许特定用户在特定时间执行特定操作。


何时使用工作流状态而非布尔值?

有时,开发者可能想使用布尔标志(如 is_published)来建模状态。在某些简单情况下这是有效的,但当应用程序复杂性增加时,仅有两个状态通常不足以建模现实中发生的事情。

例如,你的数据库中的行可能正在被某个外部程序处理。你可能有竞争条件。因此,你可能希望为这个第三方进程选择所有项目,将它们从“未处理”状态放入“处理中”状态,然后再放入“已处理”状态,以便追踪。

将这个概念扩展到协作场景,用户可能希望只与特定用户共享信息。我们希望能够拥有与权限相关的工作流状态。例如,你可能只希望对象的创建者可以编辑它,但当它进入“已发布”状态时,你可能希望匿名网站用户可以查看它,同时可能不希望所有者直接编辑已发布的内容,而是需要通过一个审查工作流将其“撤回”或提交复审。


现实世界中的复杂工作流示例

理论讨论已经足够,让我们看看现实世界的例子。许多人都曾是大学生,如果你曾经换过专业,你就会知道这个过程有多痛苦,它通常涉及大量纸质流程。

我们曾为当地一所社区大学做一个项目,旨在将更改专业的请求流程自动化并搬到线上。如果你仔细观察这个过程,它从“进行中”状态开始。学生提交更改专业的请求后,请求进入“待审查”状态。之后可能会有多个状态:例如,从“待审查”状态,它可以转到“信息不完整”状态,然后返回给学生补充信息;或者被标记为“完整”,由院长接受,然后通过剩余的工作流状态;它也可以直接进入“被拒绝”状态。

这个工作流可能很复杂,涉及许多不同的状态和转换。图中的所有圆圈代表工作流状态,所有箭头代表特定的转换。


工作流中的权限与自动化

我们不仅可以将工作流应用于更改大学课程等业务流程,还可以用于其他场景,例如在研究网站上提交病例报告以供发表,并让报告通过审查流程。

观察一个病例报告工作流的图表。对象的初始状态在左上角(例如“草稿”)。有趣的是,图表中有箭头返回到相同的状态。这可以用来追踪和记录数据库中项目何时被“保存”。“保存”操作或转换只是将其带回到原始工作流状态,主要用于追踪它被保存的时间和频率,或者当项目本身发生修改时,这可能会触发系统内部的其他行为。

大圆圈是我们的状态,箭头是我们的转换。颜色可以表示谁有权编辑项目。例如,在“草稿”状态,对象的所有者可以编辑和保存;在“处理中”状态,只有审核者可以编辑和重新提交对象。

关键点在于:谁能在何时何地做什么。此外,转换实际上可以导致返回到相同的工作流状态。另一件需要考虑的事情是,一些工作流转换可以是自动的,由触发器触发。通常,触发器是应用程序中的最终用户点击按钮,但也可能是外部进程。例如,在会员注册网站中,用户填写表单后,系统可以自动验证表单并触发状态转换。


为什么在 Django 中需要有限状态机框架?

Django 是一个固执己见的框架,但在角色和权限领域,它并不是非常固执己见。它提供了一些简单的角色(如“staff”和“admin”),但其余部分由开发者决定。权限逻辑通常非正式地分散在代码中——在模型、视图、管理命令、表单和中间件里进行检查。

如果没有一个框架,用于保护项目或确定谁可以做什么的代码会分散在各处,导致难以维护的“意大利面条式代码”。一个框架可以帮助我们,尤其是在处理“谁能在何时做什么”这种棘手问题时。

使用框架的额外好处是,我们可以轻松地开始记录“谁在何时做了什么”,从而建立审计线索。


介绍 Django FSM

我们首先采用“胖模型”的开发方式,Django 实际上鼓励创建拥有大量字段和方法的“胖模型”。django-fsm 是一个可重用的 Django 应用,它代表“有限状态机”。它很好,因为它有助于让团队中的每个人在思想上保持一致,了解如何控制应用程序中对象的状态。

django-fsm 的基础包括:

  1. 一个自定义模型字段:FSMField
  2. 一个用于转换的 Python 装饰器:@transition
  3. 通过类贡献添加到模型类中的便捷方法。

当你创建一个字段并开始用 @transition 装饰模型上的方法(如 editpublishsubmit)时,类贡献机制会自动在你的模型中添加一些方法来收集信息,例如有哪些可用的转换、所有转换是什么、有哪些状态等。


添加 FSM 字段

向现有模型添加工作流状态非常简单。你只需在模型中添加一个字段。

from django_fsm import FSMField, transition

class Article(models.Model):
    # ... 其他字段 ...
    state = FSMField(default='draft', protected=True)

FSMField 是一个基于文本的字段,用于存储各种工作流状态的字符串。还有一个 FSMIntegerField,如果你想存储枚举状态集。我们配置了哪些是可能和可用的工作流状态,以及什么是默认状态(即模型新实例创建时的初始状态)。

通常,我们会在 Django 应用中创建一个模块,在其中定义一个类来封装所有关于工作流状态的信息。

class STATES:
    DRAFT = 'draft'
    REVIEW = 'review'
    PUBLISHED = 'published'
    # ... 其他状态 ...

    CHOICES = (
        (DRAFT, '草稿'),
        (REVIEW, '审核中'),
        (PUBLISHED, '已发布'),
        # ...
    )

然后在模型字段中使用它:

state = FSMField(default=STATES.DRAFT, choices=STATES.CHOICES, protected=True)

创建状态转换

在这些工作流状态之间移动,需要通过转换来完成。直接更改数据库字段可能会导致意外的副作用。转换是我们模型上的方法,并用 @transition 装饰器标记。

@transition(field=state, source=STATES.DRAFT, target=STATES.DRAFT)
def edit(self):
    """编辑文章,但保持在草稿状态。"""
    # 这里可以执行编辑相关的逻辑,例如更新修改时间
    self.save()

观察这个转换:

  • field:指定转换影响哪个字段(这里是 state)。
  • source:转换的源状态。
  • target:转换成功后的目标状态。
  • permission:一个可调用对象,用于检查谁可以执行此转换。
  • conditions:一个可调用对象列表,所有条件都必须满足才能执行转换。

确保所有状态更改都通过转换进行。如果你通过 Django 管理后台手动更改 state 字段的值,将会绕过转换中应该发生的任何副作用(例如调用外部系统或发送电子邮件)。

这是一个更复杂的转换示例,它会在转换过程中设置其他变量并触发操作:

@transition(
    field=state,
    source=STATES.AUTHOR_REVIEW,
    target=STATES.ADMIN_REVIEW,
    permission=lambda instance, user: instance.author == user
)
def approve_by_author(self):
    """作者批准,进入管理员审核状态。"""
    self.author_approved = True
    self.admin_approved = False
    # 触发发送邮件等操作
    send_approval_email(self)

设置权限和条件

权限和条件用于控制谁可以在何时执行转换。

def can_submit(instance, user):
    """检查当前用户是否是文章的作者。"""
    return instance.author == user

@transition(
    field=state,
    source=STATES.DRAFT,
    target=STATES.REVIEW,
    permission=can_submit
)
def submit_for_review(self):
    """提交审核。"""
    pass

条件可以是更复杂的逻辑:

def can_edit(instance, user):
    """检查用户是否有权编辑。"""
    # 如果是草稿状态,且用户是作者
    if instance.state == STATES.DRAFT and instance.author == user:
        return True
    # 如果是管理员审核状态,且用户是工作人员
    elif instance.state == STATES.ADMIN_REVIEW and user.is_staff:
        return True
    else:
        return False

@transition(
    field=state,
    source=[STATES.DRAFT, STATES.ADMIN_REVIEW],
    target=STATES.DRAFT,
    conditions=[can_edit]
)
def edit(self):
    """编辑文章。"""
    pass

这些权限和条件不仅用于后端验证,还可以在前端用于动态确定应向用户显示哪些操作按钮。


便捷方法与信号

django-fsm 通过类贡献向你的模型添加了一些便捷方法:

  • get_all_state_transitions(): 获取模型中声明的所有转换。
  • get_available_user_state_transitions(user): 获取当前用户可用的转换。

这些方法可以用于在前端动态构建用户界面。

此外,django-fsm 还提供了信号,允许你在转换发生前后以松耦合的方式执行操作:

from django_fsm.signals import pre_transition, post_transition

def transition_handler(sender, instance, name, source, target, **kwargs):
    print(f"{sender.__name__} 对象 {instance.id} 从 {source} 转换到 {target} (通过 {name})")
    # 这里可以执行其他操作,如记录日志、发送通知等

pre_transition.connect(transition_handler)
post_transition.connect(transition_handler)

处理当前用户与审计日志

在转换中,知道当前用户是谁很重要,尤其是用于权限检查和审计。然而,转换可能从管理命令或外部服务调用,此时没有“当前请求”。建议使用 django-cuser 这样的中间件来设置和获取当前用户。

对于审计日志,django-fsm 提供了一个额外的装饰器 @fsm_log,可以轻松记录所有转换。

from django_fsm import FSMField, transition
from django_fsm_log.decorators import fsm_log_by

class Article(models.Model):
    state = FSMField(default='draft')

    @fsm_log_by
    @transition(field=state, source='draft', target='review')
    def submit(self):
        pass

启用 django-fsm-log 后,它会在数据库中提供一个表,记录对象上发生的所有转换:谁、在何时、调用了什么转换。


集成到 Django 管理后台

如果你想在 Django 管理后台中管理 FSM 字段,可以使用 django-fsm-admin。它可以强制 FSM 字段只能通过转换按钮来操作,而不是直接编辑字段值,从而确保模型状态的一致性。


生成工作流图表

django-fsm 内置了一个很棒的工具,可以从代码生成工作流图表。你需要安装 graphviz 模块。

# 安装 graphviz 系统库和 Python 包
# Ubuntu/Debian
sudo apt-get install graphviz
# macOS
brew install graphviz

pip install graphviz django-fsm[graph]

然后运行管理命令生成图表:

python manage.py graph_transitions -o workflow.png

这个命令会生成一个PNG图像文件,直观地展示你的模型状态和转换。这对于与业务人员沟通和验证业务流程非常有帮助。


总结

在本教程中,我们一起学习了有限状态机在 Django 中的应用。我们从工作流和 FSM 的基本概念开始,探讨了为何简单的布尔标志不足以应对复杂场景。接着,我们深入介绍了 django-fsm 库,学习了如何:

  1. 使用 FSMField 在模型中定义状态字段。
  2. 使用 @transition 装饰器创建状态转换方法。
  3. 通过 permissionconditions 参数控制转换的访问权限。
  4. 利用便捷方法和信号来增强功能。
  5. 集成审计日志 (django-fsm-log) 和管理后台支持 (django-fsm-admin)。
  6. 从代码自动生成可视化的工作流图表。

通过使用 django-fsm,你可以以一种清晰、可维护的方式在 Django 项目中建模复杂的工作流和业务流程,确保状态转换受控、可审计,并且逻辑集中,避免代码分散。

025:实施伦理指南

在本课程中,我们将学习如何开发安全、可信赖的人工智能系统。课程内容基于卡罗尔·J·史密斯的分享,将介绍构建道德人工智能所需的关键步骤、工具和框架,特别强调团队构成、技术伦理和系统设计原则。


P25:1:构建多元化团队 👥

上一节我们介绍了课程目标,本节中我们来看看构建可信赖人工智能的第一步:组建一个多元化的团队。

一个强大的团队不应仅由背景相似的数据科学家和程序员组成。多样性是团队力量的关键来源,它涵盖种族、性别、文化、教育背景、项目经验、思维过程、残疾状况等多个维度。这种多样性能够扩展团队的思维空间,带来更全面的视角。

以下是构建多元化团队时需要考虑的几个方面:

  • 多学科构成:除了技术专家,还应引入用户体验设计师、交互专家、数字人类学家等角色,他们专注于理解用户能力和系统使用方式。
  • 价值与优势:多元化团队更注重事实、更具创造力,并且成员个体对自身潜在偏见更敏感,这有助于更谨慎地处理信息。
  • 避免“同质化”思维:背景相似的团队容易忽略思维盲点,而多元化团队能更早地发现不同视角下的问题。
  • 营造归属感:确保团队中的个体差异被认可和接受,这有助于所有成员全身心投入工作,从而建立更好的系统。

远程工作环境实际上有助于展示生活多样性,让人们更自在地带入个人元素,这能帮助创建一个更具包容性和价值的社区氛围。


P25:2:采纳共享的技术伦理 📜

我们了解了团队构成的重要性,接下来需要一种“粘合剂”将多元化的团队凝聚在一起,这就是共享的技术伦理。

技术伦理由各类组织制定,例如ACM计算机协会、微软、谷歌或《蒙特利尔宣言》。它们能帮助协调团队内部不同的文化背景和思维方式,在行业快速发展的压力下,确保团队专注于核心目标。

技术伦理明确允许并鼓励团队中的每个成员思考和质疑系统可能产生的广泛影响。这有助于成员感到自己在质疑和决策过程中扮演着重要角色,并鼓励他们尽早提出担忧,以便团队能够及时处理和预防问题。

对于初学者,建议从《蒙特利尔宣言》这类内容广泛的伦理准则入手。团队可以以此为基础,开始定制最适合自身组织的技术伦理规范。


P25:3:引入可信赖人工智能框架 🏗️

我们已经有了多元化的团队和共享的技术伦理,现在需要一个框架将它们与具体工作联系起来,这就是“可信赖人工智能”框架。

该框架旨在通过系统化的对话,帮助团队明确系统目的、讨论价值观、识别潜在受伤害者、设定系统边界、规划权力分配与进展追踪。这些对话需要被主动鼓励,虽然可能令人不适,但对于保护用户至关重要。

可以使用检查清单来引导和促进这些深度对话。清单将抽象的技术伦理原则(如“不造成伤害”)与具体的系统目标连接起来,帮助团队识别隐藏任务、评估风险与收益、进行减灾规划,并使系统检查工作更有依据。


P25:4:框架核心四要素详解 🔍

框架的核心包含四个相互关联的要素。我们将通过一个名为“RightStaff”的AI排班系统场景来具体说明。该系统用于快餐店,目标是优化排班决策、减少排班偏见。

要素一:对人类负责

此要素要求确保人类对系统及其整个生命周期拥有最终控制权和监督责任。重大决策需可解释、可覆盖、可逆。

在RightStaff场景中

  • 系统可生成初始排班,但店长必须能根据实际情况(如员工生病)手动调整班次。
  • 需要明确界定AI系统与店长之间的责任:谁最终决定班次分配?如何处理突发情况(如员工辞职)?
  • 需制定计划:如果系统发现问题如何安全关闭?关闭后如何通知员工并管理班表?重启后如何同步?

要素二:识别预期风险与收益

此要素要求团队主动推测系统所有可能的益处、危害、恶意用途及意外后果。重点是思考“最坏情况”。

在RightStaff场景中

  • 潜在滥用:系统可能无意中优先安排“更易管理”或“冲突更少”的员工,反而加剧了原有的排班不公。
  • 应对措施:通过“黑镜”活动(想象系统可能出现的极端负面情景)来识别此类风险。随后需制定沟通和缓解计划:如何报告此问题?应向谁报告?是否及如何关闭系统?

要素三:尊重与安全

此要素关注人性、道德、公平与无障碍。系统需稳健、可靠、安全,并能保障用户隐私。

在RightStaff场景中

  • 隐私保护:员工向店长透露的、用于调整班次的私人信息(如健康问题),如何在系统中被保护?如何防止其他经理访问这些个人身份信息?
  • 公平性:系统设计是否考虑了所有员工的可访问性和需求?

要素四:诚实与可用

此要素强调透明度以建立信任。需明确系统的AI身份,并坦诚沟通其能力限制与已知偏差。

在RightStaff场景中

  • 透明度:系统是否明确告知用户它是AI辅助决策?
  • 偏见处理:尽管系统旨在减少数据中的已知偏见,但仍需公开承认偏见可能存在的风险。应建立便捷的偏见报告渠道,并制定预防或缓解措施。

P25:5:总结与实践鼓励 🎯

本节课中,我们一起学习了构建可信赖人工智能系统的完整路径。

我们认识到,完美的AI系统并不存在,因此我们的工作必须持续进行。关键在于:采纳共享的技术伦理以凝聚团队;鼓励深度对话以明确价值观与风险;激发团队好奇心以全面推测系统影响。

一个有效的实践建议是:奖励那些发现系统伦理漏洞的团队成员。这能积极引导团队致力于正确的工作,共同打造伟大且可信赖的人工智能系统。

最终目标是倡导人类价值观,致力于开发透明、公平的道德AI系统。这是一项需要持续投入和对话的重要工作。


总结:开发可信赖的人工智能需要多元化团队共享的技术伦理和一个包含对人类负责、识别风险收益、尊重安全、诚实可用四大要素的框架。通过清单引导深度对话,并鼓励团队好奇与质疑,才能逐步构建出更安全、更公平、更值得信赖的AI系统。

026:在Python中进行实用的隐私保护机器学习

概述

在本教程中,我们将学习如何在Python中实现实用的隐私保护机器学习。我们将探讨三种核心技术:差分隐私、加密机器学习和联邦学习。这些技术旨在帮助我们在构建准确模型的同时,保护用户的个人和敏感数据。


自我介绍与背景

我是Catherine Nelson,是SAP Concur旗下Concur Labs的高级数据科学家。我的团队专注于评估和推荐机器学习工具与技术。同时,我也是西雅图PyLadies的共同组织者,合著了《构建机器学习管道》一书,并且是机器学习领域的Google开发者专家。

今天,我想分享一个我深感兴趣的主题:隐私保护机器学习。这个话题在数据隐私法规日益严格的今天显得尤为重要。


数据隐私与机器学习的挑战

机器学习通常遵循“数据越多越好”的原则,但这与数据隐私的目标——减少对个人的了解——似乎存在矛盾。然而,两者的深层目标可以是一致的:我们希望了解群体模式,而非个体细节。

我们需要保护的数据主要分为两类:

  • 私人数据:可直接识别个人的信息,如姓名、地址、电子邮件。
  • 敏感数据:泄露后可能产生后果的信息,如健康记录或公司专有数据。

处理这类数据最直接的方法是“不收集”。例如,我们使用“数据洗衣机”项目,通过机器学习模型识别并移除文本中的个人可识别信息。

然而,有时我们必须收集敏感数据来确保模型的公平性。因此,我们需要找到既能利用数据又能保护隐私的方法。


隐私保护机器学习技术概览

上一节我们讨论了隐私与机器学习的潜在冲突。本节中,我们来看看如何通过技术手段调和这一矛盾。我们将分析一个简化的机器学习系统,涉及数据从用户收集、存储、训练到预测的全过程,并探讨在何处可以引入隐私保护。

关键在于回答一个问题:你信任谁处理你的个人数据? 根据答案的不同,我们可以采用以下三种主要技术:

  1. 差分隐私:适用于信任模型所有者,但希望确保预测不泄露个人数据。
  2. 加密机器学习:适用于不完全信任模型所有者,或希望保护推理过程。
  3. 联邦学习:适用于完全不希望原始数据离开用户设备的情况。

技术一:差分隐私 🛡️

差分隐私的核心思想是:模型的输出结果不应揭示任何单个个体是否存在于训练数据集中。它通过向计算过程(如梯度)中添加随机噪声来实现,为个人提供“否认性”。

在TensorFlow中实现差分隐私

我们可以使用 TensorFlow Privacy 库轻松地将差分隐私集成到现有的Keras模型中。

以下是将一个标准Keras模型转换为差分隐私模型的步骤:

  1. 定义标准模型

    model = tf.keras.Sequential([
        tf.keras.layers.Dense(512, activation='relu'),
        tf.keras.layers.Dense(256, activation='relu'),
        tf.keras.layers.Dense(1, activation='sigmoid')
    ])
    
  2. 引入差分隐私优化器和损失函数
    我们需要使用 tensorflow_privacy 提供的优化器来替代标准优化器。关键步骤是梯度裁剪和添加噪声。

    import tensorflow_privacy as tfp
    
    # 定义差分隐私优化器
    optimizer = tfp.DPKerasSGDOptimizer(
        l2_norm_clip=1.0,      # 梯度裁剪阈值
        noise_multiplier=0.5,  # 控制噪声量
        num_microbatches=1,    # 将批次划分为更小的微批次
        learning_rate=0.01
    )
    
    # 使用对应的差分隐私损失函数
    loss = tf.keras.losses.BinaryCrossentropy(
        from_logits=False, reduction=tf.losses.Reduction.NONE
    )
    
  3. 编译并训练模型

    model.compile(optimizer=optimizer, loss=loss, metrics=['accuracy'])
    model.fit(train_data, train_labels, epochs=10, batch_size=256)
    

衡量隐私损失:Epsilon (ε)

差分隐私的强度用 ε 来衡量。ε 值越小,意味着添加的噪声越多,隐私保护越强,但可能以模型准确性为代价。

我们可以使用库中的工具来计算训练后的 ε 值:

# 计算隐私预算epsilon
# N: 训练样本总数
# batch_size: 批次大小
# noise_multiplier: 噪声乘数
# epochs: 训练轮数
# delta: 通常设置为远小于 1/N 的值,例如 1e-5
epsilon = tfp.compute_dp_sgd_privacy(
    n=N,
    batch_size=batch_size,
    noise_multiplier=noise_multiplier,
    epochs=epochs,
    delta=delta
)[0]  # 函数返回 (epsilon, delta)
print(f‘Epsilon: {epsilon}’)

何时使用差分隐私:当你信任模型所有者可以访问原始数据,但需要确保模型预测(或模型本身)不会记忆或泄露特定个体的信息时。


技术二:加密机器学习 🔐

加密机器学习允许我们在数据或模型处于加密状态时进行计算。这适用于我们不完全信任中央服务器的情况。

使用TF Encrypted进行加密训练

TF Encrypted 库扩展了TensorFlow,允许在加密数据上执行计算。它保持了熟悉的Keras API风格。

以下是加密训练数据的基本流程:

  1. 在本地加密数据
    数据在离开用户设备前就被加密。

    import tf_encrypted as tfe
    
    # 假设有一个提供本地数据的函数
    def provide_training_data():
        # ... 加载和预处理本地数据
        return batch_data, batch_labels
    
    # 将数据转换为加密张量
    encrypted_train_data = tfe.define_local_computation(provide_training_data)()
    
  2. 在加密数据上构建和训练模型

    # 使用Keras API定义模型(这些层将在加密状态下运行)
    encrypted_model = tfe.keras.Sequential([
        tfe.keras.layers.Dense(512, activation='relu'),
        tfe.keras.layers.Dense(256, activation='relu'),
        tfe.keras.layers.Dense(1, activation='sigmoid')
    ])
    
    encrypted_model.compile(optimizer='adam', loss='binary_crossentropy')
    encrypted_model.fit(encrypted_train_data, epochs=10)
    

对已训练模型进行加密推理

我们也可以只加密已经训练好的模型,以便对用户输入的加密数据进行预测并返回加密结果,整个过程服务器无法解密。

# 克隆一个已有的Keras模型为加密版本
plaintext_model = ... # 你的已训练好的标准Keras模型
encrypted_model = tfe.keras.models.clone_model(plaintext_model)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/a45ae89f522de28987158e0b61b63946_6.png)

# 现在可以使用 encrypted_model 对加密输入进行预测

何时使用加密机器学习

  • 加密数据:当训练数据敏感且不信任模型所有者时。
  • 加密模型:当模型是公开的,但用户的输入数据和预测结果是私密时(例如,医疗诊断应用)。

技术三:联邦学习 📱

联邦学习让模型训练直接在用户设备(如手机)上进行,原始数据永不离开设备。只有模型的更新(权重变化)被加密后发送到中央服务器进行聚合。谷歌的Gboard输入法就使用了这项技术。

联邦学习的基本步骤(概念性)

虽然具体实现(如使用 PySyftTensorFlow Federated)涉及复杂的基础设施,但其核心流程可以概括如下:

  1. 中央服务器初始化一个全局模型。
  2. 选择一批用户设备,将当前全局模型权重分发下去。
  3. 每台设备在本地用自己的数据训练模型,生成模型权重更新
  4. 设备将加密后的权重更新(而非原始数据)发送回中央服务器。
  5. 中央服务器使用安全聚合技术,将所有更新聚合起来,改进全局模型。
  6. 重复步骤2-5。

何时使用联邦学习

  • 数据天然分散在大量边缘设备上(如手机、浏览器)。
  • 数据包含高度敏感或个人化信息。
  • 数据已经带有标签(因为服务器无法查看数据后进行标注)。

技术选择与注意事项

我们已经介绍了三种主要的隐私保护技术。选择哪种技术取决于你的信任模型和具体应用场景。

以下是简单的决策参考:

  • 信任模型所有者,只需保护预测结果 -> 差分隐私
  • 不完全信任模型所有者,需保护数据或推理过程 -> 加密机器学习
  • 完全不希望原始数据离开用户设备 -> 联邦学习

重要注意事项

  1. 性能成本:隐私保护通常会带来模型准确性下降、训练时间增加或系统复杂性提高。
  2. 非万能药:采用这些技术并不意味着解决了所有伦理问题。如果产品设计本身存在伦理缺陷,技术无法弥补。
  3. 组合使用:在实际系统中,这些技术经常被组合使用以提供多层保护(例如,在联邦学习中加入差分隐私)。

总结与资源

本节课中,我们一起学习了在Python中实现隐私保护机器学习的三种实用技术:差分隐私加密机器学习联邦学习。每种技术针对不同的信任假设和应用场景,为在利用数据价值的同时保护用户隐私提供了可行的工具。

如果你想深入了解:

  • 书籍:可以阅读我合著的《Building Machine Learning Pipelines》。
  • 开源项目:支持并尝试使用 TensorFlow PrivacyTF EncryptedPySyft/TensorFlow Federated 这些优秀的开源库。
  • 保持联系:欢迎通过Twitter等渠道交流问题。

隐私保护机器学习是一个快速发展的领域,希望本教程能帮助你迈出实践的第一步。

027:大 O 符号、N+1 问题与高级查询技巧 🚀

在本教程中,我们将学习如何分析和优化 Django ORM 的运行时性能。我们将从理解大 O 符号开始,深入探讨常见的 N+1 查询问题,并介绍一种使用 SQL 聚合和横向连接来减少查询数量的高级技巧。

概述 📋

Django ORM 极大地简化了数据库操作,但如果不加注意,很容易产生性能问题,尤其是在处理大量数据时。本次课程将引导你理解 ORM 查询背后的复杂度,识别低效模式,并学习如何利用 Django 的内置功能和高级 SQL 技巧来编写更高效的代码。


大 O 符号与 ORM 性能 📈

上一节我们概述了课程目标,本节中我们来看看如何量化代码的性能特征。大 O 符号是描述算法或函数性能随输入规模(n)增长而变化的一种方法。

  • O(1):表示操作时间与数据量无关,是常数时间。例如,通过主键查找单个记录:Post.objects.get(id=1)
  • O(n):表示操作时间与数据量成线性关系。例如,遍历一个列表并对每个元素执行一次数据库查询。
  • O(n²):表示操作时间与数据量的平方成正比。这通常发生在嵌套循环中,每个循环都可能触发数据库查询。
  • O(log n):表示操作时间随数据量呈对数增长,效率很高,例如二分查找。

对于 Django ORM,我们主要关注的特征是执行的 SQL 查询数量,而不是单个查询的执行时间。这是因为建立数据库连接、编译 SQL、网络传输等开销,对于大量快速查询(如主键查找)来说,往往比数据库实际执行查询的时间更长。


识别 N+1 查询问题 🔍

理解了性能度量的基础后,我们来看一个 Django 开发中最常见的性能陷阱:N+1 查询问题。

假设我们有一个博客系统,要获取所有帖子及其对应的评论和评论作者。

# 低效的方式:触发 N+1 查询
posts = Post.objects.all()
for post in posts:
    for comment in post.comments.all():  # 第一次查询帖子,然后对每个帖子查询评论
        print(comment.author.name)       # 对每个评论查询作者

上面的代码会先执行 1 次查询获取所有帖子,然后对每个帖子执行 1 次查询获取其评论,再对每个评论执行 1 次查询获取作者。如果有 N 个帖子,每个帖子平均有 M 条评论,那么查询总数将是 1 + N + (N * M),这是一个 O(n²) 级别的操作。


幸运的是,Django 提供了强大的工具来避免 N+1 问题。

select_related 用于优化“一对一”或“多对一”关系(使用 SQL JOIN)。

# 优化:使用 select_related 获取外键关联的作者
comments = Comment.objects.select_related('author').all()
for comment in comments:
    print(comment.author.name)  # 不会触发额外查询

这个操作只产生 1 次查询。

prefetch_related 用于优化“多对多”或反向“一对多”关系(执行两次查询并在 Python 内存中连接)。

# 优化:使用 prefetch_related 获取帖子及所有评论
posts = Post.objects.prefetch_related('comments').all()
for post in posts:
    for comment in post.comments.all():  # 访问预取缓存,无额外查询
        print(comment.text)

这个操作通常产生 2 次查询(一次取帖子,一次取所有相关评论)。


高级优化:SQL 聚合与横向连接 ⚡

当需要跨越多层复杂关系时,即使使用 prefetch_related,也可能因为预取多个关系而产生多个查询(O(关系数量))。本节我们介绍一种更高级的技巧,旨在使用单个查询完成复杂的数据获取。

其核心思想是使用 SQL 的 JSON 聚合函数关联子查询(Lateral Join 的思想),将嵌套的关系数据“打包”到主查询的每一行中。

假设我们想在一个查询中获取所有帖子,以及每篇帖子的所有评论(以 JSON 数组形式嵌入)。

对应的 SQL 思路如下(概念性代码):

SELECT
    post.*,
    (
        SELECT JSON_AGG(comment_data)
        FROM (
            SELECT * FROM comments WHERE comments.post_id = post.id
        ) AS comment_data
    ) AS comments_json
FROM posts post;

这个查询会为每一篇帖子执行一个子查询,将该帖子的所有评论聚合成一个 JSON 数组。

在 Django 中,我们可以使用 django-cte 或编写原始 SQL 片段,再通过自定义方法将返回的 JSON 数据加载到 Django 模型缓存中,模拟 prefetch_related 的效果。这可以将查询复杂度从 O(关系数量) 降低到 O(1)(仅一次查询)。

注意:有一个名为 django-postgres-extra 或类似功能的库可能提供此类封装。手动实现需要对 Django 内部机制有较深理解。


技术选型与基准测试 ⚖️

那么,我们应该总是使用这种高级技巧吗?答案是否定的,这取决于具体情况。

以下是需要考虑的因素:

  • 数据库支持JSON_AGG 和高效的 Lateral Join 对数据库(如 PostgreSQL)有要求。
  • 查询类型:这种模式生成的是“分析型查询”。像 CockroachDB 这类为事务处理优化的数据库,可能在此类查询上表现不佳。
  • 数据量:当顶层数据量(如帖子数量)非常大时,prefetch_related(执行少量查询,传输数据高效)的性能可能优于打包所有数据到一行的单个大查询。
  • 复杂度与维护:高级技巧增加了代码复杂度,可能降低可读性和可维护性。

最佳实践是进行基准测试。在你的实际数据模型、数据量和数据库环境下,对比 prefetch_related 和自定义聚合查询的性能。使用 Django 的 django.db.connection.queries 或在数据库端分析查询计划,以数据驱动决策。


总结 🎯

本节课我们一起学习了 Django ORM 性能优化的核心知识:

  1. 理解大 O 符号,将其应用于分析 ORM 查询数量。
  2. 识别 N+1 查询问题,这是导致性能瓶颈的常见原因。
  3. 掌握基础优化工具:使用 select_relatedprefetch_related 解决大部分关联查询的性能问题。
  4. 了解高级优化技巧:认识通过 SQL 聚合和横向连接在单次查询中获取嵌套数据的可能性及其权衡。

记住,没有银弹。在追求性能的同时,务必权衡代码的简洁性、可读性以及数据库的特性。始终基于实际场景进行测量和优化。

028:从直觉到实践 🚀

在本教程中,我们将一起学习自动微分(Automatic Differentiation, AD)的基本概念、工作原理及其在Python中的实际应用。我们将从导数和梯度的直觉开始,逐步深入到如何使用JAX、TensorFlow和PyTorch等库进行自动微分,并探索其在图像处理和梯度下降等领域的应用。课程旨在让初学者能够轻松理解并上手实践。


自动微分入门:1:导数的基本概念 📈

上一节我们概述了本课程的目标,本节中我们来看看导数的基本概念。

函数的导数描述了该函数的变化率。这个概念在科学和工程中至关重要,例如,它被用于寻找函数的最大值或最小值的高效算法。通常,导数比函数本身能提供更多信息。

在Python中,我们处理的通常是数值程序。这些程序接收数字作为输入,并输出数字。对于自动微分,输入可以是一个或多个数字,但输出通常应是一个单一的数字。对于一个输入和一个输出的函数,我们可以将其可视化:将输入放在x轴上,输出放在y轴上。

例如,函数 f(x) = x**2 将每个输入值平方。我们可以用50或100个点来评估这个函数,以了解其行为。Python的NumPy库内置了许多基础函数,例如 np.sin(x)np.tanh(x)

那么,什么是导数?在微积分中,导数给出了函数在某一点附近对于输入微小变化的输出变化率。从几何上看,它就是函数图像在该点的切线斜率(上升值除以移动值)。当移动值无限趋近于0时,这个比率的极限就是导数。

我们可以用Python近似计算导数。方法是:在点 x 附近取一个很小的距离 epsilon,计算函数在 xx + epsilon 处的差值(上升值),然后用这个差值除以 epsilon(移动值),得到近似的斜率。

def derivative(f, epsilon=1e-6):
    def df(x):
        return (f(x + epsilon) - f(x)) / epsilon
    return df

这个 derivative 函数接收一个函数 f,并返回其近似导函数 df。例如,x**2 的导数理论上是 2*x,我们的近似值会非常接近。

然而,这种数值微分方法存在一些问题:在高维度下计算效率低,且存在数值误差。自动微分则能完美解决这些问题,尤其是在处理数百万个参数时,它只需一次前向计算就能得到所有导数。


自动微分入门:2:使用JAX进行自动微分实践 ⚙️

上一节我们介绍了导数的概念和数值近似方法,本节中我们来看看如何在实践中使用JAX库进行自动微分。

JAX是一个功能强大的库,它提供了类似NumPy的API,并内置了自动微分功能。使用JAX计算导数非常简单。

以下是使用JAX计算函数导数的步骤:

  1. 导入JAX版本的NumPy。
  2. 使用 jax.grad 函数来计算给定函数的导数。
  3. 注意,jax.grad 默认要求函数的输出是单个标量值。
import jax.numpy as jnp
from jax import grad

# 定义函数
def f(x):
    return jnp.tanh(x)  # 双曲正切函数

# 计算f的导函数
df = grad(f)

# 在某个点计算导数值
x_value = 2.0
print(f"f在{x_value}处的导数值约为: {df(x_value)}")

为了绘制导函数,我们需要对一系列输入点应用这个导函数。JAX提供了 vmap(向量化映射)函数来高效地实现这一点。

from jax import vmap
import numpy as np
import matplotlib.pyplot as plt

# 生成输入点
t = jnp.linspace(-2, 2, 100)
# 使用vmap将df向量化应用到数组t上
df_values = vmap(df)(t)

# 绘制原函数和其导数
plt.plot(t, vmap(f)(t), label='f(x) = tanh(x)')
plt.plot(t, df_values, label="f'(x)")
plt.legend()
plt.show()

我们还可以轻松计算高阶导数,只需重复应用 grad 函数。

# 计算二阶、三阶、四阶导数
df2 = grad(df)   # 二阶导数
df3 = grad(df2)  # 三阶导数
df4 = grad(df3)  # 四阶导数

自动微分让我们能专注于问题本身,而不是导数计算的实现细节。


自动微分入门:3:其他流行库——TensorFlow与PyTorch 🔄

上一节我们使用JAX进行了实践,本节中我们来看看Python生态中另外两个流行的自动微分库:TensorFlow和PyTorch。它们实现相同的核心算法,但API和设计哲学有所不同。

TensorFlow 使用“梯度带”(GradientTape)的上下文管理器来追踪操作,随后计算梯度。

以下是TensorFlow中计算导数的示例:

import tensorflow as tf

# 定义变量并追踪操作
x = tf.Variable(2.0)
with tf.GradientTape() as tape:
    y = tf.math.tanh(x)

# 计算梯度
dy_dx = tape.gradient(y, x)
print(f"TensorFlow: tanh在{x.numpy()}处的导数为: {dy_dx.numpy()}")

PyTorch 的自动微分机制需要显式设置张量的 requires_grad 属性为 True,并在计算后调用 .backward() 方法。

以下是PyTorch中计算导数的示例:

import torch

# 定义张量并启用梯度追踪
x = torch.tensor(2.0, requires_grad=True)
y = torch.tanh(x)

# 计算梯度
y.backward()
dy_dx = x.grad
print(f"PyTorch: tanh在{x.item()}处的导数为: {dy_dx.item()}")

尽管代码风格不同,但这些库计算出的结果是相同的。选择哪个库通常取决于你的项目需求、社区支持或个人偏好。TensorFlow和PyTorch在深度学习领域尤其强大。


自动微分入门:4:高维导数与梯度 🌄

上一节我们比较了不同的自动微分库,本节中我们将概念扩展到更高维度,引入梯度(Gradient)的概念。

当函数从多维输入映射到单个输出时(例如 f(x, y) -> z),其变化率由梯度描述。梯度本身也是一个函数,它在每个输入点返回一个向量。这个向量指向该点处函数值上升最快的方向,其大小表示上升的速率。

一个直观的例子是地形高度图。函数输入是经纬度,输出是海拔高度。在某一点的梯度向量告诉你,向哪个方向走是上坡最陡的。

在图像处理中,我们可以将一张灰度图看作一个二维函数(像素坐标 -> 亮度值)。该图像的梯度大小图可以有效突出颜色变化的边缘。

以下是使用梯度进行边缘检测的思路:

  1. 计算图像每个像素点的梯度向量。
  2. 计算每个梯度向量的大小(模长)。
  3. 在梯度大小大的地方,就是图像颜色剧烈变化的地方,即边缘。
# 概念性代码,展示思路
gradient_magnitude = jnp.sqrt(grad_x**2 + grad_y**2)
# 高 gradient_magnitude 值对应图像边缘

自动微分入门:5:梯度下降算法 ⬇️

上一节我们介绍了梯度在高维空间的意义,本节中我们来看看自动微分最经典的应用之一:梯度下降(Gradient Descent)。

梯度下降是一种用于寻找函数局部最小值的迭代优化算法。其核心思想是:既然梯度方向是函数上升最快的方向,那么反方向就是下降最快的方向。因此,通过反复朝负梯度方向移动一小步,我们有望找到函数的最小值点。

算法步骤如下:

  1. 随机初始化一个起点 x
  2. 重复以下过程直到收敛:
    a. 计算当前点 x 处的梯度 ∇f(x)
    b. 沿负梯度方向更新 xx = x - learning_rate * ∇f(x)。其中 learning_rate 是学习率(步长)。

以下是梯度下降算法的简单实现:

def gradient_descent(grad_fn, init_x, learning_rate=0.1, num_steps=100):
    x = init_x
    for i in range(num_steps):
        grad = grad_fn(x)  # 使用自动微分计算梯度
        x = x - learning_rate * grad  # 沿负梯度方向更新
    return x

梯度下降的强大之处在于,无论函数有多少参数(维度),自动微分都能高效地一次性计算出所有方向上的梯度,从而指导更新。这避免了手动推导和编码导数公式的巨大工作量。

然而,梯度下降也有其局限性:

  • 局部最小值:算法可能收敛到局部最小点而非全局最小点,结果依赖于初始位置。
  • 学习率选择:学习率过大会导致震荡无法收敛;过小则收敛速度慢。
  • 鞍点问题:在高维空间,梯度为零的点可能是鞍点而非极值点。

尽管如此,梯度下降及其变种(如随机梯度下降、Adam等)仍然是机器学习和深度学习模型训练的基石。


总结 📝

在本教程中,我们一起学习了自动微分的基础知识与实践应用。

我们从导数的基本概念出发,理解了它作为函数变化率的含义。接着,我们探讨了数值微分的局限,并引出了自动微分这一高效精确的解决方案。

我们重点实践了如何使用JAX库进行自动微分,包括计算一阶及高阶导数,并简要对比了TensorFlow和PyTorch的实现方式。然后,我们将概念扩展到高维空间,引入了梯度,并了解了其在图像边缘检测中的应用。

最后,我们深入探讨了自动微分的关键应用——梯度下降算法,理解了其工作原理、简单实现以及需要注意的局限性。

自动微分是一个强大的工具,它将我们从繁琐的导数计算中解放出来,让我们能更专注于问题建模和算法设计。希望本教程能帮助你入门,并鼓励你在Python中探索自动微分更多富有创意的用途。

029:从网络爬虫到优雅代码的蜕变 🐍

在本教程中,我们将跟随Conor Hoekstra的演讲,学习如何将一个从博客中获取的、用于爬取HTML表格的Python脚本,通过一系列重构步骤,从60多行代码精简至不到10行。我们将重点关注如何识别并消除常见的“反模式”,利用Python的内置函数和特性(如enumerate、列表推导式、zip等)来编写更简洁、高效且易于维护的代码。


1:项目背景与目标 🎯

上一节我们介绍了本教程的背景和目标。本节中,我们来看看项目的具体动机和步骤。

我(Conor Hoekstra)在加入NVIDIA的RAPIDS团队后,决定进行一个玩具项目:分析Codeforces编程竞赛网站上提交代码所使用的语言流行度。这需要两个主要步骤:

  1. 从Codeforces网站爬取包含提交数据的HTML表格。
  2. 处理数据并分析语言使用频率。

本教程90%的内容将集中在第一步的代码重构上。


2:初始代码分析 🔍

上一节我们明确了项目目标。本节中,我们来看看从博客中找到的初始爬虫代码。

我通过搜索引擎找到了一个博客,它提供了使用Python的requestslxml库爬取HTML表格并转换为pandas DataFrame的教程代码。初始代码结构如下:

import requests
import pandas as pd
from lxml import html

url = ‘https://codeforces.com/contest/74/status‘
page = requests.get(url)
tree = html.fromstring(page.content)
tr_elements = tree.xpath(‘//tr‘)

这段代码负责导入必要的库并获取网页内容。接下来是提取表格标题和数据的循环。


3:重构循环一:提取表格标题 🔄

上一节我们导入了库并获取了HTML内容。本节中,我们来看看第一个用于提取表格列标题的循环,并进行重构。

以下是提取表格标题的初始代码:

col = []
i = 0
for t in tr_elements[0]:
    i += 1
    name = t.text_content()
    print (‘%d:“%s”‘ % (i, name))
    col.append((name, []))

我们注意到这里存在一个模式:在循环外初始化索引i,然后在循环内递增它。Python提供了enumerate函数来处理这种模式。

重构1:使用enumerate
enumerate可以同时获取迭代的索引和值。

col = []
for i, t in enumerate(tr_elements[0], 1):
    name = t.text_content()
    print(‘%d:“%s”‘ % (i, name))
    col.append((name, []))

重构2:删除调试打印语句
打印语句可能仅用于调试,可以移除。

col = []
for i, t in enumerate(tr_elements[0], 1):
    name = t.text_content()
    col.append((name, []))

重构3:转换为列表推导式
我们初始化一个空列表,然后在循环中不断修改(追加)它。这可以转换为更简洁的列表推导式。

col = [(t.text_content(), []) for t in tr_elements[0]]

通过这三步,我们消除了“初始化-修改”反模式,使代码更清晰。


4:重构循环二:提取表格数据 📊

上一节我们优化了标题提取。本节中,我们处理更复杂的嵌套循环,它用于提取表格每一行的数据。

初始的嵌套循环代码如下:

for j in range(1, len(tr_elements)):
    T = tr_elements[j]
    if len(T) != 10:
        break
    i = 0
    for t in T.iterchildren():
        data = t.text_content()
        if i > 0:
            try:
                data = int(data)
            except:
                pass
        i += 1
        col[i][1].append(data)

我们将逐步分析并重构它。

重构4:移除不必要的if语句
第一个if len(T) != 10是针对原博客示例数据的检查,对我们的数据源不必要,可以删除。

重构5:使用切片替代range和索引
前两行用于跳过表头,遍历tr_elements的其余部分。我们可以用切片更直观地实现。

for T in tr_elements[1:]: # 从第二个元素开始遍历
    i = 0
    ...

重构6:再次使用enumerate
循环内部又出现了初始化索引i并在循环内递增的模式,用enumerate替换。

for T in tr_elements[1:]:
    for i, t in enumerate(T.iterchildren()):
        ...

重构7:用条件表达式替换try-except
代码尝试将文本转换为整数,失败则保持原样。可以用条件表达式(三元运算符)更清晰地表达。

data = t.text_content()
data = int(data) if data.isdigit() else data

重构8:删除冗余注释并内联操作
删除循环上方“迭代每个元素”等不言自明的注释。同时,将数据直接追加到col中,避免中间变量。

for T in tr_elements[1:]:
    for i, t in enumerate(T.iterchildren()):
        data = t.text_content()
        val = int(data) if data.isdigit() else data
        col[i][1].append(val)

此时,代码已从60多行大幅缩减。


5:洞察数据结构与终极重构 💡

上一节我们简化了数据提取循环。本节中,我们退一步审视整体数据结构,进行更深层次的重构。

观察发现,col是一个列表,其中每个元素是一个元组(列标题, 列数据列表)。随后,这个结构被转换成字典以创建DataFrame:

Dict = {title:column for (title,column) in col}
df = pd.DataFrame(Dict)

这引发一个思考:我们真的需要将标题和数据捆绑在元组里吗?我们可以用两个独立的列表。

重构9:拆分数据与标题,并使用zip进行转置

  1. 将标题和数据存储到两个独立的列表中。
  2. 使用zip(*...)技巧来转置数据(将“按行存储”转换为“按列存储”),这是将行数据分配到各列的关键。

以下是重构后的核心代码:

# 提取标题
headers = [t.text_content() for t in tr_elements[0]]

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/d4faa4c2058edf146da305b5d6928f9c_13.png)

# 提取并转置数据:列表推导式嵌套,内部推导式处理一行,外部推导式处理所有行
# zip(*...) 负责将“行的列表”转置为“列的列表”
data_rows = [[int(td) if td.isdigit() else td for td in tr.xpath(‘./td/text()‘)] for tr in tr_elements[1:]]
columns = list(zip(*data_rows))

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/d4faa4c2058edf146da305b5d6928f9c_15.png)

# 创建字典,用于构建DataFrame
Dict = {header: column for header, column in zip(headers, columns)}
df = pd.DataFrame(Dict)

这个版本极其紧凑,利用了列表推导式和zip的强大功能。


6:反思与最佳实践 🧠

上一节我们完成了代码的终极精简。本节中,我们进行关键反思:有时最好的重构是根本不需要写代码。

在完成所有重构后,我意识到一个“重大错误”:pandas库本身有一个直接读取HTML表格的函数pd.read_html()

# 最终极的解决方案
df_list = pd.read_html(url)
df = df_list[0] # 假设第一个表格是我们需要的

核心启示:在动手编写代码前,应充分了解你所使用的库和工具。许多常见任务已有现成、优化过的解决方案。熟悉你的“算法”、“集合”和“库”是写出优秀代码的关键。


7:数据分析与可视化 📈

上一节我们深刻反思了重构的意义。本节中,我们快速完成项目的第二步:数据分析。

使用爬取并清理好的数据(包含“语言”列),我们进行如下分析:

# 1. 创建一个映射字典,将不同的提交选项(如‘GNU C++11‘)归一化为语言名(如‘C++‘)
language_map = {
    ‘GNU C++11‘: ‘C++‘,
    ‘GNU C++14‘: ‘C++‘,
    ‘Python 2‘: ‘Python‘,
    ‘Python 3‘: ‘Python‘,
    # ... 其他映射
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/d4faa4c2058edf146da305b5d6928f9c_33.png)

# 2. 应用映射并计算每种语言的出现频率
df[‘Language‘] = df[‘Language‘].replace(language_map)
language_counts = df[‘Language‘].value_counts()

print(language_counts.head())

结果显示,在该比赛中,C++是绝对主流,其次是Python和Java。


8:总结与延伸 🏁

本节课中,我们一起学习了如何将一个冗长的Python爬虫脚本通过多步重构变得简洁优雅。

我们学习的关键重构技巧包括

  • 使用 enumerate 避免手动管理索引。
  • 使用 列表推导式 替代“初始化-修改”模式。
  • 使用 切片条件表达式 使逻辑更清晰。
  • 利用 zip(*...) 进行数据转置。
  • 最重要的:优先寻找并使用库的内置功能(如pd.read_html)。

此外,我们还简要介绍了数据分析的基本步骤:数据清洗(映射替换)和聚合统计(value_counts)。编写代码时,应始终追求简洁、可读和高效,并充分利用语言特性和现有库。

030:使用Apache Airflow连接数据科学与数据工程 🚀

在本教程中,我们将学习如何利用 Apache Airflow 构建一个统一的数据科学平台,以弥合数据科学家、数据工程师和产品分析师之间的协作鸿沟。我们将探讨Airflow的核心概念、其如何标准化工作流,并通过具体示例展示其实用性。


概述:数据科学生态系统的挑战

一个典型的数据科学生态系统包含三个主要角色:

  • 数据科学家:负责构建预测模型,需要高性能计算资源(如CPU、内存、GPU),但希望避免复杂的系统配置。
  • 数据工程师:负责维护数据基础设施,关注系统的运行时稳定性与性能,需要在提供灵活性与确保系统健康之间找到平衡。
  • 产品分析师:需要便捷地访问数据(通常通过SQL),以创建仪表板和报告,为业务提供可操作的见解。

这些团队目标一致,但需求不同。数据基础设施团队常试图构建一个“保险杠轨道”模型,即在定义明确的沙箱内为用户提供灵活性,并在用户超出范围时及时提醒。


解决方案:以Apache Airflow为核心

与其从零开始构建一个复杂的数据科学平台,不如使用 Apache Airflow 作为核心。Apache Airflow是一个由Airbnb开发的、基于Python的工作流调度器。它提供了简单的Python SDK来构建执行图(DAG),使得构建、部署和监控数据管道变得非常容易。

什么是DAG?

DAG(有向无环图)是Airflow的核心概念。用户通过创建Python对象(称为Operator)来定义任务,每个Operator可以是一个Bash命令、一个Spark作业请求等。然后,使用 set_upstream>> 运算符来建立任务间的依赖关系,无需复杂的YAML或JSON配置。

# 示例:定义三个独立任务
task1 = BashOperator(task_id=‘print_date‘, bash_command=‘date‘)
task2 = BashOperator(task_id=‘sleep‘, bash_command=‘sleep 5‘)
task3 = BashOperator(task_id=‘echo‘, bash_command=‘echo hello‘)

# 设置任务并行执行(无依赖)
task1 >> task2
task1 >> task3

Airflow还提供了一个强大的监控仪表板,用户可以在此查看所有管道的健康状况、历史运行记录,并实时观察管道执行。


数据工程师的视角:标准化与扩展

对于数据工程师而言,Airflow是实施标准化、监控和保持一致性的强大工具。

核心优势

  1. 自定义Operator:数据工程师可以扩展现有Operator或创建新的Operator,在给予数据科学家最大灵活性的同时,强制执行公司标准。
  2. 简易集成:Airflow轻松集成Elasticsearch和Prometheus,简化日志捕获和系统监控。
  3. 久经考验:Airflow已在AirbnbLyft等顶级科技公司中经受每天数百万任务的考验,系统稳定可靠。

实践示例:抽象Kubernetes配置

假设数据工程师管理着一个包含GPU节点的Kubernetes集群,并希望数据科学家能方便地使用这些GPU,而无需了解Kubernetes的内部细节。

数据工程师可以创建一个自定义的Kubernetes Pod Operator,自动注入访问GPU所需的节点选择器标签。

from airflow.providers.cncf.kubernetes.operators.kubernetes_pod import KubernetesPodOperator

class GpuKubernetesPodOperator(KubernetesPodOperator):
    """
    自定义Operator,自动将任务调度到带GPU的节点上。
    """
    def __init__(self, *args, **kwargs):
        # 注入节点选择器,对数据科学家透明
        kwargs[‘node_selector‘] = {‘accelerator‘: ‘nvidia-tesla-k80‘}
        super().__init__(*args, **kwargs)

# 数据科学家可以像使用普通Operator一样使用它,无需关心底层配置
train_task = GpuKubernetesPodOperator(
    task_id=‘train_model‘,
    name=‘train-on-gpu‘,
    cmds=[‘python‘, ‘train.py‘],
    ...
)

通过这种方式,数据工程师构建了针对业务用例的核心组件,而底层基础设施则由活跃的Apache Airflow社区维护和更新。


数据科学家的视角:抽象与灵活性

一旦数据基础设施团队构建了核心自定义Operator,数据科学团队便获得了完全的抽象,无需关心连接Spark集群或AWS实例等复杂细节。

核心优势

  1. 构建块:数据科学家可以在基础设施团队提供的自定义Operator之上,进一步创建自己的Operator,从而简化和抽象他们的工作流。
  2. Python的灵活性:利用Python语言特性(如循环、条件分支),可以动态生成复杂的工作流。
  3. 参数化与版本控制:Airflow易于参数化管道,并与Git集成,便于存储历史版本。

实践示例:动态任务生成与超参数调优

假设我们有一个训练任务,希望测试不同的学习率(超参数)。

learning_rates = [0.001, 0.01, 0.1, 0.5]

# 使用循环动态创建多个并行训练任务
train_tasks = []
for lr in learning_rates:
    task = PythonOperator(
        task_id=f‘train_model_lr_{lr}‘,
        python_callable=train_model,
        op_kwargs={‘learning_rate‘: lr}  # 将超参数传入任务
    )
    train_tasks.append(task)

# 假设所有训练任务都依赖于同一个数据准备任务
data_prep_task >> train_tasks

只需几行代码,我们就能将一个单一任务转换为一个并行的超参数调优实验。


产品分析师的视角:数据可访问性与一致性

对于产品分析师团队,基于Airflow的平台带来了两大好处:

  1. 团队凝聚力:数据科学和数据工程团队之间更紧密的协作,意味着更快的交付速度和更少的沟通失误。
  2. 可靠的数据更新:Airflow提供了大量基于SQL的Operator,可以在预定时间运行参数化查询,确保分析数据库始终充满新鲜、一致的数据,供BI和产品团队使用。

完整工作流示例:从实验到生产

一个健壮的数据科学流程通常遵循“曲奇切割机”模型,分为三个阶段:实验参数化生产

1. 实验阶段:使用Jupyter Notebook

数据科学家在Jupyter Notebook中进行快速迭代和原型设计。Airflow可以无缝集成,将本地实验扩展到大规模运行。

推荐做法:将Notebook分解为一系列具有明确职责的小型任务(例如,数据清洗、特征工程、模型训练),每个任务对应一个Notebook或Python脚本。这便于故障排查和测试。

2. 参数化阶段:使用Papermill

当需要对Notebook进行参数化(例如,超参数调优或在不同数据集上运行)时,Papermill 是理想工具。

Papermill允许用户标记Notebook中的单元格,并在执行时覆盖其中的变量。

操作步骤

  1. 在Notebook单元格的元数据中,将需要参数化的变量标记为“parameters”。
  2. 使用Airflow的 PapermillOperator 来执行Notebook,并传入特定参数。
from airflow.providers.papermill.operators.papermill import PapermillOperator

parameterize_task = PapermillOperator(
    task_id=‘run_parameterized_notebook‘,
    input_nb=‘/path/to/input_notebook.ipynb‘,
    output_nb=‘/path/to/output_notebook_{{ ds }}.ipynb‘,
    parameters={‘learning_rate‘: 0.01, ‘dataset‘: ‘2023-01-01‘}  # 覆盖参数
)

3. 生产阶段:金丝雀测试

将模型部署到生产环境前,进行金丝雀测试是一种稳健的方法。它通过将新模型与当前生产模型在少量流量上进行比较,来验证新模型的性能。

Airflow实现:使用 BranchPythonOperator 根据模型比较结果决定执行路径。

from airflow.operators.python import BranchPythonOperator
from airflow.operators.dummy import DummyOperator

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/8898f77593e5792574eff12b529203cf_13.png)

def compare_models(**context):
    """
    比较新旧模型性能。
    假设从XCom或外部存储获取评估指标。
    """
    new_model_score = 0.92
    old_model_score = 0.90
    if new_model_score > old_model_score:
        return ‘deploy_new_model‘  # 返回下一个要执行的任务ID
    else:
        return ‘alert_failure‘

compare_task = BranchPythonOperator(
    task_id=‘compare_models‘,
    python_callable=compare_models,
    provide_context=True
)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/8898f77593e5792574eff12b529203cf_15.png)

deploy_task = DummyOperator(task_id=‘deploy_new_model‘)
alert_task = DummyOperator(task_id=‘alert_failure‘)

# 设置依赖:比较后分支执行
compare_task >> [deploy_task, alert_task]

如果新模型性能更优,则执行部署任务;否则,触发告警。


总结

在本节课中,我们一起学习了如何利用 Apache Airflow 构建一个统一、高效的数据科学平台。

  • 对数据工程师:Airflow提供了通过自定义Operator实施标准化和抽象的强大能力,无需从零搭建平台。
  • 对数据科学家:Airflow提供了基于Python的灵活性和对复杂基础设施的透明抽象,使其能专注于模型本身。
  • 对产品分析师:Airflow确保了数据管道的可靠运行和数据的新鲜度,为分析工作提供了坚实基础。

通过将工作流划分为实验、参数化和生产三个阶段,并利用Airflow与Jupyter、Papermill等工具的集成,团队可以实现从模型原型到生产部署的平滑过渡。Apache Airflow作为一个久经考验、社区活跃的平台,是连接数据科学、数据工程和产品需求的强大桥梁。

031:Ray 系统介绍与核心概念

在本教程中,我们将学习 Ray 系统,这是一个用于高性能、分布式 Python 应用程序的框架。我们将探讨其设计动机、核心编程模型以及如何利用它来扩展应用程序。

Ray 的动机反映了行业的几个趋势。过去五六年里,计算需求以每18个月翻三倍的速度增长,远超摩尔定律。仅靠硬件扩展无法满足需求,分布式计算成为必然。同时,由于对机器学习、人工智能和数据科学的兴趣,Python 语言持续强劲增长。这两个趋势共同创造了一个迫切需求:让在集群上分发 Python 应用程序变得更加容易,以满足可扩展性需求,同时便于 Python 开发者使用。

1:Ray 的设计愿景与应用场景

上一节我们介绍了 Ray 诞生的背景,本节中我们来看看 Ray 的设计目标和它能解决哪些问题。

如果你观察今天的机器学习领域,会发现有许多任务需要处理。例如,特征工程是找出数据的哪些方面对预测最有用。数据流处理用于实时处理数据。超参数调优对于选择最佳模型结构至关重要。模型训练通常需要运行游戏模拟器或其他类型的模拟器。模型训练完成后,还需要进行部署和服务。几乎所有这些任务都需要分布式实现才能有效扩展,尤其是在处理大规模数据时。

Ray 的愿景是创建一个核心框架,能够满足所有这些不同类型计算负载的需求。其最初目标是支持 Python,但其设计足够灵活,可以支持其他语言。例如,目前正在开发一个 Alpha 质量的 Java API。最重要的是,Ray 希望提供特定领域的库,这样用户可能根本不需要直接了解 Ray,只需使用这些库即可。但如果编写通用应用程序,则可能需要直接了解 Ray。

2:Ray 的核心编程模型:远程函数

上一节我们了解了 Ray 要解决的问题,本节中我们来看看 Ray 的核心编程概念之一:远程函数。

Ray 被设计得尽可能直观和简洁,并利用了熟悉的概念。其中之一就是用 Python 编写函数。以下是一个模拟示例:

def make_array():
    return np.array([...])

def add_arrays(array1, array2):
    return array1 + array2

这很熟悉。如果你了解 Python,应该不难理解。如果你想将这些函数变成分布式任务,Ray 使用的术语是“远程函数”。你只需用 @ray.remote 装饰器来注释这些函数,然后它们就可以通过 Ray 自动在集群中执行。

为了代码完整,需要先进行一些导入并初始化 Ray:

import ray
import numpy as np

ray.init()

现在,通过附加 .remote() 来调用这些函数:

id1 = make_array.remote()
id2 = make_array.remote()
id3 = add_arrays.remote(id1, id2)
result = ray.get(id3)

与常规 Python 调用不同,现在通过附加 .remote() 来调用函数。这样做的原因是,它可以很容易地阅读代码,确切地知道发生了什么。虽然这需要更多的代码更改,但考虑到阅读代码的频率高于编写代码,我们认为最好将 .remote() 作为一个指示器,标明哪些是 Ray 特有的,哪些是常规 Python。

实际上,你发送了一个异步计算任务,它立即返回一个 ID,这个 ID 对应于一个“未来”(Future)。我们以后可以用它来检索这个计算的结果。id1id2 就是这样。然后我们可以调用远程函数 add_arrays 来组合结果,最后用 ray.get 函数来检索计算的值。如果我们只关心 id3,我们不需要去获取另外两个 ID,尽管我们可以。ray.get 是一个阻塞调用,会一直等待直到 id3 可用,然后返回该对象(本例中是一个 NumPy 数组)。

Ray 最酷的特性之一是它会自动处理这些依赖关系的排序。在两个 make_array 调用完成之前,它不能运行 add_arrays,而 Ray 会自动为我们做到这一点。我们不需要编写逻辑来检查它们是否完成。它知道右侧的依赖图,并会自动处理。

另一个好处是,由于 add_arrays 是远程的,我们不必从这些 Future 句柄中提取数组来传递它们。Ray 在幕后自动为我们处理。所以,它有点像我们编写常规 Python 代码的方式,我们真的不必知道这些是 Future ID 而不是实际数组。当然,在某些时候,尤其是在最后一行使用 ray.get 时,你需要知道。

3:Ray 的核心编程模型:Actor(参与者)

上一节我们介绍了远程函数,本节中我们来看看如何管理分布式状态,这就要用到 Actor 模型。

编写分布式应用程序时,经常遇到的问题是:如何管理分布在集群上的状态?为此,Ray 利用了 Python 中一个熟悉的概念:类。希望大家都熟悉类。在本例中,有一个简单的计数器类:

class Counter:
    def __init__(self):
        self.value = 0

    def increment(self):
        self.value += 1
        return self.value

它跟踪一个值,每次调用 increment 时,将该值递增 1,然后返回当前值。同样,你用 @ray.remote 装饰器注释它,现在它就变成了一个 Actor。

为什么“Actor”这个词好?在 JVM 世界,它因 Erlang 语言的商业实现而闻名。其思想是,你有这些自主的代理(Actor),你通过给它们发送消息来与它们交互,无论是为了工作还是为了获取值。然后,环境提供一个线程安全的执行模型,一次处理一条消息。这些消息被保存在某个队列中。这意味着 Actor 的实现者不必担心编写线程安全代码,他们只需编写常规代码,而 Actor 模型会为你处理线程安全。这是一个非常强大的并发模型,它抽象了编写线程安全代码的许多复杂性。

现在我们有一个远程 Actor。需要注意的是,Ray 目前不支持直接读取对象内部字段的值。因此,如果仅捕获 increment 的返回值还不够,就必须编写 getter 方法。

实际发生的情况是,我们使用 .remote() 进行调用,注意它如何用于构造函数和方法调用:

counter = Counter.remote()
id1 = counter.increment.remote()
id2 = counter.increment.remote()
value = ray.get(id2)

ray.remote 装饰器实际上接受一系列可选参数,用于指定一些内容,比如你想使用多少个 GPU、在允许更多调用之前可以调用此函数多少次等。

4:Ray 系统架构与执行流程

上一节我们学习了 Actor,本节中我们来看看 Ray 在集群中是如何工作的。

假设有一个三节点集群。这些节点各司其职。一旦我们进行了这些函数调用,任务图会被安排到这样的集群中。调用本地调度程序来进行计算,它会立即返回那些 ID(这些是异步计算,所以调用立即返回)。还有一个全局控制存储,用于跟踪所有内容的位置以及发生了什么。

调度程序可能会选择本地工作节点执行第一个 make_array 调用,并可能在另一个节点上选择另一个工作节点执行第二个。每个任务完成后,它会将其结果(对象)写入对象存储。此时,这些任务可以从工作内存中删除。Actor 的处理略有不同,实际上 Actor 被固定在工作节点上,因为它们持有状态。在驱动程序代码中对它们的引用消失之前,它们会留在工作节点内。

现在我们可以安排 add_arrays 任务。它可以从共享内存中读取对象1,无需将对象复制到工作节点的内存空间中。这很方便,如果对象很大,有助于提高性能。对象2不在同一个节点上,所以必须复制,这显然会产生一些开销。当 add_arrays 完成时,它将结果写回对象存储。当我们调用 ray.get 时,全局控制存储会告诉我们它在哪里,然后我们的代码返回对象3。

Ray 在幕后为你做了很多工作。显然,所有这些都有一点开销,所以你不想使用过于细粒度的任务,因为你会招致开销,这不会给你带来太多价值。但对于完成所有这些异步工作来说,即使在分布式集群上使用大对象进行非常大的计算,它也非常棒。

5:如何开始使用 Ray

上一节我们了解了 Ray 的内部原理,本节中我们来看看如何开始使用 Ray。

假设你想开始使用 Ray,你可能想先调查一下。首先从 Ray 官网 开始,在那里你可以找到博客、文档链接等。下一个链接实际上是 文档,在那里你可以找到关于安装 Ray、入门等的详细信息。我们正在开发新的教程,在我们所谓的“Anyscale Academy”,这些将于今年春夏发布。如果你想查看 Ray 项目的代码,这是 GitHub 链接。如果你需要帮助,最好的地方是 Ray Slack,我们非常仔细地监控它,并经常在那里帮助人们。还有一个 Ray 开发邮件列表。

假设你想采用 Ray,你当然可以直接开始用 Ray 编程。但你可能已经在使用其他 API 编写代码了。我们已经实现了用于异步 I/O、joblib 和多处理池的 API。所以你可以顺其自然,在大多数情况下,只需通过更改 import 语句切换到 Ray。你不仅拥有相同的本地模式或单节点计算,现在还打破了界限,通过这种替换,你基本上可以将应用程序扩展到集群。在我们的 Ray 博客上有几篇博客链接,如果你去 ray.io,你可以访问这些博客文章,并找到更多关于如何做到这一点的信息。

6:Ray 的生态系统库:RLlib 与 Tune

上一节介绍了如何入门,本节中我们来看看 Ray 生态系统中两个重要的库:RLlib 和 Tune。

今天我只谈其中的两个。Tune 用于超参数调优,Ray SGD 是我们刚刚推出的产品,它帮助分布式训练。另一个新库是 Serve,用于模型服务。

让我们先谈谈强化学习(RL)。其思想很简单,尽管幕后显然有很多复杂性。你有一个代理在环境中行动,它观察发生了什么,并决定下一步做什么,然后观察它采取这些行动获得了什么回报。目标是在这个环境的一系列步骤中优化奖励。一些著名的例子是 AlphaGo 系统击败了世界上最好的围棋选手,它被用来玩 Atari 游戏、训练模拟机器人,甚至像模拟步行者,教它们走路。AlphaGo 变得非常复杂。如你所料,幕后有一个大型神经网络帮助做出决定。在这种情况下,动作是把棋子放在哪里,神经网络试图给你最好的选择。奖励很简单:要么赢,要么输。

我们也看到,在优化工业流程方面有很多有趣的工作正在进行,比如工厂车间和管道等,优化网络计算,这类事情。它被用作一种新的方式来进行广告服务和推荐,得到了广泛传播。一些规模问题,用传统方法和金融也是。股票市场是一个计时系统。所以无论你在构建什么应用程序,你都会做很多架构上的决定,比如,是一个代理还是多个合作代理,或者是它们的层次结构。

离线批量学习有点有趣。其想法是:我不能让你一遍又一遍地运行我的化工厂,但我确实有大量的过去表现记录。我们能根据这些记录进行训练吗?我为所有这些选择提供了一系列算法,全部由 Ray 执行。以下是 RLlib 中许多可用算法的图表。当你收到本次演讲的 PDF 文件时,可以点击这些链接。如果你是亚马逊客户,你甚至可以在 SageMaker 中运行它。

你可能在运行一个模拟器,就像一个游戏引擎、机器人模拟器或工厂模拟器。这更像是一个典型的面向对象应用程序,或者任何具有非常不同的计算图、内存访问模式和计算类型的应用程序。这不是你传统的数据处理问题,你只有大量的数据集在系统中进行查询或转换。这是非常不同的计算模型。你也在做我们一直在优化的所有常规神经网络的事情,像 TensorFlow 和 PyTorch 这样的系统。你需要一遍又一遍地重复这个,因为你会继续玩游戏,直到找到一个使回报最大化的最佳配置。因此,你需要能够有效地做到这一点。现有的系统中没有一个能真正解决伯克利正在处理的所有这些挑战,所以他们发明了 Ray。

7:超参数调优与 Tune 库

上一节提到了 Tune,本节中我们深入探讨超参数调优和用于此目的的 Tune 库。

超参数调优实际上是询问:我应该使用的最佳模型是什么?超参数告诉你模型的结构。我能想到的最简单的例子就是 K-Means 聚类。这是一个 K=3 的例子,一旦我选择了 K,我就会迭代一个相对简单的算法来找到数据集中的聚类。如你所知,看右边移动的例子,你可以看到有两个相当明显的星团,再多一个无定形的星团。所以 K=3 是对的。如果你在处理二维数据,你可以经常绘制它,看看最好的选择是什么。但是如果你要处理三维之外的多维数据,也许是非常复杂的结构,这就不那么容易了。所以有时候你只需要用不同的 K 值多次运行这种聚类算法,以找到最好的 K 值。这就是超参数调优的真正意义:找到那个结构,然后训练模型,告诉你实际的参数(在这种情况下是聚类中心)。

找到 K-Means 的 K 并不是一个极具挑战性的问题,可能有点计算成本。但真正具有挑战性的是当你研究像神经网络这样的东西时。你在这里看到的每一个数字、什么类型的层,都是超参数。显然,你可以做出巨大的选择。超参数调优的想法是优化搜索过程,这样你就可以得到最好的网络,或者一个表现相当好的网络,而不会浪费大量的计算来试图找到它。

这是一个用不同超参数运行猎豹模拟器的例子。蓝色做得最好,在这种情况下,粉红色做得最差。因此,它确实起到了作用,并作为一种工具出现,帮助你在资源昂贵的情况下找到最佳模型,就像用于神经网络训练的 GPU,而且做训练也很费时间。你想优化所有这些东西。

当你尝试了很多不同的模型结构,看看哪一个是最好的时,Tune 会处理分布式训练。它利用了很多幕后的算法和工具,比如 PyTorch、TensorFlow、Scikit-learn。它试图让你很容易地声明你想做什么,然后去做,Tune 做剩下的工作。它与 TensorBoard 集成,这样你可以看到你的超参数是什么样的。正如我们提到的,它进行资源感知调度。你可以告诉它资源限制,它进行非常无缝的分布式执行。你真的不必知道幕后发生了什么,你只知道有一群工作节点在做你的工作。在 API 中添加新算法和新工具相对简单。所以它像一把伞,是一个与框架无关的框架,支持许多流行的框架。这里是 Tune 文档 的链接。

8:使用 Ray Serve 构建微服务

上一节我们探讨了 Tune,本节中我们来看看 Ray 作为构建微服务的通用工具。

无论你现在在建立什么样的服务,我知道微服务现在受到了一些反弹。你可能听过 Uber 有句名言,他们要去“宏服务”。除了什么是适合你服务的正确规模,总体思路仍然有效,你只需要聪明地使用它。

建立微服务有几个原因。一种是它们划分领域,通常以两种方式划分:一种是接受康威定律(我稍后会解释),另一种只是分离责任,比如让网络团队担心 DNS 解析,安全团队担心身份验证等。在生产中管理这些东西也是一个重要的驱动力。

康威定律是梅尔·康威在 60 年代的观察。他注意到系统的架构通常反映了构建它们的组织的结构。换句话说,子系统倾向于反映大团队中的团队或子组织。我们实际上决定应该接受这一点,并组织我们的团队以反映我们正在构建的系统的结构。这样做的原因,或者一开始发生这种情况的原因,是为了尽量减少团队之间所需的沟通,因为维护太多的沟通渠道对人们来说是一种负担。这使得较小团队内部所需的高保真通信量变得明显。

我刚才提到了几个例子。你希望你的微服务有一个最小化的、定义良好的责任。它与其他微服务有自然的最小耦合,希望也能反映出团队的组织。

但我想谈的是 Ray 解决的管理挑战。在 DevOps 世界中,开发团队还负责运行和操作他们的微服务是很常见的。在整个组织中,你通常会看到每个微服务实际上拥有与其他微服务不同数量的实例。这些实例会以不同的速率创建和销毁。正如我在这张图上所展示的,微服务3可能不需要那么多实例,但假设它实际上变化得更快,必须更频繁地更新。另外两个可能更稳定,但它们的负载需要更多的实例。这是一种负担。

为什么我们这里有这么多的实例?有两个原因。一是我们可能需要它们来提高可扩展性,因为我们达到了一个节点的极限,因此必须访问更多的节点,从而访问更多的实例。它还有助于弹性。如果我们有一个至关重要的微服务只在一个节点上运行,当节点崩溃时,我们会有麻烦。所以弹性是另一个原因。

Ray 赋予我们的能力是,这是一种“99%”的解决方案(我不想假装这是魔法)。但因为 Ray 可以在幕后利用整个集群,它可以追溯到这样一个想法:对于我们运行的每个微服务,我们确实有一个逻辑实例。我们让 Ray 处理我们后面的可扩展性。这也与像 Kubernetes 这样的集群系统很好地吻合。如果你想到一个容器和 Kubernetes,它真的像一台小机器。Ray 可以利用物理硬件作为一个更细粒度的调度系统,在更大的调度系统(如 Kubernetes)的构造中工作。

总结

本节课中我们一起学习了 Ray 分布式计算系统。Ray 是最新的分布式计算系统,我们相信它能够将应用程序从笔记本电脑扩展到在云中或本地硬件上的一堆机器上运行。它有足够的灵活性来运行各种各样的计算任务和内存访问模式。Anyscale 公司是从伯克利分拆出来的,致力于围绕 Ray 开发产品和服务。我们正在招聘,即使在我们生活的这个 COVID 时代。你可以在 anyscale.com 上找到更多信息。

很不幸我不能回答问题。但这里有更多的链接给你。ray.io 是获取 Ray 信息的地方,了解我们的职位空缺,了解像我们的夏季比赛、峰会、连接系列这样的事件,这些是我们正在进行的在线活动。原定于5月的活动,现在最有可能发生在11月。我非常欢迎你来找我,Anyscale 的 Dean Wampler,尤其是如果你想要幻灯片。我会试着让它们可用,但我不确定它们会被贴在哪里,所以请随时通过 anyscale 网站联系我 Dean。非常感谢。

032:由内存NoSQL拯救 🚀

在本教程中,我们将学习如何为复杂的Python生产系统(如状态机)构建一个“黑盒调试”系统。该系统能实时、高效地记录编码后的运行日志,并通过可视化工具帮助开发者诊断问题,同时避免性能瓶颈和敏感信息泄露。


黑盒调试的概念 ✈️

上一节我们介绍了本教程的目标,本节中我们来看看核心概念“黑盒调试”的灵感来源。

“黑盒”这一术语通常让人联想到飞机上的黑匣子。飞机黑匣子是极其耐用的硬件,设计用于在极端条件下生存。它实时记录运行系统的所有信息。

黑匣子中的数据是经过编码的。这意味着,即使有人获得了黑匣子,也无法直接解码其中的信息。这正是全球需要设立专业实验室来解读这些数据的原因。

在软件领域,我们常常遇到类似情况:生产系统出现问题,我们难以定位根本原因。通常的做法是在疑似问题区域增加日志,然后等待问题复现以获取更多线索。然而,与飞机不同,我们有时无法承受这种“打补丁”式的调试对生产系统的影响。

因此,在黑盒调试中,我们借鉴飞机黑匣子的理念,目标是最小化对现有生产系统的修改,同时建立一个独立的、实时的数据记录系统来帮助定位问题根源。


系统架构与Python实现 🏗️

理解了黑盒调试的理念后,我们来看看它的基本架构以及在Python中的具体应用场景。

黑盒调试的架构非常简单。它包含一个名为“黑盒”的独立实体,与生产系统完全分离,且不影响任何现有组件的性能。它的唯一职责是实时获取并记录关于组件性能的数据。

就像飞机上有成千上万个组件向黑匣子发送数据一样,我们的软件组件也会将实时数据发送给黑盒。这些组件的功能不依赖于黑盒是否正常工作。

现在,让我们结合一个具体的Python案例。假设我们正在构建一个包含数百个状态、事件和转换的复杂状态机系统。在这种系统中,不存在单一的“快乐路径”(即标准流程)。系统的行为几乎无限取决于用户操作和环境因素。

因此,我们迫切需要一个黑盒功能,来记录每一个状态、活动、事件和转换。基于这些记录,我们希望生成系统完整的视觉序列图活动图,以理解内部究竟发生了什么。


日志的编码与安全 🔒

上一节我们讨论了为何需要黑盒,本节中我们来看看如何安全、高效地生成日志。

常见的日志实践会区分信息、调试、关键等不同级别。出于性能考虑,生产环境通常只启用关键日志。但在黑盒调试中,我们需要记录大量细节,这带来了性能和安全的双重挑战。

以下是我们状态机的一个简化代码示例,用于说明系统如何工作:

# 简化版状态机示例
class State:
    """状态基类"""
    pass

class Event:
    """事件基类"""
    pass

class StateMachine:
    def __init__(self, initial_state):
        self.current_state = initial_state

    def transition(self, event):
        # 根据当前状态和事件,转移到下一个状态
        # 此处包含复杂的内部逻辑
        next_state = self._calculate_next_state(event)
        self.current_state = next_state
        # 记录状态转换(这是黑盒记录点)
        self._log_transition(event, next_state)

我们不希望在日志中直接写入“我从快乐状态,因失去金钱事件,转到悲伤状态”这样的明文。相反,我们希望生成编码后的日志。

为此,我们创建一个映射表:

  • 状态快乐 -> 1悲伤 -> 2
  • 事件获得金钱 -> 3失去金钱 -> 4

最终生成的日志文件将是一串类似 1,4,2,3,1 的字符和数字,对外部人员而言如同乱码。这带来了两大好处:

  1. 安全性:即使生产日志被意外访问,他人也无法解读。
  2. 效率:编码后的简短标识比纯文本更节省存储空间。

性能挑战与解决方案 ⚡

仅仅生成编码日志还不够,我们需要一套完整的后端系统来解码和可视化日志。但在此之前,我们遇到了最大的挑战:性能

当我们尝试实时写入大量日志时,磁盘I/O(无论是文件还是数据库)成为了巨大的性能瓶颈。我们评估了几种现有策略:

以下是常见的日志缓冲策略及其问题:

  • 环形缓冲区:在写入磁盘前,先将日志暂存在内存缓冲区(例如攒够1MB再写)。这对我们不可行,因为状态转换等关键事件需要实时持久化,否则系统崩溃时将丢失关键信息。

我们最初的架构是让生产代码通过TCP连接,将日志发送到一个独立的专用日志服务器。我们尝试了三种存储方式:

  1. 直接写入文件:性能最差,耗时过长。
  2. 写入SQL数据库:性能有所改善,但仍未达标。
  3. 写入NoSQL数据库:优于前两者,但提升程度取决于数据结构和存储设计。

性能影响始终超出可接受范围。于是,我们转向了第四种方案:内存NoSQL数据库

内存数据库受限于系统内存容量,无法存储所有数据。我们的解决方案是:

  • 让内存数据库接收并暂存所有日志。
  • 在后台运行一个批处理任务,当内存使用达到阈值时,自动将数据批量转移到持久的磁盘数据库(如PostgreSQL或MongoDB)。

这个方案终于将性能控制在可接受范围内。但有一个重要警告:简单地用内存数据库替换磁盘数据库并不会自动带来巨大提升。如果数据结构设计不当,操作耗时反而会抵消内存访问的速度优势。


架构优化与最终方案 🏆

上一节的方案可行,但我们想知道能否更进一步。本节将介绍最终的优化架构。

为了追求极致性能,我们进行了如下优化:
生产代码不再跨网络发送日志,而是在每台应用服务器本地运行一个内存NoSQL数据库实例(如Redis)。

日志写入流程变为:

  1. 生产代码将日志写入本地内存数据库(速度极快)。
  2. 每台服务器上运行的后台守护进程,定期将本地内存数据库中的数据,批量同步到中心的持久化磁盘数据库。

这里有一个关键观察:对于小数据量的写入,现代SSD磁盘数据库与内存数据库的性能差距可能微乎其微。但最终选择内存数据库的原因是,守护进程从内存读取数据的速度仍然快于从磁盘读取,这使得整个数据转移流程更快。


总结与注意事项 📝

本节课中,我们一起学习了为Python生产系统构建黑盒调试系统的完整流程。

我们首先从飞机黑匣子获得灵感,提出了黑盒调试的概念,旨在以最小侵入性实时记录系统行为。接着,我们针对复杂状态机系统,设计了编码日志方案以保障安全与效率。然后,我们直面核心挑战——性能,并通过引入内存NoSQL数据库结合后台批处理持久化的架构成功解决了它。最后,我们通过将内存数据库部署到每台应用服务器本地,实现了进一步的架构优化

在结束之前,关于黑盒调试的一些重要注意事项:

  • 适用性:构建黑盒调试系统通常比较复杂,适用于长期运行业务关键的系统,短暂的小项目可能不需要。
  • 性能成本:即使使用内存数据库,写入日志仍然需要时间(纳秒或毫秒级)。你需要评估系统是否能承受这部分开销。
  • 额外价值:除了调试,这些详尽的日志能帮助你分析系统行为模式,从而进行优化。
  • 技术普适性:黑盒调试的理念并不仅限于Python,几乎可以应用于任何编程语言,各类数据库也提供了广泛的驱动支持。

通过本教程,希望你掌握了构建一个高效、安全的生产调试工具的核心思路与方法。

033:从模块到混合应用程序 🚀

概述

在本教程中,我们将学习如何将 C++ 与 Python 结合使用。我们将从编写简单的 C 扩展模块开始,逐步深入到将 Python 解释器嵌入到现有的 C++ 桌面应用程序中。通过这种方式,我们可以利用 C++ 的性能优势和 Python 的灵活性与丰富的生态系统。


章节 1:C++ 与 Python 的异同 🤝

C++ 和 Python 都是通用、多范式的编程语言,支持面向对象和函数式编程。然而,它们也存在显著差异。

C++ 是一种静态类型语言,需要编译。Python 则是一种动态类型语言,通常通过解释器执行。一个常见的观点是 C++ 复杂但速度快,而 Python 简单但速度较慢。但好消息是,我们不必在两者中只选其一,它们可以协同工作。

Python 的成功很大程度上源于其设计哲学和实现。其语法深受 ABC 语言启发,易于阅读。更重要的是,Python 解释器本身是用 C 语言编写的。这种“C 语言胶水”的特性,使得我们可以轻松地将用 C 或 C++ 编写的高性能代码、现有库或特定功能集成到 Python 中。

许多流行的 Python 库都利用了这一点。例如,NumPy 的后端大量使用了 Fortran 和 C 代码以实现高性能。PyTorch 则是一个将庞大 C++ 库(libtorch)暴露给 Python 的绝佳例子。


章节 2:C++ 快速入门 📖

如果你不熟悉 C++,以下是一个快速概览,帮助你理解后续的代码示例。

C++ 代码与 Python 代码的主要区别在于类型声明。在 C++ 中,你需要为变量和函数指定类型。

Python 示例:

def add(a, b):
    return a + b

result = add(5, 3)
print(result)

C++ 示例:

#include <iostream>
#include <string>

int add(int a, int b) {
    return a + b;
}

int main() {
    int result = add(5, 3);
    std::cout << result << std::endl;
    return 0;
}

关键点:

  • intfloatstd::string 是类型。
  • 函数使用大括号 {} 定义,并需要声明返回类型(如 int)。
  • main() 函数是程序的入口点,是强制性的。
  • 输出使用 std::cout
  • C++ 代码需要先编译成二进制文件才能执行。

章节 3:现代 C++ 中的 Python 风味 🍬

现代 C++ 标准(C++11/14/17/20)引入了许多让代码更简洁、更类似 Python 的特性。

上一节我们介绍了 C++ 的基本语法,本节中我们来看看它如何变得更“Pythonic”。

以下是几个例子:

  • 自动类型推断 (auto):类似于 Python 的动态类型,让编译器推断变量类型。

    auto x = 5; // x 是 int
    auto y = 3.14; // y 是 double
    auto z = std::string("hello"); // z 是 std::string
    
  • 元组 (std::tuple):可以返回多个值。

    #include <tuple>
    std::tuple<int, int, std::string> get_values() { return {1, 2, "hello"}; }
    auto [a, b, c] = get_values(); // 结构化绑定 (C++17)
    
  • 映射 (std::map, std::unordered_map):类似于 Python 的字典。

    #include <unordered_map>
    std::unordered_map<std::string, int> scores = {{"Alice", 10}, {"Bob", 20}};
    
  • Lambda 表达式:可以方便地定义匿名函数。

    auto square = [](int x) { return x * x; };
    
  • 模块 (C++20):引入了类似 Python 的模块系统。

    // math.cpp
    export module math;
    export int add(int a, int b) { return a + b; }
    
    // main.cpp
    import math;
    int result = add(5, 3);
    
  • 范围 (Ranges) 和算法 (C++20):支持类似 Python 生成器表达式的链式操作。

    #include <ranges>
    #include <vector>
    std::vector<int> numbers = {1, 2, 3, 4, 5};
    auto result = numbers | std::views::filter([](int n){ return n % 2 == 0; })
                          | std::views::transform([](int n){ return n * n; });
    // result 包含 [4, 16]
    

这些特性使得 C++ 代码更易读、更易写,并且与 Python 的设计哲学更加接近。


章节 4:场景一:用 C++ 扩展 Python (编写 C 扩展) ⚙️

现在,让我们进入实践环节。第一个场景是使用 C++ 来编写 Python 扩展模块,以提升性能或集成现有 C++ 库。

以下是创建一个简单 C 扩展所需的步骤:

  1. 包含头文件:需要 Python.h
  2. 编写 C 函数:实现你想要暴露给 Python 的功能。
  3. 定义模块方法表:将 Python 函数名与你实现的 C 函数关联起来。
  4. 定义模块结构:指定模块名、文档和方法表。
  5. 定义模块初始化函数:Python 导入模块时调用的函数。

示例:一个简单的 “hello” 模块

// simple.c
#include <Python.h>

// 1. 实现 C 函数
static PyObject* simple_hello(PyObject* self, PyObject* args) {
    const char* msg = "Hello from C extension!";
    return PyUnicode_FromString(msg);
}

// 2. 定义方法表
static PyMethodDef SimpleMethods[] = {
    {"hello", simple_hello, METH_NOARGS, "Print a hello message."},
    {NULL, NULL, 0, NULL} // 哨兵,表示结束
};

// 3. 定义模块结构
static struct PyModuleDef simplemodule = {
    PyModuleDef_HEAD_INIT,
    "simple", // 模块名
    NULL, // 模块文档
    -1,
    SimpleMethods
};

// 4. 模块初始化函数
PyMODINIT_FUNC PyInit_simple(void) {
    return PyModule_Create(&simplemodule);
}

编译与安装

你需要一个 setup.py 文件来编译这个扩展:

# setup.py
from setuptools import setup, Extension

module = Extension('simple', sources=['simple.c'])

setup(
    name='SimpleExtension',
    version='1.0',
    ext_modules=[module]
)

在终端中运行:

pip install .

然后就可以在 Python 中使用:

import simple
print(simple.hello()) # 输出:Hello from C extension!

章节 5:场景一实战:性能对比示例 🏎️

为了展示 C 扩展的性能优势,我们实现一个遍历目录并收集所有文件路径的函数,分别用纯 Python 和 C 扩展实现。

Python 实现 (os.walk):

import os
def list_files_py(path):
    file_list = []
    for root, dirs, files in os.walk(path):
        for file in files:
            file_list.append(os.path.join(root, file))
    return file_list

C++ 扩展实现 (使用 <filesystem> 库):

// fastlist.c
#include <Python.h>
#include <filesystem>
namespace fs = std::filesystem;

static PyObject* fastlist_listfiles(PyObject* self, PyObject* args) {
    const char* path;
    int recursive = 1; // 默认递归

    if (!PyArg_ParseTuple(args, "s|p", &path, &recursive)) {
        return NULL;
    }

    PyObject* result_list = PyList_New(0);
    try {
        if (recursive) {
            for (const auto& entry : fs::recursive_directory_iterator(path)) {
                if (entry.is_regular_file()) {
                    PyList_Append(result_list, PyUnicode_FromString(entry.path().c_str()));
                }
            }
        } else {
            for (const auto& entry : fs::directory_iterator(path)) {
                if (entry.is_regular_file()) {
                    PyList_Append(result_list, PyUnicode_FromString(entry.path().c_str()));
                }
            }
        }
    } catch (...) {
        PyErr_SetString(PyExc_OSError, "Error reading directory");
        return NULL;
    }
    return result_list;
}
// ... (方法表和模块初始化部分与上例类似)

性能对比:
在一个包含大量文件的目录下测试,C++ 扩展的实现速度通常会显著快于纯 Python 的 os.walk,尤其是在递归遍历时,因为 std::filesystem 是原生实现,避免了 Python 层的开销。


章节 6:场景二:将 Python 嵌入 C++ 应用程序 🖥️

现在,让我们看看反向操作:将 Python 解释器嵌入到一个现有的 C++ 应用程序中。这允许 C++ 程序动态执行 Python 脚本,从而增加灵活性和可扩展性。

上一节我们让 Python 调用 C++,本节中我们来看看如何让 C++ 程序运行 Python。

嵌入 Python 解释器出奇地简单,只需要几个关键步骤:

  1. 初始化 Python 解释器。
  2. 执行 Python 代码或脚本。
  3. 可选的:清理 Python 解释器。

一个最简单的嵌入示例:

// embed_simple.cpp
#include <Python.h>

int main() {
    // 1. 初始化 Python 解释器
    Py_Initialize();

    // 2. 执行一段 Python 代码字符串
    PyRun_SimpleString("print('Hello from embedded Python!')");

    // 3. 关闭 Python 解释器
    Py_Finalize();
    return 0;
}

编译与链接:
编译此程序时,需要链接 Python 库。例如,使用 CMake:

cmake_minimum_required(VERSION 3.10)
project(EmbedPythonDemo)

find_package(Python3 REQUIRED COMPONENTS Development)

add_executable(embed_simple embed_simple.cpp)
target_link_libraries(embed_simple Python3::Python)

运行编译后的程序,它会在控制台输出 Hello from embedded Python!


章节 7:场景二实战:在 C++ GUI 应用中嵌入 Python 🎨

让我们看一个更激动人心的例子:一个用 C++ 框架(如 Qt)编写的桌面应用程序,它嵌入了 Python,允许用户通过 Python 脚本实时修改 GUI 或添加功能。

以下是核心思路:

  1. C++ 主程序:创建基本的 GUI 窗口。
  2. 暴露 C++ 对象:将关键的 C++ 对象(如主窗口、按钮)暴露给 Python 解释器。这通常需要为这些类创建 Python 绑定(可以使用 PyBind11 等工具简化)。
  3. Python 脚本:用户编写脚本,通过访问已暴露的 C++ 对象来操作界面。
  4. 执行脚本:应用程序提供一种方式(如一个文本框和“执行”按钮)来运行用户输入的 Python 代码。

概念性代码结构:

  • C++ 主窗口 (简化):

    class MainWindow : public QMainWindow {
        Q_OBJECT // Qt 宏
    public:
        QPushButton* button;
        QTextEdit* textEdit;
        MainWindow() {
            button = new QPushButton("Click Me", this);
            textEdit = new QTextEdit(this);
            // ... 布局代码
            // 执行按钮的槽函数
            connect(runButton, &QPushButton::clicked, this, &MainWindow::executePythonCode);
        }
    public slots:
        void executePythonCode() {
            QString code = textEdit->toPlainText();
            // 这里将 code 传递给嵌入的 Python 解释器执行
            // 解释器已能访问 `self` (即这个 MainWindow 实例)
        }
    };
    
  • 用户 Python 脚本示例:

    # 假设 `window` 是暴露出来的 MainWindow 对象
    window.button.setText("Hello from Python!") # 修改按钮文字
    window.button.setStyleSheet("background-color: red;") # 改变按钮颜色
    # 甚至可以动态添加新组件
    new_label = QLabel("Dynamic Label")
    window.layout().addWidget(new_label)
    

通过这种方式,用户无需重新编译 C++ 程序,就能通过 Python 脚本无限扩展应用程序的功能,比如创建新的工具栏按钮、改变主题、导入数据分析库处理程序中的数据等。


总结

在本教程中,我们一起探索了 C++ 与 Python 两种语言协同工作的强大能力。

我们首先了解了它们的异同,并学习了现代 C++ 中类似 Python 的语法特性。然后,我们深入实践了两种主要场景:

  1. 用 C++ 扩展 Python:通过编写 C 扩展模块,我们将高性能的 C++ 代码集成到 Python 中,用于提升关键函数的速度或封装现有库。
  2. 将 Python 嵌入 C++:我们将 Python 解释器嵌入到 C++ 应用程序中,这使得成熟的 C++ 软件(特别是 GUI 应用)获得了动态执行脚本、运行时扩展和插件化的能力。

核心思想是打破语言壁垒,利用每种语言的优势。你不必只选择一种语言,而是可以设计一个混合技术栈,让 C++ 处理性能关键部分和底层系统交互,让 Python 负责快速原型设计、脚本编写和高级逻辑。希望本教程能帮助你减少对 C++ 的畏惧,并激发你在项目中尝试这种强大组合的兴趣。

034:静态类型详解

在本教程中,我们将学习Python中的静态类型。我们将从Python的基础类型系统开始,逐步理解动态类型与静态类型的区别,并最终掌握如何在Python项目中应用静态类型检查。

Python静态类型入门:1:Python中的类型

首先,我们需要理解Python中的类型。在Python中,每个值都有一个类型。我们可以使用内置的type()函数来查看。

type(42)        # <class 'int'>
type(42.0)      # <class 'float'>
type('foo')     # <class 'str'>
type([1, 2, 3]) # <class 'list'>

这些intfloatstrlist等不仅是内置函数,也是Python中的内置类型类。除了这些,Python的types模块还包含许多其他类型,例如NoneTypeFunctionType等,它们不一定有直接对应的内置转换函数。

上一节我们介绍了Python中的基本类型,本节中我们来看看Python类型系统的核心特征:动态类型。

Python静态类型入门:2:动态类型

当我们说Python是一种动态类型语言时,它主要包含两层含义。

第一层含义是,变量本身没有固定的类型,其类型可以在程序运行时改变。

a = 42      # a 现在是 int 类型
a = 42.0    # a 现在是 float 类型
a = "42"    # a 现在是 str 类型

第二层含义是,函数的参数和返回值可以是任何类型。

def add(a, b, c):
    return a + b + c

# 可以传入整数
result = add(1, 2, 3)  # 返回 6
# 也可以传入字符串
result = add('a', 'b', 'c')  # 返回 'abc'
# 但不能混合类型
result = add(1, 'b', 3)  # 引发 TypeError

这种灵活性带来了便利,但也使得代码意图不清晰,并可能在运行时产生错误。为了改善这一点,开发者可能会尝试编写详细的文档字符串或在函数内部使用assert进行类型断言,但这两种方法都有其局限性。

因此,Python社区更常用的是“鸭子类型”(Duck Typing):我们通过一个对象的行为(它有什么方法)来推断其类型,而不是通过其声明的类型。

理解了动态类型的优缺点后,我们来看看它的对立面:静态类型。

Python静态类型入门:3:静态类型简介

静态类型意味着变量或函数参数的类型在定义时就被确定,并且在程序运行期间不会改变。许多语言是静态类型的,例如C、Java、Rust和TypeScript。

以下是不同语言中相同加法函数的静态类型声明示例:

// C
int add(int a, int b, int c) {
    return a + b + c;
}
// Java
public static int add(int a, int b, int c) {
    return a + b + c;
}
// Rust
fn add(a: u8, b: u8, c: u8) -> u8 {
    a + b + c
}
// TypeScript
function add(a: number, b: number, c: number): number {
    return a + b + c;
}

那么,Python能否实现静态类型呢?答案是肯定的,这得益于一系列PEP提案的引入。

Python静态类型入门:4:Python静态类型发展史

Python实现静态类型的故事始于2006年的PEP 3107,它为Python 3引入了函数注解(Function Annotations)。

def add(a: int, b: int, c: int) -> int:
    return a + b + c

需要注意的是,这些注解只是元数据,不影响运行时行为。它们存储在函数的__annotations__属性中。

真正的突破来自Jukka Lehtosalo在2011年的博士研究,他创建了mypy项目。mypy最初是Python的一个实验性变体,旨在无缝混合动态和静态类型。后来,在Guido van Rossum等人的推动下,mypy的理念被整合回标准Python。

以下是推动Python静态类型化的关键PEP:

  • PEP 483:阐述了类型提示的理论基础,引入了“渐进式类型”和“可选类型”等核心概念。
  • PEP 484:正式定义了类型提示规范,引入了typing模块,提供了AnyUnionOptionalTupleCallable等基础类型构造器。
  • PEP 526:为Python 3.6引入了变量注解语法,允许我们直接注解变量,而不仅仅是函数参数。
# 使用 PEP 484 引入的类型
from typing import List, Dict, Union

def process_items(items: List[int]) -> int:
    ...

def get_value(data: Dict[str, int], key: str) -> Union[int, None]:
    ...

# 使用 PEP 526 引入的变量注解
name: str
count: int = 0

有了语法和规范,我们还需要工具来检查类型是否正确,这就是类型检查器。

Python静态类型入门:5:类型检查器

类型检查器分为静态检查器和动态检查器。静态检查器(如mypy)在不运行代码的情况下分析类型;动态检查器则在程序运行时进行检查。

目前主流的Python静态类型检查器包括:

  • mypy:最早的静态类型检查器,由Dropbox支持。
  • pytype:由Google开发。
  • pyre:由Facebook开发。
  • pyright:由Microsoft开发。

这些检查器大多支持PEP 484规范,并能与代码编辑器(如VS Code、PyCharm)集成,提供实时的类型错误提示。

它们之间也存在一些差异,主要体现在“跨函数推断”和“运行时宽容度”上。例如,mypy在跨函数调用时的类型推断可能不如pyre严格;而pytype更倾向于允许那些在运行时不会出错的代码,即使它违反了类型注解。

现在我们已经了解了静态类型的工具,接下来探讨何时以及为何要使用它。

Python静态类型入门:6:何时使用静态类型

首先,需要明确静态类型不能替代单元测试。它们各有侧重,应结合使用。

你应该在以下情况使用静态类型:

  1. 代码库规模庞大:当项目有数百万行代码时,缺乏类型提示会使代码难以理解和维护,严重影响开发效率。静态类型是管理大型代码库的利器。
  2. 代码逻辑复杂:类型注解可以看作是被机器验证的文档。当函数意图不清晰时,添加类型提示能极大地提升代码可读性。
  3. 开发公共API:如果你开发供他人使用的库,类型注解能帮助用户清楚地了解如何正确使用你的函数和类。
  4. 进行大型重构或迁移:在重构前,为关键部分添加类型提示。重构过程中,类型检查器能帮你提前发现潜在的错误。
  5. 进行试验:静态类型是渐进式的。你可以从代码库的一小部分开始尝试,感受其带来的好处。

开始使用静态类型永远不会太早,它几乎没有任何坏处。你可以自由地选择在多少代码上应用它。

Python静态类型入门:7:如何开始使用静态类型

以下是开始使用Python静态类型的五个简单步骤:

  1. 迁移到Python 3.6或更高版本:虽然旧版本也能用,但新版本支持更好的语法(如变量注解)。
  2. 安装并配置类型检查器:选择一个检查器(如mypy)并在本地安装。将其集成到你的编辑器中以获得即时反馈。
    pip install mypy
    
  3. 渐进式地添加类型注解:不要试图一次性注解所有代码。从最简单的文件(如__init__.py)或最核心的模块开始。
  4. 将类型检查加入CI流程:像运行linter一样,在持续集成(CI)中运行类型检查器,确保新增代码符合类型规范。
  5. 说服你的团队:向同事展示静态类型带来的好处,如更好的代码提示、更少的运行时错误和更清晰的文档。

总结

在本教程中,我们一起学习了Python静态类型的方方面面。我们从Python的动态类型特性出发,探讨了其灵活性与潜在问题。随后,我们回顾了Python通过一系列PEP引入静态类型支持的历史,认识了typing模块和类型注解语法。我们还了解了不同的类型检查器工具及其差异。最后,我们明确了静态类型的适用场景,并给出了上手实践的五个步骤。

静态类型是提升Python代码可维护性、可靠性和开发体验的强大工具。它允许你以渐进的方式,在享受动态类型灵活性的同时,获得静态类型系统的安全保障。

感谢阅读。希望本教程能帮助你开始在Python项目中使用静态类型。

035:Python运行时的隐藏力量

在本教程中,我们将探索Python运行时的隐藏力量。我们将学习Python解释器在执行代码时如何创建和管理内部对象,以及如何利用这些信息来构建强大的开发工具,如调试器、测试框架和死锁检测器。

Python运行时:1:理解Python运行时

Python语言以其简洁美观而闻名,但其很大一部分力量对用户是隐藏的,仅在运行时(即代码执行期间)可用。你可能没有意识到,但你每天都在使用这种隐藏的力量。例如,当我们进行测试时,像unittestpytest这样的框架在断言失败时,不仅能报告异常发生的位置,还能展示你试图比较的值的详细信息。这些信息正是从运行时获取的。

本节中,我们将学习Python运行时如何工作,以及它如何允许我们检查程序的当前状态。

Python运行时:2:运行时核心概念:对象与框架

在Python中,有一个流行的说法:“一切都是对象”。这意味着每个变量都不只是一个简单的内存片段,而是一个具有值和相关操作的复杂实体。这种基于对象的本质不仅适用于核心类型(如数字、字符串、集合),也适用于函数、类甚至模块等程序单元。

这些对象在你使用赋值语句、def关键字定义函数或class关键字定义类时被显式创建。然而,在执行过程中,Python解释器不仅创建你在代码中明确定义的对象,还会创建许多表示执行过程本身的对象。其中一个关键对象就是堆栈帧

堆栈帧对象代表了Python中的一个程序作用域。它包含了关于当前执行状态的信息,例如代码对象、局部和全局变量等。帧被存储在一个类似栈的结构中。最底层的帧(有时称为模块帧)代表开始执行的模块。当解释器进入一个新的作用域(如调用函数)时,它会创建一个新的帧对象并将其压入栈顶。当执行离开该作用域时,该帧从栈顶移除。

公式/代码描述:

# 一个简单的函数调用栈示例
def function_a():
    # 进入function_a作用域,创建新帧
    variable = 1
    function_b()  # 调用function_b,创建新帧
    # function_b返回,其帧被移除
    # 继续执行

def function_b():
    # 进入function_b作用域,创建新帧
    pass
    # 离开作用域,帧被移除

这个运行时机制(基于栈的帧)在许多编程语言中是通用的。但Python的主要区别在于,这些运行时信息是开箱即用且可访问的。在Python中,帧对象可以作为普通的Python对象被访问,你可以从中检索大量有趣的信息。

上一节我们介绍了运行时和堆栈帧的基本概念,接下来我们将学习如何实际检查这些内部执行状态。

Python运行时:3:检查内部执行状态

正如前一部分所讨论的,执行过程在程序中的任何地方都体现为堆栈帧。你可以通过调用sys._getframe()函数来获取当前堆栈帧。此函数从当前线程的调用栈中返回一个帧对象。它有一个可选的depth参数,用于指定在栈顶下方多深的调用层级获取帧。传递0将返回当前帧对象。

让我们检查一下帧对象中存储了哪些信息。

首先,帧对象包含一个字典,其中存储了当前作用域的局部变量。如果你定义了一些变量,你可以通过变量名访问它们。这只是一个普通的字典,你可以迭代它,但不能从Python代码中更新它(只能从C代码中更新)。帧对象也提供了访问全局变量字典的途径。

实际上,我们不需要帧对象来获取变量信息,因为内置函数locals()globals()返回完全相同的字典。好消息是,变量字典并非帧对象中存储的唯一有趣信息。

第二个有趣的东西是代码对象,它也作为帧的一个属性被存储。代码对象表示一块可执行代码,但它与函数对象不同,因为它不包含对全局执行环境的引用。创建代码对象最简单的方法是调用内置函数compile()。你也可以通过将代码对象传递给内置函数eval()来执行它。

公式/代码描述:

import sys
import dis

# 获取当前帧
current_frame = sys._getframe(0)

# 访问局部变量字典
local_vars = current_frame.f_locals
print(f"局部变量: {local_vars}")

# 访问代码对象
code_obj = current_frame.f_code
print(f"文件名: {code_obj.co_filename}")
print(f"函数名: {code_obj.co_name}")
print(f"变量名列表: {code_obj.co_varnames}")

# 反汇编字节码
print("字节码:")
dis.dis(code_obj)

代码对象包含许多信息:创建它的文件名、定义函数或模块的名称、该作用域中使用的变量名列表。它还包含一个字节码对象,这是由解释器生成的可执行语句序列。使用dis模块可以反汇编并读取这些字节码。

让我们回到帧对象。除了变量和代码对象,帧还存储有关当前正在执行的行号的信息。它存储一个跟踪函数,用于帮助跟踪当前帧中的事件(我们稍后会讨论)。它还存储了到前一帧的链接。正如你所记得的,帧存储在类似栈的数据结构中。

检查当前帧状态的最简单方法是遍历它们与前帧的链接。异常回溯正是这样工作的:当你的程序中发生未处理的异常时,解释器会遍历这些帧链接,生成漂亮的回溯信息并打印到标准错误输出,这样你就能理解是如何到达程序中的那个位置的。

帧对象及其之间的链接是一种机制,可以帮助你获取并展示这些信息。当然,我们提到的只是存储在帧对象中最重要的信息,并非全部。inspect标准模块实现了许多有用的函数来检查解释器栈。

但使用帧变量时,有一件重要的事情需要记住:当你离开作用域时,不应忘记删除这个变量引用。否则,你可能会创建一个引用循环,导致这些对象存活时间过长,进而引发延迟的对象销毁甚至内存消耗问题。

在这部分,我们了解到在编写和执行Python代码时,即使我们没有意识到,也有许多有趣的对象被隐式生成。我们的源代码很好,但我们如何在日常生活中使用这些运行时信息呢?下一部分将了解不同的开发工具如何利用这些信息来帮助你提高效率。

Python运行时:4:开发工具中的运行时应用

当你使用Python代码时,还记得在演讲开始时我们了解到,像pytest这样的工具可以显示断言错误中比较的具体值。现在,我们有足够的知识来理解这是如何实现的。

让我们创建自己的工具,它将显示在引发异常的语句中使用的变量的值。异常对象在其__traceback__属性中存储了一个回溯对象,而回溯对象包含了我们最重要的帧对象。我们已经知道,如果可以访问帧对象,我们就知道关于程序状态的一切。

我们的函数将异常对象作为参数。从异常对象中,我们可以从回溯中得到帧。从帧中我们可以得到代码对象。从代码对象中我们可以得到,例如,源代码。我们知道发生异常的行号,我们也有源代码。因此,在标准模块ast的帮助下,我们可以获取该行中使用的变量名称。然后,我们可以找到这些变量的值,因为我们有存储在帧对象中的局部变量字典。

公式/代码描述:

import ast
import sys

def inspect_exception(exception):
    tb = exception.__traceback__
    while tb is not None:
        frame = tb.tb_frame
        code_obj = frame.f_code
        lineno = tb.tb_lineno
        # 获取源代码(此处简化,实际需从文件读取)
        source_lines = ... # 从 code_obj.co_filename 读取
        # 使用 ast 解析该行,找出使用的变量名
        # ...
        # 从 frame.f_locals 中获取这些变量的值
        local_vars = frame.f_locals
        for var_name in found_var_names:
            if var_name in local_vars:
                print(f"{var_name} = {local_vars[var_name]}")
        tb = tb.tb_next

# 使用示例
try:
    x = 10
    y = 0
    result = x / y  # 这将引发 ZeroDivisionError
except Exception as e:
    inspect_exception(e)

这就是它的工作原理。我们可以如下使用我们的函数:在异常处理期间,将异常对象传递给我们的新函数,它将输出变量的值。即使没有异常,这也很有帮助,例如用于日志记录。如果堆栈跟踪不够,你可以自动记录失败断言语句中使用的所有变量的值。当然,它可以扩展到任何其他异常类型。

因此,正如你所看到的,掌握Python运行时的知识,你可以创造一些很酷的工具。当然,我们的函数比pytest的实现要简单得多,但主要思想非常相似。

第二个严重依赖运行时的工具是调试器。正如我在演讲开始时所说,我在PyCharm调试器上工作了好几年,这就是为什么我知道这么多隐藏在帧对象中的信息。调试器是使用这些信息的主要工具之一。

如今,调试器基于两个主要功能之一:跟踪函数帧评估函数

  • 跟踪函数:当为帧设置了跟踪函数后,程序中发生的每个事件(如行执行、函数调用)都会调用它。它接收三个参数:frameeventarg。如果指定了跟踪函数,frame对象会在其f_trace属性中存储指向它的链接。基于跟踪函数的调试器分析到达跟踪函数的程序事件,并在用户放置断点的地方暂停程序。
  • 帧评估函数:基于帧评估函数的调试器将断点代码插入到帧的代码对象中(这更内部,我们暂不深入讨论)。

这两个函数都将帧对象作为参数。我们在前一部分学到的关于帧对象的知识,帮助调试器展示信息。调试器如何理解执行是否到达了断点?它使用跟踪函数接收到的帧信息。它如何在编辑器中高亮显示相应的行?它使用帧中的行号信息。你在变量窗格中看到的局部变量,是基于帧的f_locals字典。你在调用栈中看到的帧列表,也是通过遍历帧的f_back属性从帧对象接收的。

如你所见,存储在帧中的信息帮助调试器工作,并向你展示当前程序状态的信息。

另一个工具是代码覆盖率。代码覆盖率帮助你了解在运行期间执行了代码库中的哪些行,并检查你的测试是否充分覆盖了代码。这是一个非常重要的工具,因为它能让你确信代码库得到了良好的开发和维护。最流行的代码覆盖率库是coverage.py

coverage.py基于我们已经在调试器中看到的相同的跟踪函数。由于跟踪函数接收帧对象,覆盖率工具可以收集关于文件名和行号的信息,然后在你的覆盖率报告中展示给你看。

我们讨论了测试、调试器和代码覆盖率,但还有另一组基于运行时信息的工具:在程序执行期间收集类型信息,然后帮助你在源代码中生成类型注释的工具。我发现了三种这样的工具,但可能还有更多:

  1. PyAnnotate (by Dropbox):收集函数参数的类型,然后将类型注释插入到代码中。
  2. MonkeyType (by Instagram):也收集有关参数类型的信息,然后为你的项目生成存根文件。
  3. PyCharm 的 “Collect runtime type information” 选项:如果启用此选项,调试器开始收集关于每个函数的类型信息。稍后,如果你想通过“快速文档”为某个函数生成文档字符串,将在文档字符串生成过程中使用收集到的信息。

这三个工具都使用运行时信息。让我们了解一下PyAnnotateMonkeyType是如何实现的。两者都基于sys.setprofile,它与分析函数一起工作,与跟踪函数非常相似,它需要完全相同的三个参数。但它不会捕获你代码的每一行,只会在你调用某个函数或方法时被调用。这是合乎逻辑的,因为对于类型收集,你只需要函数调用事件,而不是程序中的每个事件。

我已经说过,在Python中收集运行时信息与调试器集成,所以它还可以访问一个帧对象。那么我们如何得到参数的类型呢?如果我们能访问帧对象:

  1. 首先,我们可以得到在当前框架中定义的参数名称列表,因为所有的信息都存储在一个代码对象中(还记得co_varnames吗?)。
  2. 我们有一个带有局部变量的字典(f_locals),所以我们可以访问这些变量的值,从而得到它们的类型。

因此,对于每个函数,我们已经知道了变量的名称和它们的类型。我们可以存储这些信息,然后将其用于类型注释的生成。所有这些工具都是这样实现的,借助帧对象和相应的代码对象。

我们讨论了几种基于Python运行时信息的工具,但它们都很复杂。让我们尝试创造一些我们自己简单但有用的工具,同样基于运行时信息,来帮助我们发现问题,例如在并发执行中。

Python运行时:5:实战:构建死锁检测工具

有两种方法可以在一个Python进程中并发执行任务:线程(借助标准模块threading)和异步任务(借助asyncio)。

你可以启动一个新线程,它将并发地执行你的函数。为了在Python线程之间进行同步,有同步对象。最基本的同步对象是锁对象。线程可以获取锁对象,这意味着接下来的代码块将只由这个线程执行,直到它释放这个锁对象。Python锁对象也是上下文管理器。

运行多个线程并使用锁对象有时会导致死锁。当多个线程正在等待无法释放的资源时,就会发生死锁。重现这种情况的最简单方法如下:创建两个线程和两个锁对象,并以不同的顺序获取锁对象。

  • 线程1获取锁1。
  • 线程2获取锁2。
  • 线程1想要获取锁2,但它不可用,所以开始等待。
  • 线程2想要获取锁1,但它也不可用。

如你所见,这种情况无法在没有程序中断的情况下解决,因为线程会永远等待它们的锁对象。如果你有一个很大的代码库,可能很难在代码中检测到死锁,即使是代码分析也帮不了你,因为死锁发生在代码执行期间的运行时。

但是我们已经知道了很多关于Python运行时的有趣事情,我们可以尝试在这里应用它们。我们已经用了sys._getframe()来获取当前线程的帧。但还有另一个有用的函数sys._current_frames(),它为当前Python进程中的每个线程返回最顶层的堆栈帧。这个功能最酷的地方在于,即使对于陷入死锁的线程,它也能工作。

在此功能的帮助下,我们可以创造自己的工具,它将打印所有线程的回溯信息,帮助我们找到线程卡住的地方。

公式/代码描述:

import sys
import traceback
import threading
import time

def dump_thread_stacks():
    """打印所有活动线程的堆栈跟踪"""
    for thread_id, frame in sys._current_frames().items():
        print(f"\n--- Thread ID: {thread_id} ---")
        # 获取线程对象(可能为None)
        for t in threading.enumerate():
            if t.ident == thread_id:
                print(f"Thread Name: {t.name}")
                break
        traceback.print_stack(frame)

# 模拟死锁
lock1 = threading.Lock()
lock2 = threading.Lock()

def thread1_func():
    with lock1:
        time.sleep(0.1)  # 确保线程2获取lock2
        print("Thread 1 等待 lock2...")
        with lock2:  # 这里会死锁
            print("Thread 1 获得了两个锁")

def thread2_func():
    with lock2:
        time.sleep(0.1)  # 确保线程1获取lock1
        print("Thread 2 等待 lock1...")
        with lock1:  # 这里会死锁
            print("Thread 2 获得了两个锁")

t1 = threading.Thread(target=thread1_func, name="Thread-1")
t2 = threading.Thread(target=thread2_func, name="Thread-2")
t1.start()
t2.start()

time.sleep(1)  # 等待死锁发生
print("\n=== 检测到可能的死锁,转储线程堆栈 ===")
dump_thread_stacks()

工具准备好了!但是有一个问题:这个功能已经在标准库的faulthandler模块中实现了。它有一个dump_traceback_later功能,可以将所有运行线程的回溯信息转储到一个文件中(它是在C代码中补充的,但并非所有事情都在Python层面实现)。

如你所记,还有一种方法可以并发地执行代码:异步编程。在单个asyncio事件循环中也有非常相似的同步对象(如asyncio.Lock),因此同步死锁也可能出现在你的异步代码中。这意味着有一个地方我们可以应用我们的新知识(Python运行时),并创建一个异步版本的故障处理程序。

asyncio模块中已经实现了几个有用的功能:

  1. asyncio.all_tasks(loop): 返回循环中所有正在运行的任务。
  2. task.get_stack(): 返回此任务的帧列表。

我们的异步故障处理程序可以这样工作:在一个单独的线程中,我们循环运行,每隔一定时间转储所有异步任务的堆栈回溯。如果我们怀疑代码中的某个地方存在同步死锁,我们可以用我们的异步故障处理程序快速检查它,找到死锁出现的地方。

我们实现了一个简单但强大的工具来检测异步任务中的死锁,这是我们今天要考虑的最后一个工具。

在这部分,我们学习了几种基于程序运行时信息的工具。我想再次强调,这些工具收集的信息,仅仅从源代码是不可能得到的。你可以根据源代码做出一些假设,但只有在运行时我们才能得到变量的真实值和真实的程序状态。这是基于运行时开发的非常酷的特性。

Python运行时:6:总结与展望

今天我们学到了很多关于Python运行时的知识。正如我们在教程中所看到的,运行时信息可用于开发过程的任何部分:在测试期间、在调试期间,甚至在并行代码执行期间。

我希望在这次学习之后,你将开始更经常地使用运行时开发工具,因为它们确实有助于编写更可靠的代码,并且能更快地发现问题。也许你会创造你自己的工具。在这次学习之后,你已经有足够的信息了。如果你愿意的话,那就太好了!

如果你决定做实验,以下是一些有用的资源:

  • 包含今天示例的源代码的存储库链接(请参考原演讲者提供的链接)。
  • Python官方文档,关于我们今天使用的任何标准模块(如sysinspectdisthreadingasyncio)。

036:构建图像数据的交互式应用

概述

在本节课中,我们将学习如何利用 Dashscikit-image 这两个强大的 Python 库,来构建用于图像数据标注和处理的交互式 Web 应用程序。我们将从了解图像处理在科学和工业中的重要性开始,逐步介绍 Dash 框架的核心概念,并展示如何结合 scikit-image 的图像处理能力来创建实用的工具。


图像处理的重要性 📷

图像是科学和商业领域中非常广泛的数据来源。从这些图像中提取精确的测量数据,并将其转化为自动驾驶汽车所需的数字和科学知识,是许多应用的核心。例如,在自动驾驶中,需要实时、可靠地检测物体和距离。在遥感领域,如卫星成像,同样需要从图像中提取关键信息。

如今,神经网络算法被广泛用于图像分类和目标分割。训练这些网络需要一个带有“真实标签”的训练集,这就需要用户对图像进行标注,例如绘制边界框或精确勾勒物体轮廓。因此,构建一个高效、易用的图像标注工具至关重要。


什么是 Dash? 🚀

上一节我们了解了图像处理的需求,本节中我们来看看实现交互式应用的核心工具——Dash。

Dash 是一个用于构建分析型 Web 应用程序的 Python 框架。它是开源软件,采用 MIT 许可证。Dash 的最大承诺是:开发者只需编写 Python 代码,无需了解任何 JavaScript,即可创建功能丰富的交互式应用。

以下是一个简单的 Dash “Hello World” 应用代码片段:

import dash
from dash import html, dcc
from dash.dependencies import Input, Output

app = dash.Dash(__name__)

app.layout = html.Div([
    dcc.Input(id='my-input', value='初始值', type='text'),
    html.Div(id='my-output')
])

@app.callback(
    Output('my-output', 'children'),
    [Input('my-input', 'value')]
)
def update_output(value):
    return f'你输入了:{value}'

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/bcdfac88c2ed624ddcccd952bd64e306_7.png)

if __name__ == '__main__':
    app.run_server(debug=True)

如你所见,代码完全是 Python。开发者定义应用布局(包含各种组件如输入框、滑块、图表)和回调函数(当输入组件发生变化时触发的函数)。虽然底层有 JavaScript 运行,但开发者无需直接编写。

通过访问 Dash 示例库,可以看到更多复杂的应用,它们通常包含交互式图表,这些图表本身也能触发回调,更新应用的其他部分。


Dash 的核心组件 🧩

了解了 Dash 的基本概念后,我们来看看构成 Dash 应用的核心组件有哪些。

Dash 提供了丰富的组件库:

  • HTML 组件:对应所有常见的 HTML 元素。
  • 核心交互组件:例如滑块 (dcc.Slider)、下拉菜单 (dcc.Dropdown)、按钮 (html.Button)。
  • 交互式图表 (dcc.Graph):这是 Dash 应用的核心部分,基于 Plotly 库构建,图表本身支持点击、选择、悬停等交互事件。
  • 交互式数据表 (dash_table.DataTable):用于展示和操作表格数据。
  • 专业组件库:如用于生物信息学的 dash-bio

此外,由于 Dash 基于 React.js 框架构建,社区开发了成千上万的 React 组件(发布在 npm 上)。开发者可以轻松地将这些组件“包装”成 Dash 可用的 Python 组件,这极大地扩展了 Dash 的能力。


为图像标注构建 Dash 组件 🎨

上一节介绍了 Dash 的通用组件,本节我们聚焦于一个特定需求:图像标注。

为了快速实现图像标注功能,我们利用了现有的 JavaScript 库 react-sketch(其本身基于 fabric.js),并将其封装成了 Dash 组件 dash-canvas

dash-canvas 组件提供了一个画布窗口,用户可以在背景图像上绘制曲线、矩形等几何图形。当用户绘制时,会触发 Dash 回调函数,该函数可以读取这些几何注释的坐标数据,供后续处理使用。

该组件同样采用 MIT 许可证,可以通过 pip 安装 (pip install dash-canvas)。它的 API 非常简单:

import dash_canvas
from dash_canvas.utils import array_to_data_url

# 将图像数组转换为 Data URL 作为背景
img_data_url = array_to_data_url(image_array)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/bcdfac88c2ed624ddcccd952bd64e306_11.png)

canvas = dash_canvas.DashCanvas(
    id='canvas',
    tool='line',  # 设置绘图工具,如 ‘line‘, ‘rectangle‘, ‘polygon‘
    lineWidth=5,
    lineColor='red',
    image_content=img_data_url
)

组件还提供了一些工具函数,用于处理画布上的注释数据。


集成到 Plotly:更强大的交互图表 📈

我们有了专门的标注组件,但能否将标注功能直接集成到更流行的 Plotly 图表库中呢?答案是肯定的。

Plotly 本身就是一个高度交互的 Python 图表库。例如,在散点图中选择一部分数据点,可以同步高亮其他关联图表中的数据。它支持丰富的悬停信息展示。

在现有版本的 Plotly 中,已经可以通过 layout.shapes 在图表上叠加几何形状(如线、矩形),并且这些形状是可编辑的。当形状被移动时,可以触发回调来更新其他内容(如图像强度剖面图)。

然而,用户无法通过 UI 界面直接添加新形状。

即将发布的新版 Plotly 将引入一个“绘图模式”工具栏,提供开放的路径、闭合路径、矩形、圆形等绘图工具。当用户绘制这些形状时,会触发 Dash 应用的回调,并捕获注释的几何数据。这意味着,未来直接在 Plotly 图表上进行图像标注将变得非常简单和原生。


图像处理引擎:scikit-image 🔬

我们已经能够捕获图像上的注释了,那么如何处理这些注释和图像本身呢?这就需要强大的图像处理库。

scikit-image 是 Python 中用于科学图像处理的工具箱。它是一个库,而非终端用户应用程序,专注于科学图像(如显微镜图像、遥感图像)的处理,支持二维和三维图像。

一个常被问到的问题是:scikit-image 和 OpenCV 有什么区别?

  • scikit-image 是纯 Python 库,与 Python 科学栈(NumPy, SciPy)集成更紧密,对 Python 用户更友好,拥有完善的文档和示例库。
  • OpenCV 主要用 C++ 编写,通过 Python 绑定调用,在速度上通常更优,但架构上更偏向计算机视觉的实时应用。

scikit-image 拥有一个庞大的贡献者社区和活跃的核心维护团队。

以下是一个使用 scikit-image 的简单示例:

from skimage import io, filters

# 读取图像
image = io.imread('image.jpg')
# 应用高斯滤波去噪
filtered_image = filters.gaussian(image, sigma=1)
# 显示或保存处理后的图像

结合 Dash 与 scikit-image 进行图像处理 ⚙️

现在,让我们把前端交互(Dash)和后端处理(scikit-image)结合起来。

在 Dash 回调函数中,我们可以轻松调用 scikit-image 的功能来处理从 dash-canvas 或 Plotly 图表中获取的注释数据。

以下是几个结合使用的场景示例:

  1. 几何变换:如果用户画了一条线来校正倾斜的地平线,回调函数可以计算倾斜角度,并调用 skimage.transform.rotate 来旋转图像。
  2. 测量工具:当用户画了一条测量线,回调函数可以使用 skimage.measure.profile_line 函数获取该线上所有像素的坐标和强度值,从而绘制强度剖面图。
  3. 高级处理:scikit-image 提供了丰富的功能,如图像去噪 (skimage.restoration)、特征提取 (skimage.feature)、图像分割 (skimage.segmentation) 和对象测量 (skimage.measure)。

一个强大的演示是“智能剪刀”分割工具:用户粗略勾勒物体轮廓,回调函数调用 scikit-image 算法(如基于随机森林分类器的分割方法),自动计算出精确的物体边界。


文档与社区支持 📚

为了帮助你更好地开始,Dash 和 scikit-image 都投入了大量精力构建基于示例的文档。

  • scikit-image 示例库:网站上有大量示例,每个示例都包含代码和运行结果截图,是学习的绝佳资源。
  • Dash 文档与社区:Plotly 官方文档提供了大量教程和示例应用代码。此外,活跃的社区论坛是寻求帮助和分享经验的好地方。

如果你想了解更多或参与贡献,可以通过以下方式联系:

  • Emmanuelle Gouillart 的 Twitter: @EGouillart (注:根据视频内容推测,实际ID请以视频显示为准)
  • scikit-image 社区:欢迎多样化的贡献者加入。

总结

在本节课中,我们一起学习了如何构建用于图像数据的交互式应用。

  1. 我们首先认识了图像标注在机器学习和科学研究中的关键作用。
  2. 接着,我们介绍了 Dash 框架,它允许我们仅用 Python 就创建出功能丰富的交互式 Web 应用。
  3. 我们探讨了用于图像标注的专用 Dash 组件 dash-canvas,以及未来将直接集成到 Plotly 图表中的绘图工具。
  4. 然后,我们引入了强大的图像处理库 scikit-image,它提供了从基础到高级的各类图像处理算法。
  5. 最后,我们展示了如何将 Dash 的交互前端与 scikit-image 的处理后端无缝结合,通过具体的回调函数示例,实现了从图像标注到实时处理的全流程。

通过结合 Dash 和 scikit-image,你可以为各种图像分析任务创建出强大、易用且可定制的交互式工具,从而加速你的研究和开发流程。

037:小心探讨概率分布 🎲

在本教程中,我们将通过Python来探索概率分布的核心概念。我们将从定义一个正态分布开始,理解其概率密度函数,并学习如何从中抽取样本。接着,我们将反转视角,尝试从一组数据中推断出最可能生成该数据的分布参数。整个过程将使用Python代码和可视化工具,让抽象的概率概念变得具体可操作。


概率分布与Python:1:定义概率分布 📊

上一节我们概述了本课程的目标。本节中,我们来看看如何用Python代码定义一个具体的概率分布。

概率分布是描述随机变量可能取值及其对应可能性(或概率)的数学模型。一个经典的例子是正态分布(也称为高斯分布)。正态分布由两个参数定义:均值 μ 和标准差 σ

  • μ 代表了分布的中心位置。
  • σ 控制了分布的宽度或离散程度。

在Python中,我们可以将概率分布视为一个对象。以下是如何定义一个正态分布类的核心思路:

import scipy.stats as stats

class NormalDistribution:
    def __init__(self, mu, sigma):
        # 参数:mu可以是任意实数,sigma必须是正实数
        self.mu = mu
        self.sigma = sigma
        # 使用scipy.stats创建分布对象
        self.dist = stats.norm(loc=mu, scale=sigma)

    def pdf(self, x):
        # 概率密度函数:返回在x点的概率密度(可能性)
        return self.dist.pdf(x)

    def logpdf(self, x):
        # 对数概率密度函数,在计算中更稳定
        return self.dist.logpdf(x)

    def draw(self, n=1):
        # 从分布中抽取n个随机样本
        return self.dist.rvs(size=n)

每个概率分布都有一个相关的函数,用于为数值轴上的点分配“可信度”。对于连续分布(如正态分布),这个函数称为概率密度函数。PDF曲线下的总面积等于1。对于离散分布,相应的函数称为概率质量函数。


概率分布与Python:2:概率与可能性 📈

上一节我们介绍了如何用Python类定义概率分布。本节中我们来看看概率分布的两个核心概念:概率与可能性。

对于连续概率分布,概率被定义为概率密度函数曲线下某一区间的面积。曲线下的总面积恒为1。因此,我们可以计算某个值区间(例如,从 ab)的概率,它表示随机变量落在这个区间内的可能性大小。

可能性则是指概率密度函数在某个特定点 x 处的高度。它表示该点相对的“可信度”,但其本身不是概率。对于连续分布,任何一个单一具体值的概率在理论上都是0。

我们可以通过一个可视化示例来理解:对于一个 μ=1.7, σ=2.4 的正态分布,值在 -1.571.91 之间的概率,就是这两个点之间PDF曲线下的面积。而 x=5.64 这个点对应的可能性,就是PDF曲线在该点的高度。可能性最大的点通常出现在分布的中心(μ 附近)。

记住这个关键区别:

  • 概率 = 曲线下的面积(针对一个区间)。
  • 可能性 = 曲线在某点的高度

概率分布与Python:3:从分布中抽样 🎯

上一节我们区分了概率与可能性。本节中,我们将利用概率分布的一个关键功能:生成随机数。

概率分布本质上是一个遵循特定规则的随机数生成器。当我们从分布中“抽取”数字时,数值被抽中的概率与其概率密度函数的高度成正比。这意味着,PDF高的区域(可能性大的点),被抽中的样本点会更密集;PDF低的区域,样本点则更稀疏。

使用上一节定义的 NormalDistribution 类,我们可以轻松实现抽样:

# 创建一个正态分布对象
my_gaussian = NormalDistribution(mu=1.7, sigma=2.4)

# 从这个分布中抽取1000个样本
samples = my_gaussian.draw(n=1000)

如果我们绘制这1000个样本的直方图,其形状会近似于该正态分布的钟形PDF曲线。这种“前向”过程——已知分布参数生成数据——是模拟和蒙特卡洛方法的基础。


概率分布与Python:4:参数估计——最大似然法 🔍

上一节我们演示了如何从已知的分布中生成数据。本节中,我们将问题反转:假设我们观测到一些数据,但不知道它们来自哪个具体的分布(即不知道参数 μσ),我们如何推断出最可能的参数?这引出了参数估计

一个常见的方法是最大似然估计。其目标是找到一组参数值,使得在这组参数下,观测到当前这批数据的“可能性”最大

以下是其核心步骤:

  1. 假设数据来自某个分布家族(例如正态分布)。
  2. 对于给定的参数候选值 (μ, σ),计算每个数据点 x_i可能性,即 pdf(x_i)
  3. 计算所有数据点的联合可能性。由于各数据点通常假设独立,联合可能性是每个点可能性的乘积:L(μ, σ) = Π pdf(x_i)
  4. 由于连乘许多小数可能导致数值下溢,我们通常使用对数似然,将连乘转化为连加:log L(μ, σ) = Σ log(pdf(x_i))
  5. 通过优化算法(如梯度下降或简单搜索)调整 μσ,寻找能使对数似然 log L 最大化的那组参数。

这个过程可以直观理解为:我们滑动 μσ 的滑块,调整分布的形状,直到这个分布“最贴合”我们观测到的数据点(即数据点落在分布的高可能性区域)。


概率分布与Python:5:总结与延伸 🏁

本节课中我们一起学习了概率分布与Python交互的核心内容。

我们来总结一下关键要点:

  1. 概率分布即对象:所有概率分布都有相关的概率密度(或质量)函数。我们可以将其定义为Python对象,使其抽象概念具体化,便于交互。
  2. 概率与可能性:概率是PDF曲线下区间的面积,用于度量事件发生的可能性;可能性是PDF曲线在某点的高度,表示该点的相对可信度。
  3. 分布是生成器:概率分布是遵循特定规则的随机数生成器。我们可以从中抽取样本,用于模拟或分析。
  4. 最大似然估计:当我们需要从数据中推断分布参数时,最大似然估计是一个基本方法。其核心是找到能最大化观测数据联合可能性(或对数似然)的参数值。

除了MLE,如果你想进一步了解如何量化参数本身的不确定性(例如,得到 μ 的一个概率分布而不仅仅是一个点估计),那么你将自然进入贝叶斯统计的领域。

如果你想继续探索,scipy.stats 库提供了丰富的概率分布以及各种方法(如计算分位数、拟合数据等)。例如,对于正态分布 stats.norm,你可以查看其所有类方法,以更多方式与概率分布进行交互。

希望本教程帮助你建立了对概率分布直观、实用的理解。

038:使用Python进行记录去重 🐍

在本教程中,我们将学习如何使用Python进行记录去重(也称为记录链接或实体解析)。我们将从理解问题动机开始,逐步介绍字符串相似性计算、数据清洗、基于规则的匹配、高效的“分块”技术,以及如何利用主动学习和机器学习来提升匹配效果。最后,我们会探讨如何将匹配对聚合成有意义的实体簇。

概述

现实世界的数据通常混乱且包含重复项。例如,同一家餐厅可能以略有不同的名称和地址出现在数据集中。记录去重的目标就是识别并连接这些代表同一现实世界实体的记录。我们将使用Python库(如thefuzzunidecoderecordlinkagedupecat)来构建一个完整的去重流程。


1. 问题动机与核心概念 🎯

现实世界的数据通常很混乱,可能包含大量重复记录。例如,在餐厅数据中,同一家店可能因拼写错误、缩写或格式差异而多次出现。人眼可以轻易识别,但计算机则需要特定方法。

记录去重(Record Deduplication),也称为记录链接(Record Linking)或实体解析(Entity Resolution),其核心工作是通过融合相似信息(如名称、地址)的方式,将代表同一实体的不同记录连接在一起。


2. 字符串相似性与数据清洗 ✨

上一节我们介绍了去重的目标,本节中我们来看看如何比较两条记录是否相似。我们通常从比较字符串字段(如名称)开始。

2.1 编辑距离

我们可以使用编辑距离(如Levenshtein距离)来计算两个字符串的相似度。Python的thefuzz库提供了相关功能。

from thefuzz import fuzz
similarity = fuzz.ratio(‘Monks Coffee Shop‘, ‘Monk‘s Coffee Shop‘)
print(similarity) # 输出可能为86

然而,编辑距离对词序敏感。例如,“Coffee Shop A”和“Shop A Coffee”虽然词序不同但含义相似,编辑距离给出的相似度会偏低。

2.2 忽略词序的相似度

thefuzz库的token_sort_ratio函数可以忽略词序,计算基于词汇集合的相似度。

similarity = fuzz.token_sort_ratio(‘Coffee Shop A‘, ‘Shop A Coffee‘)
print(similarity) # 输出为100

2.3 数据清洗:去除重音

为了更准确比较,我们需要清洗数据。例如,去除字符串中的重音符号。可以使用unidecode库。

from unidecode import unidecode
cleaned_name = unidecode(‘Café‘)
print(cleaned_name) # 输出 ‘Cafe‘

结合清洗和相似度计算,我们可以更有效地匹配名称。


3. 使用多字段与地理编码 🗺️

仅靠名称可能不足以准确匹配。本节我们引入其他字段,如地址,来提升匹配精度。

我们可以对地址进行地理编码(Geocoding),将其转换为经纬度坐标,然后计算两个地址之间的物理距离。

import geocoder
# 示例:地理编码地址并计算距离(伪代码)
# lat1, lon1 = geocode(‘地址1‘)
# lat2, lon2 = geocode(‘地址2‘)
# distance = haversine_distance(lat1, lon1, lat2, lon2)

如果两家餐厅名称相似地址距离很近,那么它们很可能是同一家店。


4. 基于规则的匹配与暴力比较 ⚙️

现在,我们将名称相似度和地址距离结合起来,制定一个简单的匹配规则。

假设我们有一个小型餐厅数据集。首先,我们需要生成所有可能的记录对。

import pandas as pd
from itertools import combinations

# 假设df是一个包含餐厅记录的DataFrame
record_pairs = list(combinations(df.index, 2)) # 生成所有两两组合
print(f“共有 {len(record_pairs)} 对需要比较“)

接着,我们为每一对记录计算名称相似度得分和地址距离。

def score_pair(record_a, record_b):
    name_score = fuzz.token_sort_ratio(unidecode(record_a[‘name‘]), unidecode(record_b[‘name‘]))
    # 计算地址距离...
    address_distance = calculate_distance(record_a[‘address‘], record_b[‘address‘])
    return {‘name_score‘: name_score, ‘address_distance‘: address_distance}

最后,应用规则进行过滤。例如,将名称相似度 > 50 且地址距离 < 1公里 的记录对标记为匹配。

matches = [pair for pair in record_pairs if
           score_pair(pair)[‘name_score‘] > 50 and
           score_pair(pair)[‘address_distance‘] < 1.0]

这种方法对于小型数据集有效,但不具备扩展性。


5. 分块技术:提升效率 🚀

上一节的暴力比较法在记录数增长时会变得极其低效。对于n条记录,需要比较的对数是 n*(n-1)/2。当n=1,000,000时,需要比较近5000亿对,这是不可行的。

分块(Blocking)技术的目的是只生成那些可能是重复的记录对,从而大幅减少需要比较的数量。

5.1 什么是分块?

分块的核心是为每条记录生成一个或多个“指纹”(Fingerprint),然后将具有相同指纹的记录放入同一个“块”(Block)中。我们只需要比较同一个块内的记录对。

一个好的指纹函数应能消除不相关的变异(如大小写、重音),同时保留核心标识信息。

def simple_fingerprint(name):
    # 清洗并生成指纹
    name = unidecode(name) # 去重音
    name = name.lower() # 转小写
    tokens = name.split() # 分词
    tokens = [t for t in tokens if t.isalnum()] # 移除非字母数字字符
    tokens.sort() # 排序
    return ‘ ‘.join(tokens) # 重新连接

print(simple_fingerprint(‘Monk‘s Café‘)) # ‘cafe monks‘
print(simple_fingerprint(‘Café Monks‘)) # ‘cafe monks‘

如上所示,两个不同的名称生成了相同的指纹,它们将被分到同一个块内进行比较。

5.2 分块函数示例

以下是几种常用的分块指纹函数:

  • 排序标记:如上例所示。
  • 取前N个字符:取字符串的前N个字符作为指纹。
  • 缩写编码:如Soundex、Metaphone,适用于姓名。
  • 地理哈希:对经纬度进行编码,将附近的地点分到同一块。
  • 数量级:将数值(如价格)按数量级分组。

你可以在同一条记录上使用多个指纹函数,以增加召回可能匹配的记录对的机会。


6. 主动学习与机器学习分类器 🤖

基于固定阈值的规则匹配在复杂场景下可能不够灵活。本节我们引入机器学习分类器来更好地判断一对记录是否匹配。

6.1 挑战与解决方案

训练分类器需要已标记的匹配/不匹配数据对。在大型数据集中手动标记所有可能对是不现实的。主动学习(Active Learning)可以解决这个问题。

主动学习系统会迭代地询问用户(专家)对最“不确定”的数据对进行标记,从而用较少的标注量获得一个有效的模型。

6.2 使用 dupecat 库实践

dupecat是一个实现了主动学习的Python库。以下展示其基本工作流程:

import dupecat
# 1. 定义字段类型
fields = [
    (‘name‘, ‘string‘),
    (‘address‘, ‘string‘),
    (‘postal_code‘, ‘string‘)
]
# 2. 初始化dupecat引擎
dc = dupecat.DupeCat(fields)
# 3. 开始主动学习训练(在控制台交互)
# dc.train(records_df) # 系统会逐对询问用户是否为匹配
# 4. 训练后,dupecat会自动学习最佳的分块规则和分类模型

在训练过程中,dupecat不仅学习如何分类,还会自动发现数据中最有效的分块指纹,例如“名称的第一个词”或“邮政编码的前几位”。

训练完成后,我们可以用它对所有分块内的记录对进行预测,得到一个介于0到1之间的相似度分数。

# 预测并获取分数
scored_pairs = dc.score(records_df)
# 根据阈值过滤匹配对
threshold = 0.5
matches = scored_pairs[scored_pairs[‘score‘] > threshold]

通过调整阈值,我们可以在精确度(Precision,找出的匹配中真正正确的比例)和召回率(Recall,所有真正的匹配中被找出的比例)之间进行权衡。


7. 聚类:解决传递闭包问题 🔗

通过分类器我们得到了匹配对,但还有一个问题需要解决:传递性

例如,如果记录A与B匹配,B与C匹配,那么从逻辑上讲,A与C也应该匹配(即使分类器可能没给A-C对高分)。这种关系称为传递闭包。

直接使用匹配对列表可能导致模糊性。我们需要将相互关联的记录聚类,形成代表同一实体的簇。

# 使用dupecat进行聚类
clusters = dc.partition(records_df)
print(clusters)
# 输出可能为:[[0, 1, 2], [3, 4], [5]],表示0,1,2是同一家店,3和4是另一家,5是独立的。

聚类过程会:

  1. 创建新的匹配边:连接那些因传递性而应该匹配的记录(如A-C)。
  2. 移除矛盾的边:如果存在不一致(如A匹配B,B匹配C,但A不匹配C),聚类算法会尝试解决这种歧义,可能移除某些边以保证簇的一致性。

最终,我们得到的是清晰的实体簇,而非独立的匹配对,这更符合现实世界的认知。


8. 总结与后续步骤 🏁

在本教程中,我们一起学习了记录去重的完整流程:

  1. 理解问题:识别现实世界中混乱数据里的重复实体。
  2. 基础比较:使用字符串相似度(thefuzz)和地理编码来比较记录。
  3. 提升效率:引入分块技术,避免所有记录对的暴力比较,使用指纹函数将可能重复的记录分组。
  4. 智能匹配:利用主动学习机器学习分类器(如dupecat)替代固定规则,更灵活准确地识别匹配对。
  5. 形成实体:通过聚类解决匹配对的传递性问题,得到代表同一实体的记录簇。

想了解更多?

  • 扩展阅读:查阅《Data Matching》书籍,获取更全面的理论。
  • 高级主题:研究隐私保护记录链接(如差分隐私)、增量记录链接(处理新增数据)以及数据融合(合并簇内记录信息)。
  • 伦理提醒:记录链接技术强大,但务必谨慎用于涉及个人隐私的数据。确保你的应用符合伦理规范和相关法律法规。

希望本教程能帮助你入门记录去重这一有趣且实用的领域!


参考资料:本教程内容基于Flávio Juvenal的PyCon演讲《1 + 1 = 1 or Record Deduplication with Python》整理。相关代码和幻灯片可在其GitHub仓库找到。

039:使用pyATS编写网络测试入门教程

概述

在本节课中,我们将学习如何使用思科的pyATS框架来编写和运行网络自动化测试。我们将了解pyATS的核心概念、项目结构,并通过一个实际演示来学习如何验证网络设备的连通性和接口状态。

什么是pyATS?它解决什么问题?

在深入探讨之前,让我们先思考一个经典问题:现在网络瘫痪了吗? 无论是网络工程师、自动化工程师还是依赖网络的软件开发人员,都可能遇到需要判断问题是否出在网络本身的情况。

传统的网络测试工具(如ping)虽然能验证基本连通性,但无法回答更深层次的问题,例如:是否存在延迟?链路上是否有拥塞?流量是否按预期路径转发?这些只是判断网络是否按预期运行的一小部分。

pyATS 及其生态系统就是为了解决这些问题而生的。它的核心思想是建立一个现代化的平台和框架,让我们能够编写可靠、全面的网络测试用例,以确保网络按照我们的期望运行。

pyATS生态系统在Apache License 2.0下开源,这意味着你可以访问其源代码,并为生态系统的各个部分做出贡献。

pyATS生态系统剖析

pyATS生态系统是一个分层结构,让我们来深入了解其具体构成。

核心测试基础设施 (pyATS Core)

这是框架的基础层,即 pyats 库本身。它负责定义测试拓扑、跟踪测试执行、收集结果、生成日志,甚至发送邮件通知。它是运行测试所必需的框架,但本身不包含针对特定网络功能的测试逻辑。

可重用组件库 (Genie)

在核心基础设施之上是 Genie 库(以前称为“精灵”库)。它提供了一系列可重用的组件,可以轻松地插入到你的测试中。例如,如果你对BGP或OSPF等路由协议的状态感兴趣,使用Genie库功能可以轻松地在Python中查询网络设备,了解其运行或配置状态。

你无需担心解析CLI文本输出或在不同平台间规范化数据,所有这些都由Genie库自动处理。

业务逻辑与集成

在SDK和库之上,可以集成业务逻辑。许多团队正在研究如何将网络测试集成到更大的测试框架和CI/CD流水线中。pyATS生态系统可以轻松地与Jenkins、TestRail等工具系统集成,也可以作为Robot Framework或Ansible等基础设施即代码解决方案的一部分。

在本次教程中,我们将重点关注核心测试基础设施如何连接网络以及如何编写测试用例

如何获取与运行pyATS

和任何优秀的Python程序一样,你可以通过pip安装pyATS:

pip install pyats

pyATS需要在Linux或macOS上运行。如果你是Windows用户,可以通过Docker或虚拟机来支持。思科提供了包含运行pyATS所需一切的最新Docker容器。

pyATS现在推荐使用Python 3.5或更高版本,以充分利用Python和pyATS生态系统的所有功能。

网络测试项目包含什么?

一个最小的pyATS网络测试项目通常包含三个核心组件。

以下是这三个组件的简要介绍。

  1. 测试床文件 (Testbed YAML)
    这是一个YAML文件,用于描述你的网络拓扑。它定义了你想测试哪些设备、它们的平台类型、连接细节(如IP地址、端口、使用Telnet还是SSH、认证凭证等)。你还可以选择性地描述设备之间的链路连接,这对于高级测试非常有用。

  2. AE测试脚本 (aetest Script)
    这是一个或多个Python文件,用于描述测试的设置、执行单个或多个测试用例以及清理工作。你可以将不同的测试逻辑分解到不同的AE测试脚本中,例如一个脚本检查设备连通性,另一个检查二层配置。

  3. pyATS任务文件 (Job File)
    这是一个Python文件,用于将测试床文件一个或多个AE测试脚本组合在一起,定义一个完整的测试任务。任务文件负责运行所有指定的测试,并以可消费的方式报告结果。

安装pyATS后,CLI中内置了一些便捷命令来帮助你创建这些组件:

  • pyats create testbed:帮助你构建测试床YAML文件。
  • pyats create project:为你提供项目模板,包括任务文件和测试脚本的起点。

接下来,让我们更深入地看看每个组件。

深入理解测试床文件

测试床文件定义了网络中的所有设备。设备类型可以是路由器、交换机、防火墙,也可以是用于流量生成或验证的Linux终端主机。

每个设备都必须绑定到特定的平台和操作系统。pyATS支持多种思科网络操作系统(如IOS-XE, IOS-XR, NX-OS, ASA),也支持Linux以及其他厂商如Juniper和F5。

除了设备本身,还需要定义如何连接到设备。Unicon(通用连接器库)支持通过SSH、Telnet、NETCONF等多种协议进行连接,也支持通过终端服务器或代理连接。

深入理解AE测试脚本

AE测试脚本是一个Python文件。如果你使用过pytest或其他测试库,会发现一些相似之处。

每个AE测试脚本都包含三个阶段:

  1. 公共设置 (CommonSetup):在运行实际测试前需要完成的所有准备工作。通常在这里使用测试床文件连接到所有设备。
  2. 测试用例 (Testcase):脚本可以包含一个或多个测试用例,它们将按顺序执行。
  3. 公共清理 (CommonCleanup):测试执行完成后需要进行的清理工作。

这些阶段通过继承特定的类来定义:

  • CommonSetup 类用于公共设置。
  • 继承自 aetest.Testcase 的类用于定义实际的测试用例。
  • CommonCleanup 类用于公共清理。

在每个测试用例类中,你可以使用装饰器定义任意数量的测试方法。因为是Python,所以你可以在测试脚本中做任何Python能做的事情。

深入理解任务文件

任务文件是一个简单的Python文件,它将我们希望作为单个任务运行的所有测试脚本集合在一起。你可以为不同的网络组件编写多个测试脚本,然后将它们合并到一个任务文件中运行。

任务文件通常非常简短,只需通过 pyats.run 函数执行每个测试脚本即可。你可以为每个测试脚本指定一个任务ID,用于在日志和通知中标识。如果未提供,pyATS会自动生成一个。

运行测试与查看结果

使用 pyats run job 命令来运行你的测试任务,你需要指定任务文件和要使用的测试床文件。

运行完成后,pyATS不仅会在CLI上显示输出,还会自动生成一个HTML日志视图。这个视图提供了更清晰、更易分析的结果展示,包括:

  • 主仪表板:显示任务运行的关键统计信息和条形图。
  • 任务详情:可以深入查看每个独立任务(即每个AE测试脚本)的执行情况。
  • 测试步骤详情:点击特定测试步骤,可以查看pyATS在执行该测试时收集的所有实际输出。

这对于分析大型或复杂的测试运行结果尤其有用。

实战演示:验证网络健康状态

现在,让我们通过一个真实的演示来了解pyATS的实际应用。我们将测试一个由DevNet沙箱提供的示例网络,这是一个典型的三层网络(接入-分发-核心),并配置了基本的路由协议。

演示目标:

  1. 测试一:设备连通性 - 确认测试床中的所有设备(交换机、路由器、防火墙)均可访问。
  2. 测试二:接口错误检查 - 验证所有已连接接口上没有报告任何错误(如CRC错误、输入/输出错误)。在一个健康的网络中,通常不应看到接口错误。

演示步骤概述

  1. 准备环境:设置包含网络设备凭证的环境变量。
  2. 运行测试任务:使用 pyats run job 命令执行定义好的测试任务。
  3. 分析CLI结果:初步观察测试通过和失败的情况。在演示中,我们发现某些接口的测试失败了。
  4. 使用HTML日志深入分析:通过 pyats logs view 命令打开HTML日志查看器。我们可以清晰地看到:
    • “设备连通性”测试全部通过。
    • “接口错误”测试部分失败。点击失败项,可以精确看到是哪个设备的哪个接口出了问题,以及错误计数(例如,错误数=3)。
  5. 增强测试脚本以获取更多上下文:为了更好排错,我们修改了测试脚本,在日志中额外输出接口的所有计数器信息(如广播包计数、八位字节计数等)。
  6. 重新运行并分析:重新运行测试后,在HTML日志中,我们不仅能看到错误计数,还能看到完整的接口计数器状态,为网络故障排除提供了更丰富的上下文信息。

代码片段解析

让我们看一下演示中使用的部分关键代码概念。

连接验证测试片段:
pyATS核心基础设施提供了“步骤”的概念,用于组织测试逻辑。你可以使用 aetest.loop.mark 装饰器或上下文管理器来为每个设备或接口创建子步骤。在每个步骤中,根据条件(如 device.connected 是否为真)决定该步骤是通过还是失败。

接口错误检查测试片段:
这里展示了Genie库的强大之处。我们使用 Interface 这个Genie模型来获取设备的所有接口信息。该模型知道针对不同设备平台该执行什么命令,并自动将输出解析为易于操作的Python字典。

在测试逻辑中,我们:

  1. 遍历测试床中的每个设备。
  2. 使用Genie模型获取该设备的所有接口信息。
  3. 遍历每个接口,检查我们感兴趣的错误计数器(如in_crc_errors)。
  4. 如果任何错误计数器大于0,则将测试步骤标记为失败。
  5. 对于没有计数器的接口(如环回接口),则将该步骤标记为跳过,以避免误报。

总结与后续步骤

本节课我们一起学习了使用pyATS进行网络自动化测试的基础知识。我们了解了pyATS生态系统(Core, Genie),掌握了构建测试项目的三个核心组件(测试床、AE脚本、任务文件),并通过实战演示学会了如何编写和运行测试来验证网络连通性与接口健康状态。

pyATS的功能远不止于此。如果你想继续深入学习,可以参考以下资源:

  • 官方文档:访问 pyATS官网 阅读入门指南。
  • 代码仓库:在 GitHubPyPI 上探索代码。
  • 社区支持:加入Cisco Webex Teams中的社区空间,与pyATS开发人员和其他用户交流。

如果你对网络自动化感兴趣,可以关注思科DevNet团队,获取更多学习资源和最新动态。

希望本教程能帮助你开始使用pyATS构建可靠、自动化的网络测试!

040:使用SQLAlchemy与Alembic 🐍

在本教程中,我们将学习如何在Python中使用SQLAlchemy和Alembic进行数据库操作。我们将从ORM(对象关系映射)的基本概念开始,逐步深入到模型定义、数据库连接、查询优化以及数据迁移等高级主题。无论你是初学者还是有一定经验的开发者,本教程都将帮助你掌握使用SQLAlchemy高效、安全地管理数据库的技能。


什么是ORM? 🤔

上一节我们介绍了本教程的概述,本节中我们来看看ORM的核心概念。

ORM代表对象关系映射器。其核心思想是将右侧的Python对象映射到左侧关系数据库中的表。ORM负责建立两者之间的映射,使得我们可以在Python代码中直接操作数据库信息。

在Python社区中,有多种ORM可供选择,例如Django ORM、Peewee ORM和Tortoise ORM。然而,SQLAlchemy因其出色的通用抽象、数据库无关的查询语言以及在需要时执行数据库特定操作的能力,而被广泛认为是一个非常好的选择。


定义数据模型 📊

了解了ORM的基本概念后,本节我们将学习如何在SQLAlchemy中定义数据模型。

在SQLAlchemy中,我们使用类(通常称为“模型”)来表示数据库中的表。让我们来看一个用户模型的例子。

from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True, autoincrement=True)
    email = Column(String, unique=True, index=True, nullable=False)
    username = Column(String)
    password = Column(String)
    last_login_date = Column(DateTime, server_default=func.now())

在这个User模型中:

  • id 是主键,并设置为自动递增。
  • email 被设置为唯一且建立了索引,并且不能为空。
  • last_login_date 使用了server_default,这意味着如果未提供时间戳,数据库会自动设置当前时间。这是一种确保时间戳一致性的最佳实践。

所有模型通常继承自一个公共基类,这有助于统一管理元数据和命名约定。

from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class CustomBase(Base):
    __abstract__ = True

    @declared_attr
    def __tablename__(cls):
        return cls.__name__.lower()

你还可以在模型中加入数据验证逻辑。但请注意,模型级别的验证是数据写入数据库前的最后一道防线。对于像用户名格式这样的验证,可能更适合在前端或专门的验证层处理,以便为用户提供即时反馈。


定义表关系与约束 🔗

定义了基础模型后,我们来看看如何定义表之间的关系和约束。

以下是定义表关系的方法。例如,一个Group模型可能有一个创建者,该创建者关联到User表。

from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/0b0c0f356f369474f159c4b579a6477d_5.png)

class Group(Base):
    __tablename__ = 'groups'

    id = Column(Integer, primary_key=True)
    creator_id = Column(Integer, ForeignKey('users.id'))
    creator = relationship("User")

这里,creator_id是一个指向users.id的外键。creator关系允许我们在查询Group时,直接访问关联的完整User对象,而不仅仅是ID。

我们还可以定义更复杂的约束,例如组合唯一约束。

from sqlalchemy import UniqueConstraint

class UserPermission(Base):
    __tablename__ = 'user_permissions'
    __table_args__ = (UniqueConstraint('email', 'domain', name='uix_email_domain'),)

    id = Column(Integer, primary_key=True)
    email = Column(String)
    domain = Column(String)

这个约束确保了emaildomain的组合必须是唯一的。这意味着对于每个特定的域,用户的电子邮件地址必须是唯一的。


使用索引优化查询 ⚡

建立了数据关系和约束后,优化查询性能就变得很重要。本节我们学习如何使用索引。

索引可以显著提高查询速度。我们可以创建多列索引。

# 假设我们有一个需要频繁按 username 和 permission 查询的场景
# 在模型定义中,可以这样创建索引
__table_args__ = (Index('idx_username_permission', 'username', 'permission'),)

创建(username, permission)的索引后,以下查询会非常高效:

  • WHERE username = ? AND permission = ?
  • WHERE username = ? (因为索引的最左前缀原则)

但仅查询WHERE permission = ?则无法有效利用这个索引。

我们还可以创建部分索引,只对满足特定条件的行建立索引,以节省空间并提升特定查询速度。

from sqlalchemy import Index

# 例如,只为“影子封禁”的用户建立索引
shadow_banned_index = Index('idx_shadow_banned', User.id, postgresql_where=(User.is_shadow_banned == True))

对于更复杂的查询模式,例如基于数组字段的查询,可以使用函数表达式索引。

# 假设 comments 表有一个存储父评论ID数组的字段 parent_ids
# 我们想快速找到所有根评论(parent_ids 为空数组)
from sqlalchemy import func, Index

# 在PostgreSQL中,可以这样创建索引
root_comments_index = Index('idx_root_comments', func.array_length(Comment.parent_ids, 1), postgresql_where=(func.array_length(Comment.parent_ids, 1) == 0))

连接数据库与执行查询 🔌

现在我们已经定义了模型和索引,接下来看看如何实际连接数据库并执行查询。

连接到数据库非常简单。

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

# 创建引擎
engine = create_engine('postgresql://user:password@localhost/mydatabase')
# 创建会话工厂
SessionLocal = sessionmaker(bind=engine)

# 使用会话
session = SessionLocal()
new_user = User(email="test@example.com", username="test")
session.add(new_user)
session.commit()
session.close()

为了确保会话被正确关闭,推荐使用上下文管理器。

from contextlib import contextmanager

@contextmanager
def get_db():
    session = SessionLocal()
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()

# 使用方式
with get_db() as db:
    user = db.query(User).filter(User.email == "test@example.com").first()

在Web应用中,会话的生命周期通常与单个请求绑定。SQLAlchemy使用连接池来管理数据库连接,避免频繁建立和断开连接的开销。

engine = create_engine(
    'postgresql://user:password@localhost/mydatabase',
    pool_size=5,          # 连接池保持的连接数
    max_overflow=2,       # 池满后允许临时创建的最大连接数
    pool_timeout=30,      # 获取连接的超时时间(秒)
    pool_recycle=1800,    # 连接回收时间(秒),防止数据库断开空闲连接
    pool_pre_ping=True    # 执行前轻量级ping,检查连接是否有效
)

使用Alembic进行数据库迁移 🚀

随着应用迭代,数据库结构需要改变。本节我们学习如何使用Alembic安全地进行数据库迁移。

直接执行SQL语句修改生产数据库容易出错。迁移工具(如Alembic)允许我们以可版本控制、可测试、可回滚的方式管理数据库变更。

配置Alembic非常简单。

# 初始化Alembic环境
alembic init alembic
# 编辑 alembic.ini 文件,设置数据库连接URL
# sqlalchemy.url = driver://user:pass@localhost/dbname

创建迁移脚本。

alembic revision -m "create organization table"

生成的迁移脚本模板包含upgrade()downgrade()函数。

# alembic/versions/xxx_create_organization_table.py
from alembic import op
import sqlalchemy as sa

def upgrade():
    op.create_table('organization',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('name', sa.String(), nullable=True),
        sa.Column('flag', sa.Boolean(), nullable=True),
        sa.PrimaryKeyConstraint('id')
    )

def downgrade():
    op.drop_table('organization')

执行迁移。

# 升级到最新版本
alembic upgrade head
# 降级到特定版本
alembic downgrade -1

在迁移脚本中操作数据时,不要直接导入应用中的模型,因为模型未来可能会变化。应在迁移脚本内重新定义所需的结构。

# 在迁移脚本中定义临时表结构用于数据操作
def upgrade():
    # ... 创建表等结构变更 ...

    # 使用核心SQLAlchemy Table对象,而不是ORM模型
    organization_table = sa.Table('organization', sa.MetaData(),
        sa.Column('id', sa.Integer, primary_key=True),
        sa.Column('name', sa.String)
    )
    group_table = sa.Table('group', sa.MetaData(),
        sa.Column('id', sa.Integer, primary_key=True),
        sa.Column('organization_id', sa.Integer, sa.ForeignKey('organization.id'))
    )

    # 使用 connection 执行数据更新
    connection = op.get_bind()
    connection.execute(
        organization_table.insert().values([{'name': 'Default Org'}])
    )

常见问题与性能优化 🛠️

掌握了基本操作和迁移后,我们来看看使用SQLAlchemy时可能遇到的一些常见问题及其解决方案。

1. N+1查询问题(惰性加载)
默认情况下,SQLAlchemy使用惰性加载。当访问关联对象时,可能会触发额外的查询。

# 假设 Group 有一个 members 关系(指向 User 列表)
groups = session.query(Group).all()
for group in groups:
    for member in group.members:  # 每次循环都可能触发一次数据库查询!
        print(member.name)

解决方案:使用joinedload进行主动加载。

from sqlalchemy.orm import joinedload

groups = session.query(Group).options(joinedload(Group.members)).all()
# 现在 members 已在初始查询中通过 JOIN 加载

2. 低效查询生成
有时SQLAlchemy可能生成非预期的低效SQL(如不必要的子查询)。可以使用func等工具进行优化。

from sqlalchemy import func

# 目标:计算每个用户创建的评论数
# 可能生成低效查询的方式
# suboptimal_query = session.query(User, (select([func.count(Comment.id)]).where(Comment.user_id==User.id).label('comment_count')))

# 优化方式:使用 group_by 和 join
optimal_query = session.query(User.id, func.count(Comment.id)).\
    join(Comment, Comment.user_id == User.id).\
    group_by(User.id)

3. 调试查询
要查看SQLAlchemy生成的原始SQL语句,有助于调试和优化。

# 打印查询语句
query = session.query(User).filter(User.email == 'test@example.com')
print(str(query.statement.compile(engine)))

总结 📝

在本教程中,我们一起学习了使用SQLAlchemy和Alembic进行Python数据库开发的完整流程。

我们从ORM(对象关系映射) 的概念讲起,理解了如何用Python类映射数据库表。接着,我们深入探讨了如何定义数据模型,包括字段、主键、默认值和基础类。

然后,我们学习了如何定义表之间的关系和约束,如外键和唯一约束,以及如何通过创建索引来优化查询性能。

在连接数据库部分,我们介绍了如何建立连接、使用会话(Session) 进行CRUD操作、利用上下文管理器安全管理会话生命周期,以及配置连接池以提升应用性能。

对于数据库结构的演进,我们引入了Alembic迁移工具,学习了如何创建、编写和执行可逆的数据库迁移脚本,这是团队协作和持续交付中的重要环节。

最后,我们探讨了几个常见问题与性能优化技巧,如解决N+1查询、优化复杂查询以及调试生成的SQL,帮助你避免陷阱并编写高效的数据库代码。

希望本教程能帮助你更自信地在Python项目中使用SQLAlchemy和Alembic,构建健壮、可维护的数据库应用。

041:停止使用模拟对象(暂时)

概述

在本节课中,我们将探讨在软件开发中过度依赖模拟对象(Mocks)可能带来的问题,并学习一系列替代方案。我们将通过一个货运系统的例子,逐步了解如何通过构建适配器、使用伪造对象(Fakes)、依赖注入和集成测试来改进代码设计和测试质量。

英雄的旅程:从模拟对象开始

我叫哈里,在 hjwp 的 Twitter 上可以找到我。我代表一个叫 cosmicpython.com 的网站。这个网站讨论如何确保你的 Python 代码尽可能远离混乱。今天要讨论的一个可能的技术是:停止使用模拟对象(Mocks),至少暂时停止。

我以谈论测试驱动开发而闻名,但过去几年,我开始更多地思考软件架构。我在一家叫 made.com 的公司工作。我们将跟随一个“英雄的旅程”来讲述这个故事:从冒险的召唤开始,与导师会面,然后与“怪物”(在这里是代码设计决策)战斗,权衡利弊,最后带着“灵药”(即关于如何构造代码的想法)回到社区。

如果你停止使用处理代码中某些东西的常见方法(比如模拟对象),并尝试一些有趣的新方法,你可能会有所收获。这就是我今天想说的。

模拟对象的常见场景与问题

我假设大家都熟悉模拟对象。我并不是说模拟对象不好,你永远不应该使用它们。一旦你对软件架构和设计感兴趣,你就会明白“看情况而定”这句陈词滥调。模拟对象是一种常见的工具,但在某些情况下,你可能会发现更好的工具。

所以,我要讨论很多利弊。这里没有黑白分明,只有正反两面。每次我提出一个新的想法或模式,我都会讨论它的优点和缺点。

一个示例:货运系统

我和 made.com 的同事,尤其是一位叫鲍勃的导师,学到了很多关于软件架构的知识。当我最终写一本关于这个的书时,它叫《架构模式与Python》,你可以在 cosmicpython.com 上找到更多信息。

我想通过一些示例代码来重现我学到的东西。例如,在物流领域,假设你有想追踪的货物。一批货物(Batch)是多个订单行(OrderLine)的集合。订单行代表某个产品的一定数量,用产品标识符 SKU 表示。货物有一个订单行列表,它有自己的引用编号、预计到达时间(ETA)和 INCOTERM 等属性。

我们用一个简单的数据模型来表示系统中的数据,并可能将其保存到数据库。

第一个业务需求:当我创建货物时,需要将其与第三方货运 API 同步。

在我的代码中,我有一个类似控制器或业务服务的函数。如果你使用 Django 或 Flask,这可能是视图或从视图调用的东西。

def create_shipping(products, quantity, incoterm):
    # 生成随机引用,创建模型对象,保存到数据库
    batch_ref = str(uuid.uuid4())
    batch = Batch(ref=batch_ref, eta=..., lines=...)
    batch.save()

    # 与 API 同步
    sync_with_api(batch)

sync_with_api 函数将一些属性构建成简单的数据结构,并将其作为 JSON 发送到第三方 API。

def sync_with_api(batch):
    data = {"ref": batch.ref, "eta": batch.eta, ...}
    requests.post("https://api.shipping.com/batches", json=data)

如何测试它? 最经典的方法是使用模拟对象,特别是 unittest.mock 模块的 patchMock 技术。

from unittest.mock import patch

def test_create_shipping():
    with patch('module.requests.post') as mock_post:
        create_shipping(...)
        # 断言 requests.post 被以特定参数调用
        mock_post.assert_called_once_with(
            "https://api.shipping.com/batches",
            json={"ref": ..., "eta": ..., ...}
        )

这看起来还不错。如果你用过几次模拟对象,你可以看懂发生了什么。

但生活从未如此简单,是吗?

第二个业务需求:为新的货物做 POST 请求,为已存在的货物做 PUT 请求。

所以我的 sync_with_api 代码突然变得更复杂了。我需要先做一个 GET 请求来检查货物是否已存在,然后决定是 POST 还是 PUT。

def sync_with_api(batch):
    # 先检查货物是否已存在
    resp = requests.get(f"https://api.shipping.com/batches/{batch.ref}")
    if resp.status_code == 200:
        # 已存在,使用 PUT
        requests.put(..., json=...)
    else:
        # 不存在,使用 POST
        requests.post(..., json=...)

这对我们的测试有什么影响?为这个示例编写测试,我现在需要修补的不仅仅是 requests 模块,还有 uuid 模块,因为我需要确保 GET 请求检查的对象 ID 与 create_shipping 函数生成的 uuid 匹配。

def test_create_shipping_for_existing():
    with patch('module.uuid.uuid4') as mock_uuid, \
         patch('module.requests.get') as mock_get, \
         patch('module.requests.put') as mock_put:
        # 设置模拟返回值
        mock_uuid.return_value = ...
        mock_get.return_value.status_code = 200

        create_shipping(...)

        # 断言 PUT 被调用
        mock_put.assert_called_once_with(...)

测试变得更长、更复杂。我有两个模拟对象,这种测试有点乏味。

第三个业务需求:我们需要轮询第三方 API 以获取最新的预计到达时间(ETA)。

又有很多代码。你可能需要许多测试来完成这类事情。我试着编写的代码看起来更像我们每天使用的典型业务代码。

def poll_for_updates():
    batches = get_all_batches_from_db()
    for batch in batches:
        latest_eta = get_latest_eta_from_api(batch.ref)
        if latest_eta != batch.eta:
            # 通知延迟
            notify_delay(batch)
        if is_large_shipment(batch):
            # 触发另一个流程
            trigger_special_process(batch)

根据我的计算,我们可能有 12 个或更多的测试,每个测试都要模拟三四个不同的东西。这导致了“模拟恐怖”:每个测试都有五六个不同的模拟,很难理解到底发生了什么。

此外,模拟测试很脆弱。你的每个补丁都依赖于特定的导入方式(例如 from requests import post)。如果你决定改用 requests.Session 以提高性能,或者对实现进行微小更改,你所有的模拟测试都可能被破坏。

模拟对象的利弊总结

让我们回顾一下模拟对象的利弊。

优点

  • 熟悉:开发者熟悉这种模式。
  • 低努力(初始):我可以直接使用模拟,而不必费力改变现有代码结构。

缺点

  • 测试实现细节:你的测试在验证如何调用第三方(如 requests.post),而不是验证业务逻辑(如“如果货物延迟,则通知相关人员”)。
  • 容易遗漏:你需要记住在每个可能与第三方 API 对话的测试上添加模拟补丁,否则测试会变慢或失败。
  • 鼓励耦合:它使得业务逻辑(业务规则)与基础设施细节(JSON 格式、端点 URL)混杂在一起。
  • 仍然需要集成测试:即使有模拟测试,你仍然需要一些端到端的测试来确保事情真的有效。你可能会滑向我朋友 Ed Jung 所说的“模拟地狱”。

既然我们看到了模拟地狱,让我们来谈谈替代方案。

替代方案一:构建适配器 🧩

那么,我们该怎么办?以下是我的第一个建议:构建适配器

我说的“适配器”是什么意思?我指的是将我调用的 API 包装起来,将其从我们应用程序的核心业务逻辑中分离出来。“适配器”这个词借鉴了“端口与适配器”架构模式(也称为六边形架构、洋葱架构或整洁架构)。

让我们看看在这个例子中它是什么样子。我将使用一个类来表示我的适配器(你也可以不使用类)。

class ShippingAPI:
    def __init__(self, base_url):
        self.base_url = base_url

    def sync(self, batch):
        """将货物与外部系统同步"""
        # 内部处理 POST/PUT 逻辑
        if self._batch_exists(batch.ref):
            self._update_batch(batch)
        else:
            self._create_batch(batch)

    def get_latest_eta(self, batch_ref):
        """获取最新的预计到达时间"""
        resp = requests.get(f"{self.base_url}/batches/{batch_ref}")
        return resp.json()['eta']

    def _batch_exists(self, batch_ref):
        # 私有方法,检查批次是否存在
        ...

    def _create_batch(self, batch):
        # 私有方法,创建批次
        ...

    def _update_batch(self, batch):
        # 私有方法,更新批次
        ...

现在,我的业务逻辑(create_shipping)可以用我自己的语言与这个适配器交互,而不是直接处理第三方的细节。

def create_shipping(products, quantity, incoterm, shipping_api): # 注意新增的参数
    batch_ref = str(uuid.uuid4())
    batch = Batch(ref=batch_ref, eta=..., lines=...)
    batch.save()

    # 使用适配器同步
    shipping_api.sync(batch)

这对我们的测试有什么影响?我们不再修补 requests 模块,而是模拟我们自己的 ShippingAPI 类。

def test_create_shipping():
    mock_shipping_api = Mock(spec=ShippingAPI)
    create_shipping(..., shipping_api=mock_shipping_api)
    mock_shipping_api.sync.assert_called_once_with(...)

与之前断言 mock_post.assert_called_once_with(...) 相比,这至少简化了一点。因为我不是在模拟一个可以做数百件事的复杂库(requests),而是在模拟一个只有少数我定义的方法的简单接口。

适配器的利弊

优点

  • 解耦:将基础设施代码与业务逻辑解耦。业务逻辑现在说“我需要同步”,而不关心是 POST 还是 PUT。
  • 模拟更简单:模拟一个简单的适配器接口比模拟一个复杂的第三方库更容易,也更有信心。
  • 未来证明:如果以后要更换第三方(例如从 JSON 换到 XML),业务逻辑的测试不需要改变。

缺点

  • 更多代码:必须构建这个适配器类,增加了额外的复杂性层。
  • 理解成本:其他人可能需要理解“ShippingAPI.sync()”具体做了什么,而直接看 requests.post 可能更直观。

但这只是第一步。我们不会就此止步。

替代方案二:伪造你的适配器 🎭

本次演讲的标题是“停止使用模拟对象”。让我们继续下一个建议:伪造你的适配器

第一部分:与其使用模拟对象(Mock),我鼓励你制造一个“伪造对象”(Fake)。伪造对象和模拟对象有什么区别?我不想太纠结于此,但基本上:

  • 模拟对象 是一个你可以事后询问的对象(“你被调用了吗?怎么调用的?”)。
  • 伪造对象 是一个你用来替换真实依赖的简化但可工作的实现。它不会记录调用,而是实际执行一些逻辑(通常在内存中)。

让我们在现实生活中看看。你可以定义一个抽象基类或协议(如果你喜欢类型提示和 typing.Protocol),来说明 ShippingAPI 需要哪些方法。

# 使用 Protocol 定义接口
from typing import Protocol

class ShippingAPIProtocol(Protocol):
    def sync(self, batch) -> None: ...
    def get_latest_eta(self, batch_ref): ...

# 真实实现
class RealShippingAPI:
    def __init__(self, base_url):
        self.base_url = base_url
    def sync(self, batch): ... # 真实的网络调用
    def get_latest_eta(self, batch_ref): ... # 真实的网络调用

# 伪造实现
class FakeShippingAPI:
    def __init__(self):
        self._batches = {} # 内存存储

    def sync(self, batch):
        # 只是存储在内存字典中
        self._batches[batch.ref] = {
            'ref': batch.ref,
            'eta': batch.eta,
            # ... 其他属性
        }

    def get_latest_eta(self, batch_ref):
        # 从内存字典中查找
        return self._batches.get(batch_ref, {}).get('eta')

    # 一个方便的语法糖,用于测试断言
    def __contains__(self, batch_ref):
        return batch_ref in self._batches

现在,在测试中,我们可以使用这个伪造对象。

def test_create_shipping():
    fake_api = FakeShippingAPI()
    create_shipping(..., shipping_api=fake_api)
    # 断言:货物应该出现在伪造的 API 中
    assert batch.ref in fake_api

我希望你会同意,这最终比我们之前那些 mock_post.assert_called_once_with(...) 的测试更具可读性。

伪造对象的利弊

优点

  • 更可读的测试:测试意图更清晰(“货物应出现在 API 中”)。
  • 施加设计压力:这是一个有趣的概念。当你尝试为你的适配器编写一个伪造版本时,如果伪造起来非常困难,这可能是一个信号,表明你的适配器设计得太复杂了,也许应该拆分成更简单的部分。这有助于保持代码整洁。

缺点

  • 更多测试代码:必须构建和维护这个伪造类。
  • 维护负担:每次更改真实适配器,都可能需要手动更新伪造版本,这在使用模拟对象时是不需要的。

替代方案三:依赖注入 💉

如果你觉得构建伪造对象听起来像很多工作,那么接下来的事情在 Python 世界可能有些争议:你应该尝试使用依赖注入

如果这已经让你生气,让你觉得这像是“企业级 Java”的东西,别担心。我不想改变你的一生,我只是建议你尝试一下。与其在猴子补丁(unittest.patch)中替换依赖,不如通过参数显式传递它们。

# 生产代码
def create_shipping(products, quantity, incoterm, shipping_api): # 依赖作为参数传入
    batch_ref = str(uuid.uuid4())
    batch = Batch(ref=batch_ref, eta=..., lines=...)
    batch.save()
    shipping_api.sync(batch) # 使用传入的 api

在测试中,我们不再需要 mock.patch

def test_create_shipping():
    fake_api = FakeShippingAPI()
    create_shipping(..., shipping_api=fake_api) # 直接传入伪造对象
    assert batch.ref in fake_api

我认为这是对这段代码最简单、最易理解的测试。与旧版本及其复杂的模拟补丁设置相比,这简单多了。

依赖注入的利弊

优点

  • 更好的测试:测试设置更简单、更清晰。
  • 依赖关系明确:不可能在忘记模拟的情况下意外调用真实 API。函数签名清楚地表明了它的依赖。
  • 代码可读性:查看函数签名就能立刻知道它依赖哪些外部服务,而不必深入实现细节去寻找 import requests

缺点

  • 更多的参数:生产代码中函数签名变长,需要传递更多参数。
  • 传递负担:如果 create_shipping 调用另一个函数 recalculate_shipping,而那个函数也需要 shipping_api,那么你就需要把这个参数传递一整条调用链,这可能很乏味。
    • 有方法可以缓解,比如使用依赖注入容器或特定的架构模式来组织代码,但这本身也增加了复杂性。

关于“不必要的参数”的争论,是我经常遇到的。人们觉得为了测试而让应用程序代码“变丑”是不可接受的。但请考虑:如果能让测试的编写和维护容易两倍,而只让应用代码的阅读和维护难度增加 10%,这可能是一笔很好的交易。至少值得一试。

如何测试适配器本身? 🔍

你可能会说:“好了,哈利,我们不用模拟对象了。但我们仍然有最初的问题:你仍然需要一些真正的测试来检查适配器是否真的能与第三方工作!”

没错。对于适配器本身(即与第三方集成的代码),我建议最好的测试是集成测试

集成测试可能是什么样子?我们可能最终还是会在这一层使用模拟对象,但让我们看看能否做得更好。

# 集成测试示例
def test_shipping_api_sync():
    # 使用真实的沙箱环境 URL
    api = RealShippingAPI(base_url="https://sandbox.shipping.com")
    batch = create_test_batch()
    api.sync(batch)
    # 断言:通过 API 能成功查询到这批货
    retrieved_data = api._get_batch(batch.ref) # 假设有查询方法
    assert retrieved_data['eta'] == batch.eta

注意,这个测试关注的是行为(“同步后,数据能通过 API 正确检索”),而不是实现细节(“是否调用了 requests.post”)。

当然,集成测试也有其挑战:

  • 需要沙箱环境:第三方需要提供可用的测试沙箱。
  • 速度慢且脆弱:网络调用慢,沙箱环境可能不稳定。
  • 需要清理:测试创建的数据需要清理,否则沙箱会堆积垃圾数据,影响后续测试。

这就引出了最后一个建议。

进阶建议:为集成测试构建伪造的第三方 🌐

如果你在集成真实第三方时遇到了真正的问题(沙箱慢、脆、难清理),为什么不为你集成的第三方构建一个伪造版本呢?

这听起来有点危险,但请忍耐一下。构建一个行为像第三方的简单伪造服务器(例如,用一个小的 Docker 容器)可能比你想象的要容易。大多数 REST API 本质上是 CRUD(创建、读取、更新、删除)。一个简单的内存存储就可以模拟很多行为。

你能得到什么?

  • 更可靠的测试:测试不再受外部网络和第三方沙箱状态的影响。
  • 更快的测试:所有交互都在本地内存或进程中完成。
  • 仍然可以测试适配器:你仍然在测试你的适配器能进行正确的 HTTP 调用、JSON 编码/解码。
  • 选择性运行:你仍然可以选择偶尔针对真实沙箱运行测试(例如,在 CI 的夜间构建中),而日常开发和 PR 验证则使用快速的伪造版本。

需要注意

  • 你现在有两套测试:一套针对伪造的第三方,一套针对真实的第三方。这引入了“契约测试”的概念——确保你的伪造版本与真实版本的行为一致。
  • 有一个很酷的库叫 vcrpy,它可以记录真实 API 的响应并在测试中回放,这也是一个解决模拟问题的好方法,尽管有时也会变得复杂。

总结 🎯

回顾一下,我的建议是:

  1. 停止使用模拟对象(至少尝试一下)
  2. 构建一个适配器:创建一个代表外部依赖的类或模块,用你的领域语言提供清晰的 API。
  3. 尝试使用伪造对象:为你的适配器编写一个简化的、内存中的实现,并在测试中使用它,而不是模拟对象。
  4. 尝试依赖注入:通过函数参数显式传递依赖,而不是在模块级别隐式导入并用猴子补丁替换。
  5. 使用集成测试来测试适配器:针对真实沙箱或你构建的伪造第三方服务进行测试,以验证集成是否真正有效。

我们做这些是因为:

  • 更好的测试:更易于阅读、编写和维护的测试。
  • 施加设计压力:促使你思考如何将外部依赖分离成小而可管理的部分。
  • 强制解耦:将核心业务逻辑与易变的基础设施细节分离,允许它们以不同的速率独立变化。

希望你喜欢这些想法。如果你决定尝试停止使用模拟对象,请告诉我进展如何。你可以在 Twitter 上找到我,或者写一篇博客文章。我很想听听你的经验。

042:用更少的代码进行更全面的测试 🧪

在本教程中,我们将学习一种名为“数据回归测试”的测试方法。这种方法通过比较代码修改前后的输出数据,来高效地防止软件缺陷。我们将借助 pytest 框架及其插件 pytest-regressions 来实践这一方法,让测试变得更简洁、更健壮。


概述与背景

我叫 Igor T. Ghisi,来自巴西。我从2004年开始使用Python,并将其与C++结合用于计算流体动力学等领域的数值模拟。在2018年,我们成功地将一个大型代码库从Python 2迁移到Python 3,这主要得益于我们完善的测试覆盖。没有因迁移而产生的错误进入生产环境,整个过程花费了大约十个月。

为了庆祝这次成功的迁移,我们甚至制作了一件特别的T恤。

在本次分享中,我将介绍一种能显著提升测试效率和质量的实践:数据回归测试。


什么是数据回归测试? 🤔

通常,回归测试的定义是:确保代码更改不会引入新的缺陷。但这几乎适用于所有测试。我一直在寻找一个更具体的定义,最终提出了“数据回归测试”这个概念。

数据回归测试通过比较被修改代码的输出数据与之前版本代码生成的数据,来防止软件功能回退。

虽然这个概念在经典软件测试书籍中不常见,但实践中已有广泛应用。例如,一些团队会进行“数据库回归测试”,比较不同代码分支填充的数据库。著名的绘图库 matplotlib 则使用“图像比较测试”,将生成的图像与参考图像进行比对。

接下来,我们将看到如何利用工具来轻松实现这种测试。


引入 pytest-regressions 工具 🛠️

我们最初为了测试3D渲染算法创建了一个辅助工具,后来将其发展为 pytest 的一个插件,并开源为 pytest-regressions 库。

本教程将展示如何利用 pytestpytest-regressions 创建数据回归测试。即使你使用其他测试框架,这些概念也是通用的,你可以据此构建自己的工具。

为了更好地理解后续示例,建议你具备 pytest 基础知识和夹具(fixture)的使用经验。

你可以通过 pipconda 安装 pytest-regressions

pip install pytest-regressions

该插件主要提供四种夹具(fixture):

  1. file_regression: 用于通用文本内容。
  2. data_regression: 用于基本Python数据类型(如字典、列表)。
  3. image_regression: 用于图像二进制数据。

我们将通过例子逐一了解它们。


示例一:数据回归测试基础 📊

上一节我们介绍了数据回归测试的概念和工具。本节中,我们来看看一个基础示例,了解如何用一行代码替换繁琐的断言。

假设我们有一个存储汽车规格的类,并且有一个根据名称创建汽车对象的方法。

传统单元测试可能像这样,需要逐一检查每个属性:

def test_create_car_naive():
    car = create_car("Model S")
    assert car.name == "Model S"
    assert car.brand == "Tesla"
    assert car.range_km == 600
    # 很容易遗漏某个属性,例如 `displacement`

这种方法不仅繁琐,而且在属性众多或存在嵌套对象时难以维护。

使用数据回归测试,我们可以简化为:

def test_create_car(data_regression):
    car = create_car("Model S")
    data_regression.check(car.to_dict()) # 假设car可序列化为字典

data_regression.check() 方法会将 car.to_dict() 的输出与一个参考文件进行比对。首次运行时,它会创建参考文件;后续运行时,若输出不一致,测试便会失败。

这使测试更完整、更易维护,并且在失败时能提供清晰的差异对比,便于调试。


示例二:处理无法“测试先行”的场景 🔄

在测试驱动开发(TDD)中,我们要求在编写生产代码前先写测试。但在许多场景(如数值模拟)中,我们无法预先知道精确结果。开发者通常通过可视化图表与已知基准进行对比来验证正确性。

在这种情况下,测试主要用于防止后续的回归错误,而数据回归工具就非常方便。

我们以二次贝塞尔曲线的实现为例。即使我知道算法原理,手动计算100个点的坐标也不现实。我唯一的参考可能是一张来自维基百科的示意图。

我实现了算法并绘制了曲线,它“看起来”是对的。但如何用测试确保其正确性呢?

  1. 弱测试:只检查起点和终点。
  2. 稍好的测试:手动选取并断言曲线上的几个中间点坐标,但这不优雅且覆盖不全。
  3. 数据回归测试:将算法生成的整条曲线(100个点的坐标)全部进行回归检查。

以下是使用 data_regression 的测试代码:

def test_quadratic_bezier(data_regression):
    points = quadratic_bezier([(0,0), (1,1), (2,0)], num_points=100)
    # 将点列表转换为可序列化的格式,例如字典列表
    data = [{"x": p[0], "y": p[1]} for p in points]
    data_regression.check(data)

首次运行,插件会在特定目录创建参考文件(一个CSV或JSON文件)。测试通过。
如果后续代码引入bug,导致输出变化,测试将失败,并显示详细的差异对比。

你还可以为数值比较设置容差(tolerance),以应对浮点数精度变化:

data_regression.check(data, tolerances={‘x‘: 1e-3, ‘y‘: 1e-3})

示例三:文件与文本回归测试 📄

上一节我们使用 data_regression 处理了结构化数据。本节中,我们来看看 file_regression 夹具,它适用于任何文本内容。

假设我们有一个将HTML片段转换为Markdown的函数。

def test_html_to_markdown(file_regression):
    html = "<h1>Title</h1><p>Hello <strong>World</strong></p>"
    markdown = html_to_markdown(html)
    file_regression.check(markdown, extension=".md")

extension 参数确保生成的文件具有正确的后缀。首次运行创建参考文件,后续运行进行比对。

当测试失败时,错误信息会打印出文本差异,并生成一个HTML格式的差异对比文件链接,这在使用CI/CD系统时尤其利于调试。


在Web框架测试中的应用 🌐

file_regression 也非常适合测试基于模板的Web视图(如Flask、Django)。一个简单但脆弱的测试是直接断言整个HTML响应字符串。

更好的方法是使用如 BeautifulSoup 这样的解析库,只提取需要回归测试的核心内容(如<body>标签),忽略样式、脚本等可能频繁变动但不影响功能的元数据。

def test_hello_view(file_regression):
    client = app.test_client()
    response = client.get(‘/hello‘)
    html = response.get_data(as_text=True)

    soup = BeautifulSoup(html, ‘html.parser‘)
    body_content = str(soup.body) # 只回归测试body部分

    file_regression.check(body_content, extension=".html")

这样能减少回归文件的大小,并使测试更专注于业务逻辑。


示例四:测试Web API 🔌

数据回归测试同样适用于测试RESTful API。假设我们有一个返回英雄列表的API。

使用 pytest-flask 或类似的工具可以轻松获取API的JSON响应,然后使用 data_regression 进行检查。

def test_get_single_hero(client, data_regression):
    response = client.get(‘/api/hero/1‘)
    data_regression.check(response.get_json())

def test_get_all_heroes(client, data_regression):
    response = client.get(‘/api/hero‘)
    data_regression.check(response.get_json())

这确保了API端点返回的数据结构始终符合预期。


示例五:图像回归测试 🖼️

最后,我们来看 image_regression 夹具,它用于比较图像。

假设我们要测试一个用 matplotlib 生成3D图形的函数。

def test_3d_plot(image_regression):
    fig = create_complex_3d_plot()
    # 将图形保存到内存缓冲区
    buf = io.BytesIO()
    fig.savefig(buf, format=‘png‘)
    buf.seek(0)
    image_data = buf.read()

    image_regression.check(image_data)

首次运行生成参考图像。后续运行时,插件会逐像素比较RGB值。

你可以通过 diff_threshold 选项设置一个阈值,以忽略微小的、可接受的差异(例如不同操作系统间字体渲染的细微差别)。


高级技巧与配合工具 🧰

1. 批量更新回归文件

当你进行了一项影响广泛的更改(如修改了CSS类名),需要更新所有相关回归文件时,可以使用 --force-regen 选项运行测试套件,强制重新生成所有参考文件。

2. 与 pytest-datadir 配合使用

pytest-datadir 插件能方便地管理测试所需的支持文件。你可以创建一个与测试文件同名的文件夹,将支持文件放入其中,在测试中通过 datadir 夹具访问。

def test_count_lines(datadir):
    file_path = datadir / "input.txt"
    line_count = count_lines(file_path)
    assert line_count == 42

它会在临时目录中操作文件,避免污染源文件。

3. 与 SQLAlchemymarshmallow 配合使用

marshmallow 是一个序列化库。结合 SQLAlchemy 模型和 marshmallow,可以轻松地将数据库模型实例序列化为字典,然后进行数据回归测试。

class UserSchema(ma.SQLAlchemyAutoSchema):
    class Meta:
        model = User
        include_fk = True # 序列化外键关联对象

def test_user_model(data_regression):
    user = User(...) # 创建测试用户对象
    schema = UserSchema()
    data = schema.dump(user)
    data_regression.check(data)

这极大地简化了复杂数据模型的测试。


总结与资源 📚

在本教程中,我们一起学习了数据回归测试。我们定义了其概念,并通过 pytest-regressions 插件实践了四种主要的回归测试:

  • data_regression: 用于基本Python数据类型。
  • file_regression: 用于通用文本内容。
  • image_regression: 用于图像二进制数据。
    我们还介绍了如何与 pytest-datadirmarshmallow 等工具配合使用,以应对更复杂的测试场景。

如果你想深入学习 pytest,官方文档是极好的起点。我也推荐 Bruno Oliveira 的《pytest Quick Start Guide》一书,它由一位 pytest 核心开发者撰写,内容非常详实。

感谢Python社区和组织者。如果你有任何问题,可以通过Twitter或视频评论区联系我。本教程的幻灯片和示例源代码可以在我的GitHub主页找到。

043:小大数据处理技巧

在本节课中,我们将要学习当数据量过大,无法一次性装入计算机内存时,如何使用NumPy和Pandas等工具进行处理。我们将重点介绍三种核心技巧:压缩、分块和索引,以帮助你在资源有限的情况下高效处理数据。

在开始任何软件项目之前,最重要的问题是评估项目是否值得进行。如果你的代码可能对人或环境造成伤害,那么代码的效率越高,其潜在的负面影响可能就越大。因此,在编写代码前,请确保项目本身是值得的。接下来的内容假设你已经做出了这个决定。

问题定义:内存不足

今天要讨论的核心问题是:当你的数据量过大,例如一个巨大的CSV文件或数组,在测试时使用少量数据一切正常,但加载真实数据(如2GB、10GB)时,程序因内存不足而崩溃。如果你的电脑只有16GB内存,这就是我们需要解决的问题。

一个解决方案是使用大数据集群,即一组计算机协同工作。但这通常需要重写代码、配置集群,过程复杂且成本高昂。虽然可以使用云服务简化,但从单机迁移到集群仍然需要大量努力。

理想情况下,我们希望避免这种复杂性。本次课程的重点是:假设你只有一台普通电脑,并且希望尽可能使用现有的API和代码来处理大量数据。这被称为“小大数据”,其理念是在资源受限的单机上处理大规模数据。

为什么需要将数据加载到内存?

你可能会问,既然数据存储在硬盘上,为什么不直接在磁盘上进行读写操作?原因是速度差异巨大。从现代固态硬盘(SSD)读取数据可能需要约16000纳秒,而从内存读取仅需约100纳秒。如果只处理磁盘上的数据,代码速度可能会慢160倍。为了获得可接受的性能,我们通常需要将数据加载到内存中。

解决方案一:增加硬件资源

一个直接的解决方案是投入更多资金购买或租赁更强大的硬件。例如,你可以购买一个拥有64GB内存的工作站,或者在云上租用一台拥有大量内存的虚拟机。在很多情况下,考虑到开发时间成本,这是一个简单有效的解决方案。

然而,增加预算并不总是可行的。例如,在计算成本敏感的场景(如批量处理任务),过度使用昂贵的虚拟机可能使项目无利可图。因此,我们需要通过优化软件来减少内存使用。

核心技巧概述

在接下来的内容中,我们将重点讨论三种减少内存使用的软件技巧:

  1. 压缩数据:改变数据的表示方式,使其占用更少内存。
  2. 分块处理:将数据分成小块,一次只加载和处理一部分。
  3. 建立索引:只加载实际需要的数据子集。

这些技巧基于算法和硬件原理,具有通用性。即使你不使用本节提到的特定库,理解这些概念也很有帮助。此外,针对你的特定数据类型,可能还能设计出定制的优化变体。


上一节我们概述了三种核心技巧,本节中我们首先来看看数据压缩

技巧一:数据压缩

压缩的原理是用一种更紧凑的方式表示数据,从而减少内存占用。压缩可以是无损的(压缩后数据与原始数据完全相同),也可以是有损的(牺牲一些不重要的细节以换取更高的压缩率)。本节主要讨论无损的内存中压缩,而非磁盘上的文件压缩(如ZIP文件)。

1. 使用更高效的数据类型(NumPy)

NumPy库用于存储多维数组,每个数组都有一个指定的数据类型(dtype)。选择合适的数据类型可以显著节省内存。

例如,存储整数时,uint16(16位无符号整数)可以表示0到65535之间的值,而uint64(64位无符号整数)可以表示更大的数字,但占用内存是前者的4倍。

代码示例:比较不同整数类型的内存占用

import numpy as np

# 创建一个1024x1024的数组,使用64位整数
arr_64 = np.ones((1024, 1024), dtype=np.uint64)
print(f"64位数组内存占用: {arr_64.nbytes / 1024**2:.2f} MB")

# 创建一个1024x1024的数组,使用16位整数
arr_16 = np.ones((1024, 1024), dtype=np.uint16)
print(f"16位数组内存占用: {arr_16.nbytes / 1024**2:.2f} MB")

如果数据值范围适合更小的数据类型(例如,人数少于65535),使用uint16代替uint64可以将内存占用减少为原来的1/4。

2. 使用稀疏数组(SciPy Sparse)

如果数组中大部分元素是零(或某个默认值),存储所有这些零是浪费的。稀疏数组只存储非零值及其位置信息。

适用场景:例如表示星空图像,大部分像素是黑色(值为0),只有少数像素有星星(值>0)。

代码示例:创建稀疏数组

import numpy as np
from scipy import sparse

# 创建一个1024x1024的随机数组,并将90%的元素设为0
dense_arr = np.random.rand(1024, 1024)
dense_arr[dense_arr < 0.9] = 0

# 转换为稀疏坐标格式(COO)数组
sparse_arr = sparse.coo_matrix(dense_arr)

print(f"稠密数组内存占用: {dense_arr.nbytes / 1024**2:.2f} MB")
# 稀疏数组内存占用需要估算,通常远小于稠密数组
# 当非零元素很少时,节省的内存非常可观

稀疏数组支持许多与普通NumPy数组相同的操作。但当非零元素数量很多时,其存储坐标的开销可能使优势变小。

3. 优化Pandas数据类型

Pandas的DataFrame类似于电子表格,每列都有特定的数据类型。默认情况下,Pandas会为数值列选择较大的数据类型(如int64)。我们可以手动指定更高效的类型。

代码示例:优化DataFrame列类型

import pandas as pd

# 假设有一个包含‘id’列的DataFrame,其值都小于10000
df = pd.DataFrame({'id': [100, 200, 3000]})

# 查看默认类型
print(df['id'].dtype) # 可能是 int64

# 转换为更节省空间的类型
df['id'] = df['id'].astype('uint16')
print(f"优化后内存占用减少")

通过将int64列转换为int16uint16,可以将该列的内存使用减少为原来的1/4,且不丢失任何数据。


上一节我们介绍了通过压缩数据来减少内存占用,本节中我们来看看第二种技巧:分块处理

技巧二:分块处理

分块处理让你无需一次性将全部数据加载到内存中。其核心思想是:将数据分成较小的块,逐块加载、处理,最后合并结果。

考虑一个寻找数组最大值的例子。要找到整个数组的最大值,我们可以:

  1. 将数组分成两半。
  2. 分别找出每一半的最大值。
  3. 两个最大值中的较大者就是整个数组的最大值。
    我们可以进一步将数组分成更小的块(如四分之一),只需在内存中保留当前正在处理的块即可。

1. 使用NumPy和Zarr进行分块

Zarr是一种用于在磁盘上存储分块数组的格式。它允许你设置块大小,并在需要时仅将特定块加载到内存中。

代码示例:使用Zarr分块计算最大值

import numpy as np
import zarr

# 创建一个包含100万个元素的数组,并保存为Zarr格式,块大小为1000
data = np.arange(1_000_000)
zarr_array = zarr.array(data, chunks=1000)

# 初始化最大值变量
overall_max = -np.inf

# 迭代每个块,计算块内最大值
for i in range(0, len(zarr_array), 1000):
    chunk = zarr_array[i:i+1000] # 仅加载一个块到内存
    chunk_max = np.max(chunk)
    if chunk_max > overall_max:
        overall_max = chunk_max

print(f"整个数组的最大值是: {overall_max}")

使用zarr.array创建数组时,数据被分割存储。通过切片zarr_array[i:i+1000],我们一次只将一个块加载到NumPy数组中进行处理,从而控制内存使用。

2. 使用Pandas分块读取CSV

Pandas提供了分块读取大型CSV文件的功能,无需一次性读入内存。

代码示例:分块读取CSV并计算列最大值

import pandas as pd

# 使用 `chunksize` 参数,每次迭代返回一个包含指定行数的DataFrame
chunk_iter = pd.read_csv('large_data.csv', chunksize=100)

overall_max = None
for chunk in chunk_iter: # 每次循环,chunk是一个包含100行数据的DataFrame
    chunk_max = chunk['value_column'].max()
    if overall_max is None or chunk_max > overall_max:
        overall_max = chunk_max

print(f"整个CSV文件中‘value_column’列的最大值是: {overall_max}")

通过设置chunksize=100read_csv返回一个迭代器。每次迭代得到一个包含100行数据的DataFrame。我们可以在每个块上执行计算(如求最大值),并汇总结果,从而处理远大于内存的文件。


上一节我们学习了如何通过分块处理大数据,本节中我们来看看最后一种技巧:使用索引

技巧三:使用索引

索引就像一本书的目录。如果你想了解书中关于“滑铁卢战役”的所有内容,无需通读全书,只需查阅索引,找到对应的页码即可。索引比原书小得多,却能帮你快速定位所需信息。

分块 vs 索引

  • 分块:适用于需要处理所有数据的场景(例如,找出一本书中最长的单词)。你无法避免读取全部数据,但可以分批进行。
  • 索引:适用于只需处理部分数据的场景(例如,只想查看七月份的财务数据)。你可以直接定位并加载相关部分,跳过无关数据。

在实践中,这两种技术可以结合使用。例如,先通过索引找到七月份的所有数据,如果这部分数据仍然很大,再对其进行分块处理。

为数据建立索引:使用SQLite

SQLite是一个内置在Python中的轻量级关系数据库。它不需要单独运行服务器,数据存储在一个单独的文件中,非常适合用作索引存储。

思路

  1. 将原始数据(如CSV)全部导入一个SQLite数据库表中。
  2. 在需要快速查询的列(如streetdate)上创建数据库索引。
  3. 后续查询时,数据库利用索引快速定位相关行,只加载这些行到内存。

代码示例:使用SQLite索引加速查询
假设我们有一个包含城市居民信息的大文件,需要频繁按街道名称查询。

import sqlite3
import pandas as pd

# 第一步:将CSV数据导入SQLite数据库(只需做一次)
def load_data_to_sqlite(csv_path, db_path, table_name):
    conn = sqlite3.connect(db_path)
    chunk_iter = pd.read_csv(csv_path, chunksize=10000)
    for chunk in chunk_iter:
        chunk.to_sql(table_name, conn, if_exists='append', index=False)
    # 在‘street’列上创建索引以加速查询
    conn.execute(f"CREATE INDEX idx_street ON {table_name}(street)")
    conn.commit()
    conn.close()

# 第二步:使用索引进行查询
def query_by_street_indexed(db_path, table_name, street_name):
    conn = sqlite3.connect(db_path)
    # SQL查询,数据库会使用索引快速找到匹配行
    query = f"SELECT * FROM {table_name} WHERE street = ?"
    df = pd.read_sql_query(query, conn, params=(street_name,))
    conn.close()
    return df

# 使用示例
# load_data_to_sqlite('huge_city_data.csv', 'city_data.db', 'residents')
# result_df = query_by_street_indexed('city_data.db', 'residents', 'Main St')

性能对比

与单纯使用分块从CSV中过滤数据相比,使用SQLite索引的查询速度可能快数十倍,因为它避免了读取和解析所有不相关的行,同时内存占用依然很低。


总结与扩展

本节课中我们一起学习了在内存有限的情况下处理“小大数据”的三种核心技巧:

  1. 压缩:通过选择更高效的数据类型(如uint16代替int64)、使用稀疏数组、优化Pandas列类型,在不损失信息的前提下减少内存占用。
  2. 分块:将数据分成小块,逐块加载处理(使用Zarr for NumPy或chunksize for Pandas),从而处理大于内存的数据集。
  3. 索引:通过建立索引(如使用SQLite数据库),只加载所需的数据子集,极大提升特定查询的效率并降低内存使用。

这些技巧源于计算机硬件(内存快但贵,磁盘慢但便宜)和算法设计的基本原理,因此具有普适性。即使你使用其他数据处理工具或系统,也会遇到类似的技术。

最后,请记住,除了软件优化,增加硬件资源(购买更多内存或租赁更强大的云虚拟机)有时可能是最简单、最经济的解决方案,尤其是在项目初期或时间成本很高的情况下。

如果你想深入了解更多的技巧和策略,可以查阅相关扩展资料。如果你在将这些技术应用到具体场景时遇到问题,欢迎通过邮件或社交平台进行交流。

044:一条🐍的 Unicode 指南

在本教程中,我们将学习 Unicode 的基础知识、它的复杂性,以及如何在 Python 中有效地处理 Unicode 文本。我们将从历史背景开始,逐步深入到核心概念和实际应用,帮助你消除对 Unicode 的恐惧,并编写更健壮的代码。

概述:为什么需要 Unicode?

Unicode 是一个旨在为世界上所有书写系统的每个字符提供唯一数字代码的标准。在计算机早期,存在许多互不兼容的字符编码(如 ASCII、GBK、ISO-8859 系列),导致文本交换时出现乱码。Unicode 的出现就是为了解决这个问题,它提供了一个统一的字符集。

然而,Unicode 的复杂性常常让人望而生畏。本教程将解释这些复杂性的来源,并展示 Python 如何帮助我们管理它们。


历史背景:从电报到 Unicode

上一节我们概述了 Unicode 的目标,本节中我们来看看 Unicode 是如何从早期的文本表示系统演变而来的。

书写系统历史悠久且多样,但计算机文本处理的历史相对较短。早期的电子文本传输系统,如摩尔斯电码和博多码(ITA2),引入了使用点和划(或穿孔纸带)来表示字符和控制信号的概念。这些系统影响了后来的计算机编码。

ASCII(美国信息交换标准代码)是早期计算机中占主导地位的编码。但它只能表示128个字符,主要针对英语。世界其他地区开发了各自的编码(如 GB2312、Shift_JIS、KOI8-R),导致了“编码地狱”——你无法确定一段字节流使用的是哪种编码。

如果我们能有一个普遍认可的解决方案就好了?这就是 Unicode 的目的。


Unicode 核心概念解析

上一节我们了解了编码混乱的历史,本节中我们来看看 Unicode 究竟是什么,并澄清一些关键术语。

许多人误以为 Unicode 就是一种编码(如 UTF-8)。更准确的理解是:Unicode 是一个描述字符及其属性的标准数据库和规范。它定义了字符的标识、名称、属性以及如何编码成字节流(如 UTF-8、UTF-16)的规则。

为了深入讨论,我们需要明确几个核心术语:

  • 代码点:Unicode 给每个字符分配的唯一数字标识。通常写作 U+ 后跟十六进制数字,例如 U+0041 代表拉丁字母 ‘A’。代码点是抽象的概念。
  • 编码:将代码点转换为字节序列的规则。UTF-8 是最常见的编码。
  • 代码单元:在特定编码中使用的固定大小的基本单元。例如,UTF-8 的代码单元是 8 位(1字节),UTF-16 的是 16 位(2字节)。
  • 字位簇:对用户来说的一个“可视字符”。它可能由一个或多个代码点组成(例如 ‘é’U+0065 (e) 和 U+0301 ( ́ ) 组成)。

一个重要结论是:一个“字符”(对用户而言)并不总对应一个代码点,一个代码点也不总对应一个“字符”。理解这一点是掌握 Unicode 复杂性的关键。


Unicode 的复杂性体现

上一节我们定义了核心概念,本节中我们通过具体例子来看看 Unicode 的复杂性体现在哪里。

以下是 Unicode 复杂性的几个主要方面:

  1. 组合字符:同一个可视字符可能有多种表示方式。

    • 预组合形式:用一个代码点表示,如 U+00E9 (é)。
    • 分解形式:用多个代码点(基础字符 + 组合标记)表示,如 U+0065 (e) + U+0301 ( ́ )。
    • 这两种形式在 Unicode 中被认为是规范等价的。
  2. 规范化:为了处理等价序列,Unicode 定义了规范化。主要有四种形式:

    • NFC:尽可能使用预组合字符。
    • NFD:尽可能使用分解形式。
    • NFKC:在兼容性等价基础上进行组合(更激进,可能丢失信息)。
    • NFKD:在兼容性等价基础上进行分解。
    • 对于大多数用途,NFKC 是一个安全的选择。
  3. 大小写处理:大小写转换并非简单的一对一映射。

    • Python 中的 .upper().lower() 方法用于大小写转换。
    • 对于不区分大小写的比较,应使用 .casefold() 方法,它比 .lower() 更彻底、更一致。
  4. 可变宽度编码:不同的编码使用不同数量的字节存储代码点。

    • UTF-8:兼容 ASCII,英文字符1字节,中文通常3字节。
    • UTF-16:对于基本多语言平面(BMP)内的字符用2字节,之外的(如许多表情符号)用4字节(代理对)。
    • UTF-32:每个代码点固定使用4字节,简单但空间效率低。

Python 中的 Unicode 支持

上一节我们探讨了 Unicode 本身的复杂性,本节中我们来看看 Python 是如何处理这些问题的。

Python 3 极大地简化了 Unicode 处理。与 Python 2 不同,Python 3 的 str 类型直接表示 Unicode 代码点序列。还有一个独立的 bytes 类型用于处理原始字节。

编码与解码

  • 解码:将字节序列 (bytes) 根据特定编码转换为字符串 (str)。bytes_obj.decode(‘utf-8’)
  • 编码:将字符串 (str) 根据特定编码转换为字节序列 (bytes)。str_obj.encode(‘utf-8’)

黄金法则:在程序的边界(如读写文件、网络通信)进行编码/解码。在程序内部,始终使用 str 对象进行处理。

Python 标准库提供了强大的 Unicode 工具:

  • unicodedata 模块:查询字符名称、类别等属性。
    import unicodedata
    print(unicodedata.name(‘A’)) # 输出:LATIN CAPITAL LETTER A
    print(unicodedata.category(‘9’)) # 输出:Nd (数字,十进制)
    
  • str 方法:.isalpha(), .isdigit() 等都是基于 Unicode 属性的。
  • 规范化:unicodedata.normalize(form, unistr)
    from unicodedata import normalize
    combined = ‘é’ # U+00E9
    decomposed = normalize(‘NFD’, combined) # 得到 ‘e\u0301’
    

注意:Python 字符串按代码点索引和切片。对于由多个代码点组成的字位簇(如 ‘👨👩👧👦’),直接 len() 或切片可能会得到意外结果。此时可以使用第三方库(如 regex)来按字位簇处理。


常见陷阱与最佳实践

上一节我们介绍了 Python 提供的工具,本节中我们总结一些常见的陷阱和应对的最佳实践。

  1. 文件系统路径:在某些系统上,文件名可能是无效的字节序列。Python 会尽力处理(使用“代理转义”机制),但最安全的方式是尽早知晓系统的文件系统编码。
  2. 网络数据:始终明确指定编码。HTTP 头、数据库连接等都应设置正确的字符集(通常是 UTF-8)。
  3. 正则表达式:默认的 \d, \w, \s 等匹配的是 Unicode 字符类别,而不仅仅是 ASCII 范围。如果需要限制,请明确写出,例如 [0-9] 匹配 ASCII 数字,\d 匹配任何语言的数字字符。
  4. 比较与排序:简单的 == 比较可能因为规范化形式不同而失败。在比较前考虑是否需要先进行规范化。对于标识符(用户名、变量名)的比较,可能需要更复杂的规则(如 Unicode 技术报告 #36)。
  5. 安全考虑:警惕视觉混淆字符(如 Cyrillic 字母 ‘а’ 看起来像拉丁字母 ‘a’)。有第三方库(如 confusable-homoglyphs)可以帮助检测。

总结

在本教程中,我们一起学习了 Unicode 的完整图景:

  1. 起源与目标:Unicode 是为了解决多种编码并存的问题而生的统一字符标准。
  2. 核心概念:理解了代码点编码(如 UTF-8)、代码单元字位簇的区别,这是驾驭 Unicode 的基础。
  3. 复杂性来源:认识了组合字符规范化大小写映射可变宽度编码带来的挑战。
  4. Python 3 的支持:Python 3 将 str 定义为 Unicode 代码点序列,并通过 encode/decodeunicodedata 模块等提供了强大支持。
  5. 黄金法则与最佳实践:在程序边界处理编码/解码,内部使用 str;注意文件系统和网络数据的编码;谨慎使用正则表达式和进行字符串比较。

记住,Unicode 的复杂性源于人类书写系统的丰富性。Python 提供了良好的工具来管理这种复杂性。理解这些原理后,你就能更有信心地编写出能够正确处理全球文本的应用程序。不必再害怕 Unicode!

045:与哈维尔·豪尔赫·卡诺对话

概述

在本教程中,我们将学习自动语音识别的基本概念、核心组件和开发流程。我们将了解如何获取和处理数据,如何构建声学模型和语言模型,以及如何将它们结合起来进行语音识别和系统评估。


语音识别简介:为何重要?

语音识别,或称自动语音识别,其目标是将语音信号转换为文本。虽然市面上已有许多成熟的语音助手,但该领域仍面临诸多挑战。

语言多样性是主要挑战之一。全球有数千种语言,但主流语音服务仅支持其中一小部分。许多语言缺乏相应的语音识别工具,这影响了文化的传承与多样性。

技术层面也存在挑战。例如,在教育内容场景中,系统需要处理复杂的专业词汇、不同的硬件录音条件、多人对话以及多语言混合的情况。

此外,语音识别是更宏大语言技术图景的一部分。更好的ASR系统可以提升语音翻译、自动字幕生成等下游任务的性能。


构建ASR系统:所需资源

上一节我们介绍了语音识别的意义与挑战,本节中我们来看看构建一个ASR系统需要哪些基础资源。

构建ASR系统主要需要三类资源:

  1. 音频文件:包含语音信号的原始数据。
  2. 转录文本:与音频文件对应的、准确的文字内容。
  3. 词典:为所考虑的单词提供音素级别的语音转录。

以下是获取这些开放资源的途径:

  • 语音命令数据集:来自谷歌,适合初学者入门,任务是识别孤立的单词或短语。
  • LibriSpeech:包含大量朗读语音,适合进行更复杂的句子级识别任务。
  • Common Voice项目:由Mozilla发起,涵盖多达40种不同的语言,是探索多语言ASR的宝贵资源。

数据准备与特征提取

既然我们知道了在哪里可以找到资源,接下来我们需要学习如何准备这些数据以供模型使用。

数据准备步骤包括下载数据、进行质量控制(检查音频质量与文本格式),以及对原始数据执行必要的转换。

以下是执行数据准备可以使用的工具:

  • 音频转换:可以使用 pydubsox 库。
  • 文本规范化:可以直接使用Python字符串处理,或使用 NLTK 进行更复杂的操作。
  • 词典获取:如果数据集中未提供,可以使用 LTK 等工具获取额外的语言资源。

原始音频波形数据包含的信息不够丰富,模型难以直接理解。因此,我们需要进行特征提取,将数据转换为更有意义的表示,例如从时域转换到频域。

一个常用的特征是梅尔频率倒谱系数。特征提取过程将原始音频转换为一系列特征向量(或称“帧”),这些向量能更好地反映声音的模式。

# 特征提取示例(概念性代码)
# 输入:原始音频波形
# 过程:通过预加重、分帧、加窗、FFT、梅尔滤波、DCT等步骤
# 输出:MFCC特征向量序列
mfcc_vectors = extract_mfcc(audio_signal)
# 输出形状可能为 (98, 13),表示98帧,每帧13维MFCC特征

声学模型:从声音到音素

我们已经讨论了资源及其转换方法,现在进入模型构建环节。首先从声学模型开始。

声学模型负责将声音特征映射到音素或单词。考虑到同一单词的发音存在时间上的变化,我们需要一个能建模时序的模型。

隐马尔可夫模型是传统ASR中常用的声学模型。HMM将语音产生过程模拟为一个有限状态机,包含状态、状态间的转移以及从状态发射出观测特征(如MFCC向量)的概率。

一个音素可以用一个三状态的HMM来建模,分别对应音素的开始、中间和结束部分。这些HMM可以串联起来形成单词的模型。

发射概率最初可以用高斯混合模型来建模,但当前最先进的方法是使用深度神经网络与HMM结合的混合系统。

# HMM训练示例(概念性代码)
# 定义HMM参数:状态数、转移概率、发射概率分布(如GMM)
hmm_model = initialize_hmm(num_states=3, gmm_components=16)
# 使用提取的特征和对应的音素标注进行训练
trained_hmm = train_hmm(hmm_model, mfcc_vectors, phone_labels)

语言模型:从单词到句子

上一节我们介绍了如何用声学模型识别音素和单词,本节我们来看看如何将这些单词组织成合乎语法的句子,这就需要语言模型。

语言模型的核心是为一个词序列分配概率,即评估一个句子在语言中出现的可能性。它根据上文来预测下一个词出现的概率。

最经典的语言模型是 n-gram模型,它基于前n-1个词来预测第n个词。更先进的方法则使用循环神经网络Transformer等神经网络,将上文编码为向量后再进行预测。

语言模型还可以被表示为一个图结构,其中节点代表上下文,边代表单词及其概率,这便于与声学模型结合进行解码。

# 语言模型评估示例(概念性代码)
# 训练一个n-gram模型
lm = train_ngram_lm(text_corpus, n=3)
# 计算一个句子的概率
sentence_prob = lm.score("the black dog")
# 评估模型性能常用“困惑度”,越低越好
perplexity = lm.perplexity(test_sentences)

解码:结合声学与语言模型

我们已经看到了建模部分的两个主要组件,现在进入识别步骤的核心:解码。解码的目标是将声学模型和语言模型结合起来,找到与输入语音最匹配的文本序列。

具体方法是构建一个加权有限状态换能器(WFST)搜索图。这个图综合了以下信息:

  1. 声学模型(HMM状态级)
  2. 词典(单词到音素的映射)
  3. 语言模型(单词级概率)

解码过程就是在该巨大的搜索图中,为输入的特征向量序列找到一条最优路径(累积得分最高)。路径得分由两部分构成:

  • 声学得分:特征向量与HMM状态发射概率的匹配程度。
  • 语言模型得分:单词序列的流畅度概率。

由于搜索空间巨大,我们使用束搜索算法进行近似最优解码,它仅保留每个时间步得分最高的若干条路径,从而大幅减少计算量。

# 解码识别示例(概念性代码)
# 构建解码图(整合声学模型、词典、语言模型)
decoding_graph = build_wfst(acoustic_model, lexicon, language_model)
# 对输入特征进行束搜索解码
best_hypothesis, alignment = beam_search_decode(decoding_graph, mfcc_vectors, beam_width=100)
print(f"识别结果: {best_hypothesis}")

系统评估与前沿方向

最后,我们需要评估ASR系统的性能。主要的评估指标有两个:

  1. 词错误率:这是最核心的指标。通过计算识别结果与标准答案之间的编辑距离(考虑插入、删除、替换操作)得出。

    • 公式WER = (S + D + I) / N * 100%
    • 其中 S=替换数,D=删除数,I=插入数,N=参考文本总词数。
    • 例如,WER为15%表示每100个词中约有15个错误。通常WER低于20%的系统被认为可用。
  2. 实时因子:衡量系统效率,指处理一段音频所需时间与该音频时长的比值。RTF小于1表示能实时处理。

当前ASR领域的前沿方向包括:

  • 端到端模型:如基于序列到序列的模型,它试图将声学模型、发音模型和语言模型整合到一个神经网络中统一训练。
  • 自适应技术:使系统能适应特定的说话人或领域。
  • 无监督/自监督学习:利用大量未标注语音数据提升模型性能。

总结

在本教程中,我们一起学习了自动语音识别的基础知识。我们了解了ASR的应用价值与现存挑战,熟悉了构建ASR系统所需的音频、文本和词典资源。我们深入探讨了系统的两大核心组件:声学模型(将声音映射为音素)和语言模型(约束单词组成合理句子),并学习了如何通过解码过程将它们结合,以搜索出最优的识别文本。最后,我们掌握了用词错误率实时因子评估系统性能的方法,并简要了解了该领域的端到端模型等前沿方向。希望这篇教程能帮助你建立起对语音识别技术的基本认识。

046:构建阴阳牧场分布式计算机视觉管道 🚀

在本课程中,我们将学习如何利用Python构建一个分布式计算机视觉系统,用于管理一个小型农场。我们将从整体设计开始,逐步深入到各个组件,包括使用树莓派作为图像采集节点、利用ZMQ进行图像传输、在集线器上处理图像与事件,以及如何响应查询。课程内容力求简单直白,适合初学者理解。


概述

本节课我们将要学习一个名为“阴阳牧场”的分布式计算机视觉系统的设计与实现。该系统旨在帮助管理一个小型永续农场,通过分布在农场各处的树莓派节点采集图像,并利用计算机视觉技术监控水表、追踪动物、管理光照等,从而实现更可持续的农场管理。


项目背景与动机 🌱

上一节我们介绍了课程的整体目标,本节中我们来看看这个项目的起源和核心理念。

我退休后,一直致力于将我的两英亩郊区土地改造为一个小型永续农场,我称之为“阴阳牧场”。永续农业是一系列旨在实现更可持续生活的实践集合。其核心思想是模仿自然生态系统:一个自给自足的系统,其能量输入与输出平衡,产生的废物极少,且主要依靠太阳能运作。这正是我希望在我的农场中效仿的模式。

为了实现这一目标,我引入了计算机视觉技术来辅助管理。


计算机视觉在农场中的应用 👁️

上一节我们了解了项目的初衷,本节中我们来看看计算机视觉具体能做什么。

在我的农场中,分布式计算机视觉管道帮助我完成了多项任务:

  • 它可以读取水表,帮助我高效用水。
  • 它可以统计蜜蜂、蝴蝶等授粉昆虫的数量。
  • 它可以追踪郊狼、兔子等动物的活动。
  • 它可以监控日常事务,例如车库门是否关闭,或车道是否被车辆阻挡。
  • 它可以追踪日照时长和光照强度,以了解光合作用情况。
  • 它还可以整合许多非摄像头传感器,如温度、湿度、运动传感器和太阳能电池板输出数据。

系统架构概览 🏗️

上一节我们列举了系统的功能,本节中我们来俯瞰整个系统的架构设计。

整个系统的核心是一个分布式计算机视觉管道。所谓“管道”,指的是一系列按顺序执行的计算机视觉程序。而“分布式”意味着这些程序运行在网络中多台不同的计算机上。

以下是系统的主要组成部分:

  1. 图像节点:由树莓派担任,配备树莓派相机,负责采集图像并进行初步分析。
  2. 图像集线器:通常运行在Mac或Linux电脑上,负责接收并存储来自各个节点的图像和事件消息。
  3. 查询与响应系统:基于集线器收集的数据,回答用户查询,或通过仪表板、短信等方式通知用户。

图像节点每秒可能采集多达16帧图像,但它们不会持续发送所有帧。相反,它们只发送被检测器软件判定为“重要”的图像,这大大减少了网络负载。


技术栈与工具 🛠️

上一节我们介绍了系统的三大组成部分,本节中我们来看看构建这个系统所需的具体技术和工具。

我主要使用以下工具集:

  • 编程语言Python 3
  • 硬件平台
    • Raspberry Pi(运行Raspbian/Linux)作为图像节点。
    • Mac OSLinux 笔记本电脑作为图像集线器。
  • 核心库
    • OpenCV:一个包含超过2500种计算机视觉算法的庞大库,是大多数处理工作的基础。其图像本质上是NumPy数组,便于处理。
    • ZeroMQ (ZMQ)及其Python绑定:用于在计算机之间高效传输图像和消息的通信库。
    • imutils:一个包含便利函数的辅助库。
  • 硬件相关
    • Raspberry Pi Camera Module:性能优异的专用相机模块。
    • 各种电子元件(如MOSFET、温度传感器),通过树莓派的GPIO板进行控制。

节点与集线器的工作流程 🔄

上一节我们准备好了工具,本节中我们来深入看看图像节点和集线器内部是如何协作的。

图像节点(树莓派)的伪代码逻辑

图像节点运行一个持续的事件循环,其核心逻辑如下:

while True:
    图像 = 从相机捕获一帧()
    将图像放入处理队列()
    检测结果 = 运行检测器分析队列()
    if 检测到重要事件:
        发送事件消息()
        发送相关图像(2-3帧)()
        等待集线器响应()
    处理集线器响应()

节点上的检测器负责判断何时发生了需要关注的事件(例如水表开始转动、动物出现),并只在这些时刻发送图像。

图像集线器的工作

集线器的任务相对简单:

while True:
    名称, 图像 = 监听并接收来自节点的消息()
    存储消息和图像()
    发送确认回复(“收到”)()

集线器专注于可靠地接收和存储数据,复杂的图像识别和分析可以交给其他更强大的计算机(如图书馆员程序)来完成。


代码示例:基础图像传输

上一节我们理清了逻辑,本节中我们通过一个简单的代码示例,看看图像如何从树莓派发送到集线器。

以下是运行在树莓派(发送方)上的节点代码核心部分:

import socket
import imutils
from imutils.video import VideoStream
import imagezmq

# 实例化图像发送者,指定集线器地址
sender = imagezmq.ImageSender(connect_to='tcp://192.168.1.100:5555')
# 获取树莓派主机名,用作标识
rpi_name = socket.gethostname()
# 初始化视频流
video_stream = VideoStream(usePiCamera=True).start()

while True:
    # 捕获图像
    image = video_stream.read()
    # 发送图像到集线器
    sender.send_image(rpi_name, image)

以下是运行在集线器(接收方,如Mac电脑)上的代码:

import cv2
import imagezmq

# 实例化图像集线器
image_hub = imagezmq.ImageHub()
while True:
    # 接收图像和发送者名称
    rpi_name, image = image_hub.recv_image()
    # 显示图像
    cv2.imshow(rpi_name, image)
    cv2.waitKey(1)
    # 发送回复确认
    image_hub.send_reply(b‘OK’)

这个例子展示了8个树莓派持续向一台Mac发送图像,Mac能够同时显示这8个视频流,每秒处理10-12帧。


实战案例:智能水表监控 💧

上一节我们看了一个基础的传输示例,本节中我们通过一个完整的实战案例——智能水表监控,来理解分布式管道的优势。

这个系统能自动读取地下水管的水表读数。

节点端(树莓派)

  1. 树莓派打开LED灯照亮水表。
  2. 以每秒16帧的速度持续捕获图像。
  3. 对图像进行处理:转换为灰度图、进行阈值处理,以识别表盘指针。
  4. 检测指针或漏水指示器是否在运动。
  5. 关键:只有当检测到运动状态变化(如开始流水或停止)时,才触发事件,并向集线器发送几帧图像,而不是持续发送。

集线器端(Mac)

  1. 接收并确认图像。
  2. 将事件消息添加到日志。
  3. 存储和索引图像。
  4. 运行独立的“图书馆员”程序,保存事件历史并响应用户查询(例如“水还在流吗?”)。

这种设计的好处是网络负载极低。水表大部分时间是静止的,节点只在状态变化的瞬间发送少量图像,就完成了监控任务。复杂的数字识别工作则由集线器端的Mac来完成。


为什么选择分布式架构? 🤔

上一节我们通过水表案例看到了分布式的好处,本节中我们来总结一下分布式架构的设计哲学。

专业化是关键。我们应该让合适的计算机做它最擅长的事:

  • 树莓派的优势:价格低廉、可靠、无风扇(防尘)、功耗极低、停电后恢复快。树莓派相机模块功能强大且灵活。它非常适合部署在野外(如谷仓),负责图像采集和初步检测,并快速将结果发送到网络。
  • 树莓派的局限:SD卡频繁写入大文件不可靠且慢;内存和处理能力有限;USB和以太网速度较慢。不适合运行复杂的识别模型。
  • Mac/Linux电脑的优势:强大的无线和有线网络连接;快速的SSD硬盘;强大的计算能力,能高效运行复杂的图像识别算法。
  • Mac/Linux电脑的局限:功耗高(一台Mac的耗电量相当于10-12个树莓派);昂贵;不适合部署在恶劣环境中。

因此,分布式计算机视觉管道的精髓在于:让管道中的每台计算机都做它最适合的工作,从而构建一个高效、可靠且经济的整体系统。


深入节点代码与ZMQ通信 📡

上一节我们理解了架构分工,本节中我们更深入地看看节点代码的细节以及核心的通信机制ZMQ。

图像节点代码结构

节点的主程序是一个强化版的事件循环:

# 初始化图像节点(包含相机、检测器、传感器等设置)
image_node = ImageNode(config=‘settings.yaml’)
while True:
    # 1. 获取传感器数据(温度、光照等)
    sensor_data = image_node.read_sensors()
    # 2. 处理数据,检测事件(运动、光线变化等)
    events = image_node.detect(sensor_data)
    # 3. 将事件放入发送队列
    image_node.enqueue_events(events)
    # 4. 尝试发送队列中的消息,并等待集线器确认
    try:
        image_node.send_messages()
    except TimeoutError:
        # 如果超时,尝试修复连接
        image_node.reconnect()

使用YAML配置文件来管理大量设置(如检测器类型、相机参数)非常方便,它可以很容易地被读入Python变成字典。

关于ZeroMQ (ZMQ)

ZMQ是本项目选择的通信库,它有以下优点:

  • 无代理:纯点对点通信,无需额外的消息代理服务器。
  • 出色的并发管理:非常适合管理来自多个树莓派的并发连接。
  • 灵活的模式:本项目使用请求-回复模式,但ZMQ支持其他多种模式。
  • imagezmq:我基于ZMQ开发了imagezmq这个Python库,它封装了通过ZMQ发送图像(OpenCV / NumPy数组)的细节,甚至可以自动压缩为JPEG以节省带宽。它已经过多年生产环境测试,可以通过pip install imagezmq安装。

另一个案例:谷仓动物监控 🐺

上一节我们探讨了节点和通信细节,本节中我们再看一个案例,看看系统在复杂场景下的应用。

我在谷仓后设置了一个节点,用于监控野生动物(如郊狼、山猫)。

  • 节点:使用树莓派和黑皮红外相机,配合12伏红外照明灯。
  • 工作流程:树莓派每天可能捕获百万张图像,但通过运动检测,它每天只发送300-500帧真正有动物活动的图像到集线器。
  • 分布式检测:初步运动检测在树莓派上完成,而更复杂的物体识别(区分是郊狼还是山猫)则在集线器端的Mac电脑上运行。识别结果(例如“发现郊狼,置信度63%”)可以通过短信通知我。

这个案例再次体现了分布式管道的核心价值:在源头过滤数据,只传输有价值的信息,从而最大化利用网络和计算资源。


经验总结与技巧分享 💡

在本课程的结尾,我们一起来总结在构建此类系统中获得的一些重要经验和实用技巧。

  1. 优化顺序:先实验和观察,再进行优化。不要过早陷入参数调优。
  2. 图像优化
    • 调整计算机视觉参数(如阈值、图像大小)对结果影响巨大。
    • 发送能完成任务的最小尺寸图像。许多物体检测器只需要300像素或更小的图像。
    • 将图像压缩为JPEG可以显著减少网络传输量。
  3. 硬件技巧
    • 防水:梅森罐是便宜好用的防水外壳。旧的屋顶瓦片也能提供保护。
    • 供电:对于较长距离,使用12伏供电比5伏更稳定,然后使用廉价的“车充”适配器转换为5伏给树莓派供电。
    • 红外照明:可能需要多个红外泛光灯才能获得理想效果。
    • GPIO保护:了解并使用MOSFET管来控制大电流设备,避免烧坏树莓派的GPIO引脚。

未来展望与资源链接 🔮

本节课我们一起学习了如何构建一个用于农场管理的分布式计算机视觉管道。从设计理念、架构分工,到具体的代码实现和实战案例,我们看到了如何让简单的树莓派和强大的电脑各司其职,协同工作。

我的下一个目标是让系统能告诉我花园的哪部分需要浇水,通过对比植物浇水前后的图像来实现。

本项目所有代码都是开源的,你可以通过以下资源深入了解和复现:

  • 项目仓库:Github上的“Yin Yang Ranch”相关仓库。
  • 核心库imagezmq 库 (pip install imagezmq)。
  • 学习资源:OpenCV官方教程、ZMQ指南、永续农业实践资料。

如果你有任何问题,欢迎在项目仓库中提出。感谢你的学习,也感谢所有让知识得以分享的社区贡献者。

047:用Python解决纽约停车问题 🚗💻

在本节课中,我们将学习如何利用Python、Twitter API和Twilio服务,构建一个自动化系统来解决日常生活中的实际问题——纽约市的街道清洁停车限制问题。我们将通过获取特定Twitter账户的推文,分析内容,并在特定条件下自动发送短信通知。


概述

本教程将引导你构建一个自动化脚本。该脚本会每天检查纽约市官方停车信息Twitter账户的推文。如果推文中包含“暂停”和“明天”等关键词,意味着第二天没有街道清洁限制,脚本就会通过Twilio服务向你发送一条“今晚无需挪车”的短信。这样,你就不必每晚都进行挪车的例行公事。


准备工作 🛠️

在开始编写代码之前,你需要准备一些必要的账户和工具。以下是构建此解决方案所需的步骤:

  1. 创建Twitter开发者账户和应用:访问 developer.twitter.com 申请API访问权限。你需要创建一个应用来获取API密钥和访问令牌。
  2. 加入Twitter开发者实验室:为了使用“最近搜索”端点获取过去7天的推文,你需要注册Twitter开发者实验室的预览功能。
  3. 注册Twilio账户:访问 Twilio官网 注册并获取一个电话号码以及账户SID和认证令牌。
  4. 安装必要的Python库:在终端中运行以下命令来安装所需的库。
    pip install twilio pandas requests
    

第一步:连接Twilio API

上一节我们介绍了项目所需的准备工作,本节中我们来看看如何与Twilio API建立连接,以便后续发送短信。

我们将创建一个名为 twilio_connect_demo.py 的脚本。这个脚本的核心功能是安全地连接到Twilio服务。

import os
from twilio.rest import Client

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/6c12007f20d8318e4d002998fe914b45_9.png)

def twilio_connect():
    """
    此函数用于安全地连接到Twilio API。
    它从环境变量中读取敏感信息,避免在代码中硬编码。
    """
    account_sid = os.environ.get('TWILIO_ACCOUNT_SID')
    auth_token = os.environ.get('TWILIO_AUTH_TOKEN')
    client = Client(account_sid, auth_token)
    return client

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/6c12007f20d8318e4d002998fe914b45_11.png)

def send_message(client):
    """
    此函数使用已连接的Twilio客户端发送一条短信。
    """
    message = client.messages.create(
        from_=os.environ.get('TWILIO_PHONE_NUMBER'),
        to=os.environ.get('MY_PHONE_NUMBER'),
        body="你今晚不用移动你的车。好好享受你的夜晚!"
    )
    print(f"消息已发送,SID: {message.sid}")

关键点说明

  • 环境变量:我们使用 os.environ.get() 来获取 TWILIO_ACCOUNT_SIDTWILIO_AUTH_TOKENTWILIO_PHONE_NUMBERMY_PHONE_NUMBER。你需要在运行脚本前在终端中设置这些变量,例如:
    export TWILIO_ACCOUNT_SID='你的账户SID'
    export TWILIO_AUTH_TOKEN='你的认证令牌'
    
  • Client对象twilio.rest.Client 是Twilio库的核心,所有API调用都通过它进行。

第二步:获取并分析Twitter数据

现在我们已经能够发送短信了,接下来需要获取数据源。本节我们将学习如何从Twitter API获取推文并进行内容分析。

我们将在Jupyter Notebook中完成这部分工作,以便于交互式地查看和处理数据。

import pandas as pd
import yaml
import json
import requests
from twilio_connect_demo import twilio_connect, send_message

# 1. 定义目标Twitter账号的API端点
handle = ‘nycasp’
url = f‘https://api.twitter.com/2/tweets/search/recent?query=from:{handle}’
print(f‘目标URL: {url}’)

# 2. 加载包含Twitter API密钥的配置文件 (务必将其加入.gitignore)
with open(‘config.yaml’, ‘r’) as file:
    data = yaml.safe_load(file)

# 从配置中提取Bearer Token
bearer_token = data[‘search_tweets_api’][‘bearer_token’]

# 3. 设置API请求的认证头
headers = {“Authorization”: f“Bearer {bearer_token}”}

# 4. 向Twitter API发送请求
response = requests.get(url, headers=headers)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/6c12007f20d8318e4d002998fe914b45_17.png)

# 5. 检查请求是否成功,并解析返回的JSON数据
if response.status_code == 200:
    json_data = response.json()
    print(“成功获取推文数据。”)
    # 提取‘data’部分,其中包含实际的推文列表
    tweets_data = json_data.get(‘data’, [])
else:
    print(f“请求失败,状态码: {response.status_code}”)

# 6. 将推文数据转换为Pandas DataFrame以便于查看和分析
df = pd.DataFrame(tweets_data)
print(df[[‘id’, ‘text’]].head()) # 查看前几条推文的ID和内容

关键点说明

  • API端点:我们使用Twitter API v2的 recent search 端点,并通过 query=from:{handle} 参数指定只获取来自 @nycasp 账号的推文。
  • Bearer Token认证:这是一种简单的认证方式,只需在请求头中附带令牌即可。
  • 数据处理:使用Pandas将JSON数据转换为表格形式的DataFrame,使得数据浏览和分析更加直观。

第三步:整合逻辑与发送通知

我们已经能够获取推文并连接短信服务,本节我们将把这两部分结合起来,实现核心的业务逻辑:分析推文内容并决定是否发送通知。

以下是整合后的核心逻辑代码块:

# 连接到Twilio服务
client = twilio_connect()

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/6c12007f20d8318e4d002998fe914b45_21.png)

# 假设我们从DataFrame中获取最新一条推文的文本
latest_tweet_text = df.iloc[0][‘text’] # 获取第一条(最新)推文的文本

# 核心逻辑:检查推文中是否同时包含“暂停”和“明天”这两个关键词
if ‘暂停’ in latest_tweet_text and ‘明天’ in latest_tweet_text:
    print(“条件符合!准备发送短信...”)
    send_message(client) # 调用之前定义的函数发送短信
    print(“短信已发送:今晚无需挪车。”)
else:
    print(“条件不符合。今天没有‘暂停’通知。”)
    print(“推文内容为:”, latest_tweet_text)

逻辑流程

  1. 脚本首先调用 twilio_connect() 函数建立与Twilio服务的连接。
  2. 然后,它检查从Twitter获取的最新一条推文。
  3. 使用一个简单的条件语句判断推文文本中是否同时存在“暂停”和“明天”这两个词。
  4. 如果条件满足,则调用 send_message(client) 函数,你会收到一条告知无需挪车的短信。
  5. 如果条件不满足,则只在控制台输出提示信息。

第四步:部署与自动化运行

一个本地运行的脚本还不够,我们需要让它每天自动执行。上一节我们完成了核心逻辑,本节我们来探讨如何让这个脚本持续自动运行。

你可以选择多种部署方式:

  • 云服务器(如DigitalOcean):在服务器上设置一个Cron任务,让脚本在特定时间(例如每天下午5点)自动运行。
    # 示例Cron任务,每天下午5点运行你的Python脚本
    0 17 * * * /usr/bin/python3 /path/to/your/parking_script.py
    
  • 无服务器函数(如AWS Lambda):将代码部署到Lambda,并配置CloudWatch Events定时触发,这样你无需管理服务器。
  • 其他云平台:Heroku、Google Cloud Functions等也提供类似的定时任务功能。

选择哪种方式取决于你的熟悉程度和项目需求。无服务器方案通常更简单且成本更低。


总结与启发

本节课中我们一起学习了如何利用Python构建一个实用的自动化解决方案。我们从定义问题(避免不必要的挪车)开始,逐步完成了获取数据(Twitter API)、处理数据(关键词分析)、执行动作(Twilio发送短信)以及部署自动化的全过程。

这个项目的核心价值在于它展示了编程如何直接解决个人生活中的痛点。它不复杂,但非常实用。现在,轮到你了:

请思考你日常生活中是否有可以通过类似自动化思路解决的问题? 也许是追踪快递信息、监控商品价格、自动备份文件,或者收集感兴趣的新闻。动手尝试构建你自己的解决方案吧!

如果你在构建过程中有任何问题,或者基于此项目做出了有趣的东西,欢迎通过Twitter [@jessicagarson] 与我分享。


:本教程所有代码均可在GitHub仓库中找到:github.com/twitterdev/parking。相关博客文章和更多资源也链接在该仓库中。

048:使用 Doctest 进行开发与测试 🐍

在本教程中,我们将学习什么是 Doctest,为什么它很有用,以及如何使用 xdoctest 模块来更轻松地编写和运行 Doctest。我们将从基本概念开始,逐步深入到实际应用和高级技巧。


什么是 Doctest? 🤔

如果你熟悉 Python,你可能知道如何编写一个函数。如果你将一个字符串直接放在函数定义之后,这就是一个文档字符串(docstring)。在文档字符串中,你可以放置任何你想要的文档。一个非常有用的做法是,在其中演示如何使用你编写的函数。

你可以在文档字符串中输入一些代码。如果在代码前加上三个大于号 >>>,那么这段代码就成为了一个 Doctest。例如,我们创建一个演示输入,展示如何将这些输入传递给函数本身,并断言输出看起来是合理的。

核心概念:Doctest 是嵌入在文档字符串中的可执行代码示例,用于验证代码行为。

def example_function(x):
    """
    这是一个示例函数。
    >>> example_function(2)
    4
    """
    return x * 2

如果能提取此代码并在持续集成(CI)套件中进行测试,不仅可以增加测试覆盖率,还能更有信心地确保你写的代码是正确的。


如何将 Doctest 作为开发示例? 🔧

每当我开始编写一个新类时,在编写任何功能之前,我喜欢创建一个名为 demo 的类方法。基本想法是创建一个输入示例,执行感兴趣的操作。我会在 Python 交互式环境(如 IPython)中创建这些输入的实例,并开始与它们交互,最终聚焦在我感兴趣的功能上。

但这并不是我在交互式环境中做的唯一事情。我也会编写检查,确保事情按我期望的那样进行。与其退出 IPython 让所有的检查白白浪费,为什么不把这些也复制到源文件中呢?有什么比把它们放在 Doctest 里更好的地方呢?

这意味着,作为开发周期的自然副产品,你不仅编写了感兴趣的功能,还编写了它的测试。这个关键的测试是与代码紧密耦合的。如果你必须重构模块,测试总是伴随着它。

此外,当你开发 Doctest 时,你在任何地方都有入口点。Doctest 总是允许你创建所需的输入,因此可以逐行地遍历一段代码,而无需任何其他先决条件。如果有一个长时间运行的代码堆栈可能会出错,那么这是非常有用的。你可以在具有 Doctest 的函数中重现该错误,并可能修复它,而不必重新运行那个长时间运行的堆栈太多次。

现在我们已经了解了什么是 Doctest 以及如何开发 Doctest,让我们具体谈谈如何编写 Doctest,以及最重要的是,如何首先运行它们。


如何编写和运行 Doctest? ✍️

要编写一个 Doctest,你只需要有一个函数。在函数的文档字符串中,在测试代码前加上三个大于号 >>> 和一个空格,然后将这个文本块插入到函数文档字符串中。除了小的例外,这基本上就是编写 Doctest 所需要做的一切。

然而,要运行一个 Doctest,这有点棘手。让我们通过几个案例研究来了解为什么会这样。

案例研究 1:段落处理函数

这个函数的概念是接收一段文字,去掉所有多余的空行和空格,然后返回输出。

为了测试这个,我们创建一些输入文本,用三个引号包围。这意味着文本将充满额外的换行符和空格。我们通过函数处理这篇文章,得到输出,然后断言在原始文本中有一个换行符,但输出文本中没有换行符。这似乎是一个合理的测试。

让我们把它创建为一个 Doctest。我们创建一个文件 talk.py,把函数放进去,然后把 Doctest 插入到文档字符串中,以适当的三个大于号作为前缀。

def paragraph(text):
    """
    处理段落文本,移除多余空行和首尾空格。
    >>> input_text = \"\"\"
    ...     这是一段
    ...     有很多空行
    ...     和空格的文本。
    ... \"\"\"
    >>> output = paragraph(input_text)
    >>> '\\n' in input_text
    True
    >>> '\\n' in output
    False
    """
    # 函数实现...
    lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
    return ' '.join(lines)

让我们使用 Python 内置的 doctest 模块来运行这个测试。我们可以用 python -m doctest 命令,后面跟上文件名。

python -m doctest talk.py

我们可能会得到一个错误:SyntaxError: unexpected EOF while parsing。实际上,内置的 doctest 模块无法优雅地处理多行字符串语句。我们可以通过在多行语句之后取所有额外的行,并将前三个单引号替换为 ... 来解决这个问题。

如果我们用这个新编辑的测试重新运行 Doctest,我们看到它起作用了。我们必须告诉 Python 如何解析自己的代码,这有点烦人,但我们做到了。

案例研究 2:检查可迭代对象元素是否相同

这个函数的想法是接收一个可迭代对象,确定迭代中的所有项是否相同。

我们将使用 Doctest 的一个功能:如果执行一个函数,它返回一个值,你可以在下一行给出期望得到的值的字符串表示形式。这叫做“欲擒故纵”(原文为“want”):你通过调用函数得到了一些东西,想要的东西在这里传递一个字符串。

def all_equal(iterable):
    """
    检查可迭代对象中所有元素是否相同。
    >>> all_equal([1, 1, 1])
    True
    >>> all_equal([])
    True
    >>> all_equal([1, 2, 3])
    False
    >>> items = [1, 2, 3]
    >>> first = items.pop(0)
    >>> all_equal(items)  # 现在 items 是 [2, 3]
    False
    """
    it = iter(iterable)
    try:
        first = next(it)
    except StopIteration:
        return True
    return all(first == x for x in it)

让我们做这个 Doctest,把它放在 talk.py 文件中,使用内置的 Python doctest 模块运行。

python -m doctest talk.py

没有多行语句,所以这似乎又是正确的。但我们可能会在 next(iterable) 这一行失败。我们没有期待什么,但一无所获。如果你还记得,我们弹出那个可迭代的第一个元素,因为我们想测试迭代。我们没有给它期望的东西,因为没有给它任何值。

我们发现,内置的 doctest 模块会出错,因为它迫使你为任何输出输入期望值。我们可以通过在这一行的末尾添加带有 doctest: +ELLIPSIS 的注释来指示省略号,启用 doctest 的省略号特性。这意味着如果我们给它 ...,它将与语句中的任何内容匹配,从而避免我们需要关心这里发生了什么。

如果我们再重播一次,这将很好地工作。但让我们跳过前面,因为运行 Doctest 似乎比它需要的更难。


为什么 Doctest 没有无处不在? 🤨

它们看起来真的很有用,但正如我们所见,它们运行起来有点棘手。你确实经常在野外看到它们,但问题是很多时候,应该提供的代码示例已经不管用了,因为代码写出来后就变了。因为人们不在他们的持续集成服务器上运行他们的文档测试,他们没有捕捉到这些错误。这是有道理的,因为运行 Doctest 是棘手的。

所以,如果有办法减少运行文档测试的麻烦,这就引出了我今天要讲的内容:xdoctest


介绍 Xdoctest 🚀

xdoctest 基本上是一个向后兼容的模块,关键特点是它更宽容,有更长的字符串,它使用静态解析(解析你的 Doctest),与内置的动态解析不同。它有一个增强的运行器,输出消息稍微好一点,它有一个干净的 CLI。但到目前为止,xdoctest 最重要的特点是,它有更简单的 Doctest 语法。

你不用在代码前面加上大于号或点,取决于它是否是多行语句。xdoctest 有一个规则:在所有东西前面放三个大于号,然后就完成了。

这怎么可能?为什么内置的 Python doctest 模块有这样的语法限制,但是 xdoctest 没有?要很好地理解这一点,我们需要快速了解一下形式语言理论。

乔姆斯基层级与 Python 解析

我有一套叫做乔姆斯基层级的东西,它描述了语言的复杂性和表现力。在最底层我们有正则语言,它是最没有表现力的,但最容易解析。往上一层,我们有上下文无关语言,再往上是上下文相关语言,最上面是递归可枚举语言,相当于图灵机。

Python 在乔姆斯基层级中处于什么位置?我们可以把 Python 归类为上下文无关语言。如果你上网查,你很快就会发现 Python 并不是完全上下文无关的,但这主要是由于缩进和作用域。如果你把这个抽象出来,你就会得到一种上下文无关的语言,所以它现在已经足够接近我们的目的了。

Python 有两个我感兴趣的模块:re(正则表达式)模块,它解析正则语言;ast(抽象语法树)模块,它解析 Python 的上下文无关语言。

因此,doctest 模块的问题在于,它试图使用正则表达式来划分 Doctest。现在 Doctest 只是 Python 代码,所以 Python 太复杂了,不能用正则表达式来解析。你不能从数学上做到这一点。

xdoctest 中,我使用了 ast 模块(抽象语法树)而不是正则表达式模块。这就是我如何提取哪些行属于 xdoctest 中的哪些语句。本质上,Doctest 是 Python 代码,我们需要这样对待它们。


使用 Xdoctest 重温案例研究 🔄

现在我们对 xdoctest 有点熟悉了,让我们回顾一下我们的案例研究。

重新运行段落 Doctest

与其使用 doctest 模块,我们将使用 python -m xdoctest

python -m xdoctest talk.py paragraph

xdoctest 允许你指定要运行的 Doctest 的函数名称。我们运行这个,它正常工作,没问题。xdoctest 能够处理缩进和前缀,不管语句是多行还是单行。多行字符串有时有那些大于号前缀是有点烦人的,你可以根据需要把课文写出来,xdoctest 也可以处理这种情况。因为它像解析代码一样解析 Doctest,它不需要知道哪一行属于哪个语句。所以你可以省略多行语句中的大于号,但你不必。

重新运行“检查相同元素” Doctest

如果你还记得,上次 next(iterable) 这一行给我们带来了问题,因为它返回的东西,我们没有告诉它我们期待着什么。让我们在 talk.py 文件上运行 python -m xdoctest,再次测试这个函数。

python -m xdoctest talk.py all_equal

这个也行。xdoctest 默认情况下,如果你不提供任何检查,它假设你不关心检查该值。如果你提供一个期望值,它仍然会检查。但如果你不在乎,它也不在乎了。xdoctest 更灵活。


Xdoctest 的优势与使用 🎯

使用原始的、内置的 doctest 模块运行 Doctest,你只能给它一个文件路径,它将运行它能在文件中找到的所有文档测试。另一方面,xdoctest 你可以向它传递模块名称、模块路径,或者一个特定的函数或类来运行。更确切地说,如果函数或类中有多个 Doctest,你可以使用冒号索引语法来指定要运行的 Doctest。

任何失败的文档测试都将列在底部。它们不仅会被列出,而且会以这样一种方式列出,它给你一个命令,你可以用它来重新执行失败的 Doctest 并进行可能的调试。xdoctest 不仅提供与 Doctest 本身有关的故障发生的行号,还提供文件里的 Doctest 行号。最后,它用颜色给输出上色,让所有的东西都更容易阅读。

xdoctest 有更好的指令。我们讨论了一些存在于内置 doctest 模块中的指令,尤其是 ELLIPSIS 指令。但 xdoctest 也有一些新的指令,例如 SKIP,它的作用是本质上跳过你所需要的行,基于命令行参数有条件地跳过正在执行的行。在这种情况下,这一行将不会运行,除非命令行上存在 --show 参数。

你还可以检查一个模块是否存在并且是可导入的。在本例中,我们检查 numpy 模块是否存在。或者你可以根据操作系统条件执行。你可以查看文档以了解更多可能使用条件的示例。


与 Pytest 集成 🧪

xdoctest 附带一个默认内置的 Pytest 插件。如果你使用 Pytest 并安装了 xdoctest,你可以通过配置告诉 Pytest 禁用其内置的 Doctest 插件,并启用 xdoctest 插件。这将把你所有的 Doctest 添加到你的测试套件中。

当你的 Doctest 有效时,Pytest 会告诉你它找到了多少个测试,以及有多少个测试将运行。然后向你展示将要运行的测试的源代码,然后显示输出,最后它会告诉你通过了多少测试。

当 Doctest 失败时,xdoctest 会打印出要运行多少个测试,打印源代码和输出以及结果是什么。最后它会告诉你哪些测试失败了,并提供一个命令行,你可以将它放入你的 shell 中以重现该测试。


Xdoctest 的局限性与总结 📝

xdoctest 并不完美,有一些局限性。一是它比原始的 doctest 模块稍微慢一点,但那主要是因为使用了抽象语法树而不是正则表达式,这很难避免。但即使这样,它可能还有一些事情可以做得更有效率一点。它并不慢,但是它比内置的测试模块要慢。

另一个限制是,它不是百分之百向后兼容的。大部分都在那里,但有一些指令没有实现。同时我对这些指令的一些默认值进行了调整,使其本质上更加宽容,使文档测试更有可能在没有给程序员带来太多麻烦的情况下运行。

总结

在本节课中,我们一起学习了:

  1. 什么是 Doctest:嵌入在文档字符串中的可执行示例,用于验证代码。
  2. Doctest 的优势:作为开发副产品自然产生测试,与代码紧密耦合,提供随时可用的入口点。
  3. 内置 doctest 模块的局限性:使用正则表达式解析 Python 代码导致语法限制和运行困难。
  4. xdoctest 的解决方案:使用抽象语法树正确解析代码,提供更简单、更宽容的语法,更好的输出和更强的灵活性。
  5. 核心理论:正则表达式用于标记(Token),抽象语法树用于 Python 代码。不要使用正则表达式试图解析 Python 代码,从数学上讲这是不可能的。

原始的 doctest 模块内置到标准库中,它使用正则表达式解析 Python 代码,有一个限制性的语法,输出简洁但难阅读,一次只能运行一个文件。但它背后有巨大的惯性,因为它是标准库的一部分。

另一方面,xdoctest 是一个外部 pip 可安装模块,使用抽象语法树来正确解析 Python 代码,它有一个更宽松的语法,有更好的指令,颜色和更可读的输出,它基本上是向后兼容的。它可以运行单个函数或整个模块。

如果你想为 xdoctest 做贡献,你可以在 GitHub 上找到它。如果你想开始使用,只需运行:

pip install xdoctest

记住:使用正确的工具做正确的事。对于复杂的 Python 代码解析,抽象语法树是你的朋友。

049:使用马尔可夫链和NLTK生成押韵与格律诗 🎭

概述

在本教程中,我们将学习如何利用计算机程序生成诗歌。我们将从人类诗歌的基本特征入手,然后介绍两种核心技术:马尔可夫链用于生成有意义的词序,以及NLTK工具包用于分析单词的发音和韵律。最终,我们将结合这些技术,教计算机创作出既押韵又符合特定格律的诗歌。


人类诗歌的特征

在教计算机写诗之前,我们需要理解诗歌是什么。诗歌是用文字创造的艺术,它不仅关乎文字的意义,也关乎文字的声音、节奏和视觉排列。它旨在唤起美感和情感。

以下是几种常见的诗歌形式,它们都具备一些可被计算机识别的模式。

示例分析

示例一:童谣

有一个孩子戴着兜帽,他很喜欢,很好,上面贴着。

这首诗具有节奏押韵(“兜帽”与“好”押韵)。这是一个简单的起点。

示例二:打油诗

我们都喜欢游戏中的鹅,他最近摇摇晃晃地成名了。一种没有悔恨的虚无主义力量,一只没有怜悯和羞耻的鸟。

打油诗是一种幽默诗歌形式,其特点是特定的押韵模式(AABBA)和节奏。

示例三:莎士比亚十四行诗

我能把你比作夏日吗?你更可爱,更温和,狂风确实会摇晃五月的可爱嫩芽,夏天的租期太短。

莎士比亚的十四行诗有严格的格式:十四行,每行十个音节,采用抑扬格五音步,并有固定的押韵 scheme(如 ABABCDCDEFEFGG)。

共同特征

这些诗歌的共同点是押韵(行尾声音相同)和格律(重读与非重读音节的规律模式)。计算机擅长处理这类模式,这为我们教它写诗奠定了基础。


使用马尔可夫链生成文本

在生成诗歌之前,我们需要先教计算机生成连贯的文本。一种简单有效的方法是马尔可夫链生成

核心概念

马尔可夫链基于一个思想:词序至关重要。例如,“猫坐在墙上”有意义,而“猫墙坐在上”则无意义。因此,我们可以利用现有文本来学习哪些词序是有效的,并用其生成新的文本。

工作原理

我们以一个简短的文本为例,例如披头士乐队的歌词片段:“我是海象,我是送鸡蛋的人,他们是卖鸡蛋的人,我是海象,咕咕。”

以下是生成新句子的步骤:

  1. 随机选择一个起始词,例如 “I”。
  2. 查看源文本中“I”后面出现的所有单词(这里“am”出现了两次)。
  3. 根据后续词出现的频率随机选择下一个词(这里选择“am”)。
  4. 现在以“am”为当前词,重复步骤2和3。查看“am”后面出现的词(“the”出现两次,“a”出现一次),并随机选择。
  5. 持续这个过程,直到生成了所需长度的句子。

通过这个过程,我们可能会生成“我是鸡蛋人”这样的新句子。虽然“我是鸡蛋”和“鸡蛋人”在原文中相邻,但组合成的全新句子“我是鸡蛋人”并未在原文中出现。这就是马尔可夫链的生成能力。

代码实现

我们将使用Python来实现一个简单的马尔可夫链文本生成器。

首先,构建模型(一个字典),记录每个词后面可能跟随的词及其频率。

source_text = "i am the walrus i am the eggman they are the eggmen i am the walrus goo goo g‘joob"
words = source_text.split()

model = {}
for i in range(len(words) - 1):
    current_word = words[i]
    next_word = words[i + 1]
    if current_word not in model:
        model[current_word] = []
    model[current_word].append(next_word)

print(model)
# 输出示例:{'i': ['am', 'am', 'am'], 'am': ['the', 'the', 'the'], ...}

接下来,使用这个模型生成文本。

import random

def generate_text(model, num_words=10):
    if not model:
        return ""
    # 随机选择一个起始词
    current_word = random.choice(list(model.keys()))
    output = [current_word]

    for _ in range(num_words - 1):
        # 如果当前词不在模型中,则停止生成
        if current_word not in model:
            break
        # 从可能的下一个词列表中随机选择一个
        next_word = random.choice(model[current_word])
        output.append(next_word)
        current_word = next_word

    return ' '.join(output)

print(generate_text(model, 8))
# 可能输出:”i am the eggman they are the eggmen“

通过这种方法,我们可以基于任何源文本(如书籍、歌词、推文)生成新的、具有原文风格的句子。结合不同的源文本还能产生有趣的混搭效果。


引入NLTK:分析单词发音

为了生成押韵和符合格律的诗歌,我们需要让计算机理解单词的发音。Python的自然语言工具包(NLTK) 提供了强大的语言学工具,其中包含卡内基梅隆大学发音词典(CMU Pronouncing Dictionary)

使用发音词典

这个词典不仅包含单词的发音,还标明了音节的重音信息。

  • 0 表示非重读音节。
  • 1 表示主重读音节。
  • 2 表示次重读音节。
import nltk
# 需要先下载词典数据:nltk.download(‘cmudict‘)
from nltk.corpus import cmudict

pronouncing_dict = cmudict.dict()

word = “python“
pronunciations = pronouncing_dict.get(word.lower())
print(pronunciations)
# 输出: [[‘P‘, ‘AY1‘, ‘TH‘, ‘AA0‘, ‘N‘], [‘P‘, ‘AY1‘, ‘TH‘, ‘AH0‘, ‘N‘]]
# ‘AY1‘ 中的 ‘1‘ 表示该音节重读。

理解重音

英语单词有固定的重音模式。例如,“separate”作动词时读作 /ˈsep.ə.reɪt/(重音在第一音节),作形容词时读作 /ˈsep.ər.ət/。发音词典会列出所有可能的读音。


生成押韵的诗句

控制马尔可夫链的结尾去押韵很困难,但控制其开头则相对容易。因此,我们的策略是:先找到两个押韵的词作为种子,然后从这两个词开始反向生成诗句,最后将生成结果反转回来

实现步骤

  1. 构建押韵词典:遍历源文本中的所有单词,使用CMU词典获取其发音,提取从最后一个重读音节开始到词尾的部分,作为“押韵音”。将具有相同“押韵音”的单词分组。
  2. 选择种子词:当需要生成K行押韵的诗句时,从押韵词典中找到一个包含至少K个单词的“押韵音”组,并从中随机选择K个不同的单词作为种子。
  3. 反向生成:修改马尔可夫链模型,使其记录每个单词前面可能出现的单词(即反向模型)。用每个种子词作为起点,在反向模型中生成一行文本。
  4. 反转输出:将生成的每一行文本反转,得到正向的、以押韵词结尾的诗句。

通过这种方法,我们可以确保生成的诗句在结尾处押韵。


生成符合格律的诗句

格律指诗句中重读与非重读音节的规律排列。例如,抑扬格五音步就是“非重读-重读”(0 1)组合重复五次,共十个音节。

实现方法:回溯算法

我们可以在马尔可夫链生成过程中加入格律检查:

  1. 定义目标格律,例如用0101010101表示抑扬格五音步。
  2. 从选择一个词开始生成。
  3. 每添加一个新词,就查询其发音,获取其音节重音模式(如[1, 0]表示“重读-非重读”)。
  4. 将这个词的重音模式与当前格律要求的位置进行匹配。
    • 如果匹配,则继续生成下一个词。
    • 如果不匹配,则回溯:放弃当前词,尝试同一个位置的其他可选词。
    • 如果所有可选词都不匹配,则继续回溯到上一个词,尝试不同的路径。

这个过程虽然计算量可能较大,但能确保最终生成的每一行诗都严格符合指定的格律。


整合:生成押韵且符合格律的诗歌

现在,我们将押韵生成和格律生成结合起来。

完整流程

  1. 输入:源文本、目标格律(如0101010101)、需要押韵的行数(K)。
  2. 预处理
    • 构建反向马尔可夫链模型。
    • 构建押韵词典,并过滤掉押韵单词数量少于K的组。
  3. 生成
    • 从押韵词典中随机选择一个包含至少K个单词的押韵组。
    • 从该组中随机选择K个不同的单词作为种子。
    • 对于每个种子词,使用结合了格律检查的回溯算法,在反向模型中生成一行文本。
    • 将生成的每一行文本反转。
  4. 输出:将生成的K行押韵且符合格律的诗句组合成诗节。

实际应用

作者将此算法封装成一个Web应用,使用披头士乐队的所有歌词作为源文本,成功生成了具有披头士风格且符合十四行诗格律的诗歌。


总结

在本教程中,我们一起探索了使用编程生成诗歌的奇妙过程。我们从理解诗歌的押韵格律这两个核心特征开始,然后学习了马尔可夫链如何基于现有文本生成连贯的新文本。接着,我们利用NLTK中的发音词典让计算机理解单词的发音和重音。最后,我们通过巧妙的反向生成回溯算法,将押韵控制和格律检查融入文本生成过程,从而教会计算机创作出既押韵又符合特定格律的诗歌。

这项技术不仅适用于诗歌创作,其思想也可应用于歌词生成、创意写作辅助等众多领域。希望本教程能激发你的创造力,尝试用代码探索语言艺术的无限可能。


代码与幻灯片资源可在相关GitHub仓库获取。

050:部署到底是什么?🚀

在本教程中,我们将跟随 Katie McLaughlin 的演讲,系统地探讨“部署”这一概念,特别是针对 Django 应用程序。我们将了解从本地开发到生产环境所需的核心组件、面临的挑战以及可选的解决方案。目标是让初学者清晰地理解部署 Django 应用需要做什么。


1. 从本地开发开始 🛠️

上一节我们概述了课程内容,本节中我们来看看 Django 项目在本地是如何运行的。

Django 为本地开发提供了极大的便利。通过几个简单的命令,我们就可以启动一个项目。

以下是创建一个新 Django 项目并启动本地开发服务器的步骤:

  1. 安装 Django:在终端中运行 pip install Django
  2. 创建项目:运行 django-admin startproject myproject . 在当前目录创建名为 myproject 的项目。
  3. 应用数据库迁移:进入项目目录,运行 python manage.py migrate。这会根据默认设置创建一个 SQLite 数据库文件(db.sqlite3)。
  4. 启动开发服务器:运行 python manage.py runserver。访问控制台输出的地址(通常是 http://127.0.0.1:8000/),你将看到 Django 的默认成功页面。

python manage.py runserver 命令启动了一个轻量级的开发 Web 服务器,它非常适合本地开发和调试。


2. 理解生产环境的差异 🏭

上一节我们让 Django 在本地运行了起来,本节中我们来看看为什么不能直接将这个“开发服务器”用于生产环境。

Django 官方文档明确指出:切勿在生产环境中使用 runserver。这个开发服务器没有经过安全审计或性能测试。Django 的核心是一个 Web 框架,而不是一个 Web 服务器。

要将应用部署到生产环境,我们需要替换或补充三个在开发中由 Django 简易提供的部分:

  1. Web 服务器:需要一个生产级的服务器(如 Nginx, Apache)来处理 HTTP 请求。
  2. 数据库:需要将轻量的 SQLite 替换为更健壮的生产级数据库(如 PostgreSQL, MySQL)。
  3. 静态文件服务:需要专门的服务器或服务来处理图片、CSS、JavaScript 等静态文件。

生产环境是一个“活”的、面向真实用户的环境,它要求稳定性、安全性和性能。Django 本身是生产就绪的框架,但它依赖的外部服务需要我们自己配置。


3. 核心:WSGI 与 Web 服务器 🔌

上一节我们明确了生产环境的需求,本节中我们来看看如何让 Django 与生产级 Web 服务器通信。

Django 与 Web 服务器之间通过 WSGI 标准进行通信。WSGI 是 Python 的 Web 服务器网关接口,它定义了 Web 服务器如何与 Python Web 应用程序交互。

当你创建 Django 项目时,会自动生成一个 wsgi.py 文件,它提供了 WSGI 的可调用应用对象。这个文件是连接 Django 和任何兼容 WSGI 的 Web 服务器的桥梁。

代码示例:wsgi.py 的核心作用

# myproject/wsgi.py
import os
from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
application = get_wsgi_application() # 这就是 WSGI 应用对象

常见的生产级 WSGI 服务器包括 GunicornuWSGI。你需要选择一个 WSGI 服务器来运行你的 Django 应用,然后通常在其前方放置一个像 Nginx 这样的 Web 服务器作为反向代理,处理静态文件和负载均衡。


4. 选择部署策略:托管服务 🤔

上一节我们介绍了 WSGI,本节中我们来看看如何选择部署应用的基础设施。关键在于:你希望自己管理多少底层基础设施?

主要有两类托管服务提供商:

  1. 平台即服务:你主要关心应用代码和数据。提供商负责操作系统、Web 服务器、运行时环境等。例如:Heroku, PythonAnywhere, Google App Engine。

    • 优点:简单快捷,无需系统管理。
    • 缺点:灵活性和控制度较低。
  2. 基础设施即服务:你拥有虚拟机或容器,需要自己安装和配置操作系统、Web 服务器、数据库等。例如:AWS EC2, Google Compute Engine, DigitalOcean Droplets。

    • 优点:控制度高,灵活性大。
    • 缺点:需要系统管理知识,维护负担重。

建议:如果你没有特殊需求,只是想快速部署应用,平台即服务 是更好的起点。如果你想深入学习或有特定的定制需求,可以选择 基础设施即服务


5. 处理数据库与静态文件 💾

上一节我们讨论了部署环境,本节中我们来看看两个关键的有状态组件:数据库和静态文件。

数据库选择与托管

Django 支持多种数据库。对于生产环境,PostgreSQL 是一个强烈推荐的选择,因为它功能丰富且与 Django 社区集成度最高。

你可以选择自己搭建和管理数据库服务器,但更推荐使用 托管数据库服务。云提供商都提供此类服务。这样做的好处是:

  • 提供商负责备份、复制、软件更新和硬件扩展。
  • 确保数据安全性和高可用性。
  • 让你专注于 Django 模型设计,而非数据库运维。

静态文件服务

Django 的 python manage.py collectstatic 命令用于收集所有静态文件到一个目录。在生产中,你需要通过其他方式提供这些文件:

  1. 使用云存储:如 AWS S3, Google Cloud Storage。这是最常用和推荐的方式,可扩展性好。
  2. 使用 Web 服务器:配置 Nginx 等服务器直接提供静态文件目录。
  3. 使用 CDN:将静态文件托管在内容分发网络上,加速全球访问。

核心公式:部署 Django ≈ 复制代码 + 配置数据库 + 设置静态文件服务 + 运行 WSGI 服务器


6. 总结与回顾 🎯

在本节课中,我们一起学习了 Django 部署的核心概念。

我们首先从本地开发服务器入手,理解了它与生产环境的区别。然后,我们明确了生产部署必需的三个组件:生产级 Web 服务器生产级数据库静态文件服务方案

接着,我们深入了解了 WSGI 作为连接 Django 与 Web 服务器的标准接口。面对部署,我们分析了 平台即服务基础设施即服务 两种主要策略,帮助你根据自身情况做出选择。

最后,我们探讨了如何为生产环境选择合适的数据库以及如何有效地处理静态文件

记住,每个生产环境都略有不同,没有唯一的“正确”答案。最佳的部署方式取决于你的具体需求、技术栈和维护能力。希望本教程为你提供了清晰的路线图,帮助你自信地迈出部署 Django 应用的第一步。

051:解码竞技视频游戏中的偏见与叙事 🎮📊

在本教程中,我们将学习如何利用 Python 构建一个视频分析系统,来解码竞技视频游戏(以《守望先锋》为例)直播中可能存在的偏见和叙事倾向。我们将从动机出发,逐步讲解系统架构、技术选型、数据处理,并最终验证一些关于观众观看偏好的初始假设。

概述

本次课程将引导你完成一个完整的项目:分析《守望先锋》职业联赛(OWL)的比赛录像,量化不同英雄和战队在直播画面中的“屏幕时间”。我们将通过假设驱动的方法,探究直播制作方可能存在的叙事偏好,例如是否更偏爱展示明星选手、特定角色或领先的队伍。


动机与背景 🤔

上一节我们概述了课程目标,本节中我们来看看项目背后的动机和相关背景知识。

我负责 VS Code 的 Python 扩展开发,但主要进行前端开发,更常使用 JavaScript 或 TypeScript。我决定开展一个副业项目来更好地学习 Python,并且这个项目应该围绕我喜欢的事物展开。

我喜欢玩《守望先锋》,也喜欢观看职业选手的比赛。挑战媒体所呈现的内容是很有意义的,这促使我跳出固有思维,使用假设驱动的方法进行验证:先提出假设,再收集数据,最后用数据来验证或否定这些假设。

在开始之前,需要简要了解电子竞技和《守望先锋》。

电子竞技是一种以电子游戏为媒介的体育竞赛形式。据统计,最受欢迎的电竞赛事观看时长可达数十亿小时。这些内容在 Twitch、YouTube Gaming 等流媒体平台,甚至 ESPN 等传统电视频道播出。电子竞技产业已经形成了包括联盟特许经营、传统体育俱乐部投资在内的成熟生态。

《守望先锋》 是一款团队基础的第一人称射击游戏。玩家分为两支六人队伍,在地图上争夺目标。队伍角色构成通常为:2 名坦克、2 名输出、2 名支援。游戏拥有众多英雄,各自拥有独特能力和外观设计。

《守望先锋联赛》(OWL) 是一个以城市为基础的特许经营联赛。比赛在 YouTube 上直播,采用多地图赛制,先赢得三张地图的队伍获胜。


核心问题与假设 ❓

了解了背景后,我们面临的核心问题是:在团队游戏中,直播镜头会跟随谁?谁来决定镜头中的“行动”?

为了回答这个问题,我提出了以下假设:

  1. 角色偏见假设:即使这是团队游戏,观众也更想看“明星”选手,而输出英雄通常更容易打出精彩操作,因此镜头可能会更偏向于展示输出英雄。
  2. 战队偏好假设:观众是更愿意为处于劣势的“underdog”战队加油,还是更愿意观看胜率更高的战队?直播镜头是否会更多地给到比赛中领先的队伍?

这些假设涉及多个层面:观众的真实喜好、制作方认为的观众喜好、以及制作方希望引导的观众喜好。


系统架构与工作流程 ⚙️

上一节我们提出了待验证的假设,本节中我们来看看如何构建分析系统来获取数据。

系统的工作流程如下:

  1. 提取帧:从比赛直播视频中提取出每一帧图像。
  2. 裁剪区域:将每一帧图像裁剪,只保留屏幕底部显示玩家游戏内ID的区域。
  3. 文字识别:将裁剪后的图像发送到光学字符识别(OCR)服务,识别出玩家ID。
  4. 数据存储:将识别出的玩家ID、时间戳等信息存储到数据库中。
  5. 统计分析:从数据库中查询数据,计算各英雄、各战队的屏幕时间占比,并进行可视化。

以下是该流程的架构示意图:

视频流 -> 提取帧 (FFmpeg) -> 裁剪图像 (Pillow) -> OCR识别 (Azure CV) -> 数据存储 (TinyDB) -> 统计分析 & 可视化 (Plotly/Dash)

技术选型与实现细节 🛠️

本节将详细介绍构建系统时所做的技术选型及其原因。我的原则是:选择设置简单、文档完善、较为流行的工具。

1. 视频处理与帧提取

我选择了 FFmpeg,这是一个功能强大的命令行音视频处理工具。它拥有 Python 绑定库 ffmpeg-python,允许在代码中直接调用,无需创建子进程。

# 示例:使用 ffmpeg-python 提取帧
import ffmpeg
stream = ffmpeg.input('match_video.mp4')
stream = ffmpeg.output(stream, 'frame_%04d.png')
ffmpeg.run(stream)

2. 图像裁剪

我使用 Pillow(PIL Fork)库进行图像处理。为了最小化发送到 OCR 服务的数据量和干扰信息,我只裁剪包含玩家ID的固定矩形区域。

from PIL import Image
def crop_player_area(image_path, crop_box):
    img = Image.open(image_path)
    cropped_img = img.crop(crop_box) # crop_box = (left, top, right, bottom)
    return cropped_img

挑战:游戏内UI会随玩家移动而跳动,导致裁剪区域可能不总是精确包含完整ID。

3. 光学字符识别

我首先尝试了本地的 Tesseract OCR,但它对游戏特殊字体和颜色处理效果不佳。随后我转向了 Azure 计算机视觉服务。它提供异步的“Read API”,能更好地处理这类图像。

# 示例:使用 Azure CV Read API (异步)
import azure.cognitiveservices.vision.computervision as cv
computervision_client = cv.ComputerVisionClient(endpoint, credentials)

with open(image_path, "rb") as image_stream:
    read_operation = computervision_client.read_in_stream(image_stream, raw=True)
    # ... 等待并获取结果

注意:视频分辨率需至少为 720p,以保证文本清晰度。免费层有调用次数限制。

4. 数据存储

基于“最小设置”原则,我选择了 TinyDB。它是一个轻量级、面向文档的数据库,无需外部服务器,直接存储 Python 字典(类似 JSON)。

from tinydb import TinyDB, Query
db = TinyDB('match_data.json')
table = db.table('map_1')
table.insert({'frame': 1001, 'player_name': 'PlayerA', 'team': 'Team1'})

数据结构设计:一个数据库文件对应一场比赛,其中每张地图是一个表,每帧图像是一条记录。

5. 数据可视化与展示

我使用 Plotly 生成交互式图表,并用 Dash 构建了一个简单的 Web 仪表板来整合图表和说明文字。Dash 结合了 Plotly、React 组件和 Flask 服务器。

import dash
import dash_core_components as dcc
import dash_html_components as html
import plotly.express as px

app = dash.Dash(__name__)
app.layout = html.Div([
    html.H1("屏幕时间分析"),
    dcc.Graph(figure=px.bar(data, x='role', y='screen_time_percent'))
])

生成的图表可以展示角色(输出/坦克/支援)的屏幕时间分布,以及不同战队之间的屏幕时间对比。


遇到的挑战与改进空间 🔧

在构建系统的过程中,遇到了一些挑战,也发现了可以优化的地方:

以下是几个主要的改进方向:

  1. 动态区域裁剪:由于游戏UI跳动,固定裁剪区域不可靠。未来可以尝试使用目标检测模型动态定位玩家ID区域,或裁剪更大的区域再进行二次分析。
  2. OCR 精度提升:可以结合图像预处理(如二值化、颜色过滤)来提高OCR的准确率。
  3. 技术选型反思:TinyDB 适合原型开发,若处理大规模数据需考虑更强大的数据库(如 PostgreSQL)。性能并非本项目首要考虑因素。
  4. 数据源的扩展:目前仅分析了第一人称视角画面。直播中还有第三视角、队伍Logo、目标点进度等可视信息,以及解说音频(强调某位选手),这些都可作为分析数据源。
  5. 可视化优化:图表的美观度和信息呈现方式有较大提升空间,可以深入研究 Plotly 文档或选用其他可视化库。

数据分析与假设验证 📈

系统搭建完成后,我们使用第10周的10场OWL比赛数据进行了分析。

数据集:共10场比赛,记录了每场比赛前各战队的胜负记录以计算胜率。

现在,让我们回顾并验证最初的假设:

  1. 角色偏见假设得到支持。平均屏幕时间分布严重偏向输出英雄,占比远高于坦克和支援。
  2. 战队偏好假设:结果混合。
    • 在10场比赛中的8场,最终获胜的队伍获得了更高的总屏幕时间百分比。
    • 但只有一半的比赛中,赛前胜率更高的队伍获得了更高的屏幕时间。

结论:数据显示,直播制作方确实倾向于展示输出英雄和比赛中领先(最终获胜)的队伍。然而,“观众是否更爱看强队”这一点,数据给出的信号并不绝对明确。

分析局限性

  • 样本量小:仅10场比赛。
  • 数据维度单一:仅分析了玩家ID的屏幕时间,未计入其他镜头语言(如特写、队伍标识)和解说评论。
  • 因果关系:数据呈现相关性,但无法断定是制作方刻意引导,还是因为领先队伍/输出英雄本身就处于镜头焦点位置。

总结与收获 🎯

本节课中,我们一起学习并实践了一个完整的 Python 数据分析项目。

我们从对游戏直播的观察中提出假设,然后运用 Python 生态系统中的工具(FFmpeg, Pillow, Azure Cognitive Services, TinyDB, Plotly/Dash)构建了一个视频分析流水线,从原始视频中提取、处理、存储并可视化数据。最后,我们用数据对最初的假设进行了验证和讨论。

这个项目的核心收获在于:

  • 假设驱动开发:有了想法(假设),就用技术和数据去探索和验证。
  • Python 的广泛应用:Python 在多媒体处理、云服务调用、数据分析和可视化方面都有强大的库支持。
  • 数据的诠释:数据能揭示模式,但解读需要谨慎。同样的数据可能支持不同的观点。

本项目仅仅触及了从游戏视频中提取信息的表面。你可以扩展它,分析更多类型的数据,甚至应用于其他游戏或领域。希望这个教程能激发你利用 Python 去做一些有趣的分析项目!

项目资源

  • 代码仓库:GitHub Repo
  • 作者推特:可通过推特联系作者进行交流。

052:九年的成长与变革之路

在本教程中,我们将跟随 Lorena Mesa、Elaine Wong 和 Mariatta 的分享,回顾 PyLadies 社区九年的发展历程。我们将了解其起源、使命、取得的成就、面临的挑战以及未来的发展方向。内容将涵盖社区建设、多样性倡议和组织结构演变等核心主题。

起源故事:一个共同的需求

一切始于个人寻找归属感的旅程。分享者德拉梅在2010年毕业后,作为一名记者,希望提升技能并发现了Python语言。她发现Python在数据解析方面非常强大,有助于她的调查工作。

然而,当她尝试加入本地Python用户组时,感到非常不适应。在一个主要由经验丰富的男性开发者组成的80人房间里,作为一名编程新手和科技界新人,她很难找到归属感。即使人们很友好,她也常常不确定对方是真心帮助还是别有意图。这促使她开始寻找像自己一样的女性Python爱好者。

全球运动的诞生:从洛杉矶到世界

这种寻找同伴的需求并非个例。早在2011年,亚特兰大PyCon的一群女性(包括Christine Cheung、Jessica Stanton等)也有相似的感受。她们希望聚集一群对Python充满热情的女性。

奥黛丽为此撰写了一份资助提案,聚焦于教育、会议和社区拓展三个领域。Python软件基金会(PSF)批准了这份提案,因为他们看到了解决社区性别代表性问题的必要性。PSF后来成为PyLadies的财务赞助方,使其成为基金会内的一个实体。

第一章节在洛杉矶成立。随后,社区开始成长:墨尔本成立了第二章,接着是华盛顿特区和旧金山。从2011年开始,PyLadies证明了其存在的必要性,并开始在全球范围内扩展。

使命与早期建设:奠定基础

早期志愿者团队为社区奠定了坚实基础。他们制定了一份清晰的使命声明:

PyLadies是一个国际会员组织,致力于帮助更多女性成为Python开源社区的参与者和领导者。我们的使命是通过拓展、教育、会议、活动和社交聚会,促进多元化的Python社区发展。PyLadies旨在为女性提供一个友好的支持网络,并搭建通往更广阔Python世界的桥梁。任何对Python感兴趣的人都受到鼓励参与。

这份使命声明由最初的九人团队通过PSF资助的项目推动,发起了一场全球运动。如今,PyLadies已发展成为一个国际导师组织,专注于帮助女性在Python社区中变得更加活跃并成为领导者。

庆祝成就:走过的漫长道路

在过去的九年里,PyLadies取得了显著成长。目前,我们在全球拥有90多个分会,遍布许多国家。每个分会都在本地运行,为女性提供了一个发展事业和技能的社区。

我们的社区帮助培养了新一代的女性演讲者和开源贡献者。如果我们回顾9年前Python大会演讲者的性别多样性历史,只有1%的演讲者是女性。如今,这个数字已经提升到约40%,并且保持相对稳定。

社区成员分享了他们的感受:

  • 来自迈阿密的Madeline Cambos提到:“支持与知识共享。”
  • Fishao Hanman说:“PyLadies是唯一让我感到可以舒适地提出编程问题的地方。克服因身为女性而被严厉评判的恐惧非常困难,这个社区在很大程度上帮助我跨越了这个障碍。”

面临的挑战与持续努力

尽管取得了进展,我们仍面临挑战。五年前的Python语言峰会上,没有女性核心开发者,甚至没有女性与会者。这激励了分享者开始为Python做贡献,并最终成为核心开发者。

今年的语言峰会有7名女性参与,其中3人发表了演讲,这比以前好多了。然而,我们仍然面临让女性进入积极领导角色的挑战。

一个核心问题是草根运动中的资源差异,这影响了不同地区的分会。例如,芝加哥和巴西的分会情况截然不同,反映了各自社区的独特需求。

维持成员活跃度和留存率是一个瓶颈,尤其是在获取资金方面。强大的地方分会有助于更好地控制内容创作和传播。我们希望建立一种全球社区感,而不仅仅是基于地理位置的连接。

展望未来:新的组织结构

在思考未来时,我们面临一个复杂的问题:如何在本地自主性和全球统一性之间找到平衡?我们如何让人们以更有意义的方式参与?

我们举办了一次研讨会,旨在汇集全球观点,讨论下一步该做什么。我们知道,空谈大问题而无具体行动是无法取得进展的。我们需要切实可行的步骤。

我们受到了“Django Girls”等组织的启发,它们拥有强大的地方分会,是一个由许多志愿者管理的基层组织。这为我们提供了一个很好的起点。

请求评论模型:全球领导团队与项目小组

我们提出了一个“请求评论”模型。核心是建立一个全球领导团队(后称全球理事会)。这个团队将成为Python软件基金会的代言人,负责处理如“我的芝加哥分会如何获得资金”这类问题,并着眼于更大的全局性项目。

同时,我们设立了项目小组,涵盖金融资源、技术、营销、行为准则等领域。这些小组像开源项目一样管理,允许人们根据自己的热情参与具体工作,而不必成为活动组织者或演讲者。这回归了志愿服务的本质:让人们能够从事他们热爱的事情

这个全球领导模式旨在让一群人引领方向,同时为PyLadies成员创造更多有意义的参与机会。

实施与推广:确保全球代表性

我们为2019年的研讨会做了大量工作,并与社区进行了对话。我们非常强调全球代表性,思考如何让世界各地的PyLadies成员参与进来。

我们做的一件事是将相关材料翻译成多种语言。在Python翻译社区和Pillow项目的帮助下,我们迅速完成了翻译工作。最终,我们提供了阿拉伯语、中文(普通话)、法语、葡萄牙语、俄语和西班牙语版本,加上英语共七种语言。这是为了用人们感到舒适的语言与他们沟通。

对于全球理事会,我们制定了区域要求:不超过三分之一的理事会成员可以来自同一个国家。例如,如果总共有9个席位,那么来自美国的最多只能有3人。这是为了确保我们获得真正想要的全球品牌和视角。

此外,我们还考虑了多样性,确保职业生涯不同阶段的人都有机会参与,而不仅仅是那些知名度高的人。

当前进展与加入方式

我们已经开通了专门的网站,用于提名和申请成为全球理事会成员。同时,我们也开放了试点会员注册,注册会员将拥有投票权。网站支持多种语言。

对理事会成员的公开招募于5月6日开始,时间线与整体工作安排保持一致。

我们衷心感谢所有帮助过我们的人,无论是参与月度会议、回复信息,还是协助翻译。正是大家共同努力,让Python和PyLadies社区变得更好。

如果你想保持联系,我们有几个渠道:

  • 可以访问我们的网站获取所有资源。
  • 我们有一个Slack群组(需遵守行为准则)。
  • 可以加入我们的Github组织。
  • 我们有一个临时的全球小组每月开会一次,任何人都可以在Github仓库的议题中添加讨论内容。
  • 请在Twitter上关注我们,并随时注册成为PyLadies会员。

本节课总结:我们一起回顾了PyLadies社区九年的发展历程。从个人寻找归属感开始,到发展成为一个拥有90多个分会的全球性组织,PyLadies始终致力于通过教育、支持和社区建设,提升女性在Python开源领域的参与度和领导力。我们探讨了其明确的使命、庆祝了在提升演讲者多样性等方面取得的成就,也坦诚面对了资源分配和全球代表性等持续挑战。最后,我们了解了社区为适应未来发展而提出的新组织结构——全球理事会与项目小组模型,以及如何参与其中。PyLadies的故事证明了社区力量在推动技术领域多元化中的重要性。

053:使用Python和MIDI硬件制作音乐

在本教程中,我们将学习如何使用Python的AsyncIO库与MIDI硬件合成器进行交互,从而创作音乐。我们将从零开始构建一个程序,该程序能够接收外部时钟信号、驱动鼓机和贝斯合成器,并实现多轨同步播放。整个过程将展示AsyncIO在实时、并发任务处理中的强大能力。


硬件介绍

在深入代码之前,我们先了解一下将要使用的硬件设备。

  • Circuit:一个功能完整的“音乐工作站”硬件。它内置鼓机、合成器和音序器,可以独立创作音乐。我们将用它作为我们的鼓机和主时钟源
  • Mono Station:一个单声道模拟合成器,专门用于演奏贝斯音色。它是“模拟”的,意味着其声音由真实的电子电路产生,会受环境因素影响,这也带来了独特的“温暖”音色。

这两款设备都支持USB-MIDI协议,这意味着我们可以用一根USB线将它们连接到电脑,并用Python程序进行控制。

核心概念:AsyncIO 和 MIDI

上一节我们介绍了硬件,本节中我们来看看编程所需的核心概念。

AsyncIO 基础

AsyncIO 是 Python 用于编写并发代码的库,使用 async/await 语法。其核心是协程,它们类似于函数,但可以在等待外部操作(如I/O)时暂停,让出控制权给其他协程,从而实现协作式多任务。

一个简单的协程示例:

async def example_coroutine():
    print("开始")
    await asyncio.sleep(1)  # 等待1秒,期间其他协程可以运行
    print("结束")

要同时运行多个协程,可以使用 asyncio.gather

async def main():
    await asyncio.gather(
        coroutine_one(),
        coroutine_two(),
        coroutine_three()
    )

MIDI 协议

MIDI 是一种在80年代早期由音乐硬件厂商制定的通信协议,用于电子乐器之间的对话。它的伟大之处在于其简单性和广泛的行业支持。

MIDI消息是实时、单向的。最常见的消息类型包括:

  • Note On:按下琴键,开始发声。消息包含音符编号(0-127)、力度(击键速度,影响音量)和通道(0-15)。
  • Note Off:松开琴键,停止发声。
  • Clock:同步时钟信号。标准定义每四分音符会发送24个时钟脉冲。所有设备遵循同一时钟源才能同步演奏。

在代码中,我们可以用简单的数据结构表示这些消息:

@dataclass
class MidiMessage:
    message_type: str  # 例如 ‘note_on‘, ‘clock‘
    channel: int
    data1: int  # 例如 音符编号
    data2: int  # 例如 力度
    delta: float  # 距离上一条消息的时间

项目搭建:连接与通信

上一节我们了解了AsyncIO和MIDI的基础,本节中我们来看看如何搭建项目框架,让Python能够与硬件对话。

首先,我们需要一个库来处理底层的MIDI通信。这里我们使用 python-rtmidi。同时,为了获得更好的性能,我们将使用 uvloop 作为AsyncIO的事件循环替代品。

以下是初始的项目结构:

import asyncio
import click
from rtmidi.midiutil import open_midiinput, open_midioutput

# 安装uvloop以获得更好性能
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())

@click.command()
def main():
    """主命令行入口点"""
    asyncio.run(async_main())

async def async_main():
    # 1. 查找并打开MIDI输入(接收来自Circuit的时钟)和输出(向合成器发送音符)端口
    # 2. 设置回调函数,处理接收到的MIDI消息
    # 3. 运行主事件循环
    pass

if __name__ == "__main__":
    main()

处理MIDI回调:使用队列

rtmidi 在C++线程中监听硬件消息,并通过回调函数通知Python。为了避免在回调中进行复杂处理,我们使用一个线程安全队列作为桥梁。

以下是实现方案:

  1. MIDI回调函数:将接收到的原始数据包装成 MidiMessage 对象,并放入队列。
  2. AsyncIO消费者协程:在Python主线程中运行,从队列中获取消息并进行处理(如转发时钟、触发音符)。
import asyncio
from queue import Queue
from threading import Thread

# 创建线程安全队列
midi_queue = Queue()

def midi_callback(message, time_stamp):
    """由rtmidi在C++线程中调用"""
    # 解析message,创建MidiMessage
    midi_msg = create_midi_message(message, time_stamp)
    # 放入队列
    midi_queue.put(midi_msg)

async def midi_consumer():
    """在AsyncIO事件循环中运行,消费队列中的消息"""
    while True:
        # 等待队列中出现新消息
        if not midi_queue.empty():
            msg = midi_queue.get()
            # 处理消息,例如打印或转发
            print(f"收到消息: {msg}")
        # 短暂让出控制权,避免阻塞事件循环
        await asyncio.sleep(0.001)

实现核心功能:时钟同步与音序器

上一节我们建立了硬件通信的基础,本节中我们来实现最核心的部分:时钟同步和音序器。

状态管理:Performance 类

我们将创建一个类来管理整个表演的状态,包括使用的设备、当前节奏和最后一个播放的音符等。

from dataclasses import dataclass
from typing import Optional

@dataclass
class Performance:
    clock_port: Any  # 接收时钟的MIDI端口(Circuit)
    drum_port: Any   # 发送鼓音符的MIDI端口(Circuit)
    bass_port: Any   # 发送贝斯音符的MIDI端口(Mono Station)
    last_note: int = 36  # 最后播放的音符,默认为C1
    # 我们稍后会在这里添加节拍器

节拍器与同步

为了让我们的Python音序器与外部硬件时钟完美同步,我们需要实现一个节拍器。它的作用是:每收到一个外部时钟脉冲,就通知所有正在等待特定脉冲数的协程。

我们使用 asyncio.Future 来实现这个同步机制。Future 代表一个尚未完成的异步操作结果。

以下是简化的节拍器实现:

class Countdown:
    """一个倒计时器,在达到零时完成一个Future"""
    def __init__(self, pulses: int):
        self.pulses = pulses
        self.future = asyncio.Future()

    def tick(self):
        """每收到一个时钟脉冲调用一次"""
        self.pulses -= 1
        if self.pulses <= 0 and not self.future.done():
            self.future.set_result(True)  # 标记完成

class Metronome:
    """管理多个倒计时器的节拍器"""
    def __init__(self):
        self.countdowns = set()

    def tick(self):
        """通知所有倒计时器脉冲已到"""
        for cd in list(self.countdowns):
            cd.tick()
            if cd.pulses <= 0:
                self.countdowns.remove(cd)

    async def wait_for_pulses(self, pulses: int):
        """等待指定数量的脉冲"""
        countdown = Countdown(pulses)
        self.countdowns.add(countdown)
        await countdown.future  # 在此等待,直到倒计时完成

Performance 类中初始化节拍器,并在每次收到 Clock 消息时调用 metronome.tick()。这样,任何协程调用 await performance.metronome.wait_for_pulses(24) 时,都会精确等待一个四分音符的时间(24个脉冲)。

鼓机音序器

现在我们可以实现一个简单的鼓机循环。它将在指定的通道上,按照节奏播放音符。

以下是鼓机协程的示例:

async def drum_machine(performance: Performance):
    """鼓机音序器协程"""
    while True:
        # 播放底鼓(音符36),通道9(MIDI鼓通道通常是9)
        performance.note_on(performance.drum_port, channel=9, note=36, velocity=127)
        # 等待一个四分音符(24个脉冲)
        await performance.metronome.wait_for_pulses(24)
        # 停止底鼓
        performance.note_off(performance.drum_port, channel=9, note=36)

        # 播放军鼓(音符38),等待半个四分音符
        performance.note_on(performance.drum_port, channel=9, note=38, velocity=95)
        await performance.metronome.wait_for_pulses(12)
        performance.note_off(performance.drum_port, channel=9, note=38)

贝斯音序器与交互

对于贝斯合成器,我们可以创建一个更复杂的音序器,例如一个循环播放特定音符序列的“琶音器”。

async def bass_arp(performance: Performance):
    """贝斯琶音器协程"""
    # 定义一个音符序列(以半音为单位偏移)
    sequence = [0, 7, 12, 19, 24]  # C, G, C(高八度), G(高八度), C(两个八度)
    index = 0

    while True:
        # 计算要播放的音符:基础音符(C1) + 序列中的偏移
        note_to_play = 36 + sequence[index % len(sequence)]
        performance.note_on(performance.bass_port, channel=0, note=note_to_play, velocity=90)
        # 等待一定脉冲数
        await performance.metronome.wait_for_pulses(24)
        performance.note_off(performance.bass_port, channel=0, note=note_to_play)

        index += 1
        # 每4个音符,将基础音符更新为最后接收到的外部键盘音符,实现交互
        if index % 4 == 0:
            note_to_play = performance.last_note

为了让贝斯线能够响应现场演奏,我们需要在MIDI回调中,当收到 Note On 消息时,更新 performance.last_note


整合与运行

上一节我们实现了各个独立的音序器,本节中我们将它们整合起来,并处理启动/停止逻辑。

在主异步函数中,我们需要:

  1. 创建 Performance 实例。
  2. 启动MIDI消费者任务。
  3. 监听来自Circuit的 Start/Stop 消息。
  4. 当收到 Start 时,创建并启动鼓机和贝斯音序器的后台任务。
  5. 当收到 Stop 时,取消这些任务,并向所有合成器发送“所有音符关闭”消息,防止音符卡住。
async def async_main():
    # ... (初始化硬件端口)
    performance = Performance(clock_port=in_port, drum_port=out_port_circuit, bass_port=out_port_mono)

    # 启动消息消费者
    consumer_task = asyncio.create_task(midi_consumer(performance))

    # 用于存储音序器任务
    sequencer_tasks = []

    def handle_midi_message(msg):
        if msg.type == 'clock':
            performance.metronome.tick()
            # 将时钟转发给贝斯合成器,保持其同步
            performance.bass_port.send_message([0xF8])
        elif msg.type == 'start':
            # 启动音序器
            drum_task = asyncio.create_task(drum_machine(performance))
            bass_task = asyncio.create_task(bass_arp(performance))
            sequencer_tasks.extend([drum_task, bass_task])
        elif msg.type == 'stop':
            # 停止音序器
            for task in sequencer_tasks:
                task.cancel()
            sequencer_tasks.clear()
            # 静音所有设备
            performance.all_notes_off()
        elif msg.type == 'note_on':
            # 更新最后一个音符,用于交互
            performance.last_note = msg.data1

    # ... (将handle_midi_message注册为回调或放入消费者逻辑)

    try:
        await consumer_task
    except asyncio.CancelledError:
        # 优雅关闭
        performance.all_notes_off()

总结与扩展

本节课中我们一起学习了如何使用Python的AsyncIO库控制MIDI硬件合成器来制作音乐。我们从零开始,逐步构建了一个能够:

  1. 与硬件通信:通过 rtmidi 库连接并控制Circuit和Mono Station。
  2. 处理并发:利用AsyncIO的协程和任务,让鼓机、贝斯音序器以及MIDI消息处理同时进行。
  3. 实现精确定时:通过自定义的节拍器类,将Python音序器与外部MIDI时钟源同步,达到专业级的时序精度。
  4. 创作音乐:编写了简单的鼓循环和交互式贝斯琶音器,生成了富有节奏感和变化的声音。

这个项目只是一个起点。基于此框架,你可以轻松地:

  • 添加更多轨道:创建更多的协程来控制其他合成器或采样器。
  • 实现复杂算法:用Python生成更复杂的旋律、和声或节奏模式。
  • 增加交互性:响应更多的MIDI控制器信息(如旋钮、滑块)来实时改变音序参数。
  • 连接软件:让你的Python程序作为插件或扩展,与Ableton Live、Logic等数字音频工作站协同工作。

希望本教程让你看到了AsyncIO在实时交互和创意编程中的强大潜力。音乐与代码的结合,为表达和探索开辟了全新的可能性。

054:局限性与社会风险

概述

在本节课程中,我们将探讨面部识别技术的核心争议、其技术局限性以及可能带来的社会危害。我们将从算法偏见、情感识别、性别识别等具体问题入手,分析这些技术如何在现实中被应用,并讨论开发者应承担的责任。


免责声明与背景

本次演讲中的所有观点仅代表演讲者个人立场,与其雇主无关。演讲内容源于个人研究兴趣,与工作职责无涉。

自去年五月以来,我们观察到全球多个城市和执法机构开始部署面部识别技术。这引发了关于公民自由、安全与隐私的广泛担忧。

近期,随着COVID-19大流行,一些国家开始讨论利用面部识别技术来识别违反居家隔离令的个人,或追踪确诊患者以确保其自我隔离。


超越监控:面部分析与“新颅相学”

上一节我们提到了监控应用,本节中我们来看看面部分析技术的兴起。这种技术声称能通过扫描人脸来分析个人的内在特质,例如智商或是否是一名好员工。

这种理念基于一种古老的伪科学——颅相学。颅相学在18、19世纪曾被用来宣扬种族主义,声称不同种族的大脑结构存在差异,并由此决定个人能力。

如今,我们给这种有害思想披上了技术的外衣。通过所谓的“面部特征分析”,我们正在用科技手段延续种族主义和社会偏见。当我们讨论人工智能的危险时,所指的并非科幻电影中的超级AI,而是算法今天就在加剧社会不平等、伤害具体人群的现实。


算法偏见:以“性别阴影”研究为例

以下是关于算法偏见的一个著名案例研究。

波拉伊尼·乔伊在麻省理工学院媒体实验室就读时,曾遇到一个无法识别她面孔的机器人,直到她戴上一个白色面具。这次经历促使她开展了名为“性别阴影”的研究。

在该研究中,她审计了多个商用面部分析系统对不同人群的识别准确率。她采用了交叉性审计方法,不仅单独考察种族或性别,还考察了它们的交叉影响(例如:黑人女性)。

研究关键发现

  • 这些系统对深色皮肤女性的误识别率,比浅色皮肤男性高出20%到35%
  • 如果一个数据科学家训练的模型仅有67%的准确率,很可能无法投入生产。然而,这些存在严重偏见的商用系统却被广泛部署。

这项研究产生了巨大影响,促使IBM、谷歌等公司承诺改进其系统,并催生了相关立法讨论。


数据伦理:如何“公平”地收集数据?

上一节我们看到了算法偏见的问题,本节中我们来看看试图解决该问题的方法及其伦理困境。

为了纠正偏见,公司需要构建更具多样性的人脸数据集。然而,其数据收集方式往往存在严重伦理问题。

不道德的数据收集案例

  • IBM:通过爬取Flickr上肤色较深个人的度假照片来构建数据集。
  • 谷歌:曾尝试付费让黑人和棕色人种提供自拍,或雇佣人员在亚特兰大拍摄无家可归者的照片。

作为回应,“性别阴影”研究的原作者们创建了“名人面孔”数据集子集。该数据集使用白人男性、白人女性、黑人男性、黑人女性名人的照片,从而避免了侵犯普通人隐私的问题。

这项后续研究揭示了隐私公平之间的张力,并为希望进行算法审计的研究人员提供了重要指导,例如:如何设计有意义的审计,以鼓励目标公司真正改进其软件。


情感识别:技术能否“读懂”情绪?

接下来我们将讨论情感识别,或称自动情绪识别技术。其核心问题是:我们能否仅凭一张面部照片来准确估计一个人的情绪状态?

该领域的基础理论源于心理学家保罗·艾克曼。他提出了人类共有六种基本情绪:快乐、悲伤、恐惧、愤怒、惊讶、厌恶。他还提出了“微表情”概念,并开发了面部动作编码系统。该系统将面部划分为不同的动作单元,一种情绪被定义为特定动作单元的组合。

例如,在FACS中,愤怒可能被编码为:嘴唇收紧、双眼瞪大、眉毛皱起。

然而,去年一项对1000多篇论文的综述得出结论:面部表情并不能正确代表情绪状态

原因有三点

  1. 表达方式的多样性:同一种情绪可以通过多种不同的面部表情来表达。
  2. 缺乏特异性:面部构造与特定情绪之间不存在唯一映射。一种表情可能对应多种情绪。
  3. 缺失上下文:仅凭一张静态照片,我们无法了解情绪产生的背景(例如,哭泣可能是因为悲伤,也可能是喜极而泣)。

自动性别识别:对跨性别与非二元性别人群的排斥

现在我们来探讨计算机如何看待性别,特别是自动性别识别系统对跨性别和非二元性别人群的影响。

这类系统的一个常见应用场景是:出租车后座的平板电脑扫描乘客面部,估计其性别,然后推送“最相关”的广告。

2018年,Os Keyes 的一项文献回顾发现,这些系统存在根本性问题:

  • 性别总是被定义为二元变量(男/女)。
  • 性别完全由外表决定(看起来像男性即为男,看起来像女性即为女)。

这种定义本质上排斥了跨性别和非二元性别人群,因为他们的性别认同可能与其外表不一致。例如,优步的司机验证系统就曾因无法识别经历激素替代疗法的跨性别司机而将其账户锁定。

跟进Os Keyes的理论研究,科罗拉多大学的三名研究人员进行了实证审计。他们从Instagram收集了代表七种性别身份的2400张图片,并测试商用性别识别系统的表现。

审计发现

  • 带有“跨性别男性”或“跨性别女性”标签的图片,被错误识别性别的比例,比顺性别个体高出10%至30%
  • 这些系统常在未经同意的情况下,通过监控摄像头对人脸进行扫描和性别标记,可能导致个体违背自身意愿被错误对待,造成情感和身体伤害。

实践中的滥用、危害与开发者责任

前面我们讨论了面部识别技术在种族、情绪、性别识别方面的局限性。现在让我们探讨这些系统在实践中的应用,以及我们为何不能将技术与它的使用场景割裂看待。

凯特·克劳福德有一句有力的论断:“这些工具在失败时是危险的,在成功时是有害的。”

滥用案例:纽约警察局曾被曝出丑闻,警员将名人的照片或手绘草图输入面部匹配系统以“生成线索”,并据此进行逮捕。这完全背离了系统的设计用途,且缺乏问责机制。

正确使用造成的伤害:即使技术被“正确”使用,也可能造成巨大伤害。例如,有报道称面部识别技术被用于特定地区的监控与压迫。目前,国际上缺乏关于如何使用(或禁止使用)面部识别和生物识别数据的标准与问责制。

关键在于权力:技术是一种力量。我们作为软件开发人员必须认识到,我们构建的算法和技术存在于人类社会的权力结构中。如果我们只是让技术更好地识别黑人和棕色人种的面孔,然后将其卖给一个旨在残酷对待有色人种社区的机构,那么我们就是在为他们提供更高效的压迫工具。

制造这些工具的技术人员(多为享有特权的群体)与受这些技术伤害最深的人群(常为边缘化群体)往往是分离的。这就是为什么科技领域和人工智能领域的多样性至关重要——是房间里的人决定用技术解决什么问题、如何使用技术。不同的生活经历能带来不同的视角。


核心问题:我们是否应该建造它?

在讨论如何构建或设计一项技术之前,我们应该先问自己一个根本性的问题:我们是否应该建造这个系统?

在人工智能伦理和负责任创新的领域,我们必须能够提出这个问题。如果我们相信某项技术会造成严重伤害,那么答案应该是否定的——我们根本不应该建造它。

现在,我们开始看到这种反思转化为行动:

  • 软件工程师联合抗议其公司对气候的影响。
  • 超过1000名大学生签署承诺,拒绝为某些制造伤害性技术的公司(如Palantir)工作。

一个重要议题随之出现:我们如何赋予开发者、数据科学家和工程师“拒绝的权利”? 如何让他们能够安全地集体组织起来,对有害的技术说“不”?

这项工作可以从政府法规(如京都议定书)和组织管理策略中汲取灵感,研究何种组织结构能让员工感到安全,敢于提出担忧。同时,也需要研究如何设计系统,使得当发现其会造成伤害时,能够被轻易关闭或撤销。

在研究层面,我们希望设计出通用的框架,以支持科技从业者为此目标而努力。


总结

本节课中,我们一起学习了面部识别技术的多个关键议题:

  1. 技术存在严重的算法偏见,对不同种族、性别的人群识别准确率差异巨大。
  2. 试图修正偏见的数据收集过程,本身可能涉及严重的伦理问题
  3. 情感识别技术的科学基础存在争议,面部表情与情绪状态之间并非一一对应。
  4. 自动性别识别系统基于二元的、外表至上的性别定义,排斥并伤害跨性别与非二元性别人群。
  5. 技术在实践中的滥用正确使用都可能造成危害,且缺乏有效问责。
  6. 技术的开发与使用关乎权力结构,开发者的背景多样性影响技术发展的方向。
  7. 最根本的问题是,在建造之前,我们必须勇于提问:这项技术是否值得建造? 并赋予开发者集体拒绝建造有害技术的权利。

作为技术的创造者,我们有责任思考其社会影响,并努力确保技术不被用于巩固压迫性的社会权力不平等。

055:如何建立一个智能“室内花园”

在本节课中,我们将跟随演讲者玛丽安(Maria José Molina-Contreras)的分享,学习如何从零开始构建一个智能室内花园系统。我们将了解项目背景、核心概念、硬件选择、代码实现以及系统的迭代升级过程。

项目背景与动机

大家好,今天我们将讨论如何建立一个智能室内花园。

在开始之前,先简单介绍一下我自己。我的全名很长,但可以叫我玛丽安。我拥有植物分子生物学博士学位。四年前,我带着梦想搬到柏林,计划开始新生活。那是一段艰难时期,但得益于柏林Python社区的支持,我坚持了下来,并找到了数据科学家这条新的职业道路。

不仅如此,我还开始在家中用Python进行实验。本次演讲就是一个例子。我想感谢Python社区给予我的每一份支持和信任。请记住,分享就是关怀,让我们分享知识,支持更多人加入这个美好的社区。

我将要谈到的项目源于一位朋友的想法。凭借我的植物学背景和Python知识,我决定探索一个全新领域:微控制器。这个世界逐渐变得清晰,我构建了一个最简单且实用的植物浇水系统,让没有软硬件背景的人也能在家中复制。当然,我也想激励新人进入Python世界。

在进行生物实验时,我从未有机会为任何硬件编程,因此我对此一无所知。幸运的是,我的一位朋友参加了2018年的Python大会,得到了一个微控制器。他把它给了我,并告诉我:“你会喜欢这个的,试试这些微控制器。”我记得我回答说:“但我不知道怎么用,我会把它弄坏吗?我需要安装什么?需要焊接吗?”

他告诉我:“只需把它连接到你的笔记本电脑。它里面已经有Python了,看看主题演讲。”我非常震惊,因为这一切如此简单。而尼娜的主题演讲太棒了,我立刻开始构思新的项目创意。那一刻,我并没有明确说“好吧,很明显,我需要开发这个浇水系统”,但我有了实现它的关键希望。

项目启动与核心概念

但在开始一个项目时,最重要的是什么?

不,这只是个玩笑。我开始以正确的方式看待这个项目。同时,我开始画项目草图自娱自乐。最后我画了植物图,因为我觉得这很有趣、很可爱,这也算是这个项目的形象。好了,不再谈论logo了,让我们回顾一些在开始构建浇水系统之前的重要概念。

老实说,有时让植物保持生长状态是很难的。

快乐,而浇水是关键,但并非唯一因素。

第一件事,根据你的环境条件选择植物是成功的关键。并非所有植物都适合户外。如果想创建室内花园,你需要有足够的光照。考虑使用人造光,注意温度、相对湿度和房屋朝向。

但这并非我们需要考虑的唯一因素。我们还需要考虑湿度传感器。我们需要了解植物土壤的特性,以及如何测量土壤湿度。有许多类型的传感器,但我只尝试了两种:电阻型和电容型。

我尝试了电阻型,尽管知道它们有腐蚀问题,因为这适用于短时间接触水的情况。但如果你需要长期放置,请使用电容型。因此,你会看到在某些时候我使用第一种,而在其他时候我使用第二种,以便从土壤湿度传感器中获得更可靠的读数。

建议你首先根据计划监测的特定土壤类型进行校准。因为不同类型的土壤会影响传感器读数。因此,你的传感器可能对使用的土壤类型更敏感或不敏感。

当然,在开始项目之前,第一件要做的事是取样。例如,在这个案例中,我取了三个装有水和土壤的杯子,分别是相当湿润和非常干燥的土壤。我测量了从干燥到湿润的球形物质变化。然后你需要进行实验,获取数据,用数字参数定义什么是干燥,什么是湿润。

现在唯一的问题是,谁告诉你你的计划是什么?如果我问你哪个是正确的方法,A、B还是C?答案是:取决于植物物种和生长状态。并非所有植物都喜欢相同的浇水方法。你可能会想,但为什么?一个重要因素是根系分布。

表层生长的植物偏好方法A。深层生长的植物,A、B、C都可以。方法A可能会导致额外的土壤矿物流失。方法B有时不足以让植物充分吸收水分。或者,这两种方法结合可能是个好主意,情况C能够保持良好的矿物浇水平衡。

但让我们开始考虑将方法A和B作为我们的浇水系统。经过这样的考虑,我们可以去制作一个在这方面表现良好的标志或原型。

构建第一个原型

在这一切过半的时候,事情变得清晰。最后,我设计了我的第一个原型。就是这样。

在这里你可以看到我最初的想法。我想,我有一个计划,一个连接到微控制器的湿度传感器,以及一个能够启动水泵的方式。我觉得这看起来真的很简单,于是我开始研究如何做到这一点,并开始购买一些组件,但有些情况需要很多组件。

以下是我为第一个系统使用的所有元素。

微控制器是我得到的,是左下角的锥形松果状设备(Circuit Playground Express)。湿度传感器是一个花朵形状的设备(图中C)。水泵是中间的一个白色水槽(图中D)。需要使用五个小电池(图中E)。顶部的蓝色部件是一个继电器,简单来说,它是一个开关信号。不错,所以我能够连接它来控制浇水的时间。当然,还有一些电缆连接了不同的部分。

我使用的微控制器叫做 Circuit Playground Express,由Adafruit开发。这个微控制器在不同项目中具有巨大潜力,因为它集成了运动、温度、光、声音等传感器。运行在其中的Python版本叫做 Circuit Python,是Micro Python的一种形式,使用起来非常简单,完全适合初学者。它有许多传感器的库和驱动程序,你可以通过USB连接它,然后启动Python控制台与之交互,这太神奇了。

在这里你可以看到最后一切是如何连接的。我知道有很多电缆,并且将手掌放在继电器旁边并不安全。我知道这一点,但没关系。这只是为了展示。请在家里不要这样做。保持安全。尝试,但不要这样做。

系统工作原理与代码

好吧,让我们在视频中看看系统是如何工作的。[音乐]

那代码呢?我在这里跳过了导入部分,但我只是想向你展示如何与设备交互。由于我向继电器发送信号,所以我有一个数字输出连接。湿度传感器是一个模拟输入,因为我需要从中读取一个数字。

主循环非常简单。你获取传感器值,如果低于定义的阈值,你只需启用继电器并给植物浇水几秒钟。简单吧?

# 伪代码示例
while True:
    sensor_value = read_analog_input()
    if sensor_value < THRESHOLD:
        enable_relay()  # 打开水泵
        sleep(WATERING_TIME)
        disable_relay() # 关闭水泵

你可以在我的GitHub账号中找到完整代码,以防你想尝试一下。

系统迭代:增加功能

在我完成这个项目或这个方法后,我提供了一些工具,很多人对此感兴趣,并开始给我反馈,询问新功能。所以我决定构建一个具有更多功能的更大系统。

由于Circuit Playground Express的端口不多,我找到了另一个设备。

它站在那里连接,所以我有添加更多东西的选项。如你所见,我在系统中包含了扬声器和水位传感器。这里是所有组件和新的组件。

在图中D层,电路板接地下的绿色电路称为 Crickit,它包括许多额外的端口,可以连接更多东西到你的Circuit Playground。我还买了一个扬声器,并把它放在盒子里(图中A,红色部分)。水位传感器位于B层。当然,我们还有旧组件,比如继电器、电池、水泵和状态良好的湿度传感器(图中C)。如你所见,对于这个项目,我使用了电容型传感器。

以下是新系统的功能:

  • 注意水位低,请填充水容器。
  • 注意水位低,请填充水容器。

这里是代码,层次有点长,但仍然足够简单,适合每个想尝试的人。我写了一个Python类来封装系统的所有功能。所以我做的第一件事是创建一个名为plantaris的对象。

主循环有几个步骤,你可以在右侧看到函数的实现。首先,你可以看到方法water_level_ok做的事情与我们在系统一中做的类似,只是检查值并返回真或假。当水位不正常时,我们在那里播放一条消息。我只是打开一个WAV文件并通过扬声器播放。所以这就是你需要的全部内容,以便在水位过低时获得语音通知。

第二个条件是检查湿度值是否正常。这是系统一中的相同代码。我检查湿度水平是否正常,如果不正常,我就执行浇水计划。

这里给植物浇水就是打开继电器几秒钟,然后关闭它。如果你认为这是个错误,因为我写了False来打开,那是因为继电器有两种状态:常闭或常开。由于我不想让水泵一直工作,所以我使用了常闭阀门,因此需要为保持打开状态写False。这就是为什么写False意味着继电器将处于打开状态。

当然这不是完整代码,你可以在我的GitHub账户中找到它。

连接互联网与数据可视化

由于我生成了大量数据,我决定做点什么。我想,我应该把我的系统连接到互联网。因此,我开始寻找能帮我解决这个问题的组件,我注意到Adafruit有一个新的系统叫做PyPortal,所以我去了商店并得到了它,开始尝试。

这是一个非常有趣的组件,允许你进行I/O操作,并且还有一个屏幕。虽然我尽了最大努力,但由于经验不足,我在将组件连接到设备时遇到了很多问题。我花了很多时间阅读关于GST连接器的内容,也看到了孔洞甚至Wi-Fi微芯片。我仍在弄清楚这些东西的细节,但那是另一个话题或其他时刻的故事。不过,我认为这真的是一个非常有趣的组件,如果你有使用PyPortal的经验,请与我联系,因为我想将其纳入我的系统中。

但是由于我遇到了一些困难和挑战,我也想通过物联网改进我的系统。我在物联网中找到了另一条道路。我非常幸运,因为当我在寻找选项时,来自柏林和汉堡的PyLadies决定在PyCon DE之后组织一个关于同一主题的工作坊,猜猜我得到了什么?树莓派!太棒了。

这是我的Pi的基础系统。

是的,为了使用寿命,我通过SSH连接到它,所以我能够安装Python。但后来我想,我可以安装很多其他东西,那么如果我在树莓派上运行一个网络服务器会怎样呢?这样我就不必再担心系统上有限的存储空间,因为我有我的Pi的SD卡。所以我开始检查他们是如何连接树莓派和微控制器的。

我发现了一个Pi串口。说实话,我开始尝试不同的代码,翻来覆去,最终找到了一种将所有微控制器引脚数据导出为CSV文件的方法。哇,这就是全部。

在某个时刻,我做了一些快速教程。我有一些设置简单网络应用程序的经验,所以考虑到当前处理数据的需求,构建一个监控系统是一个简单的决定。

你可以在图片中看到,我在一个表格上显示日志,右侧可以看到随着时间推移的湿度数据,以及设备在顶部小表格中的最后报告。正如你所看到的,图表上的下降意味着植物被浇了水。真是太神奇了。

远程访问与Telegram机器人

好吧,这很好,但当我开始研究如何从家外访问我的树莓派时,事情变得非常复杂。我问了很多人,尽管我通过一个叫做no-ip的服务设法实现了它,但我并不觉得安全,因为保护网站或服务器太困难了。所以我想,如何才能在不设置复杂事物的前提下通过互联网访问它。

哦,抱歉,我收到了一条消息。哦,让我们看看,是植物眼。好的,数据看起来不错。让我们看看家里发生了什么。好吧,一个看起来不错。Telegram。我使用Telegram已经很久了,我知道理论上这是可行的。

设置一个机器人很简单,我有一些朋友也有一个,所以我上网查找,教程真的很简单。你告诉机器人爸爸(BotFather),你想要一个新的机器人,就这样。他给你一个令牌。当然,Python又一次帮助了我,因为有一个库可以在Python中为你的机器人添加功能,就像你在幻灯片中看到的那样。这就是故事,希望你喜欢。这也是一个机器人。

未来计划与总结

我有很多疯狂的想法,关于我想开始研究的话题,但是……

当然,一次只做一件事。目前我正在尝试理解PyPortal板,因为拥有一个屏幕将大大改善系统。但我也在考虑是否应该购买一个Raspberry Pi的屏幕,并尝试将其集成到我的系统中,但这需要我慢慢考虑。

同时,我仍然在处理几个附近的数据,所以我不相信云服务。这将改善我当前的状况,但在某些情况下我已经审查了一些,以防我需要更好的基础设施。我尝试了谷歌云存储和其他服务。让我们看看未来会发生什么,但目前我认为我不需要它。

由于隔离,我无法建立我设计的外壳基础设施,但相信我,很快就会准备好。目前我正在使用一个并行系统建立高效的浇水系统,所有的继电器和湿度传感器都在其中。你可以在右侧的并行系统中看到,这仍然是一个原型,但很快就会推出。我已经有两个或三个想法了,所以请保持关注。我会尽量通过社交媒体与大家沟通所有新内容。

相信我,我已经准备好了很多传感器,我甚至没有时间去尝试它们。希望你能找到几个组件,获取它们并开始尝试,但最重要的是让我们的植物快乐。

感谢观看我的演讲。我希望现在你能有动力开始在家中为你的植物自动化更多系统。如果你有更多问题或评论,请告诉我。如果你想查看代码、组件和实施细节,请查看我的GitHub。

非常感谢。谢谢。(嗡嗡声)

[蜂鸣器响]


本节课总结

在本节课中,我们一起学习了如何从零开始构建一个智能室内花园系统。我们从项目背景和动机出发,了解了选择合适植物和传感器的重要性。接着,我们逐步探索了如何利用 Circuit Playground Express 微控制器和 Circuit Python 构建第一个自动浇水原型,其核心逻辑是通过读取土壤湿度传感器的模拟输入值,控制继电器的数字输出来开关水泵。

随后,我们看到了系统如何迭代升级,增加了水位监测、语音提示等更多功能,并学习了如何通过编写Python类来组织代码。为了处理数据和实现远程监控,我们引入了树莓派来搭建本地Web服务器进行数据可视化。最后,为了更安全便捷地远程访问,我们介绍了通过Python库创建Telegram机器人的方法。

整个项目展示了如何将植物学知识、硬件(微控制器、传感器、执行器)和软件(Python编程)结合起来,解决实际问题,并在此过程中不断学习、实验和优化。希望这个教程能激发你动手创造自己的智能花园项目。

056:领导力、身份与社区构建

在本节课中,我们将学习 Marlene Mhangami 关于领导力与身份的见解。我们将探讨领导者如何被想象、身份认同的重要性,以及如何通过包容性构建统一的社区。课程将结合哲学概念与 Python 社区的实践案例,帮助初学者理解领导力的本质。

课程概述

大家好,我是 Maureen。今天我们将讨论领导力,以及它在泛非洲 Python 运动中的角色。我居住在津巴布韦的哈拉雷,并担任 Python 软件基金会董事和 PyCon Africa 主席。这些经历让我对领导力有了深刻的思考。

领导者作为想象中的存在

上一节我们介绍了课程主题,本节中我们来看看人们对领导者的固有印象。亚里士多德曾说:“灵魂从不在没有形象的情况下思考。”这意味着我们在思考“领导者”时,脑海中会自然浮现出一个形象。

2014年《纽约时报》报道的一项研究显示,当被要求描绘“领导者”时,约90%的参与者画出了男性形象。这引出了一个核心问题:我们脑海中的领导者原型从何而来?

研究人员 Elizabeth McClean 指出:“人们脑海中对领导者的原型有自己的定义。当我们看到一个个体时,会问,他们是否符合这个原型?”

领导力观点的历史演变

为了理解领导者原型的来源,我们需要回顾历史。古希腊哲学家柏拉图提出了“哲学王”的概念,认为领导者应是社区中最聪明、最有道德的人。他早期曾用“牧羊人”比喻领导者,但后来认为这个比喻不妥,因为它暗示领导者与追随者是不同物种,强化了等级观念。

快进到信息时代的今天,领导者与追随者之间的知识差距正在缩小。2018年,Elm 语言创造者 Evan Czaplicki 在演讲中指出,许多在线社区对等级制度持负面看法,普遍不信任试图建立结构的领导者。

我们看到了两种极端的领导力观点:一种是浪漫化的完美领导者,另一种是负面的控制型暴君。两者都不理想。

领导者作为统一者:织布工的比喻

那么,什么才是恰当的领导者比喻呢?柏拉图提出了一个深刻的比喻:领导者就像织布工

织布工将两种不同的羊毛——柔软的“纬纱”和粗糙的“经纱”——编织成一块完整的布料。同样,优秀的领导者能够将持有不同观点、来自不同背景的人们团结起来,围绕一个共同目标协作。

好领导者 = 统一者

然而,在全球化的 Python 社区中,随着成员背景日益多样,统一工作变得更具挑战性。在深入探讨如何成为统一者之前,我们需要明确统一不等于同质化,也不仅仅是表面上的多样性。

以下是统一社区的两个关键点:

  • 统一不是同质化:统一不要求所有人观点、经历相同。
  • 统一超越多样性:仅有 diverse 的人群不足以形成统一体,关键在于包容性与归属感。

身份认同与社区归属感

如何让人们感到归属?理解人们的身份是关键。哲学家查尔斯·泰勒认为:“为了了解我们是谁,我们必须对我们如何成为现在的自己以及我们要去往何处有一个概念。”

“我们如何成为”指向我们的历史和传统。以我个人的 Shona 族身份为例,我们的问候方式、聚会时的歌舞、传统食物 Sadza 都是身份的重要组成部分。在 PyCon Africa 2019 上,加纳的传统美食、开幕舞蹈和集体舞环节,都让来自非洲各地的人们感受到了文化共鸣和归属感。

然而,身份不仅关乎历史(“我们如何成为”),也关乎未来(“我们要去往何处”)。这引出了领导者的另一个核心角色。

领导者作为创造者:构建多彩的未来

我认为领导者的第三个角色是创造者,他们塑造未来。未来应该是多彩而充满活力的,就像 RGB 色彩模型。

RGB 模型:红、绿、蓝三原色本身是美丽的。但当它们混合统一时,能创造出无限丰富的新色彩。

同样,当我们的社区汇聚了来自不同背景、拥有不同视角的成员,并通过有效的领导将他们统一起来时,创造力、创新和发现的潜力将会呈指数级增长。

未来领导者的特质

那么,未来的领导者应具备哪些特质?以下是三个关键特征:

以下是未来领导者的三个关键特征:

  1. 好奇:主动了解他人的信仰、传统和文化,甚至踏入令自己不适的领域。
  2. 谦逊:不总是坚持自己的观点最正确,能够与意见不同者合作。
  3. 开放:不仅倾听和学习,也愿意分享自己的经历,展现脆弱性,以此建立同理心。

通过培养这些特质,我们可以建立更强大、更统一的社区。

课程总结

本节课中我们一起学习了领导力与身份在构建社区中的作用。我们探讨了领导者从“想象中的存在”到“统一者”(织布工)再到“创造者”的角色演变。我们明白了身份认同(历史与未来)对归属感的重要性,并指出了未来领导者应具备好奇、谦逊和开放的特质。在泛非洲乃至全球 Python 社区中,包容性地统一多样性,是创造创新、多彩未来的关键。

057:持续集成文档教程 🚀

概述

在本教程中,我们将学习如何将文档视为代码,并为其建立持续集成(CI/CD)流程。我们将探讨传统文档管理方式的弊端,介绍“文档即代码”的理念、核心工具以及如何通过自动化流程提升文档质量和开发体验。


章节 1:传统文档管理的问题

上一节我们介绍了本教程的主题,本节中我们来看看传统文档管理方式存在哪些问题。

这是一种常见的文档处理流程。开发人员编写代码,提交代码,代码经过审查和测试以确保质量。如果测试通过,代码就准备发布。如果未通过,开发人员则返回修改代码。

当代码准备发布时,就需要有人来编写文档,因为用户期望有文档与代码配套。编写文档的人可能是开发者、技术写手,甚至是项目外的新员工。这有时会导致文档质量参差不齐。

当前做法的问题在于,文档几乎是事后才想到的。在漫长的发布周期中,事情容易被遗忘。例如,如果发布周期长达8个月,开发者很难记得最初的工作细节和需要记录的内容。

此外,实施者(开发者)和文档作者之间的分离层越多,文档不准确的可能性就越大。有些公司让技术写手与工程师紧密合作,这很好。但如果让不熟悉代码的工程师写文档,这层隔离很可能导致不准确。

最大的问题是,许多开发者不喜欢编写文档。开发者喜欢编写代码、讨论代码,但往往不喜欢编写文档。真正的问题在于,大多数开发者并非讨厌文档本身,而是讨厌被迫使用的工作流程。开发者需要切换工具,离开熟悉的代码编辑器和终端,转而去使用可能并不顺手的Word或Wiki工具。这种上下文切换让他们不愿意写文档,导致任务被推迟到最后。


章节 2:将文档视为代码的解决方案

上一节我们讨论了传统工作流的痛点,本节中我们来看看如何通过改变思路来解决问题。

我们换个角度思考。与其将文档视为独立产物,不如像对待代码一样对待文档。这意味着:

  • 文档文件存储在版本控制系统(如Git)中。
  • 文档可以与源代码存放在同一目录。
  • 文档的构建和部署是自动化的。
  • 每次提交或拉取请求时,都会自动构建文档工件。

我们通常如何运行单元测试?我们会从测试中获取报告。同样,我们也可以构建文档,以便查看每次变更对文档的影响。

我们可以建立一套可信的评审流程,确保对文档的审查一丝不苟。我们一直对代码进行代码审查,为什么不对文档也进行同样细致的审查呢?

我们可以检查文档的准确性和功能性。是的,文档是可以测试的。例如,Sphinx工具可以测试超链接是否有效,代码片段是否能产生预期的输出。

这还允许我们在无需人工干预的情况下发布文档。


章节 3:“文档即代码”的优势

上一节我们介绍了核心理念,本节中我们来看看这样做能带来哪些具体好处。

如果我们像对待代码一样对待文档,将获得以下优势:

以下是“文档即代码”的主要优势列表:

  1. 促进协作:GitHub等平台促进了源代码的协作,同样也能促进文档的协作。任何人(如其他团队成员、支持人员或开源社区成员)发现文档错位或破损,都可以提交拉取请求来修复。
  2. 跟踪文档错误:文档中的错误应被视为重要的Bug。因为它们可能导致用户错误使用产品,进而引发抱怨。应将文档错误视为优先级较高的Bug。
  3. 强制文档更新:很多时候,添加新代码时需要更新文档。如果你修复了Bug、增加了新功能或进行了性能优化,文档可能需要相应更新。将此作为流程的一部分,能确保文档同步。
  4. 构建统一美观的文档:可以创建流程,确保整个公司或项目的文档具有一致的外观和良好的体验。
  5. 利用开发者工具和工作流:我们可以利用一直在使用的敏捷、GitOps等开发工具和工作流,将这些高效实践应用到文档管理中,例如使用Git进行版本控制。
  6. 赋能开发者编写文档:当文档成为代码评审的一部分时,开发者会逐渐习惯编写文档。这种方式让开发者更愿意并持续地更新文档。

章节 4:案例研究与工作流转变

上一节我们列举了诸多优势,本节中我们通过案例来看看实际效果,并对比工作流的变化。

有两个案例可以证明其效果。第一个案例来自Vero公司,一个网站可靠性工程团队。在开始开发产品之前,他们在GitHub组织中添加的第一个仓库就是文档仓库。所有架构讨论和决策都被记录并推送到Markdown文件,然后自动更新到网站。这使得文档始终保持最新且维护良好。

第二个案例是,在引入“文档即代码”工作流后,团队内部文档得到了极大改善。新成员 onboarding 所需时间从两个月缩短到几天,因为所有流程和知识都已妥善记录。

这将如何改变我们之前讨论的传统工作流呢?

  1. 开发者编写代码,同时也编写文档。
  2. 开发者提交代码和文档(它们可能在同一个仓库)。
  3. 代码和文档一同经过审查和测试。
  4. 如果通过,只需按下一个按钮即可发布,因为文档已是流程的一部分。
  5. 如果未通过,开发者返回修改代码和文档。

开发者无需中断编码流程去专门编写文档。如果你曾进行过“文档冲刺”,强烈建议尝试此工作流,你将获得更高效的迭代周期。


章节 5:什么是文档的 CI/CD?

上一节我们看到了工作流的转变,本节中我们来明确一下核心概念:CI/CD。

CI/CD 是持续集成和持续部署的缩写。

  • 持续集成:代码被持续测试并与其他代码变更集成。
  • 持续部署:代码被持续部署到测试或生产服务器。

对于文档而言,CI/CD 意味着用每一个补丁构建一个完整的文档工件。每次提交都会生成新版本的文档。实际上,文档版本应与代码版本完美对齐。你可以持续测试每个补丁的内容,例如测试代码片段或确保链接有效。文档可以自动发布,并且拥有版本号。

版本文档非常有用,用户可以明确知道他们正在查看的文档对应哪个API或软件版本。


章节 6:文档类型与工具

上一节我们定义了CI/CD,本节中我们来看看有哪些文档类型和工具可以应用此理念。

文档主要分为长格式文档(如用户指南、常见问题解答)和基于源代码的API文档(如Swagger、SDK手册)。

对于长格式文档,常用的工具是静态站点生成器,数量非常多。对于基于源代码的文档,有像Sphinx、Javadoc这样的工具,它们可以从代码注释生成文档。有些工具甚至能生成API测试客户端。

文档工具通常有一个特点:工具功能越强大,使用可能越复杂。例如,Microsoft Word易于使用但功能有限;而LaTeX或Sphinx功能强大但学习曲线较陡。


章节 7:核心工具介绍:MkDocs 与 Sphinx

上一节我们了解了文档工具生态,本节中我们重点介绍两个强大的工具。

我们将讨论两个我喜欢的文档工具:一个用于静态站点(MkDocs),一个用于代码文档生成(Sphinx)。

MkDocs
MkDocs 是基于Markdown和YAML配置的静态站点生成工具。它非常简单,从零到“Hello World”大约只需30秒。它易于配置,支持许多扩展和主题(如Material主题),并具有出色的站内搜索功能。它是基于Python的,因此易于扩展。我最喜欢的一点是,它可以与flowchart.jssequence.js等JavaScript库结合,直接从Markdown生成流程图,将图表也纳入版本控制。

Sphinx
Sphinx 是一个基于reStructuredText(也支持Markdown)的文档工具,是创建Python代码文档最常用的工具。它功能极其强大,几乎可以生成任何格式的输出。Sphinx一个非常棒的功能是文档测试,它可以解析文档中的代码示例,实际执行它们,并验证输出是否与声明的一致,这为文档准确性提供了另一层保障。


章节 8:实战演示:自动化文档流水线

上一节我们介绍了核心工具,本节中我们通过一个演示来看看如何搭建自动化流水线。

演示目标:创建一个开源项目文档站点,要求格式统一、开箱即用、构建发布自动化,让非技术人员也能轻松贡献。

解决方案如下:
以下是实现自动化文档流水线的步骤:

  1. 生成文档框架:使用cookiecutter模板工具生成一个预配置的MkDocs项目结构。
  2. 编写文档:作者在生成的docs目录中编写Markdown文件。
  3. 提交与推送:作者通过Git提交并推送更改到GitHub仓库。
  4. 自动构建与发布:配置CI/CD工具(如Travis CI),在每次推送时自动构建文档站点,并发布到GitHub Pages(或其他托管服务)。

演示步骤简述:

  1. 运行 cookiecutter 命令,回答项目名称、作者等问题,生成项目。
  2. 进入项目目录,可以看到mkdocs.yml配置文件、docs文档文件夹等。
  3. 本地运行 mkdocs serve,即可在浏览器实时预览文档,编辑文件后页面会自动刷新。
  4. 在GitHub创建新仓库,将项目推送上去。
  5. 配置Travis CI,添加GitHub Token以允许其向GitHub Pages推送构建结果。
  6. 此后,每次向GitHub仓库推送更改,Travis CI都会自动构建并更新在线文档站点。

章节 9:如何开始与最终建议

上一节我们完成了整个流程的演示,本节中我们提供一些起步资源并总结核心建议。

你可以自己尝试!UnlockEDU是一个开源项目,致力于创建免费教育资源。他们提供了一个cookiecutter模板,可以用一个命令设置完整的文档管道,就像演示中一样。

本次演讲的很多灵感来源于《Docs Like Code》这本书,强烈推荐阅读以深入了解。

最后的一些建议:

  • 实施此工作流后,团队 onboarding 更容易,客户更满意,支持工单更少。
  • 让文档变得有趣,将其作为流程的一部分,而不是在最后进行的“惩罚性”的文档冲刺。
  • 如果你的文档很糟糕,人们会放弃你的项目。在当今时代,如果某物不能立即工作,用户就会转向下一个。
  • 版本文档非常棒,我们应该更多地使用它。所有文档都应该有版本,这样用户就能知道他们对API的期望是什么。
  • 请务必对你的文档进行版本控制。

总结

在本教程中,我们一起学习了“文档即代码”的理念。我们分析了传统文档管理的弊端,探讨了将文档纳入版本控制、自动化构建和测试、以及建立CI/CD流程的诸多好处。我们还介绍了MkDocs和Sphinx等实用工具,并通过一个演示展示了如何搭建从编写到发布的全自动文档流水线。记住,优秀的文档是项目成功的关键,通过像对待代码一样对待文档,我们可以让文档编写变得更高效、更协作、也更愉快。

058:从个人贡献者到管理者的转变

在本节课中,我们将学习如何从一名专注于编写代码的软件开发者,成功转型为一名专注于团队和人员发展的管理者。我们将探讨管理的本质、转型的动机、角色变化以及所需的技能和心态。

什么是管理?👥

上一节我们介绍了课程概述,本节中我们来看看管理的核心定义。

管理是关于人的工作。你整天都在与人打交道,包括你的团队成员、其他团队的经理、不同部门的同事,以确保业务目标的执行。如果你不喜欢与人一起工作,你将在管理岗位上举步维艰。这并不意味着内向的人不能成为管理者,关键在于你是否享受与人互动。

作为管理者,你是团队的主要接口。当外界对团队工作有疑问或需求时,他们首先会联系你。因此,你需要具备一种近乎客户服务的态度。如果人们与你交谈后感觉不佳,他们就会停止沟通,你的团队可能会因此错失机会或重要信息。你需要成为一个让人乐于接触的人。

本质上,管理者是团队的缓冲器。工程师是宝贵的资源,他们专注于重要的目标。你的职责是保护他们免受干扰,确保不成熟的想法在到达团队前已被充分完善,识别并提前清除障碍,让他们能集中精力实现目标。这并不意味着你永远不让团队接触外部信息,但你需要尽量减少不必要的干扰。

此外,管理者也是团队成员的职业教练。你的目标是帮助个人贡献者成长,让他们变得更有价值。这不仅对组织有利,也能让团队成员感到充实和有收获。如果他们觉得没有成长,可能会选择离开。因此,一对一沟通的主要目的就是指导他们的职业发展,并将他们与符合其个人目标的项目联系起来。

为什么管理可能不适合你?🚧

现在我们讨论了什么是管理,接下来我们谈谈哪些原因可能意味着管理岗位并不适合你。

如果你将晋升为管理者视为职业阶梯上的必然一步,你需要停下来思考。晋升意味着角色本质的改变。你需要确认这是你真正想做的事情,因为留在当前岗位并没有错。管理并非传统意义上的“晋升”,它是一次彻底的角色转变

另一个不适合进入管理层的原因是:如果你对组织现状感到沮丧,并认为成为管理者是改变组织的方式。这种想法并非全错,因为管理者确实被赋予了一些权力。但管理者真正的核心技能是影响力。如果你在作为个人贡献者时都难以影响组织,那么作为管理者,这个问题可能会被放大。因为管理者不能单靠命令行事,更需要通过影响力来驱动团队和协调外部资源。

为什么你应该考虑管理?✅

现在我们已经讨论了一些你可能不想考虑管理的原因,让我们谈谈一些积极的、适合转向管理的理由。

首先,你需要从他人的成功中获得动力。作为管理者,你的角色是让他人完成工作。你必须能从团队成员的成功和整个团队的成功中获得满足感。

其次,你需要具备高度的同理心和换位思考能力。管理者整天与人打交道,并会面临许多目标冲突和意见分歧。如果你能理解他人的立场,就更有可能找到双赢的解决方案或妥协。虽然不能让每个人都满意,但理解他人是解决问题的关键。

第三,你需要享受思考团队和组织动态,并乐于规划如何将想法转化为可执行的项目。这包括与团队分解项目、创建工单、协调其他团队、管理依赖关系,并确保个人需求与组织目标保持一致。这又回到了同理心:你需要协调组织需求、团队需求和个人贡献者的需求,在可能的情况下实现多赢。

成为管理者后,什么会改变?🔄

上一节我们探讨了转向管理的动机,本节中我们来看看角色转变带来的具体变化。

第一,代码不再是你的主要产出。管理是关于人的工作。你的主要职责是协调、沟通和清除障碍,确保工作能顺畅地交付给团队。你可能几天都不会写代码。但这并不意味着你应该完全脱离技术。在职业生涯早期,你仍需对团队工作的系统有技术上的理解。保持技术敏锐度的方法包括:主动承担一些非关键路径的项目、处理功能或缺陷、协助调试生产问题。但关键是,你选择的工作必须是可以随时放下的,绝不能因为你的参与而阻塞团队进度。

第二,成功变得模糊。你不能再轻易地指着某个具体功能说“这是我做的”。衡量你成功的标准变成了团队的成功,而这通常难以量化。你需要适应这一点,并认识到自己工作的价值,即使它不那么显而易见。行业内的工程师都能分辨出好经理与坏经理的区别,这就是你价值的体现。

第三,你的日程表将变得异常繁忙。作为管理者,你的大部分时间将被会议占据。你需要更有策略地管理日历。请记住:忙碌不等于高效。你需要有自我意识,区分“看起来很忙”和“真正创造价值”。

以下是应对繁忙日程的几个建议:

  • 开发一个任务管理系统。无论是使用待办清单、看板还是Jira,关键是将想法从大脑中移出,进行组织。你的大脑是用来思考的,不是用来记忆的。
  • 学会主导和参与高效会议。会议应有明确目标、产出行动项,并且规模宜小。如果发现会议低效,应提供反馈并推动改进。
  • 接受不确定性。当团队或他人向你寻求答案时,你可能并不总是知道。你需要习惯这种状态,并能够在不确定中推动团队前进。诚实地说“我不知道”是可以的,但同时要准备好引导团队寻找解决方案。

理解组织与政治 🏛️

现在我要谈论一个可能有些争议的话题:办公室政治。在工程组织中,常有一种观点认为工程师应超越政治,只关注“正确的代码”。我认为这是一种相当天真的想法。

政治在这里的定义是:一群人如何协作并做出决策。任何组织都存在决策系统,无论是正式的层级结构还是非正式的关系网络。如果你想推动想法落地,就必须理解并与这个系统合作。

作为管理者,尤其不能忽视政治。拥有正确的想法并不够,你还需要知道如何让想法在组织中获得通过。请记住,管理者的权力很大程度上是“虚构的”,真正的力量来自于影响力和对组织运作方式的理解。

人际关系至关重要。你需要与你的团队、其他管理者以及组织内其他成员(如产品经理、设计师)建立牢固的关系。没有这些关系,你将寸步难行。

以下是处理组织关系时需要注意的几点:

  • 为团队辩护,但保持合作。你的职责之一是维护团队的利益,但要记住大家同属一个更大的组织。在发生争执时,应寻求合作与妥协,力求双赢,避免“焦土政策”。
  • 建立团队品牌。确保组织中的每个人都清楚你的团队做什么以及不做什么。明确的团队愿景和边界可以防止其他团队主导你的工作路线图或将你随意列为依赖项。团队品牌有助于建立这种认知。

项目管理与流程 📊

现在我们谈论了政治和在组织中定位团队,让我们来谈谈如何选择和执行项目。

管理者对团队做什么、何时做,并没有绝对的话语权。高管常常会直接指派项目。作为工程经理,你需要做的是影响这个过程,并确保你的团队为成功做好准备。

实现这一点的关键是建立一个强大的工作流程。一个良好的流程能为你提供与业务部门有效对话的工具。每个团队都有流程,即使它是“随心所欲”。但一个可预测、可衡量的流程(如Scrum或看板)能让你清晰地了解团队的能力。

作为管理者,你需要寻找既受业务重视,又能拓展团队技能的项目。这两者有时会冲突。例如,一个对业务极具价值的移动项目,如果你的团队没有移动开发经验,风险就会很高。但如果团队中有人对此感兴趣,这可能是拓展团队能力的好机会,前提是不能让团队无法有效执行。

关于流程,最后要记住:人比流程更重要。流程应该服务于团队,而不是反过来。你需要让流程适应你的团队。

促进团队成长 🌱

作为管理者,你有强烈的动机推动团队不断成长和扩大影响力。这不仅对企业有利(团队成员更有价值),也与你个人的成功息息相关(你的价值通过团队体现)。同时,关注工程师的个人成长也是一种道义责任,确保他们在职期间有所收获。

促进工程成长意味着平衡业务需求和工程师的个人发展需求。这并非总能完美协调。有时业务需求优先,有时你需要给工程师提供成长机会,即使短期内对业务并非最优。通常,如果你相信他们,给他们机会,他们就能解决问题。

此外,避免过度依赖某一位明星工程师。将所有重要项目都交给同一个人,不仅会限制其他人的成长,也会在该成员离开时让团队陷入困境。应努力将责任和机会分配给你负责的每一个人。

职业发展是一种伙伴关系。最终,每个工程师都要为自己的职业发展负责。管理者的角色是提供指导和支持,但无法替代个人的主动性。

推荐资源 📚

最后,我想分享一些我认为有价值的资源:

  • 《Help, I have a manager》 by Julia Evans:这是一本简短的杂志,从个人贡献者的角度解释经理是做什么的,以及应该如何与经理相处。无论对个人贡献者还是新经理都很有价值。
  • 《The Manager‘s Path》 by Camille Fournier:这可能是我读过的最好的管理书籍。它清晰地描绘了从技术骨干到经理,再到管理经理的成长路径,即使你从未进入管理层,读来也很有趣。
  • Marco Rogers 在 Twitter 上的一个长推文:这个推文系列深入探讨了企业需求与关怀型领导者需求之间的张力,非常发人深省。
  • 《Ask a Manager》博客:这是一个关于一般性管理建议的有趣博客,里面有很多好建议和故事。

总结

在本节课中,我们一起学习了从软件开发者向管理者转型的核心要点。我们探讨了管理的本质是与人打交道,分析了适合与不适合转向管理的原因,并详细说明了角色转变带来的变化,包括工作重心从代码转向人、成功标准的模糊化以及日程的巨变。我们还深入讨论了理解组织政治、建立高效流程以及促进团队成长的重要性。记住,管理是一次深刻的角色转变,其核心在于通过影响力和对人的关注,来驱动团队和组织的成功。

059:在规模化环境中部署 Dask

在本教程中,我们将学习如何在分布式硬件上部署 Dask,并探讨在企业或机构环境中可能遇到的挑战。我们将重点关注环境管理、安全合规以及成本控制等核心问题,帮助初学者理解如何安全、高效地使用 Dask 进行大规模数据科学计算。

什么是 Dask?🤔

在深入探讨部署挑战之前,让我们先了解 Dask 是什么以及它能做什么。

Dask 是一个用于 Python 的并行计算库,它能够将计算任务扩展到多台机器上。其核心思想是模仿单机体验,但在分布式环境中运行。Dask 的架构包含三个主要组件:

  • 调度器:一个中心化的协调者,负责管理任务和工人。
  • 工人:多个执行实际计算任务的 Python 进程。
  • 客户端:用户与之交互的接口(如 Jupyter Notebook 或 Python 脚本)。

一个简单的 Dask 集群设置流程如下:

  1. 在一台机器上启动调度器:dask scheduler
  2. 在其他机器上启动工人并连接到调度器:dask worker tcp://scheduler-address:8786
  3. 在客户端代码中连接调度器:from dask.distributed import Client; client = Client('tcp://scheduler-address:8786')

环境管理:让集群像一台电脑 🖥️

上一节我们介绍了 Dask 的基本概念,本节中我们来看看如何管理分布式环境,使其对数据科学家而言像使用一台笔记本电脑一样简单。这主要涉及三个挑战。

统一的软件环境

所有机器(调度器、工人、客户端)必须运行相同版本的 Python、Dask 以及其他依赖库。在动态变化的数据科学工作中,这尤其具有挑战性。

解决方案:使用容器化技术(如 Docker)或环境管理工具(如 Conda)来打包和分发一致的软件环境。

资源共享

在多人协作的机构中,集群需要在不同用户和工作负载之间共享。

解决方案:利用资源管理器,例如:

  • Kubernetes
  • Hadoop YARN
  • 作业调度系统(如 Slurm, PBS, LSF)

这些系统可以动态分配和回收计算资源。

数据访问

在单机上,数据通常存储在本地硬盘。在分布式集群中,数据可能位于远程对象存储(如 Amazon S3)或网络文件系统。

解决方案:使用共享存储系统,并确保 Dask 配置了正确的凭据来访问这些数据源。

安全与合规:保护数据和资源 🔒

环境管理确保计算能够进行,而安全合规则确保计算在受控和授权的环境下进行。以下是需要关注的重点。

认证与授权

必须确保只有正确的用户才能访问集群和数据。在云环境中,需要安全地管理凭据(如 AWS 密钥),避免将其硬编码或明文传输。

解决方案:集成企业的身份认证系统(如 Kerberos),并使用密钥管理服务安全地传递凭据。

网络安全

像 Dask 这样的系统会在网络间传输代码和数据。如果没有保护,恶意用户可能窃听或冒充他人。

解决方案:为 Dask 集群启用传输层安全协议。Dask 支持 TLS/SSL 加密,需要为其配置证书。

成本管理:避免意外账单 💰

在云端,计算资源直接与成本挂钩。赋予数据科学家强大的伸缩能力的同时,也需要建立机制来控制成本。

避免资源闲置

数据科学工作负载通常是突发性的:短时间使用大量机器进行计算,然后长时间进行分析。闲置的机器会产生不必要的费用。

解决方案

  • 自适应伸缩:配置 Dask 集群根据负载自动增加或减少工人数量。
  • 自动空闲超时:设置规则,在工人空闲一段时间后自动关闭它们。

监控与追踪

需要了解资源的使用情况,以便优化和问责。

解决方案:使用监控系统(如 Prometheus、Datadog 或云服务商的控制台)来追踪每个用户、团队或任务的实际资源消耗和成本。

性能剖析

低效的代码在单机上可能问题不大,但在成百上千台机器上运行时,会显著放大成本。

解决方案:定期使用 Dask 的内置性能分析工具来识别和优化计算瓶颈。代码示例如下:

from dask.distributed import performance_report
with performance_report(filename="dask-report.html"):
    # 执行你的 Dask 计算
    result.compute()

总结与解决方案 🎯

本节课中我们一起学习了在规模化机构中部署 Dask 时会遇到的主要挑战:环境管理、安全合规和成本控制。

对于这些问题,存在许多成熟的解决方案:

  • 环境与资源:Kubernetes、Docker、YARN 等。
  • 安全:TLS、企业身份认证集成。
  • 成本与监控:自适应伸缩、Prometheus、Dask 性能分析器。

你可以选择组合这些开源工具来自行搭建和管理平台,也可以考虑采用托管解决方案(如 Coiled)。托管服务提供了开箱即用的集成,但可能会产生额外费用并有一定程度的供应商锁定。对于刚起步的团队,托管服务通常是快速上手的有效途径。

总之,在大型组织中部署分布式数据科学计算是复杂的,但通过理解这些核心挑战并利用现有工具,完全可以构建出既强大又可控的 Dask 计算环境。

060:用代码创造艺术的乐趣

在本课程中,我们将学习生成艺术的基本概念、历史、核心算法以及如何利用编程(特别是Processing和Python)来创作独特的数字艺术作品。课程将从基础的艺术元素和原则讲起,逐步深入到随机性、几何算法、分形、混沌理论,乃至遗传算法和生成对抗网络等高级主题。

概述

生成艺术是通过自主系统(如算法和代码)创造的艺术形式。艺术家设计一个包含随机性或规则的过程,由系统执行并产生艺术作品。本节课将系统性地介绍生成艺术的各个方面,帮助你理解其原理并开始自己的创作。


生成艺术:1:什么是生成艺术?

生成艺术是通过使用自主系统创造的艺术。Processing等工具使用迭代命令在屏幕上绘制基于矢量的形状。大多数生成艺术作品受到现代艺术,尤其是大量使用几何图案的波普艺术的启发。创作过程始终需要一个自主系统。

如果没有自主系统,作品就更偏向于数字艺术。随机性是一种常见的自主系统,它确保每次创建的设计都是独特的。艺术家的角色是设计一个包含某种自主性的过程,艺术家控制艺术中的随机性和顺序。因此,艺术的元素和原则是由系统提供的。


艺术基础:2:艺术的元素与原则

在创作艺术作品时,必须理解并应用艺术的基础构件,即元素和原则。艺术元素是用来创作艺术作品的构件,可以单独或组合使用。

以下是主要的视觉艺术元素:

  • 颜色:由色调、明度(亮度或暗度)和饱和度(强度)组成。
  • 形状:将三维形式转换为二维的艺术元素,可以是几何形状或有机形状。
  • 线条:定义形状和轮廓的基本元素。
  • 空间:物体之间及其周围的区域。
  • 质感:艺术作品表面的视觉或触觉质量。

艺术原则指导如何组合这些元素:

  • 节奏:艺术作品中的视觉运动感。
  • 对比:元素之间的差异,如颜色、明度、质感或大小。
  • 运动:引导观众视线在作品中移动的方式。
  • 比例:作品中各元素之间的大小关系。
  • 和谐:所有元素和原则结合形成的整体统一感。

上一节我们介绍了艺术的元素与原则,本节中我们来看看如何将这些理论应用于一个具体的生成艺术示例。


实践示例:3:从原则到生成作品

这个想法相当简单。我们将从画布上的一些随机点开始,然后开始画线。线条朝随机方向延伸,但一旦这些线条相互碰撞,它们就会开始以90度的角度创建新线条。这一点令人兴奋,因为最终呈现的艺术效果每次都真的非常不同。

这个程序很有趣,你可以看到这个作品是使用Python和Processing创建的。


历史背景:4:生成艺术简史

在模拟艺术(即手工艺术)中,复杂性和规模需要成倍增加的时间和精力。而计算机擅长于几乎无止境地重复过程而不感到疲惫。计算机生成复杂图像的便利性极大地推动了生成艺术的发展。

早期生成艺术家面临的一大挑战是输出设备的限制。当时主要的输出来源是绘图仪,一种由计算机指令控制握笔运动的机械设备。绘图作品通常是黑白的,因此大多数早期作品也都是黑白的。

最早制作彩色绘图的艺术家之一是弗里德·纳克,他们的艺术作品《致敬》创作于1965年,通常基于保罗·克莱的画作《高路与小路》。弗里德·纳克将克莱对水平与垂直线条及椭圆之间比例和关系的探索作为作品的基础。他在大小、比例以及线条和椭圆的部分上引入了随机性。

最早且最著名的生成艺术作品之一是乔治·尼斯于1968年创作的《Schotter》。Schotter从一排12个正方形开始,随着向下移动,旋转和位置的随机性逐渐增加。

计算机是创造这种艺术作品的最佳工具之一。想象一下,手工绘制上述图像可能需要一个小时。而通过向计算机提供一些简单的命令,我们可以在几分钟内创建数千件这样的作品,而且每次都有独特的风格。


关键工具:5:Processing 简介

用代码进行素描的需求催生了Processing语言。本·弗莱和凯西·瑞斯是该语言的创始人,并在过去的19年中一直致力于其发展,使其成为最知名的生成艺术家的首选平台。

Processing是为媒体艺术社区构建的编程语言和环境。它的目的是在媒体艺术背景下教授计算机编程的基础知识,并作为软件草图本。目前,Processing支持Java模式、JavaScript、Python、树莓派和Android模式。

在本教程中,我们将看到许多使用Processing和Python的示例。


核心概念:6:随机性与画布

在生成艺术中,随机性是一个主要因素。我们需要一种方法来生成随机数。random()函数在不同的编程语言中有所不同,但主要目标是提供一个介于0和1之间的随机浮点数。

在Processing中生成随机数:

float r = random(1); // 返回一个0到1之间(不包括1)的浮点数
float r2 = random(50, 100); // 返回一个50到100之间(不包括100)的浮点数

在Python中生成随机数:

import random
r = random.random() # 返回一个0到1之间(不包括1)的浮点数

在我们继续探索使用向量和形状创作艺术作品之前,需要了解画布的坐标系。画布就像一个2D笛卡尔平面,每个点可以被视为一个向量。一个二维向量 v 可以表示为:
v = (x, y)
其中 xy 是从原点到该点的坐标。我们可以对向量执行缩放、线性变换、旋转等操作。


基础绘图:7:点、线与向量

在Processing中创建点和线非常简单。

创建点:

point(x, y); // 在坐标(x, y)处画一个点

创建线并设置样式:

stroke(255, 0, 0); // 设置线条颜色为红色 (RGB)
strokeWeight(3); // 设置线条粗细为3像素
line(x1, y1, x2, y2); // 从点(x1,y1)到点(x2,y2)画一条线

我们也可以使用向量运算来创造动态效果。可以定义一个位置向量 P,以及速度向量 v 和加速度向量 a。它们的关系是:
v = v + a
P = P + v
通过改变加速度 a 中的小值,可以模拟物理效果(如风),使点产生运动。


形状与插值:8:创建基本形状

以下是使用Processing创建基本形状的方法。

创建椭圆和矩形:

ellipse(a, b, c, d); // (a,b)是中心点,c是宽度,d是高度
rect(a, b, c, d); // (a,b)是左上角坐标,c是宽度,d是高度
rect(a, b, c, d, radius); // 带圆角的矩形
rect(a, b, c, d, tl, tr, br, bl); // 每个角有不同半径

线性插值(lerp())在进行创意编码时是一个非常重要的函数,它在两个值之间进行平滑过渡。
公式为:
lerp(start, stop, amount) = start + (stop - start) * amount
其中 amount 通常介于0和1之间。
在Processing中使用:

float x = lerp(10, 20, 0.5); // x 等于 15


高级噪声:9:Perlin 噪声

Perlin噪声由肯·佩林在1980年代开发,用于创建更自然的程序化纹理。与完全随机的噪声不同,Perlin噪声生成的数字序列是平滑且有机的。

在Processing中使用Perlin噪声:

float n = noise(x); // 一维噪声,x是输入坐标
float n2 = noise(x, y); // 二维噪声
noiseSeed(seed); // 设置噪声种子,使结果可复现
noiseDetail(octaves, falloff); // 调整噪声细节水平

利用Perlin噪声,我们可以创建出复杂的向量场、地形或有机运动效果。


几何与分形:10:从模式到混沌

几何图案是生成艺术的常见主题。一个著名的例子是谢尔宾斯基三角形,它是一个递归分形:将一个等边三角形递归地细分为更小的等边三角形。

当我们谈论分形时,曼德博集是最著名的例子之一。它在复平面上定义,迭代公式为:
z_{n+1} = z_n^2 + c
其中 zc 都是复数。通过迭代计算平面上各点的发散速度,并赋予颜色,就能得到曼德博集分形图。

混沌理论意味着初始状态的微小变化会导致最终结果的巨大差异。吸引子(如洛伦兹吸引子)是混沌系统的数学可视化,其坐标演化遵循特定的微分方程。


图像处理技法:11:模拟绘画与像素排序

我们可以用代码模拟绘画或水彩效果。一种技术是“变形”,先创建基本形状(如多边形),然后通过算法将其向外扩展,形成细腻的边缘和纹理。

像素排序是一种图像处理技术,它根据亮度等标准,对图像中特定区域的像素进行排序和重排。基本步骤是:

  1. 加载图像并获取像素数组。
  2. 选择一行或一列像素。
  3. 根据像素的亮度值对该行/列进行排序。
  4. 将排序后的像素放回图像中。
    这个过程可以创造出独特的、条纹状的艺术效果。

进化与智能:12:遗传算法与GAN

遗传算法是一种模拟自然选择过程的优化技术。在生成艺术中,它可以用来“进化”出一组能近似复制目标图像的形状。
基本步骤包括:

  1. 初始化:创建包含随机形状(基因)的初始种群。
  2. 评估:计算每个个体(一组形状)与目标图像的差异(适应度)。
  3. 选择:选择适应度高的个体作为父代。
  4. 交叉:组合父代的基因创建后代。
  5. 变异:对后代的基因进行随机微小改变。
  6. 迭代:重复步骤2-5,直到得到满意的结果。

生成对抗网络是另一种强大的工具。它包含两个神经网络:

  • 生成器:尝试生成新的、逼真的图像。
  • 判别器:尝试判断图像是真实的还是生成器生成的。
    两者在对抗中共同进步,最终生成器能创造出以假乱真的艺术作品。


总结

本节课我们一起探索了生成艺术的广阔世界。我们从定义和艺术基础开始,了解了其历史和发展。我们学习了如何使用Processing和Python,通过随机性、向量、基本形状和Perlin噪声来创作。进而,我们探讨了更复杂的主题,如几何分形、混沌理论,并接触了高级技术如像素排序、遗传算法和生成对抗网络。

生成艺术的核心在于将艺术家的创意构思转化为算法和规则,并拥抱自主系统带来的意外之美。希望本教程能为你打开一扇门,鼓励你开始用代码探索和创造属于自己的独特艺术。

061:告别Print,拥抱调试器 🐛

在本教程中,我们将学习如何在Python中进行高效调试。我们将探讨使用调试器相比传统print语句的优势,介绍不同类型的调试器(如pdbipdb和IDE集成调试器),并分享一些实用的技巧和最佳实践。无论你是初学者还是有经验的开发者,都能从中获得新的启发。


调试器基础:为何告别Print? 🚫

上一节我们概述了本教程的内容,本节中我们来看看为何应该使用调试器。

使用print语句进行调试存在几个问题。它无法提供足够的上下文信息。当你需要调整打印内容时,过程可能非常繁琐,尤其是在处理大型嵌套数据结构时。有时,错误甚至可能隐藏在print语句本身中。

调试器则允许我们轻松探索运行程序的状态。我们可以交互式地编写新代码片段并进行实验,甚至可以将有用的片段保存并添加回代码库。调试器将你置于代码执行点,不仅能查看对象的字符串表示,还能检查函数参数、作用域内的变量等。

核心概念:设置断点
在Python 3.7+中,设置断点非常简单:

breakpoint()  # 程序执行到此会暂停

对于更早的Python版本,可以使用:

import pdb; pdb.set_trace()

调试器实战:从CLI到IDE 🛠️

上一节我们介绍了调试器的基本概念,本节中我们来看看具体的工具和工作流程。

有多种调试工具可供选择。命令行调试器(如pdb)便携且无需额外配置。我个人更喜欢ipdb,它提供语法高亮和更好的自动补全。对于Python 3.7+,内置的breakpoint()函数非常方便,并且可以通过环境变量PYTHONBREAKPOINT来指定使用的调试器。

核心概念:常用调试命令
以下是pdb/ipdb的一些基本命令:

  • l(list): 列出当前断点附近的代码。
  • n(next): 执行下一行代码(不进入函数内部)。
  • s(step): 进入函数调用内部。
  • c(continue): 继续执行,直到下一个断点或程序结束。
  • interact: 启动一个交互式Python shell,可以使用当前作用域的所有变量。

如果你更喜欢图形界面,集成开发环境(IDE)如VS Code提供了强大的调试功能。你可以直观地设置断点、悬停查看变量值、添加监视表达式以及使用条件断点。

核心概念:条件断点
在VS Code中,你可以设置断点仅在特定条件为真时触发。例如,只当language == "python"时才暂停:

# 在VS Code断点条件框中输入
language == "python"


高级技巧与最佳实践 ✨

上一节我们比较了不同调试工具,本节中我们来看看一些能提升效率的高级技巧和需要注意的事项。

以下是几个有用的技巧:

  1. 重复命令:在pdb提示符下,直接按Enter键会重复执行上一个命令。
  2. 跳出循环:使用until命令可以继续运行,直到行号超过当前行,这有助于快速跳出循环。
  3. 调试测试:在失败的单元测试中设置断点,是理解测试失败原因的绝佳方式。
  4. 增强交互体验:通过创建~/.pdbrc文件,可以配置pdb使用IPython作为交互shell,从而获得语法高亮、自动补全等强大功能。

重要提醒:生产环境注意事项
切勿将包含breakpoint()的代码提交到生产环境!这可能导致程序意外暂停。对于Python 3.7+,可以通过设置环境变量PYTHONBREAKPOINT=0来全局禁用断点。更好的做法是使用pre-commit这样的Git钩子工具,在提交代码前自动检查并阻止包含调试语句的提交。


总结 📝

本节课中我们一起学习了Python调试的核心知识。我们了解了调试器相比print语句的强大之处,实践了从命令行pdb/ipdb到VS Code图形化调试器的使用,并掌握了一些提升调试效率的高级技巧。

记住,使用调试器是提升开发效率、系统性地定位和修复Bug的关键技能。它帮助你验证关于代码行为的假设,并快速找到问题根源。希望本教程能给你足够的信心,告别低效的print调试,拥抱更强大的调试器工具。

062:演讲者Pratyush Das

概述

在本教程中,我们将跟随Pratyush Das的演讲,系统性地学习Python在高能物理(HEP)这一前沿科学领域中的应用与发展。我们将探讨高能物理的计算挑战、Python如何成为解决这些挑战的关键工具,以及相关的核心软件库和生态系统。

高能物理简介与计算起源

高能物理,也称为粒子物理,研究构成世界的基本粒子及其相互作用。高能物理学家试图回答宇宙的构成和支配力量等根本性问题。

一个令人惊讶的事实是,计算机的早期发展与物理学研究密不可分。第一台电子、非编程数字计算设备——阿塔纳索夫-贝瑞计算机(ABC)——由物理学家约翰·文森特·阿塔纳索夫和克利福德·贝瑞于1937年创造。随后,ENIAC计算机由物理学家约翰·莫奇利和J. Presper Eckert为弹道计算而开发。蒙特卡洛方法由尼古拉斯·梅特罗波利斯在洛斯阿拉莫斯国家实验室为物理问题而发明。奠定现代计算机基础的冯·诺依曼架构也由物理学家约翰·冯·诺依曼提出。甚至全球互联网也是由Tim Berners-Lee在欧洲核子研究中心(CERN)发明的。

高能物理的计算挑战

在探讨Python的应用之前,我们需要理解高能物理面临的核心计算挑战。高能物理实验会产生海量数据。基本粒子运动速度极快,它们之间频繁的相互作用需要被记录和分析。

这不仅仅是数据规模的问题,数据的复杂性同样构成挑战。粒子、轨迹和碰撞的表示本身就非常复杂。实验内置的触发系统会直接过滤掉大部分数据,但即便如此,需要存储和分析的数据量依然巨大。例如,CERN在2017年的数据存储量就突破了200 PB。随着大型强子对撞机(LHC)升级到高亮度阶段(HL-LHC),数据量将呈指数级增长。

为应对这一挑战,高能物理界建立了全球LHC计算网格(WLCG),这是一个分布在世界各地研究机构的计算机网络,用于分担数据存储和计算任务。然而,WLCG仍不足以满足所有需求。行业标准基准SPEC CPU 2006显示,通用CPU性能提升了近5倍,但针对高能物理应用优化的HS06基准显示性能仅提升约2倍。这表明通用CPU性能的提升并未完全惠及高能物理的特定计算模式。

解决方案:GPU与专用库

既然CPU性能提升有限,高能物理界开始转向GPU。许多高能物理计算是高度并行的,非常适合GPU架构。使用GPU可以显著提升特定物理问题的计算性能。

为了高效处理复杂的高能物理数据,专用的软件库被开发出来。例如,Awkward Array库就是为了处理复杂、不规则的高能物理数据而构建的。它的设计包含不同抽象层级,允许未来集成GPU后端,并通过Python接口提供易用性。

Python为何适合高能物理?

那么,是什么让一种编程语言在高能物理中变得理想?

  1. 易于使用:研究人员不希望花费大量时间学习新语言。Python以其简洁的语法和较低的学习曲线而闻名。
  2. 速度:处理海量数据需要语言足够快。虽然原生Python可能较慢,但与NumPy等库结合时,其性能可以媲美甚至超越C++。
  3. 主流与生态:主流语言拥有丰富的学习资源、社区支持和成熟的库生态系统。根据TIOBE指数,Python是世界上最流行的编程语言之一。

因此,尽管Python不是高能物理中最早使用的语言,但它凭借这些优势逐渐被广泛采纳。

Python在高能物理中的历史演进

物理学家很早就开始尝试使用Python。

  • 1994年,Jeff Templon等物理学家开始编写小脚本。
  • 1997年,美国费米实验室发表了关于将Python用作D0实验扩展语言的论文。
  • 1998年,Jeff Templon发表了题为《Python作为集成语言》的论文。
  • 2000年起,Python在CHEP(计算高能物理国际会议)上的相关演讲逐渐增多。
  • 2003年,Python开始被集成到一些实验的分析框架中,标志着物理学家开始认真使用Python进行分析。

根据J. Pivarski绘制的图表,Python在高能物理中的使用率在2019年首次超过了C++。这预示着未来的发展趋势。

核心工具:ROOT与Python接口

几乎所有高能物理研究者都使用ROOT。ROOT是一个用C++编写的综合性科学软件工具包,提供了处理大数据所需的一切功能:统计分析、可视化、存储、机器学习等。它几乎成为了高能物理计算的代名词,2012年希格斯玻色子的发现就是使用ROOT进行数据分析的。

然而,社区对Python接口的需求日益增长。ROOT提供了名为PyROOT的Python绑定。由于ROOT代码库极其庞大(数百万行代码),它没有为每个C++类手动创建绑定,而是采用了CPyCppyy技术。CPyCppyy能动态创建Python到C++的绑定,最初为ROOT开发,现在也作为独立项目存在。

尽管PyROOT在不断改进,但它仍存在一些问题,例如C++与Python对象间的所有权问题、风格不够“Pythonic”、处理某些数据时速度较慢。

替代方案:uproot

为了解决PyROOT的某些问题,uproot被开发出来。uproot是ROOT文件I/O在Python中的纯替代实现,完全用Python编写。它由Jim Pivarski及其团队创建,旨在提供更快速、更符合Python习惯的ROOT文件读写体验。

uproot虽然相对较新(始于2017年底),但已成为高能物理中最广泛使用的Python包之一。使用统计显示,它在科学Linux系统上的使用率与NumPy、SciPy等行业标准工具相当。

性能:Python不一定慢

与流行观点相反,Python在与高性能库结合时速度并不慢。在一个计算分形的基准测试中:

  • 使用NumPy的向量化操作,速度几乎与C++相当。
  • 使用uproot处理ROOT数据,速度比ROOT的C++实现快30倍。
  • 使用NumPy编译的特定代码,速度比C++ ROOT快90倍。

当然,这只是一个特例,但它证明了通过合适的库(如NumPy、Numba),Python代码可以获得极高的性能。

技术桥梁:PyBind11

PyBind11是一个用于在Python中创建C++绑定的流行工具。在高能物理中,它被用于将现有的C++代码暴露给Python。例如,Awkward Array库的最新版本就用C++和PyBind11进行了重写,以解决原有纯Python版本在扩展性和维护性上的挑战,同时满足物理学家对命令式接口的需求。其他库如直方图库pyhfGoOFit也使用了PyBind11。

生态系统:Scikit-HEP项目

Scikit-HEP项目旨在汇集高能物理研究所需的所有Python工具。它将许多活跃开发的高能物理Python库组织在一起,为物理学家提供了一个可互操作的工具箱。物理学家可以根据当前任务选择所需库,并在需要时轻松切换到项目内的其他工具。

Scikit-HEP包含各种功能的包,如粒子跟踪、衰变模拟、直方图处理、拟合、模拟等,并且仍在不断增长。该项目拥有活跃的社区,方便开发者与用户交流。

机器学习与未来趋势

机器学习和深度学习的最新进展也加速了Python在高能物理中的采用。虽然ROOT有自己的机器学习库TMVA,但行业标准框架如PyTorch和TensorFlow更强大、更流行。高能物理界对此持开放态度,甚至邀请PyTorch的联合创始人在学术会议上介绍其应用。

从物理学家的视角看,Python的优势在于开发效率。一位物理学家(Chris Burr)指出,他90%的代码可能只使用一次,因此编写和执行代码的时间至关重要。虽然C++运行快,但Python编写更快、更易读,总体而言可能更节省研究时间。

Python在其他物理领域的应用

Python在天文学领域的应用甚至更为广泛和成熟。Astropy库与Scikit-HEP类似,但采用度更高。统计显示,在天文学出版物提及的软件中,用Python编写的比例呈指数增长,已完全主导了其他语言。

推广Python采用的策略

对于如何在高能物理中进一步推广Python,主要有三种策略:

  1. 使用CPyCppyy创建绑定:对于已有的大型C++代码库(如ROOT),这是最简单的方法,但该技术可能尚未准备好用于普遍推广。
  2. 使用PyBind11封装:这是更通用的方法,但可能需要编写大量额外的封装代码。
  3. 用Python重写:对于新项目或小型库,这是最可取的,因为它对不熟悉C++的研究者更友好。但若对性能要求极高,开发者需要熟悉NumPy、Numba等优化库。

总结

通过本教程的学习,我们可以清晰地看到:

  • Python因其易用性、强大的库生态系统和与高性能计算结合的能力,在高能物理这类对性能有严苛要求的科学领域日益受到欢迎。
  • 与C++相比,Python具有更高的可读性和开发效率,这对主要兴趣在于物理而非编程的研究者至关重要。
  • Python是连接机器学习等现代数据科学工具与高能物理研究的自然桥梁。
  • 随着uproot、Awkward Array、Scikit-HEP等专门工具的发展,以及PyROOT的持续改进,Python在高能物理社区的地位已经确立并将长期存在。

对于希望进入高能物理领域的新人,熟悉Python及其科学计算栈(如pandas, TensorFlow)将比学习ROOT的特定替代品(如RooFit, FAMOS, TMVA)更具优势。


致谢与资源(根据原文整理):
本演讲内容得到了Jim Pivarski、Jeff Templon、Henry Schreiner、Eduardo Rodríguez等人的帮助和启发。相关项目链接可在原演讲幻灯片中找到。

  • 演讲者联系方式:ricktas@gmail.com
  • 演讲者GitHub:ricktas

063:隐私保护方法 - 构建安全项目 🔒

在本节课中,我们将学习如何在构建项目时保护用户隐私。我们将探讨几种核心的隐私保护技术,理解它们如何工作,以及在不同场景下如何应用它们来确保数据安全。


概述

大家好,我是丽贝卡。我来自巴西,是一名计算机工程师。今天我将讲解隐私保护方法,特别是如何构建安全项目。许多大公司经常测试他们能用我们的数据做什么。我将通过一些滥用隐私的案例,说明保护用户隐私的重要性,并介绍几种实用的技术方法。


1:为什么隐私保护至关重要?⚠️

上一节我们介绍了课程主题,本节中我们来看看为什么隐私保护是一个不容忽视的问题。

政府试图通过法规来确保人们在应用程序和系统中的隐私。然而,效果存疑。例如,有人仅因使用骑行追踪应用程序,就被错误地列为抢劫案嫌疑人。另一个例子是,政府使用手机数据监控社交隔离。这些案例引发了关于社会需要进行的辩论及其后果。

科学家们缺乏足够数据来建立新模型,因为最重要的数据往往掌握在大公司手中。最糟糕的是,人们普遍感到缺乏安全感,觉得一直被监视。但我们可以采取行动来保护隐私。


2:从简单案例开始 - 收集敏感数据 📊

上一节我们讨论了隐私的重要性,本节中我们来看看一个简单的数据收集案例。

假设你是一名研究人员,想调查运动员是否使用过违禁药物。你有两个选项:“是”(使用过)或“否”(未使用)。如果人们诚实回答,你会得到一个数据集,并可以从中获取统计数据。

但这里存在一个问题:即使数据集匿名(不包含姓名),仅通过统计数据也可能泄露个人隐私。攻击者可以通过提出不同的问题(例如,结合其他已知信息)来识别个体,这种攻击被称为“关联攻击”。


3:如何保护受访者?引入随机化回答 🎲

上一节我们看到简单的匿名统计并不安全,本节中我们来看看如何通过技术保护受访者。

我们仍然想收集数据,但要保护受访者。方法如下:我们给用户一枚硬币,并制定规则。

  • 如果硬币正面朝上,用户必须回答“是”。
  • 如果硬币反面朝上,用户则诚实回答真实情况。

这样,任何“是”的答案都可能是由抛硬币决定的,为受访者提供了“合理的否认”。这项技术被称为随机化回答。它通过引入随机性(偏见)来保护个人答案,同时当样本量足够大时,仍能计算出总体的统计比例(例如,使用违禁药物的用户百分比)。

核心思想:人们无需完全诚实地回答问题,系统引入的随机性保护了个人。


4:发布数据集时的风险与对策 🛡️

上一节我们介绍了保护数据收集过程的方法,本节中我们来看看发布数据集时面临的挑战。

有时,你需要与合作伙伴分享或公开发布数据集。通常的做法是删除姓名、身份证号等直接标识符。然而,一些被称为“准标识符”的属性(如邮编、性别、出生日期),当组合在一起时,很可能唯一地识别出一个人。例如,87%的美国人口可以通过这三条信息被唯一识别。

更危险的是“链接攻击”:当你的匿名数据集与其他公开信息源结合时,可能重新识别出个人。例如,Netflix和巴西电信公司Vivo都发生过此类事件。

一种防御技术是 k-匿名化

  • 做法:将准标识符泛化(例如,将具体年龄变为年龄段)或抑制(删除某些记录),使得任何一组准标识符都至少对应k个人。
  • 目标:你无法从表中直接指向任何一个体。

但k-匿名化并非完美,未来出现的新数据仍可能发起链接攻击。这里存在一个根本性的权衡:数据隐私数据可用性。你无法在极度限制数据的同时保持其高度有用。


5:差分隐私 - 强大的隐私定义与技术 🧮

上一节我们讨论了发布静态数据的保护方法,本节中我们来看看一种更强大的、适用于交互式查询的隐私技术。

如果我们不想发布原始数据集,但允许外界对数据集进行查询(例如,统计查询),该如何保护隐私?这引出了差分隐私的定义。

差分隐私的核心是:数据持有者与数据主体之间的协议,确保数据主体的隐私不会因为其数据被使用而受到影响。关键在于,无论攻击者拥有多少外部辅助信息,都无法推断出特定个体是否在数据集中。

在数据库上下文中,这意味着:从数据集中添加或删除任何一个人的记录,对查询结果的影响微乎其微。这为个人提供了“合理的否认”。

技术实现:通过在查询结果中添加精心设计的随机噪声来防止推理攻击。噪声的添加是随机的,这是该机制的关键。

差分隐私的数学定义(简化)
对于两个仅相差一条记录的相邻数据集 DD‘,以及任何查询输出 S,满足:
Pr[M(D) ∈ S] ≤ e^ε * Pr[M(D’) ∈ S]
其中 M 是随机化算法,ε 是隐私预算参数。ε 越小,隐私保护越强,但数据可用性可能越低。

差分隐私有很多优秀的开源实现(如Google的DP库、IBM的Diffprivlib),是构建隐私保护应用的强大工具。


6:联邦学习 - 协作训练,数据不离本地 📱

上一节我们学习了保护数据分析过程的差分隐私,本节中我们来看看如何在训练机器学习模型时保护隐私。

假设你想建立一个预测模型(如手机键盘的下一个词预测),但训练数据(用户输入)包含敏感信息。传统方法需要将数据集中到云端,存在隐私风险。

联邦学习提供了解决方案:

  1. 服务器将初始模型发送给各用户设备。
  2. 设备在本地用自己的数据训练模型,生成模型更新。
  3. 设备将模型更新(而非原始数据)发送回服务器。
  4. 服务器聚合所有更新,改进全局模型,再下发。

优点:训练数据始终保留在用户设备上,从未上传。这降低了延迟,并有助于建立更通用的模型。

注意:联邦学习本身不能完全保证隐私。通过分析发送的模型更新,仍可能推断出部分用户信息。因此,它常需与差分隐私等技术结合使用。


7:安全多方计算与同态加密 🔐

上一节我们介绍了联邦学习,本节中我们来看看另外两种在多方协作或数据外包场景下的高级隐私技术。

场景一:多方共同计算,互不泄露输入
假设六个人想知道平均工资,但谁也不愿透露自己的具体数额。可以使用安全多方计算

  • 过程:第一人生成一个随机大数,加密后加上自己的工资,传给下一人。每人依次加上自己的工资。最后一人传回给第一人,第一人减去最初的随机数,得到工资总和,再计算平均值公布。
  • 核心:所有人都知道结果,但无人知道其他人的具体输入。适用于多方协作建模,无需可信第三方。

场景二:委托处理加密数据
如果你因合规或安全原因,不能将数据下载到本地,但又需要云服务进行处理,可以使用同态加密

  • 过程:你将加密的数据发送给第三方。第三方在不解密的情况下,直接对密文进行计算,并将加密的结果返回给你。只有你拥有密钥,可以解密最终结果。
  • 优点:保护了你的数据(即使对处理方也不可见),也保护了第三方的算法模型(模型内部逻辑不被你窥见)。它被认为是后量子安全的,但目前计算开销较大,适用于较小规模的数据或计算。

总结

本节课中我们一起学习了构建安全项目时保护隐私的多种方法:

  1. 发布数据集:需警惕准标识符和链接攻击,可采用k-匿名化,但需权衡隐私与可用性。
  2. 进行聚合分析差分隐私提供了强大的隐私保证,通过在查询结果中添加噪声来实现。
  3. 训练机器学习模型联邦学习允许在数据不离本地的情况下协作训练模型。
  4. 多方协作或数据外包安全多方计算使多方能在不公开输入的情况下共同计算;同态加密允许对加密数据进行操作,结果解密后仍有效。

请记住关键原则:数据很难在完全匿名的同时保持高度有用;大数据集和统计查询并不自动意味着安全;务必警惕链接攻击。如果你处理欧洲公民数据,请务必寻求法律咨询以确保合规。隐私保护是一个持续的过程,需要结合业务需求、技术手段和法律法规来综合考量。

064:函数对象与作用域详解

在本节课中,我们将深入探讨 Python 函数的内部机制。我们将学习函数如何被 Python 视为对象,以及函数对象的各种属性如何决定其行为,例如参数处理和作用域规则。理解这些概念将帮助你编写更高效、更健壮的代码。

函数即对象 🧱

上一节我们介绍了课程概述,本节中我们来看看 Python 如何看待函数。在 Python 中,当你使用 def 关键字定义一个函数时,实际上发生了两件事:创建了一个函数对象,然后将该对象赋值给一个变量名。

例如,以下代码定义了变量和一个函数:

x = [10, 20, 30]
d = {'a': 1, 'b': 2, 'c': 3}
def hello(name):
    return f'Hello {name}!'

Python 将 xdhello 都视为全局变量,只是它们引用的对象类型不同。hello 变量引用的是一个函数对象。

将函数视为对象(名词)而不仅仅是可执行的代码(动词),为我们带来了灵活性。以下是函数作为对象的一些特性:

  • 函数可以被赋值给其他变量hi = hello,现在 hihello 引用同一个函数对象。
  • 函数可以作为参数传递print(hello(‘world’)) 中,hello 作为动词执行,而其返回值作为参数传递给 print
  • 函数对象拥有属性:像所有 Python 对象一样,函数对象也有属性。例如,dir(hello) 可以列出其所有属性。

函数的“心脏”:__code__ 对象 💓

上一节我们了解了函数作为对象的基本概念,本节中我们来看看函数对象的核心属性。函数对象上最重要的属性是 __code__ 对象,它包含了函数的“心脏、灵魂和大脑”——即编译后的字节码和各种元数据。

Python 不是纯粹的解释型语言。当我们定义一个函数时,它会被编译成一种称为“字节码”的中间形式,并存储在 __code__ 对象中。执行函数时,Python 虚拟机就运行这些字节码。

__code__ 对象本身也有属性,这些属性决定了函数如何工作。例如,Python 如何知道函数需要多少个参数?答案就在 __code__ 的属性中。

以下是 __code__ 对象中与参数相关的关键属性:

  • co_argcount:表示函数接收的位置参数的数量(不包括 *args**kwargs)。
  • co_varnames:一个包含函数所有局部变量名称的元组,其中前 co_argcount 个元素就是参数名。
  • co_flags:一个整数,其二进制位用于标记函数的特殊特性,例如是否包含 *args**kwargs,是否是生成器函数等。可以通过位运算进行检查,例如 hello.__code__.co_flags & 0x04 检查是否有 *args

当调用函数时,Python 会检查传入的参数数量是否与 co_argcount 匹配。如果不匹配,它会利用 co_varnames 中的信息生成清晰的错误信息,例如“hello() missing 1 required positional argument: ‘name’”。

处理特殊参数:默认值、*args**kwargs 🧩

上一节我们探讨了函数如何通过 __code__ 跟踪基本参数,本节中我们来看看更复杂的参数类型。函数可以定义默认参数、接收任意数量位置参数的 *args 和接收任意数量关键字参数的 **kwargs

对于默认参数,信息存储在函数对象本身的一个属性中:

  • __defaults__:一个元组,包含了所有位置参数的默认值。如果没有默认值,则为 None

一个常见的陷阱是使用可变对象(如列表)作为默认值。因为 __defaults__ 中的默认值在函数定义时就被创建并固定了,后续所有调用都会共享同一个可变对象。

def add_one(x=[]): # 危险!默认值是一个在定义时创建的列表对象
    x.append(1)
    return x

print(add_one()) # 输出:[1]
print(add_one()) # 输出:[1, 1] 而不是预期的 [1]

结论:永远不要使用可变对象作为函数参数的默认值。

对于 *args**kwargs,Python 通过检查 __code__.co_flags 中的相应位来判断函数是否接受它们。此外,还有一个属性专门记录仅限关键字参数(keyword-only arguments)的数量:

  • __code__.co_kwonlyargcount:表示仅限关键字参数的数量。

作用域规则与变量查找 🔍

上一节我们讨论了函数的参数,本节中我们来看看函数内部如何查找变量,即作用域规则。Python 遵循 LEGB 规则查找变量:Local(局部)-> Enclosing(闭包)-> Global(全局)-> Built-in(内置)。

Python 在编译函数时(即执行 def 语句时),就会确定哪些变量是局部变量。这个信息记录在 __code__.co_varnames 中。

考虑以下代码:

x = 100
def func():
    print(x) # 这行代码会让 Python 认为 x 是局部变量吗?
    x = 200

func() # 会引发 UnboundLocalError

运行 func() 会得到 UnboundLocalError: local variable ‘x’ referenced before assignment。这是因为函数体内存在 x = 200 这个赋值语句,Python 在编译阶段就将 x 标记为局部变量。当执行到 print(x) 时,Python 在局部作用域查找 x,发现它尚未被赋值,因此报错。

如果需要修改函数外部的变量,可以使用 globalnonlocal 声明:

  • global:声明变量来自全局作用域。
  • nonlocal:声明变量来自外层(非全局)作用域,用于嵌套函数。
def outer():
    total = 0
    def inner(x):
        nonlocal total # 声明 total 来自外层作用域
        total += x
        return total
    return inner

counter = outer()
print(counter(10)) # 输出:10
print(counter(20)) # 输出:30

nonlocal 使得内部函数 inner 可以修改外部函数 outer 的局部变量 total。Python 通过一种称为“闭包”的机制来实现这一点,外部函数的变量会被“捕获”到内部函数对象的 __closure__ 属性中。

总结 📚

本节课中我们一起学习了 Python 函数的内部工作原理。

首先,我们明确了 def 语句的双重作用:创建函数对象并将其赋值给一个名称。

其次,我们深入探讨了函数对象的各种属性,特别是 __code__ 对象,它存储了决定函数行为的核心信息,如参数数量(co_argcount)、参数名(co_varnames)、特殊标志(co_flags)以及编译后的字节码。

接着,我们分析了如何使用 __defaults__ 处理默认参数,并强调了避免使用可变默认值的重要性。我们还了解了 Python 如何通过 co_flagsco_kwonlyargcount 来支持 *args**kwargs 和仅限关键字参数。

最后,我们研究了 Python 的作用域规则(LEGB)和变量查找机制。理解了 Python 如何在编译时确定局部变量,以及如何使用 globalnonlocal 声明来跨作用域修改变量。这些知识揭示了那些我们习以为常的函数行为背后的精确逻辑。

065:用 Apache Arrow 实现高效多语言数据交换

概述 📋

在本教程中,我们将学习如何使用 Apache Arrow 来解决不同团队和系统之间交换和处理数据时遇到的常见问题。我们将探讨传统数据格式(如 CSV、Parquet)的局限性,并了解 Arrow 如何通过其内存中的列式数据结构,提供一种高性能、语言无关的解决方案,从而简化数据共享和分析流程。


数据交换的常见挑战与现有方案 🔄

在数据驱动的团队协作中,一个核心问题是数据交换。当不同角色(如数据工程师、数据科学家、业务分析师)需要基于同一份数据进行协作时,通常会面临一系列挑战。

以下是数据交换中常见的几个问题:

  • 数据格式不一致:不同团队可能使用不同的专有数据格式。
  • 存储成本高昂:存储大量数据,尤其是为了协作而存储多份副本,成本很高。
  • 协作执行困难:在共享数据上执行分析或修改操作流程复杂。
  • 性能瓶颈:使用通用文件格式(如 CSV)处理大规模数据时,性能往往不佳。

上一节我们介绍了数据交换的普遍难题,本节中我们来看看业界通常如何解决这些问题。

一般来说,解决团队间的数据交换问题有两个主要方向:

  • ETL与数据管道:使用专门的工具进行数据提取、转换和加载。例如,在 JVM 生态中有 Spark、Flink、Hive 等;在 Python 生态中可以使用 PySpark、Dask 等。这些工具擅长处理大规模数据的摄入、修改和写出。
  • 共享存储与分析:将数据推送到一个共享存储(如数据湖、数据库),然后不同团队使用 SQL 或分析工具(如 Tableau)直接访问。常见做法是将数据导出为 CSV 或 Parquet 文件供下游分析。

然而,这些方案仍有痛点。例如,当需要分析一个 10GB 的数据集时,下载文件可能受限于本地磁盘空间、内存或网络带宽。


Parquet 格式:优势与局限 📦

面对上述挑战,社区和公司广泛采用的一种方案是使用 Parquet 文件

Parquet 是一种为大规模数据分析设计的列式存储格式,它拥有以下主要优点:

  • 为分布式系统设计:非常适合 Hadoop 等分布式文件系统。
  • 高效的压缩:显著节省存储空间,减少网络传输流量。
  • 编码优化:数据经过专门编码,读取效率高。

但是,Parquet 也存在一些局限:

  • 需要完整解码:读取 Parquet 文件时,通常需要将整个文件或相关列解码到内存中,才能转换为程序可用的数据结构(如 Pandas DataFrame 或 Spark DataFrame)。
  • 序列化开销:在不同技术栈(如 Python 和 Spark)间传递数据时,可能涉及额外的序列化和反序列化过程。
  • 更新成本高:Parquet 文件本身不适合频繁的写操作。任何修改都可能需要重写整个文件,虽然像 Delta Lake 这样的项目旨在解决此问题,但可能引入额外的复杂性或成本。

Apache Arrow:内存中的列式数据协议 🏹

与 Parquet 侧重于持久化存储不同,Apache Arrow 的核心是定义一个内存中的数据结构,用于高效计算。

Arrow 的核心是一个语言无关的二进制列式内存协议。它定义了数据在内存中的布局方式,使得不同编程语言和系统可以零拷贝地访问相同的数据。

以下是 Arrow 的关键特性:

  • 列式内存布局:数据按列连续存储在内存中,这与 Parquet 在磁盘上的列式存储理念一致,但发生在内存层面。
  • 语言无关性:任何语言(如 Python、R、Java、C++)只要实现了 Arrow 规范,就能以相同的方式理解和操作这些内存数据,无需序列化/反序列化。
  • 进程间通信:这种标准化的内存格式非常适合作为进程间通信(IPC)的载体,例如在 Spark 和 Python 用户自定义函数(UDF)之间快速交换数据块。
  • 恒定时间访问:由于其数组结构,访问任何元素的时间复杂度是 O(1),性能极高。

为了更直观地理解,请看以下对比:

  • 传统行式存储:数据在内存中按行分组。第一行所有字段紧挨着,然后是第二行所有字段,依此类推。
  • Arrow 列式存储:数据在内存中按列分组。所有行的“session_id”值构成一个连续数组,所有行的“timestamp”构成另一个连续数组,等等。

这种布局使得基于列的分析操作(如对某一列求和、求平均值)速度极快,因为相关数据在内存中是连续存放的。


Arrow 与 Parquet 的协同工作 🤝

上一节我们介绍了 Arrow 的内存协议,本节中我们来看看它如何与 Parquet 等持久化格式协同工作。

Arrow 和 Parquet 并非替代关系,而是互补关系,它们共同构成了现代数据栈的高效链路:

  • Parquet 用于长期存储:Parquet 设计用于持久化存储,适合构建数据湖,保存数年甚至十年的历史数据。它压缩率高,节省存储成本。
  • Arrow 用于高速计算:当需要读取和分析 Parquet 数据时,可以利用 Arrow 将其快速加载到内存中。许多工具(如 Pandas、Spark)已经支持直接从 Parquet 文件读取到 Arrow 格式的内存数据,避免了中间转换。
  • 数据交换的“中间件”:Arrow 可以作为不同团队和工具之间数据交换的“中间件”。数据工程师可以将数据以 Arrow 格式共享在内存或共享文件系统(如 S3)上,数据科学家可以直接用 Python 读取并进行分析,实现无缝协作。

简而言之,可以理解为:Parquet 是磁盘上的高效列式存储,而 Arrow 是内存中的高效列式计算。两者结合,实现了从存储到计算的全流程优化。


实践示例:使用 Python 操作 Arrow 数据 🐍

理论介绍完毕,现在让我们通过一些简单的 Python 代码示例来看如何实际操作 Arrow 数据。

首先,你需要安装必要的库。我们将使用 pyarrow 库。

pip install pyarrow pandas

假设我们作为一名数据工程师,从一个数据源(例如一个 CSV 文件)获取数据,进行转换,然后将其保存为 Parquet 格式,同时利用 Arrow 进行高效处理。

示例1:读取 CSV,转换并保存为 Parquet

import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq

# 1. 模拟从巴西COVID数据网站下载数据集(这里我们假设已有一个CSV)
# 假设 df 是从 `brazil_io` 等数据源读取的 Pandas DataFrame
# df = pd.read_csv('covid_data.csv')

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/4dd30e079d37ea0f75742d616703ccc5_10.png)

# 为了示例,我们创建一个模拟的 DataFrame
data = {
    'date': ['2023-01-01', '2023-01-02', '2023-01-03'],
    'state': ['SP', 'RJ', 'MG'],
    'cases': [100, 150, 80],
    'deaths': [5, 8, 3]
}
df = pd.DataFrame(data)

# 2. 将日期列转换为正确的日期类型
df['date'] = pd.to_datetime(df['date'])

# 3. 将 Pandas DataFrame 转换为 Arrow Table
table = pa.Table.from_pandas(df)

# 4. 将 Arrow Table 写入 Parquet 文件
pq.write_table(table, 'covid_data.parquet')
print("数据已保存为 Parquet 文件。")

示例2:读取 Parquet 文件,使用 Arrow 进行过滤和子集选择

# 1. 从 Parquet 文件读取数据到 Arrow Table
table_from_parquet = pq.read_table('covid_data.parquet')

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/4dd30e079d37ea0f75742d616703ccc5_12.png)

# 2. 使用 PyArrow 的计算功能进行过滤(例如,选择州为 'SP' 的数据)
# 注意:PyArrow 有自己的一套表达式 API,这里使用简单的 Pandas 风格过滤进行演示
# 更高效的方式是使用 pyarrow.compute 模块
df_filtered = table_from_parquet.to_pandas()  # 转换为 Pandas DataFrame 进行过滤(小数据演示)
df_sp = df_filtered[df_filtered['state'] == 'SP']

# 3. 将过滤后的数据转换回 Arrow Table 并保存为新的 Arrow 文件(.arrow 或 .feather)
table_sp = pa.Table.from_pandas(df_sp)
# 使用 Feather 格式(基于 Arrow 的二进制文件格式,用于存储 Arrow 数据)
pa.feather.write_feather(table_sp, 'covid_data_sp.arrow')
print("SP 州的数据已保存为 Arrow 文件。")

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/4dd30e079d37ea0f75742d616703ccc5_14.png)

# 你也可以直接保存为 Parquet
pq.write_table(table_sp, 'covid_data_sp.parquet')

示例3:直接操作 Arrow 数据结构

# 创建一个原生的 Arrow Table
arrays = [
    pa.array([1, 2, 3, 4]),
    pa.array(['foo', 'bar', 'baz', None]),
    pa.array([True, False, True, True])
]
schema = pa.schema([
    pa.field('id', pa.int64()),
    pa.field('value', pa.string()),
    pa.field('flag', pa.bool_())
])
native_table = pa.Table.from_arrays(arrays, schema=schema)
print(native_table)
print(native_table.schema)

在这些示例中,pyarrow 库使得在 Pandas DataFrame、Arrow Table 和 Parquet 文件之间的转换变得非常流畅和透明。feather 格式则是专门为存储 Arrow 内存数据而设计的,读写速度极快。


总结 🎯

本节课中我们一起学习了如何利用 Apache Arrow 来应对多语言环境下的数据交换挑战。

我们首先分析了在团队协作中数据交换面临的格式、存储、协作和性能问题。接着,探讨了 Parquet 格式作为持久化存储方案的优点与不足,特别是其在读取和更新时的开销。

然后,我们深入介绍了 Apache Arrow 的核心价值:它是一个语言无关的列式内存数据结构协议。它通过定义数据在内存中的标准布局,实现了:

  • 零拷贝共享:不同系统间无需序列化即可访问数据。
  • 高性能计算:列式内存布局非常适合现代分析型查询。
  • 生态互通:作为 Spark、Pandas、R 等多种工具之间的高速数据桥梁。

最后,我们通过 Python 代码示例演示了如何将 Pandas 数据转换为 Arrow 格式,如何与 Parquet 文件交互,以及如何直接操作 Arrow 数据结构。

Arrow 并非要取代 Parquet,而是与它协同工作,共同构建从高效存储(Parquet)到高效计算(Arrow)的完整数据链路。对于需要在 Python 及其他语言生态中进行高性能数据交换和处理的开发者来说,理解和应用 Apache Arrow 是一项非常有价值的技能。


资源链接

希望本教程能帮助你入门 Apache Arrow,并在你的数据项目中实现更高效的处理和交换。

066:装在箱子里的蛇 🐍📦

概述

在本节课中,我们将学习如何使用 Briefcase 工具来打包 Python 应用程序。我们将了解为什么需要专门的打包工具,Briefcase 是如何工作的,以及如何用它来为不同平台(如 Windows、macOS、Linux、iOS 和 Android)创建独立的、用户友好的应用程序安装包。


为什么需要打包 Python 应用程序? 🤔

上一节我们介绍了 Python 打包的背景,本节中我们来看看分发 Python 代码的不同场景及其挑战。

Python 生态系统为库的分发提供了良好的解决方案(如 pip 和 PyPI)。然而,当我们需要将 Python 代码作为独立的应用程序分发给最终用户时,情况就变得复杂了。这些用户可能不是 Python 开发者,他们不关心虚拟环境或依赖管理,他们只希望像安装其他软件一样轻松地安装和运行你的应用。

以下是几种常见的分发场景及其面临的挑战:

  • 库分发:例如 requests。用户通过 pip install 安装,然后在代码中 import 使用。Python 对此有成熟的工具链(setuptools, pip, twine)。
  • 项目分发:例如一个 Django 网站或 Jupyter 笔记本集合。通常通过复制代码仓库来分发,运行环境(依赖、解释器)的设置留给用户,这需要用户具备一定的 Python 知识。
  • 命令行工具分发:例如 pytestblack。虽然可以通过 pip 安装并生成命令行入口点,但对于非 Python 开发者或希望全局安装的用户来说,体验并不友好。
  • 图形界面应用程序分发:例如一个用 Python 编写的桌面应用(如 Slack)。最终用户希望以熟悉的方式(如安装程序、应用商店)安装和运行,完全无需感知 Python 的存在。

目前,只有第一种场景(库分发)在 Python 生态中有完善的解决方案。Briefcase 的目标就是解决后两种场景,特别是图形界面应用的分发问题,让 Python 代码能像原生应用一样被交付给最终用户。


什么是 Briefcase? 🧰

上一节我们探讨了应用程序分发的难题,本节中我们来看看 Briefcase 提供的解决方案。

Briefcase 是 BeeWare 项目下的一个工具,专门用于将 Python 项目打包成可以独立分发的应用程序。它的核心目标是:让没有 Python 经验的最终用户能够安装和运行你的应用,而无需知道它背后是 Python。

Briefcase 是一个符合 PEP 518 的构建工具,使用 pyproject.toml 文件进行配置。它的工作原理可以概括为一个简单的公式:

Briefcase 应用 = 你的代码 + 所有依赖 + 完整的 Python 解释器

它将这些组件一起打包,形成适合目标平台的格式:

  • Windows: 生成 .msi 安装程序。
  • macOS: 生成 .dmg 磁盘映像或 .app 应用包。
  • Linux: 生成 AppImage 等格式。
  • iOS / Android: 生成可提交到相应应用商店的项目。

Briefcase 具有高度可扩展性,理论上可以支持任何需要打包 Python 代码的平台。


Briefcase 工作流程 🔄

上一节我们介绍了 Briefcase 的概念,本节中我们通过一个“Hello World”示例,一步步了解使用 Briefcase 打包应用的生命周期。

1. 创建新项目

首先,你需要安装 Briefcase 并创建一个新项目。

# 创建虚拟环境并安装 briefcase (可选但推荐)
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate
pip install briefcase

# 使用向导创建新项目
briefcase new

运行 briefcase new 后,会启动一个交互式向导,询问项目信息:

  • 正式名称:展示给用户的名称(如 Hello World)。
  • 应用名称:内部使用的 Python 标识符(如 hello-world)。
  • Bundle ID:应用商店使用的唯一标识符,通常是反向域名(如 com.example.helloworld)。
  • 项目名称:包含多个应用的父项目名称。
  • 作者、描述、URL、许可证:项目元数据。
  • GUI 框架:选择模板(如 Toga, PySide, 空项目)。

向导会生成一个包含基础代码、图标和配置文件的项目目录。

2. 项目配置文件 pyproject.toml

生成的项目核心是 pyproject.toml 文件,它包含了项目的所有配置。

[build-system]
requires = ["briefcase"]
build-backend = "briefcase.backend"

[tool.briefcase]
# 项目级配置
version = "0.0.1"
description = "A simple Hello World application."

[tool.briefcase.app.hello_world] # 应用配置
formal_name = "Hello World"
description = "A simple Hello World application."
sources = ['hello_world'] # 源代码目录
requires = [] # Python 依赖列表
icon = "icon" # 图标基名(不包含扩展名)

# 平台特定配置(覆盖应用级配置)
[tool.briefcase.app.hello_world.macos]
requires = ["toga-cocoa==0.3.0.dev31"] # macOS 特定依赖

关键配置项说明:

  • sources: 指定要打包的源代码目录列表。必须有一个目录与应用名称匹配
  • requires: 定义 Python 依赖,格式与 pip install 相同。
  • 配置具有继承和覆盖关系:平台配置 > 应用配置 > 项目配置。
  • sourcesrequires 是累积的(追加),而其他配置是覆盖的。

3. 开发模式运行

在开发过程中,你可以使用 briefcase dev 命令快速运行应用,它会在本地虚拟环境中安装依赖并启动应用。

briefcase dev

这相当于执行了 pip install 你的依赖,然后运行 python -m hello_world。修改代码后,重新运行此命令即可。

4. 为分发创建、构建和打包应用

当你准备好分发应用时,需要执行以下步骤:

a. 创建 (Create)

briefcase create

此命令会:

  1. 获取当前平台(如 macOS)的应用模板。
  2. 下载一个包含完整 Python 解释器的“支持包”。
  3. 将支持包解压到模板中。
  4. 将你的应用代码和所有依赖安装到模板里。
    生成一个位于 macOSwindows 等平台文件夹下的、可执行的应用程序骨架。

b. 构建 (Build)

briefcase build

此命令执行平台特定的编译步骤(在 macOS 上可能什么都不做,因为 .app 目录已是可执行格式)。

c. 运行测试 (Run)

briefcase run

运行你刚刚打包好的应用程序,确保它能正常工作。

d. 打包 (Package)

briefcase package

此命令生成最终的分发包,如 .dmg (macOS)、.msi (Windows) 或 AppImage (Linux)。它还会处理一些收尾工作,如代码签名(部分平台支持)。

5. 更新应用

如果你修改了代码或依赖,不需要从头开始。使用 update 命令:

briefcase update # 仅更新应用代码
briefcase update -d # 更新依赖
briefcase update -r # 更新资源(如图标)

更高效的开发循环是直接使用:

briefcase run -u # 运行前先更新应用代码

Briefcase 的优势与当前限制 ⚖️

上一节我们走完了打包流程,本节中我们总结一下 Briefcase 的特点和需要注意的地方。

优势

  1. 简单直接:Briefcase 采用最直观的方式运行 Python——在一个包含解释器的目录中执行。这避免了其他工具(如 PyInstaller)将代码打包进可执行文件可能带来的复杂性和兼容性问题。
  2. 真正的跨平台:一份 pyproject.toml 配置文件,可以为所有支持的平台生成安装包。
  3. 对用户友好:最终用户获得的是一个标准的、平台原生的应用程序,安装和运行体验与任何其他软件无异。
  4. 可扩展性:架构支持轻松添加新的目标平台(如游戏主机、电视)或新的打包格式(如 Flatpak、Snap)。

当前限制与待改进之处

  1. 应用体积较大:由于捆绑了完整的 Python 解释器和标准库,生成的应用包体积较大(约 200MB)。解决方案:可以创建自定义的、精简过的“支持包”,将体积减小到 30MB 左右。
  2. 平台功能完善度:某些平台的特定功能尚在开发中,例如:
    • Linux 桌面图标集成。
    • Windows 代码签名。
    • iOS 部署到物理设备。
    • macOS 公证。
  3. 命令行工具支持不足:Briefcase 主要面向 GUI 应用,对纯命令行工具的分发模式尚不明确。
  4. 对其他 GUI 框架的测试:虽然支持(如 PySide、Tkinter),但主要测试集中在 Toga 框架上。对其他框架(特别是游戏库如 PyGame)的兼容性需要更多社区验证。

总结

本节课中我们一起学习了如何使用 Briefcase 工具来打包 Python 应用程序。

我们从为什么需要专门的应用程序打包工具开始,分析了 Python 代码分发给最终用户时的不同场景和挑战。然后,我们介绍了 Briefcase 的核心概念,它通过将你的代码、依赖和完整的 Python 解释器一起打包,来解决图形界面应用的分发问题。

我们详细走过了使用 Briefcase 的典型工作流程:从通过 briefcase new 创建新项目 并配置 pyproject.toml 文件,到使用 briefcase dev 进行开发测试,再到使用 createbuildrunpackage 命令为分发创建、构建和打包应用。我们还了解了如何使用 update 命令来快速迭代。

最后,我们讨论了 Briefcase 的主要优势(简单、跨平台、用户友好)和当前的限制(应用体积、平台特定功能待完善),这些信息可以帮助你评估它是否适合你的项目。

Briefcase 是让 Python 突破开发环境,进入普通用户桌面和移动设备世界的重要工具。虽然仍有改进空间,但它已经为分发独立的 Python 应用程序提供了一个强大而可行的解决方案。

067:组织以改善你的工作场所,了解你的权利 🛠️

概述

在本节课中,我们将学习科技工作者为何以及如何组织起来,以改善工作场所并了解自身权利。我们将探讨组织的原因、可用的法律工具、具体步骤,以及一些成功的案例。


为什么科技工作者需要组织?🤔

上一节我们介绍了课程概述,本节中我们来看看科技工作者组织起来的根本原因。

许多人认为科技行业待遇优厚,无需组织。然而,科技工作场所存在诸多问题。例如,薪酬不平等普遍存在,报告显示,在60%的情况下,从事相同工作的女性薪酬低于男性同事。此外,硅谷60%的科技行业女性曾遭遇性骚扰,其中三分之二的骚扰来自上级。

有色人种在科技领域的代表性严重不足,且常面临歧视和不被信任的处境。超时工作也是常见问题,超过一半的科技工作者每周工作超过40小时。

科技工作者常被要求签署保密协议(NDA),其中15%的人表示这些协议阻止了他们对外讨论公司问题。许多公司还强制要求员工签署仲裁协议,这剥夺了员工在法庭起诉的权利。

工作场所的虐待不仅限于薪酬和技术问题。例如,Facebook的内容审核员因长期接触有害内容而缺乏支持,导致创伤后应激障碍(PTSD)。简而言之,雇主会做他们认为可以逃脱惩罚的事情。

科技行业并非永远供不应求。当需求下降时,现有问题可能会恶化。组织起来不仅是为了改善自身处境,也是对我们所创造的技术对社会产生的影响负责。


组织的权利与法律保护 ⚖️

上一节我们讨论了组织的原因,本节中我们来了解法律赋予我们的权利和保护。

你们组织的权利不仅限于成立工会。任何两名或以上员工为改善雇佣条款和条件而进行的活动,都称为“受保护的协同活动”。这是所有员工都拥有的权利,无论州法律如何规定,也无论你签署了什么合同。

如果你的老板因你参与此类活动而解雇你,这是非法的。当然,他们可能借口其他理由。例如,亚马逊解雇了要求COVID-19防护的克里斯·斯莫斯,并声称是因为他违反了社交距离规定。

需要注意的是,个人举报(告密)并不总是协同活动,但它受到其他法律的单独保护。任何有合理理由相信存在违法违规或公共安全危险的个人,在披露信息时都应免受报复。


如何组建工会:分步指南 📝

上一节我们了解了法律基础,本节中我们来看看组建工会的具体步骤。

以下是组建工会的关键步骤:

第一步:与同事交谈
如果你想组织起来,首先应与同事交流,了解共同的关切和不满。在此阶段,通常需要对雇主保密。

第二步:建立组织委员会
在交流过程中,一些同事可能对组建工会感兴趣。你们可以一起创建一个组织委员会,这有助于更好地代表所有工人。

第三步:争取多数支持
一旦有了强大的组委会,就需要争取大多数同事的支持。此时,雇主可能会开始反对,例如举行强制性的反工会会议。

第四步:签署工会授权卡
在争取支持时,需要分发“工会授权卡”。员工在卡上签名,表示他们希望由工会代表。这些卡应保密,不交给雇主。

第五步:卡片核查与工会承认
当足够多的卡片被提交给政府机构后,会进行“查卡”以验证签名真实性。如果大多数工人的卡片通过认证,工会即被承认。

第六步:谈判合同
工会被承认后,下一步是与雇主谈判合同。合同必须通过工会成员的无记名投票,并获得多数批准才能生效。


应对雇主报复与建立力量 💪

上一节我们介绍了组建工会的流程,本节中我们探讨如何应对可能出现的雇主报复。

工人组织时最大的担忧之一是雇主的报复。法律上,雇主不得因员工参与受保护的协同活动而进行报复。然而,现实中报复仍会发生,例如Kickstarter和谷歌都曾解雇过组织者。

我们可以采取一些措施来防止和应对报复:

  • 建立互助基金:为遭受经济困难的同事提供财务支持。
  • 构建支持网络:帮助被报复的员工寻找新工作,维护其声誉。
  • 提供全面支持:包括情感、社会、法律和后勤支持。
  • 依靠集体力量:最有效的方法是团结多数人。当许多人站在一起时,雇主很难针对个人进行报复。

科技工作者的高薪源于市场对我们的需求,而非老板的善意。这意味着我们拥有议价能力。通过团结,我们可以将这种能力转化为改变工作场所的力量。


科技工作者的组织文化 💻

上一节我们讨论了应对报复,本节中我们来反思科技行业特有的文化如何影响组织。

许多科技工作者将自己视为“高管”而非“工人”,或希望工作与“政治”分离。这类似于编程中的“单一责任原则”,即希望每个部分只关注自身功能。

然而,这种抽象是“漏洞百出”的。我们编写的代码可能侵犯隐私,我们构建的工具可能用于监视。忽视这些影响,就像认为我们只对写代码负责一样,是不现实的。我们有责任共同解决我们创造的技术所带来的问题。


成功案例与资源 🌟

上一节我们探讨了行业文化,本节中我们来看看一些成功的组织案例以及可用的资源。

成功案例

  • Kickstarter工会:尽管管理层反对并解雇组织者,员工最终投票成立了工会。
  • Glitch工会:超过90%的员工表示支持后,公司自愿承认了工会。
  • HCL科技公司工会:员工成功组建了工会。
  • Amazonians United:通过组织压力,为所有亚马逊员工赢得了带薪病假。
  • 谷歌结束强制仲裁:在“谷歌人”组织的持续压力下,谷歌停止了对新员工强制要求仲裁协议。

资源与组织
以下是一些可以帮助你开始的组织和资源:

  • 工会
    • 美国通讯工人协会:旗下有“CODE倡议”,专门组织数字雇员。
    • 钢铁工人联合会:曾帮助HCL员工组织工会。
    • 专业雇员办公室:曾帮助Glitch成立工会。
  • 组织平台
    • Coworker.org:数字平台,帮助员工联系并配有训练有素的组织者提供指导。
  • 科技工作者团体
    • 技术工人联盟:全球性的非正式组织,在许多地区有分会。
    • 游戏工人团结会:针对游戏行业工人的组织。
    • 许多大型科技公司内部也有特定的员工组织。

来自组织者的心声:Kickstarter案例 🎤

上一节我们列举了外部资源,本节中我们通过一位亲历者的讲述,深入了解组织过程中的挑战与收获。

克拉丽莎是Kickstarter工会的早期组织者之一。她参与组织的契机是与同事泰勒的一次通话,他们讨论了管理层对提出异议的员工进行报复的情况,并意识到需要一个工会来防止此类行为。

组织过程中的挑战:

  • 建立信任:需要与同事深入交流,了解他们的关切,同时确保信息不会提前泄露给管理层。
  • 解释工会价值:有些同事与经理关系良好,难以理解工会的必要性。组织者需要解释,工会是关于建立更健康的权力结构,而不仅仅是对抗管理层。
  • 应对恐惧:员工普遍害怕报复、组织失败或需要漫长等待。
  • 管理层的意外反对:即使公司标榜进步价值观,管理层仍可能强烈反对工会,这是组织者始料未及的。

经验与建议:

  • 为自己而战:组织不仅是为他人,也是为自己。克拉丽莎在组织过程中才发现自己的薪酬低于男性同行。
  • 提前准备法律保护:如果可能,应更早地了解如何合法地保护自己,例如详细记录工作表现和管理层的对待方式。
  • 识别管理层的策略:管理层可能在发现组织活动后突然改善某些条件,这可能是破坏工会努力的一部分策略,而非真诚的改变。

投票时刻:投票日令人激动,同事们集体前往办公室投票,展现了团结的力量。目前,工会正在等待与公司谈判合同。


总结

本节课中,我们一起学习了科技工作者组织起来的原因、法律赋予的权利、组建工会的具体步骤,以及如何应对挑战。我们看到,组织不仅是改善薪酬和工作条件的手段,也是对我们创造的技术负责的表现。通过团结一致,科技工作者可以赢得更好的合同、结束不公正的做法,并共同塑造一个更公平、更有影响力的行业。记住,健康的社区始于健康的工作场所。

068:从数据挑战到BERT应用

在本节课中,我们将学习如何处理多语言自然语言处理(NLP)任务。我们将探讨多语言数据(如代码切换和音译文本)带来的独特挑战,了解现有的语言识别框架及其局限性,并深入研究像BERT这样的先进深度学习模型如何应对这些挑战。课程最后,我们将总结构建强大多语言NLP系统的实用步骤。

多语言数据的重要性

目前,全球约有70亿人使用超过6800种语言。其中,约16亿人说普通话,5亿人分别说英语、西班牙语和印地语。这一庞大的统计数据表明,我们的自然语言系统需要不断发展,以整合全球用户并消除语言障碍。

常见的NLP任务包括:

  • 标记化:将输入句子分解为词汇表中已有的单词。
  • 词性标注:为单词分配名词、形容词等标签。
  • 命名实体识别:识别文本中的人名、地名等实体。
  • 语言建模:预测句子或下一个词出现的概率。
  • 机器翻译:将一种语言翻译成另一种语言。
  • 序列分类:如情感分析,将序列归类到特定类别。

代码切换与音译数据

上一节我们介绍了常见的NLP任务,本节中我们来看看两种特殊的、混合了多种语言现象的数据类型。

代码切换数据是指在单一序列中混合使用多种语言的语法和词汇。这常见于多语言使用者的对话中。

  • 示例1(英语+马来语):I nak pergi (前两个词是英语,其余是马来语)。
  • 示例2(西班牙语+英语):Vamos a la party (部分是西班牙语,部分是英语)。

音译数据是指将一种文字书写的词汇转换为另一种文字体系,通常是由于设备不支持原生文字输入。

  • 示例:将印地语文本 नमस्ते 用英文字母写作 Namaste
  • 像Facebook这样的平台会提供音译工具,帮助用户用自己的语言(通过拉丁字母)交流,并自动翻译为其他语言。

语言识别框架及其挑战

了解了多语言数据的类型后,我们需要能够识别文本所使用的语言。以下是几种常见的语言识别框架。

CLD3

CLD3是谷歌发布的语言检测模型,基于N-gram和神经网络构建。它支持多种语言,包括一些音译语言。

以下是使用CLD3的示例代码逻辑:

# 伪代码示例
detected_language = CLD3.get_language(text)
print(detected_language.language, detected_language.probability)

然而,CLD3在处理混合语言或短文本时面临挑战:

  • 对于混合法英的文本 J‘aime le http://python.org,它可能因URL而无法准确识别法语部分。
  • 对于音译的印地语问候 Aap kaise hain? Main theek hoon.,它可能错误地预测为芬兰语或盖尔语。
  • 对于西班牙语-英语代码切换文本,它可能预测为毛利语等不相关语言。

Langid.py 与 Langdetect

Langid.py 是一个预训练了97种语言的工具,基于朴素贝叶斯分类器。它的一个优点是允许用户限定待检测的语言范围。

# 伪代码示例:限定检测语言集
langid.set_languages(['en', 'hi', 'ru', 'it'])
result = langid.classify(text)

Langdetect 基于字符N-gram,但仅支持约49种语言,且具有非确定性(对短文本可能给出不同结果)。

这些框架的共同挑战包括:

  1. 文本长度过短:提供的信息不足。
  2. 借用词:一种语言中包含来自他语言的词汇。
  3. 音译方案多样:同一语言可能有多种音译写法。
  4. 词汇重叠:不同语言有相同拼写但含义不同的词。
  5. 训练数据有限:缺乏足够的代码切换和音译文本数据。

应对策略:数据增强与定制化工具

面对现成工具的局限性,我们可以通过数据增强和定制化工具来提升模型在多语言数据上的表现。

一种核心策略是构建自己的增强数据集。例如,如果你想提升对印地语音译文本的识别能力,可以:

  1. 获取印地语维基百科文章(原生文字)。
  2. 构建一个基于规则的音译器,将其转换为拉丁字母文本。
  3. 将生成的大规模音译文本与原有的小规模数据集结合,用于训练。

以下是一个简单的规则音译器概念示例:

# 简化的规则映射示例
transliteration_map = {
    ‘अ‘: ‘a‘,
    ‘आ‘: ‘aa‘,
    ‘ब‘: ‘b‘,
    # ... 更多映射规则
}
def simple_transliterate(hindi_text):
    # 应用映射规则和特定位置规则进行转换
    english_text = apply_rules(hindi_text, transliteration_map)
    return english_text

此外,可以寻找针对特定语言的开源工具。例如,indic-transliteration 库提供了预训练的神经机器翻译模型,能将罗马化文本转换为印地语原生文字,并可用于词元级别的语言识别。

深度学习模型:Transformer与BERT

在讨论了基于规则和传统机器学习的方法后,我们转向更强大的深度学习解决方案。Transformer 模型架构是当前许多NLP模型的基石,其核心创新是多头注意力机制

注意力机制 让模型能够衡量序列中不同词之间的关联程度。多头注意力 则使用多组不同的权重矩阵(如8个),从不同角度理解词与词之间的关系,这对于处理复杂句义至关重要。

BERT 是基于Transformer构建的里程碑式模型。它的特别之处在于预训练与微调范式。

  • 预训练:在大规模无标注语料上通过两个任务学习:
    • 掩码语言模型:随机遮盖输入中15%的词汇,让模型预测被遮盖的词。
    • 下一句预测:判断两个句子是否连续。
  • 微调:将预训练好的BERT权重,针对特定下游任务(如分类、问答)进行少量调整。

BERT多语言版 的一个关键优势是使用了词片标记化。它拥有一个约12万词片的共享词汇表,这些词片是通过分析104种语言字符共现概率得到的子词单元。这使得它能有效处理多种语言,即使某些语言的词汇被切分成多个词片。

from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained(‘bert-base-multilingual-cased‘)
tokens = tokenizer.tokenize(“¡Hola! ¿Cómo estás?”) # 示例:西班牙语句子
# 输出可能包含子词,如 [‘¡‘, ‘Hol‘, ‘##a‘, ‘!‘, ‘¿‘, ‘C‘, ‘##omo‘, ‘est‘, ‘##ás‘, ‘?‘]
# ‘##‘ 表示该词片应附着在前一个词片上。

评估BERT在多语言数据上的表现

现在,让我们看看BERT多语言模型在代码切换和音译数据上的实际表现。我们可以通过其掩码语言模型任务来进行评估。

我们定义一个函数,让BERT预测句子中被遮盖 [MASK] 的词汇。

from transformers import BertForMaskedLM, BertTokenizer
import torch

model = BertForMaskedLM.from_pretrained(‘bert-base-multilingual-cased‘)
tokenizer = BertTokenizer.from_pretrained(‘bert-base-multilingual-cased‘)

def predict_masked_word(text, target_word):
    # 将 target_word 位置替换为 [MASK]
    masked_text = text.replace(target_word, ‘[MASK]‘)
    inputs = tokenizer(masked_text, return_tensors=‘pt‘)
    with torch.no_grad():
        outputs = model(**inputs)
    predictions = outputs.logits[0, masked_position].topk(5)
    # 解码并返回最可能的候选词
    return [tokenizer.decode([idx]) for idx in predictions.indices]

测试示例

  1. 代码切换文本(西英混合):输入 “Live and let [MASK]“(实际为 “Live and let vivir“)。模型成功预测出 “vivir“(西班牙语“生活”),并且其他候选词如 “solo“(独自)、“live“(英语生活)也符合上下文。
  2. 音译印地语歌词:输入一段宝莱坞歌曲的罗马化歌词,要求预测高频词 “MEIN“。模型不仅能正确预测,其候选词也包含了常与之共现的词语 “HAI“,显示了其利用双向上下文的能力。

构建多语言NLP模型的路线图

本节课中我们一起学习了多语言NLP的挑战与解决方案。最后,我们总结一下构建自己的多语言NLP模型的实用步骤:

  1. 需求分析:确定你的任务是否需要处理多语言数据,以及具体涉及哪些语言。
  2. 数据评估与增强:检查现有数据是否充足。如果不足,利用规则音译器或机器翻译模型生成合成数据来增强数据集。
  3. 模型选择:选择一个合适的多语言预训练模型(如BERT多语言版)。
  4. 任务定制与微调:根据你的具体任务(如文本分类、序列标注),在预训练模型基础上添加任务层(如Softmax分类层),并进行微调。
  5. 评估:使用适当的指标进行评估,例如:
    • 对于分类任务:使用准确率、F1分数。
    • 对于生成任务(如翻译、音译):使用BLEU分数,它通过比较生成文本与参考文本之间的N-gram重叠度来评分。

总结:在本节课中,我们探讨了音译和代码切换数据带来的独特挑战,回顾了现有语言识别工具的不足,并展示了如何通过数据增强和利用像BERT这样的先进多语言模型来应对这些挑战。关键在于理解数据特性,善用预训练模型,并针对具体任务进行精心微调。

069:用 Slackbot 自动化无聊任务

概述

在本教程中,我们将学习如何利用 Slack 平台和 Python 来创建聊天机器人,以自动化日常工作中重复且枯燥的任务。我们将从简单的消息发送开始,逐步深入到创建能够交互、并能与外部 API 及库集成的智能机器人。


背景与动机 🎯

上一节我们了解了本教程的目标。本节中,我们来探讨创建这些自动化工具的动机。

组织 Python 社区活动涉及大量重复性任务,例如会员管理和场地协调。Python 小组数量超过 40 个,许多组织者反复询问类似问题。程序员通常不喜欢重复性劳动。

因此,我们希望创建一个能自动处理这些工作的“助手”,就像一个不知疲倦的虚拟同事。本次演讲的目的,就是展示如何创建简单的机器人、如何设置交互式仓库,以及如何通过案例研究,利用强大的库和 API 来扩展机器人的功能。


简单集成:使用 Incoming Webhook 📨

上一节我们讨论了自动化动机。本节中,我们来看看如何通过 Incoming Webhook 与 Slack 进行最简单的集成。

当程序通过 HTTPS 向 Slack 提供的专属 Webhook URL 发送消息时,该消息就会出现在指定的 Slack 频道中。以下是设置 Incoming Webhook 的步骤:

  1. 在 Slack 中创建一个新的应用程序。
  2. 在该应用程序中启用 “Incoming Webhooks” 功能。
  3. 为应用程序创建一个新的 Incoming Webhook,并将其添加到你的工作空间。

创建 Webhook 后,你会获得一个唯一的 URL。我们可以使用 requests 库向这个 URL 发送一个包含简单信息的 JSON 数据。

import requests
import json

webhook_url = "你的 Webhook URL"
message = {"text": "你好,这是来自机器人的消息!"}
response = requests.post(webhook_url, data=json.dumps(message), headers={'Content-Type': 'application/json'})

如果你想发送更复杂的消息,例如带有附件或特定布局的消息,可以构建更复杂的 attachmentsblocks JSON 对象。Slack 提供了 “Block Kit Builder” 工具,可以通过拖放组件的方式直观地设计消息样式,并生成对应的 JSON 代码。

总结:使用 Incoming Webhook 可以轻松地从程序中向 Slack 发送消息,无论是简单文本还是带有附件和复杂布局的富文本消息。


创建交互式机器人 🤖

上一节我们学会了如何单向发送消息。本节中,我们来看看如何创建一个能够双向交互的 Slack 机器人。

当我在频道中对机器人说“嗨”时,我希望它能回复“嗨,我是 Bot”。为了实现这种交互,我们需要创建一个 Slack App 并为其添加机器人功能。

以下是创建交互式机器人的步骤:

  1. 创建一个新的 Slack App。
  2. 为这个 App 添加一个“机器人用户”(Bot User)。
  3. 将应用程序安装到你的工作空间,以获得 Bot Token(以 xoxb- 开头)。
  4. 将机器人邀请到相关频道。

我们将使用 slackbot 库来快速构建机器人。这是一个 Python 的 Slack 聊天机器人框架。

首先,安装必要的库:

pip install slackbot

一个基本的 slackbot 项目结构非常简单。关键文件是 slackbot_settings.py,用于配置 API 令牌。

# slackbot_settings.py
API_TOKEN = "你的 Bot User OAuth Token (xoxb-...)"

然后,创建一个响应逻辑文件,例如 mybot.py

# mybot.py
from slackbot.bot import respond_to

@respond_to('嗨')
def greeting(message):
    message.reply('嗨,我是 Bab!')

运行机器人:

python -m slackbot.run

现在,当在机器人所在的频道中发送“嗨”时,它就会回复你。

核心机制@respond_to 装饰器用于监听匹配特定模式的消息。当消息匹配时,对应的函数就会被调用,并通过 message 对象进行回复。

除了 respond_to,还可以使用 listen_to 来监听未被直接提及的消息。message 对象提供了 replysendreact 等方法,用于回复消息、发送新消息或添加表情反应。

你还可以使用正则表达式从消息中捕获参数:

@respond_to('给我一个(.*?)和(.*?)')
def order(message, item1, item2):
    message.reply(f'这是你的 {item1} 和 {item2}。')

总结:使用 slackbot 框架,我们可以轻松创建能够理解指令、捕获参数并以多种方式进行交互的 Slack 机器人。


案例研究:集成外部库与 API 🔗

上一节我们构建了基础的交互机器人。本节中,我们将通过几个实际案例,看看如何集成强大的 Python 库和外部 API 来扩展机器人的能力。

案例一:集成 Sympy 作为计算器

在手机上调用计算器应用有时并不方便。如果能让 Slack 机器人充当计算器会很有用。Sympy 是一个用于符号数学的 Python 库。

以下是计算器功能的实现:

from slackbot.bot import respond_to
import sympy

@respond_to('计算 (.*)')
def calculate(message, formula):
    try:
        # 使用 sympy 计算表达式
        result = sympy.sympify(formula)
        message.reply(f'结果:{result}')
    except Exception as e:
        message.reply(f'计算错误:{e}')

当用户发送“计算 1+1”或“计算 2**10”时,机器人会返回计算结果。

案例二:简易点赞计数器

我想在团队中培养一种互相感谢的文化。可以创建一个简单的“点赞”计数器。

我们使用一个小型 ORM 库 peewee 来管理数据。首先定义一个数据模型:

# model.py
from peewee import SqliteDatabase, Model, CharField, IntegerField

db = SqliteDatabase('kudos.db')

class Kudos(Model):
    name = CharField()
    count = IntegerField(default=0)
    class Meta:
        database = db

db.connect()
db.create_tables([Kudos], safe=True)

然后创建机器人指令:

@respond_to('给 (.*?) 点赞')
def give_kudos(message, name):
    record, created = Kudos.get_or_create(name=name)
    record.count += 1
    record.save()
    message.reply(f'{name} 现在的点赞数是 {record.count}!')

案例三:查询 Jira 问题

Jira 是常用的任务管理系统,但它的界面有时比较重。我们可以让机器人快速查询问题详情。

使用 jira 库来连接 Jira API:

from slackbot.bot import respond_to
from jira import JIRA

jira = JIRA(server='你的 Jira 地址', basic_auth=('用户名', '密码'))

@respond_to('查询问题 (.*)')
def get_issue(message, issue_id):
    try:
        issue = jira.issue(issue_id)
        summary = issue.fields.summary
        message.reply(f'问题 {issue_id}: {summary}')
    except Exception as e:
        message.reply(f'查询失败:{e}')

案例四:批量创建 Google Calendar 事件

在组织活动时,可能需要批量创建数十个日历事件。手动创建非常耗时。

这需要用到 Google Calendar API。首先需要在 Google Cloud Platform 上创建项目、启用 API、下载凭据文件。然后使用 google-api-python-client 库进行认证和操作。

核心创建事件的代码逻辑如下:

from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

# 加载凭据
creds = Credentials.from_authorized_user_file('token.json')
service = build('calendar', 'v3', credentials=creds)

event = {
  'summary': 'Pi Camp 会议',
  'start': {'dateTime': '2023-10-01T10:00:00', 'timeZone': 'Asia/Tokyo'},
  'end': {'dateTime': '2023-10-01T11:00:00', 'timeZone': 'Asia/Tokyo'},
}
created_event = service.events().insert(calendarId='primary', body=event).execute()

我们可以让机器人读取一个包含多个会议信息的文件,然后循环调用此 API 来批量创建事件。

案例五:管理 Google 用户组

管理用户和组是另一项常见的管理任务。我们可以集成 Google Admin SDK。

与 Calendar API 类似,需要先启用 Admin SDK API 并完成认证。然后可以使用以下代码列出用户:

from googleapiclient.discovery import build

service = build('admin', 'directory_v1', credentials=creds)
results = service.users().list(customer='my_customer', maxResults=10).execute()
users = results.get('users', [])

机器人可以响应如“列出所有用户”或“将 user@example.com 添加到某组”等指令,自动化这些管理操作。

总结:通过将 Slack 机器人作为前端界面,后端集成各种专业的库和 API,我们可以将许多复杂、繁琐的工作流程转化为简单的聊天指令,极大地提升效率。


总结 🎉

在本教程中,我们一起学习了如何利用 Slack 和 Python 实现任务自动化。

我们从最简单的 Incoming Webhook 发送消息开始,逐步深入到使用 slackbot 框架创建可交互的机器人。最后,我们通过多个案例研究,展示了如何将机器人强大的计算库(如 Sympy)、数据持久化工具(如 Peewee)以及企业级 API(如 Jira、Google Calendar、Google Admin SDK)相结合,来解决实际工作中的痛点。

核心思想是:识别你工作中重复、枯燥的部分,思考其输入和输出,然后用 Slack 机器人作为便捷的输入界面,用 Python 脚本和丰富的生态库作为处理引擎,构建属于你自己的自动化工具链。

现在,是时候动手构建你自己的机器人了。找出你“无聊的东西”,用自动化解放你的时间,去进行更有创造性的工作吧!

070:核心概念与最佳实践

概述

在本教程中,我们将学习如何让Docker与Python在数据科学和机器学习项目中安全、高效地协同工作。我们将从理解Docker的核心概念开始,探讨数据科学场景下的常见痛点,并深入一系列关于安全性、性能优化和自动化的工作流程与最佳实践。


Docker与Python安全协作教程:1:为什么选择Docker?

在数据科学或机器学习项目中,开发通常在本地计算机上进行。然而,当需要将应用程序、模型或研究项目交付给他人(如客户或同事)时,常常会遇到环境依赖问题。

例如,您可能遇到“模块不存在”的错误。如果用户使用不同的Python运行时(如Python 2.7与Python 3),或者不同的操作系统(如Ubuntu、Red Hat、Windows),问题会更加复杂。用户可能不清楚需要安装哪些依赖项或如何配置环境。

Docker正是解决此类问题的工具。Docker允许您创建、部署和运行容器化的应用程序或项目。容器提供了一种解决方案,可以将您的软件、模型或应用程序从一个计算环境可靠地转移到另一个计算环境。

这种转移可以是从您的笔记本电脑到测试、预发布或生产环境,也可以是在不同人员的笔记本电脑之间。


Docker与Python安全协作教程:2:容器与虚拟机

如果您熟悉虚拟机,可能会觉得容器有些相似。让我们来比较两者的架构。

在Docker容器的工作方式中,您的基础设施(可能是服务器、云或您的电脑)上运行着主机操作系统。Docker引擎安装在操作系统之上。然后,您可以运行许多不同的、容器化的应用程序。

在这种设置中,每个应用程序都被封装。Docker和容器在应用程序级别工作,每个应用程序作为一个独立的进程运行。整个操作系统和基础设施都被抽象化了。因此,无论您是在Ubuntu还是Red Hat上开发,而其他人在Windows上使用,都没有关系。

另一方面,虚拟机从基础设施开始,在其上运行虚拟机管理程序。因此,虚拟机管理程序位于物理服务器的顶部,您可以在其上运行两个完全独立的操作系统。

虚拟机在硬件层面工作,这意味着您不仅虚拟化了应用程序,还虚拟化了整个操作系统、二进制文件、依赖项以及所有在基础设施上工作的东西。


Docker与Python安全协作教程:3:镜像与容器

当您深入Docker世界时,可能会遇到“镜像”和“容器”这两个术语,它们在开始时可能令人困惑。

镜像是一个存档文件,包含了运行应用程序所需的所有数据。它需要有一个标签,可以是“latest”,也可以是具体的版本号(如语义版本v1.2.3或日历版本2023.01),或者是提交的引用哈希。

当您从仓库(如Docker Hub或您的私有仓库)拉取镜像后,使用docker run命令运行该镜像时,Docker实际上会创建一个容器。您所有的开发工作都将在这个隔离的容器中进行。


Docker与Python安全协作教程:4:数据科学与Docker的常见痛点

上一节我们介绍了Docker的基本概念,本节中我们来看看在数据科学和机器学习项目中结合使用Docker时,有哪些常见的挑战。

首先,我们倾向于使用复杂的设置或依赖项,这源于Python生态系统的特性。一些软件包和库高度依赖特定的系统库或编译环境。

其次,数据科学项目严重依赖数据和数据库。数据是我们最宝贵的资产之一。

第三,数据科学和机器学习项目往往发展非常迅速,处于研究和开发阶段时,我们倾向于采用快速迭代的过程。随着项目深入,环境管理会变得复杂,可能需要大量时间来扩展和学习。

另一个常见问题是容器的安全性。当处理医疗保健数据、金融数据或任何其他可能轻松识别个人身份的敏感数据时,我们必须确保容器足够安全。


Docker与Python安全协作教程:5:创建Docker镜像的基础

如果您从未使用过Docker镜像,让我们深入了解基础知识,这将帮助您更好地理解工作流程,特别是如果您之前使用过Docker,本节也可作为复习。

我们通过Dockerfile文件来指定构建镜像的所有指令和要求。以下是一个简单的示例(并非最佳实践,但足够说明问题):

# 使用Python官方镜像作为基础
FROM python:3.9-slim

# 设置工作目录
WORKDIR /app

# 复制依赖文件
COPY requirements.txt .

# 安装依赖
RUN pip install --no-cache-dir -r requirements.txt

# 复制应用程序代码
COPY . .

# 定义容器启动命令
CMD ["python", "./your_script.py"]

Dockerfile中包含的每个指令都会创建一个层。最接近的类比是想象一个洋葱:您的基础镜像将是核心,每次运行一个指令(如RUN),它就会产生一个新层,包裹住之前的层。

您得到的指令越多,您的“洋葱”或容器就越大,层数也越多。因此,您必须明智地创建这些层以优化镜像大小和构建速度。


Docker与Python安全协作教程:6:选择基础镜像与社区镜像

构建容器时,一个关键部分是选择最佳的基础镜像。您可能会在很多地方读到,Alpine Linux是最佳选择,因为它非常轻量。

但请注意,如果您要从头开始构建所有镜像,请确保使用官方的Python镜像。您可以在Docker Hub上找到不同版本和标签的镜像。

回到Alpine Linux的问题,它是一个非常轻量级的基础镜像,但这也意味着您可能需要花费大量时间来构建库和依赖项。如果这种复杂性不值得,对于Python项目,建议使用slim变体,例如python:3.9-slim-buster。它基于Debian,并且有长期支持,因此很可能会有持续的安全更新。

从头创建Docker镜像可能需要很长时间。如果您发现需要Conda、Jupyter Notebook以及整个科学Python生态系统(这在数据科学和机器学习中很常见),可以考虑使用社区维护的镜像。

例如,Jupyter社区在制作这些容器和镜像上付出了很多努力。Jupyter Docker Stacks包含了一系列镜像,它们都从Ubuntu基础镜像开始,一层层叠加了科学计算环境。

如果您需要更复杂的设置,例如包含TensorFlow或PySpark,也可以使用jupyter/tensorflow-notebookjupyter/pyspark-notebook作为基础镜像来构建。


Docker与Python安全协作教程:7:Dockerfile最佳实践

以下是构建Docker容器的一些通用最佳实践,这些实践会让您受益匪浅。

首先,始终使用具体的镜像标签。避免使用“latest”标签,因为当创建下一个推送或下一个镜像时,它可能会改变您的构建结果。例如,使用jupyter/base-notebook:python-3.9.7而非jupyter/base-notebook:latest

使用标签提供上下文很重要。您可以添加重要信息,例如维护者、环境(如prodstaging)等。

有时我们可能觉得有必要使用非常复杂的RUN语句,但可读性非常重要。确保拆分RUN片段并对它们进行排序。也更倾向于使用COPY而不是ADD命令来添加文件。

Docker擅长使用缓存。如果某一层中的依赖项或文件没有改变,Docker会保留该层以加快构建速度。因此,在组织您的Dockerfile时要非常小心,将变动频繁的指令(如复制源代码)放在后面,将安装依赖项等相对稳定的指令放在前面。

只安装必要的软件包。有时我们会觉得有必要添加额外的包“以防万一”,但这会使您的镜像膨胀,同时增加潜在的安全风险。

使用.dockerignore文件明确忽略不需要的文件。这类似于Git的.gitignore文件,可以帮助您避免将不必要的文件(如虚拟环境目录、本地数据、日志文件)打包进镜像。

永远不要将敏感数据或秘密(如密码、API密钥)硬编码到Dockerfile或镜像层中。我们将在安全章节详细讨论。


Docker与Python安全协作教程:8:数据管理与容器运行

我已经告诉过您不要将数据添加到您的Dockerfile中。那么如何让容器访问数据呢?有以下几种方式。

您可以配置容器访问远程数据库,确保容器能够通过网络连接到数据库。

对于本地开发,您可以使用绑定挂载将主机上的目录挂载到容器中。这样,您可以在容器内进行开发,所有更改将直接保存在主机的本地文件中。例如:

docker run -v /path/on/host:/path/in/container my-image

您还需要确保以非root用户身份运行容器。默认情况下,以root身份运行容器可能会带来安全风险,并且在访问或更改文件权限时可能遇到问题。我们将在下一节深入讨论安全问题。


Docker与Python安全协作教程:9:容器安全核心要点

使用Docker和容器的主要问题之一是安全性。我之前提到过,运行容器时应使用非特权用户,这主要是为了遵循最小权限原则

这对于最大限度地减少或防止攻击非常重要。默认情况下,第一次运行docker run时可能需要root权限来更新内核库,但之后应切换到非root用户。

确保最小化用户的能力。例如,在您的Dockerfile中,可以显式创建一个名为appuser的非特权用户:

RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser

如果您使用Jupyter Stack容器中的任何一个,则无需担心,因为Jupyter社区已经确保您不是以特权用户身份运行。

处理敏感信息时要非常小心。保护您容器的第一道防线是将包含这些秘密的文件添加到.dockerignore中,这样它们就无法进入镜像。

不要将这些秘密写入Dockerfile。您可以通过运行时环境变量(使用-e标志)或Docker Secrets等其他机制来提供。

请注意,即使您在某一层中复制了秘密文件,然后在后续层中删除它,由于镜像层的缓存机制,这些文件仍然可能被访问到。一个更强大的方法是使用多阶段构建


Docker与Python安全协作教程:10:多阶段构建

一个非常强大的方法来保护您的Docker容器和秘密,是使用多阶段构建。如果您在中间层获取和管理机密,那么该层或该镜像会被丢弃,因此它们不会在最终的镜像中持续存在。

另一个优势是,特别是在科学Python生态系统中,并非所有的依赖项都会以预编译的“wheel”格式提供。您可能需要一个编译器(如gcc)来编译二进制文件。

使用多阶段构建时,您可以在一个“构建”镜像中安装编译器和所有构建依赖项,执行编译,然后仅将编译好的成品(如Python包、可执行文件)复制到一个干净的“运行时”镜像中。这有助于创建更小的最终镜像。

以下是一个简化的多阶段构建示例:

# 第一阶段:构建阶段
FROM python:3.9 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/26d0330b83dc71ac17ca3f140960d709_8.png)

# 第二阶段:运行时阶段
FROM python:3.9-slim
WORKDIR /app
# 从构建阶段复制已安装的包
COPY --from=builder /root/.local /root/.local
# 复制应用程序代码
COPY . .
# 确保使用用户安装的包
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "./your_script.py"]

最终的镜像是运行时镜像,它不包含编译器和其他构建时文件,因此更小、更安全。


Docker与Python安全协作教程:11:自动化与可复现性

到目前为止,我给了您很多建议,但手动完成这一切可能会非常令人生畏,尤其是如果您只为个人用途构建容器。

使用Docker容器的主要优点之一是可复现性。在机器学习研究和科学计算中,我们非常关心这一点。我们通常希望与他人分享我们的资产,以便他人可以验证我们的研究和发现。

因此,我们能做的最好的事情之一就是标准化。每当我谈到可复现性时,我一直推荐的是使用标准模板。这样,每个人都有一个已知的起点,并且项目的结构是清晰一致的。

如果您正在为数据科学项目寻找好的项目模板,可以尝试使用cookiecutter-data-science。如果您对它的Docker版本感兴趣,可以使用cookiecutter-docker-science。这些工具将为您创建一个健壮的项目模板,包含预定义的目录结构、Dockerfile示例等。


Docker与Python安全协作教程:12:利用社区工具(Repo2Docker)

第二步是不要重新发明轮子。社区中已经开发出了非常好的软件包。

我最喜欢的包之一是Repo2Docker。它接受一个代码仓库(可以是本地的或远程URL),并自动构建一个适用于该仓库的Docker镜像。

主要优点是它已经为数据科学和科学计算进行了配置和优化。常见的工作流程是,您拥有一个配置文件(如environment.yml, requirements.txt, Pipfile)来描述项目的依赖项。

一旦您运行jupyter-repo2docker命令并提供路径(本地目录或Git仓库URL),它将使用Jupyter社区维护的Docker基础镜像为您构建Docker容器。

最妙的是,您甚至不用写一个Dockerfile,也不必花很多时间完善它。因此,如果您在寻找一种可靠的方法来创建Docker容器,Repo2Docker是一个极好的工具。


Docker与Python安全协作教程:13:持续集成与自动构建

最后,将构建过程委托给持续集成系统。我提到过您可以创建并标记您的镜像。在很多情况下,您需要创建新版本的Docker镜像,例如当您发布了新版本的包、模型或应用程序时。

一个非常好的做法是定期重建您的镜像。如果您广泛使用Docker进行研究、开发、测试和生产,建议每周重建一次,以确保获得所有安全更新。

手动完成这件事可能很麻烦,因此最佳方法是使用持续集成工具,如GitHub Actions、GitLab CI/CD、Travis CI等。将构建过程自动化,这样您就可以将精力集中在代码上。

以下是一个GitHub Actions工作流的简化示例,它会在创建新Git标签或每周日触发镜像构建:

name: Build and Push Docker Image

on:
  push:
    tags:
      - ‘v*’ # 当推送v开头的标签时触发
  schedule:
    - cron: ‘0 2 * * 0’ # 每周日凌晨2点 (UTC)

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Log in to Docker Hub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v2
        with:
          context: .
          push: true
          tags: |
            your-username/your-image:latest
            your-username/your-image:${{ github.ref_name }} # 使用标签名

在这个工作流中,您的代码和配置文件(如requirements.txt)存储在版本控制中。当触发事件发生时,CI系统会自动构建镜像并将其推送到镜像仓库。


Docker与Python安全协作教程:14:顶级技巧总结

本节课中我们一起学习了Docker与Python协作的完整工作流。最后,让我为您提供与Docker和数据科学协作的顶级技巧总结。

  1. 定期重建镜像:如果您要使用您的容器,应该定期重建它。这确保了您能获得安全更新,并且依赖项不会过时。
  2. 最小化权限:永远不要以root身份运行容器。您的权限越低越好。同时,注意跟踪您正在使用的所有端口以及如何将它们绑定到容器。
  3. 明智选择基础镜像:不建议盲目使用Alpine Linux。对于Python,考虑slim-buster或Jupyter Stack镜像。始终知道您期望什么,使用具体的版本标签。
  4. 利用构建缓存:优化Dockerfile指令顺序以充分利用Docker的缓存层,这能显著影响镜像构建的性能。
  5. 专镜专用:确保每个项目使用一个Dockerfile。您可以有一个基础镜像,然后针对不同项目进行扩展。避免创建包含大量不必要依赖项的“万能”大镜像。
  6. 使用多阶段构建:如果您选择从头开始创建自己的容器,并且需要编译代码或缩小镜像大小,多阶段构建是一个非常好的选择。
  7. 标识镜像环境:确保您的镜像是可识别的。通过标签区分是用于测试、生产还是研发。
  8. 安全访问数据:访问数据库时要小心。使用环境变量和密钥管理工具,不要硬编码秘密。
  9. 善用社区工具:如果您不需要非常复杂的设置,使用Repo2Docker或Jupyter生态系统堆栈可以让您快速开始,无需从头手动完成所有事情。
  10. 自动化一切:如果您已经在使用GitHub,尝试探索GitHub Actions。如果使用GitLab,它也有出色的CI/CD集成。利用它们自动化构建和部署流程。
  11. 使用Linter:在编写Dockerfile时,可以尽早发现错误。VS Code等编辑器具有出色的Docker扩展,可以帮助进行语法检查、镜像检查和代码提示。

如果您遵循这些顶级技巧,我可以保证您的工作流程和镜像将与机器学习、Python和数据科学更好地协作。明智地自动化,明智地重用现有工具和模式。

071:工具原理与实践 🛡️

在本教程中,我们将学习如何使用一个名为 CV 二进制工具 的 Python 工具来检测二进制文件中的已知安全漏洞。我们将了解其工作原理、使用方法以及如何为开源安全做出贡献。

概述

软件安全是一个复杂且重要的问题。作为开发者或系统管理员,我们经常需要确认所运行的软件是否包含已知的安全漏洞。本教程将介绍一个免费、开源的 Python 工具,它通过扫描二进制文件中的特定字符串来识别软件版本,并与国家漏洞数据库进行比对,从而发现潜在的安全风险。


为什么需要检测二进制文件?🤔

当你从网站下载一个可执行文件时,通常会采取一些安全措施,例如运行病毒扫描、验证签名或检查更新。然而,现代软件通常包含大量依赖项,这使得手动追踪所有组件及其安全状态变得异常困难。

例如,在 Python 中安装 cryptography 库时,它会自动安装 OpenSSL 作为依赖。但你很难直接知道系统中安装的 OpenSSL 具体版本,更不用说其中是否存在已知漏洞。因此,自动化工具对于高效管理软件安全至关重要。


CV 二进制工具简介 🧰

CV 二进制工具 是一个旨在简化漏洞检测过程的工具。它最初是一个用于检测旧版本 OpenSSL 的小脚本,后来逐渐发展成一个功能更全面的工具。其核心目标是提供一个快速、免费且开源的解决方案,适合集成到持续集成流程中,或与外部合作伙伴共享。


工具工作原理:引擎盖下的秘密 🔍

上一节我们介绍了工具的目标,本节中我们来看看它是如何实现漏洞检测的。

1. 识别软件内容

工具本身并不知道它在扫描什么。它的思路类似于黑客或渗透测试员,使用非常简单的启发式方法。

首先,它利用 Unix 实用程序 strings 来提取二进制文件中所有长度大于四个字符的字符串。

strings binary_file

输出中可能包含大量无用信息,但也会隐藏着揭示软件身份和版本的线索。

2. 定位版本信息

接着,工具使用文本搜索工具 grep 来缩小范围,寻找特定的版本字符串模式。

例如,对于 OpenSSL,其版本字符串通常遵循类似 OpenSSL 1.1.1d 29 Sep 的格式。通过分析多个版本的 OpenSSL,可以总结出用于匹配的签名(Signature)。

一个简单的 OpenSSL 签名模式可能如下:

OpenSSL \d+\.\d+\.\d+[a-z]?

这个模式表示“OpenSSL”后跟由点分隔的三组数字,可能以一个字母结尾。

3. 处理特殊情况

并非所有软件都使用简单的版本字符串。例如,SQLite 就没有直接可解析的版本号,但它包含一个独特的“源标识符”字符串。对于这种情况,工具会先检测到这个大字符串,然后通过哈希映射回具体的版本号。

当然,也存在一些目前无法轻易检测的库,工具会记录这些情况以待未来改进。


从版本号到已知漏洞 📋

一旦获得了软件名称和版本号,下一步就是查询已知漏洞数据库。

国家漏洞数据库

工具使用 国家漏洞数据库 作为漏洞信息源。这是一个公共领域的国际数据库,包含了大量已公开的软件弱点信息,每个漏洞都被赋予一个唯一的 CVE 编号。

数据库提供了机器可读的 JSON 数据,其中包含每个漏洞的描述、受影响版本的范围等信息。例如,一个 CVE 条目可能指明 OpenSSL 1.1.1a1.1.1k 之间的版本存在某个漏洞。

版本匹配与解析

工具需要解析从二进制文件中提取的版本号,并判断它是否落在某个 CVE 条目指明的受影响范围内。

这里有时会遇到挑战。例如,OpenSSL 的版本号末尾可能包含字母(如 1.1.1d)。为了进行范围比较,工具需要将这些字母转换为可排序的数字。

此外,NVD 数据库由人工维护,偶尔会出现错误。例如,数据可能错误地标明“8.4 之前的所有版本都易受攻击”,但 8.4 版本本身却不受影响。工具团队会尽力修正这些发现的数据问题。


如何使用 CV 二进制工具 🛠️

了解了原理后,本节我们来看看如何实际使用这个工具。

安装与基本扫描

你需要 Python 3.6 或更高版本。使用 pip 安装工具:

pip install cv-binary-tool

安装后,你可以在任何文件或目录上运行它:

cv-bin-tool /path/to/scan

工具会扫描给定的文件或目录。如果它识别出某个已支持检查的库,并发现其版本存在已知漏洞,就会在控制台输出报告。

输出格式

工具提供多种输出格式以适应不同工作流:

  1. 控制台输出:人类可读的摘要,列出发现的漏洞。
  2. CSV 格式:逗号分隔值格式,方便导入电子表格进行漏洞分类、跟踪和记录。
  3. JSON 格式:机器可读的格式,便于集成到其他自动化流程或工具中。

重要限制

请注意,cv-bin-tool 中的 bin 指的是二进制文件。工具只扫描二进制文件。这是因为其基于字符串的签名检测方法在扫描源代码或文档时会产生大量误报。对于源代码,通常有更适合的工具(如软件成分分析工具)来管理依赖项。


超越二进制扫描:csv2cve 工具 📄

如果你已经通过其他方式(如 pip freeze、构建脚本)获得了软件组件列表,可以跳过二进制扫描步骤。

安装 cv-binary-tool 时,会附带一个名为 csv2cve 的实用程序。它可以直接处理一个包含组件名版本号的 CSV 文件列表,并查询相同的漏洞数据库,输出这些组件的已知漏洞。

这对于处理 Python 的 requirements.txt 文件或类似的依赖清单非常有用。


发现漏洞后该怎么办?🚨

运行工具后,如果它报告了漏洞,你可以遵循以下步骤:

以下是处理已发现漏洞的一般流程:

  1. 调查详情:将工具报告的 CVE 编号 输入搜索引擎,可以找到关于该漏洞的详细描述、影响和修复状态。
  2. 升级修复:最直接的方法是将受影响的库升级到已修复该漏洞的版本。通常升级到最新稳定版或长期支持版是推荐做法。
  3. 应用补丁:如果无法立即升级,可以寻找是否有官方提供的向后移植的安全补丁。但请注意,手动打补丁可能引入新问题,且 cv-bin-tool 无法检测到这种修复,会继续报告该漏洞。
  4. 实施缓解措施:如果既无法升级也没有补丁,应寻找可以降低风险的缓解措施,例如禁用某些功能、修改配置或增加网络防护。
  5. 通知用户:如果你是软件的分发者,有责任告知用户潜在风险及建议的最佳实践。

如何参与和贡献 🤝

CV 二进制工具 是一个开源项目,欢迎社区贡献。

以下是几种参与方式:

  1. 使用工具:最简单的帮助就是使用它。在你的系统上(例如扫描 /bin/usr/lib)或 CI/CD 流水线中尝试运行,并反馈使用体验。
  2. 贡献新的检查器:如果你关心的某个库尚未被支持,可以为其添加检查器。主要步骤包括:
    • 确定要添加的软件。
    • 在 NVD 数据库中查找对应的供应商-产品对。
    • 分析该软件的二进制文件,找到可靠的版本字符串签名
    • 编写测试用例,验证检查器在真实二进制文件上有效。
  3. 担任导师:项目通过 Google 代码之夏 等活动接纳了许多新贡献者。如果你擅长代码审查、调试或热爱开源,可以考虑担任导师,帮助学生成长。

项目地址和最新信息可在 GitHub 上找到。


总结

在本教程中,我们一起学习了:

  • 为什么检测二进制文件中的漏洞对于软件安全至关重要。
  • CV 二进制工具 的基本原理:它通过 stringsgrep 提取二进制文件中的版本签名,并与 国家漏洞数据库 进行比对。
  • 如何安装和使用 cv-bin-tool 进行扫描,并理解其各种输出格式。
  • 如何利用 csv2cve 工具直接分析已知的组件列表。
  • 发现漏洞后的标准处理流程。
  • 如何作为用户或贡献者参与到这个开源安全项目中。

CV 二进制工具 虽然不是解决安全问题的“银弹”,但它提供了一个轻量、实用的起点,能有效帮助开发者和运维人员发现并管理已知漏洞,是构建更安全软件生态的有力工具之一。

072:从循环到推导式

在本教程中,我们将学习 Python 中的列表推导式。这是一种将循环转换为更简洁、更具描述性代码的强大工具。我们将从基础的 for 循环开始,逐步展示如何将其重构为列表推导式,并探讨其优势、语法变体以及最佳实践。

概述

列表推导式是 Python 中用于将一个列表(或任何可迭代对象)转换为另一个列表的特殊语法。它的核心目的是:修改元素筛选元素,或两者兼有。虽然 for 循环也能完成这些任务,但列表推导式通常更简洁,并能更清晰地表达“创建新列表”的意图。

为什么在 Python 中常见“创建新列表”?

在 Python 中,将旧列表转换为新列表是非常普遍的操作,主要有两个原因:

  1. 避免修改原列表:直接修改正在遍历的列表(如删除元素)容易出错且代码复杂。Python 鼓励我们创建新列表。
  2. 变量是引用:Python 变量是指向对象的引用。如果通过一个变量修改了列表,所有指向该列表的变量都会看到变化。为了避免意外的副作用,创建副本或新列表是更安全的选择。

因此,将 for 循环用于“创建新列表”是 Python 中的常见模式。

for 循环到列表推导式

让我们从一个简单的 for 循环开始,它遍历一个列表,对每个元素进行平方,并将结果存入新列表。

# 使用 for 循环
numbers = [1, 2, 3, 4, 5]
squared_numbers = []
for n in numbers:
    squared_numbers.append(n ** 2)

上面的循环可以等价地写成一个列表推导式:

# 等价的列表推导式
numbers = [1, 2, 3, 4, 5]
squared_numbers = [n ** 2 for n in numbers]

这个推导式将四行代码浓缩为一行,但包含了相同的核心组成部分。

推导式的组成部分

我们可以将 for 循环中的关键部分映射到列表推导式中:

  • 新列表变量名:在推导式中,我们直接将结果赋值给 squared_numbers
  • 方括号 []:表示正在创建一个新列表。
  • 循环逻辑 (for n in numbers):定义了遍历的来源。
  • 要附加的表达式 (n ** 2):定义了如何转换每个元素。
  • 筛选条件 (if 语句):可选,用于过滤元素(稍后介绍)。

列表推导式的语法可以概括为:
[表达式 for 元素 in 可迭代对象 if 条件]

转换秘诀:复制粘贴法

如果你有一个格式正确的 for 循环,可以遵循以下步骤将其转换为列表推导式:

  1. 写下空列表的方括号 []
  2. append() 方法中的表达式复制到方括号的开头。
  3. for 循环行(从 forin 的部分)复制到表达式之后。
  4. 如果循环中有 if 条件,将其复制到 for 子句之后。

例如,从下面的筛选循环开始:

result = []
for n in numbers:
    if n % 2 == 1:  # 筛选奇数
        result.append(n ** 2)

应用复制粘贴法:

  1. []
  2. 表达式是 n ** 2 -> [n ** 2
  3. for 子句是 for n in numbers -> [n ** 2 for n in numbers
  4. if 条件是 if n % 2 == 1 -> [n ** 2 for n in numbers if n % 2 == 1]

最终得到等价的列表推导式:[n ** 2 for n in numbers if n % 2 == 1]

提高可读性:使用多行格式

列表推导式通常写在一行,但这有时会降低可读性。Python 允许在括号内换行,我们可以将推导式分成多行以提升清晰度。

# 单行推导式(可能较难阅读)
squared_odds = [n ** 2 for n in numbers if n % 2 == 1]

# 多行推导式(推荐)
squared_odds = [
    n ** 2
    for n in numbers
    if n % 2 == 1
]

建议:开始时都在多行编写推导式,只有在确实能提高可读性时才合并为一行。善用空格是写出好 Python 代码的关键。

使用列表推导式不仅是为了减少行数,更是为了明确代码的意图。当看到推导式时,读者能立刻明白这是在基于旧的可迭代对象创建一个新的列表。

列表推导式的变体

上一节我们介绍了基础的列表推导式,本节中我们来看看它的几种常见变体。

1. 仅映射(无筛选)

如果只需要转换元素而不进行筛选,可以省略 if 条件。

# 仅映射:将所有单词转为小写
words = [‘Hello‘, ‘World‘, ‘Python‘]
lowercase_words = [word.lower() for word in words]

2. 仅筛选

如果只需要筛选元素而不修改它们,表达式部分直接使用元素本身。

# 仅筛选:只保留奇数
numbers = [1, 2, 3, 4, 5]
odd_numbers = [n for n in numbers if n % 2 == 1]

3. 嵌套循环

可以在推导式中使用多个 for 子句来扁平化嵌套结构或生成笛卡尔积。

# 扁平化一个二维列表
matrix = [[1, 2], [3, 4], [5, 6]]
flattened = [item for row in matrix for item in row]
# 结果: [1, 2, 3, 4, 5, 6]

注意for 子句的顺序与在嵌套 for 循环中写的顺序一致。对于复杂的嵌套推导式,务必使用多行格式。

其他类型的推导式

Python 不仅支持列表推导式,还支持集合推导式字典推导式

集合推导式

使用花括号 {},用于创建不包含重复元素的集合。

# 创建一个包含单词长度的集合
words = [‘apple‘, ‘banana‘, ‘cherry‘]
word_lengths = {len(word) for word in words}
# 结果: {5, 6} (注意 ‘apple‘ 和 ‘cherry‘ 长度都是5)

字典推导式

同样使用花括号 {},但表达式部分是 键: 值 对。

# 创建一个将单词映射到其长度的字典
words = [‘apple‘, ‘banana‘, ‘cherry‘]
length_map = {word: len(word) for word in words}
# 结果: {‘apple‘: 5, ‘banana‘: 6, ‘cherry‘: 5}

对于字典推导式,通常也建议使用多行格式以提高可读性。

生成器表达式:惰性的推导式

生成器表达式在语法上与列表推导式极其相似,只是用圆括号 () 代替方括号 []

# 列表推导式 - 立即计算并生成列表
list_comp = [x**2 for x in range(10)]

# 生成器表达式 - 惰性计算,返回一个生成器对象
gen_exp = (x**2 for x in range(10))

生成器是惰性一次性的可迭代对象:

  • 惰性:只有在被请求时(例如在循环中)才会计算下一个值,不会一次性占用大量内存。
  • 一次性:遍历一次后,生成器就会耗尽,再次遍历将得不到任何元素。

核心规则:如果你创建了一个列表推导式,但只对其进行一次遍历,那么应该使用生成器表达式。这样可以避免创建不必要的临时列表。

生成器表达式的常见用法

生成器表达式经常直接传递给会遍历它的函数。

# 例子1:拼接字符串
words = [‘Hello‘, ‘World‘]
# 先转列表再拼接
result = ‘ ‘.join([w.lower() for w in words])
# 更好的方式:直接使用生成器表达式
result = ‘ ‘.join(w.lower() for w in words)

# 例子2:计算平方和
numbers = [1, 2, 3, 4, 5]
# 先列清单再求和
total = sum([n**2 for n in numbers])
# 更好的方式:使用生成器表达式
total = sum(n**2 for n in numbers)

在第二个例子中,sum(n**2 for n in numbers) 比原始的 for 循环更能清晰地表达“求平方和”的意图。

何时不使用推导式

推导式虽好,但并非万能。以下情况应避免使用:

  1. 代码有副作用:推导式应用于创建新数据,而不是执行如打印之类的操作。

    # 不好:推导式产生了无用的 None 列表
    [print(n) for n in numbers]
    # 好:使用 for 循环来执行操作
    for n in numbers:
        print(n)
    
  2. 有更简单的内置方法:某些情况下,内置函数或构造函数更简洁、更高效。

    # 情况1:将元组列表转为字典
    pairs = [(‘a‘, 1), (‘b‘, 2)]
    # 使用推导式
    my_dict = {k: v for k, v in pairs}
    # 更好:使用 dict() 构造函数
    my_dict = dict(pairs)
    
    # 情况2:将文件行读入列表
    # 使用推导式
    with open(‘file.txt‘) as f:
        lines = [line.rstrip(‘\n‘) for line in f]
    # 更好:使用 list() 构造函数(意图更明确)
    with open(‘file.txt‘) as f:
        lines = list(f) # 或 lines = f.readlines()
    

总结与速查表

本节课中我们一起学习了 Python 推导式的核心知识:

  • 列表推导式 [expr for item in iterable if cond] 是创建新列表的简洁方式。
  • 集合推导式 {expr for item in iterable if cond} 用于创建新集合。
  • 字典推导式 {key_expr: value_expr for item in iterable if cond} 用于创建新字典。
  • 生成器表达式 (expr for item in iterable if cond) 是惰性的,适用于只需单次遍历的场景。
  • 转换秘诀:对于格式为 新建空列表 -> for循环 -> (可选if) -> append(表达式) 的循环,可以使用复制粘贴法转为推导式。
  • 提高可读性:对于复杂的推导式,使用多行格式。
  • 使用时机:推导式最适合“转换数据”。避免在其中执行带副作用的操作,并优先使用更简单的内置函数(如 dict(), list(), sum() 配合生成器表达式)。

记住,空白是你的朋友。合理使用多行格式能让你的推导式代码更清晰、更易维护。

希望本教程能帮助你更好地理解和使用 Python 推导式,让代码更加 Pythonic!

073:CLI的起源与Python实现

概述

在本教程中,我们将学习命令行界面(CLI)的起源、工作原理以及如何使用Python构建功能强大的CLI应用程序。我们将从终端的历史讲起,逐步深入到Python中实现CLI的各种工具和最佳实践。


命令行界面的历史:P73:1:从电传打字机到终端模拟器

CLI的起源可以追溯到电传打字机时代。最初,人们使用莫尔斯电码进行通信。后来,有人将打字机与通信线路连接起来,创造了电传打字机。操作员无需再手动处理莫尔斯电码,从而极大地提高了信息键入和发送的速度。

在1932年的一段视频中,叙述者描述电传打字机仅需几秒钟就能将信息从伦敦传递到爱丁堡。这与早期需要一名男教练花费一周时间进行四百英里旅行的通信方式形成了鲜明对比。

与此同时,计算机变得足够强大,可以处理多任务并与用户实时交互。于是,又有人想到将电传打字机与计算机连接起来,使用户能够远程与计算机交互。

电传打字机因其坚固和灵活的特性,被适配为早期计算机的用户界面,这就是命令行界面的起源。用户在对输入感到满意后,在纸上打印的提示符后键入命令,将指令发送到计算机。最后,计算机的输出又会被印在纸上。

电传打字机作为计算机的接口一直使用到20世纪70年代末,直到视频显示器变得广泛可用。视频终端迅速流行起来,成为许多不同类型计算机上的I/O设备。一旦制造商转向一套共同的标准,只需一个串口就能将终端连接到计算机。

如今,物理电传打字机和视频终端已经过时,我们使用终端模拟器来代替。终端模拟器是真实硬件的软件模拟。现代终端模拟器继承了许多旧式硬件的特性。

一个明显的遗产是名字。如果你从“teletype”(电传打字机)中提取“tty”,它就变成了基于Unix操作系统的虚拟终端的前缀和名称。

在虚拟终端上运行的基本应用程序类型是Shell。Shell提示用户输入命令,并在用户按下回车键后将其发送给系统执行,这与电传打字机的工作流程类似。

直观地看,Shell的工作流程如下:键盘将输入传递到终端,终端将其传递给进程。进程执行工作,并将输出返回给终端,终端将其显示在显示器上。

在终端和进程之间,有一个称为“tty”(终端)的抽象层。它有点像接口,用于设置套接字通信的一些默认参数。stty 是一个实用工具,可以用来查看或更改这些设置。

例如,使用 stty -a 可以显示所有设置及其当前值,例如串行通信的速度以及行和列的数量。


终端设置详解:P73:2:规范模式、回显与信号

上一节我们介绍了终端的历史和基本概念,本节中我们来看看一些关键的终端设置,了解它们如何影响CLI的行为。

以下是 stty 工具可以控制的一些重要设置:

icanon(规范模式)
此设置启用基本的行编辑功能,例如在命令被发送到程序之前,允许用户使用退格键删除字符或前后移动光标。大多数交互式应用程序(如文本编辑器)会关闭此设置,自行处理所有的行编辑。默认情况下,规范模式是开启的。我们可以用 stty -icanon 把它关掉。

让我们看看它的作用。首先,我们打开 cat 命令。因为规范模式是开启的,输入会被缓冲,直到我们按下回车键。我们还可以使用退格键来删除字符。

cat

现在,我们用 stty -icanon 关掉规范模式,并再次使用 cat

stty -icanon
cat

如你所见,文本没有被缓冲。现在我们一输入一个字符,cat 就会立即收到它并打印出来,而不是一次处理一行。

我们可以通过 stty icanon 重新打开规范模式。

onlcr(将换行转换为回车换行)
此设置查找输出中的换行符(\n),并为每个换行符添加一个回车符(\r)。回车确保光标在换行后回到第一列,类似于电传打字机将纸张 carriage return(回车)到第一列的操作。

没有换行符的字符用于在现代终端上制作进度条。程序通过将光标移回第一列,然后用新的进度信息覆盖先前的信息来更新进度。默认情况下,此设置是开启的,可以使用 stty -onlcr 关闭。

让我们看看关闭后的效果。我们输入 ps 命令。

ps

输出看起来是结构化的。现在让我们用 stty -onlcr 关闭它,再看看 ps 的输出。

stty -onlcr
ps

我们可以看到效果消失了。光标不会返回到第一列,即使这些行被打印在新的行上。许多应用程序都假设终端会自动将光标移回新行的第一列。

echo(回显)
默认情况下,回显是开启的。echo 会指示终端将我们键入的每个字符打印回显示器上。如果我们把它关掉会怎么样?

我们再看一次 cat。我们可以看到我们键入的“hello world”。

cat
# 输入 hello world

但当我们用 stty -echo 把它关掉时,会发生这种情况:

stty -echo
cat
# 输入 hello world

我们没有看到字符被打印出来,甚至直到 cat 在后面将我们的输入字符串打印出来时,我们才看到输入。关闭回显常用于向用户询问密码时。

如果你尝试更改了太多设置,可以使用 reset 命令将所有设置恢复到默认值。你还可以查看Python标准库中的 termios 模块,从Python代码中打开或关闭这些设置。

改变终端状态的另一种方法是通过带内和带外信号。

  • 带内信号:意味着你在输入流中加入一些特殊字符。终端将这些特殊字符解释为命令,而不打印它们,相反,它执行预期的操作。
    • 实现带内信令的一种方法是使用控制字符。例如,Ctrl+H 将执行退格操作,Ctrl+C 将中断正在运行的进程。
    • 另一种方法是使用转义序列,它可以控制光标位置和文本颜色。例如,打印 \033[2J 序列将清除屏幕。在字符串前打印 \033[1m 序列会使字符串变为粗体。

终端还预先配置了输入和输出流:

  • stdin(标准输入)映射到键盘,程序从这里读取输入数据。
  • stdout(标准输出)是输出流,程序在这里写入输出数据。
  • stderr(标准错误)是错误信息流,程序在这里写入错误信息。

这种默认自动将输入和输出映射到键盘和显示器的能力是Unix操作系统的一个突破。在Unix之前,程序需要显式连接到适当的I/O设备,这是一件乏味的事情,因为缺乏跨系统的标准。

当然,可以使用重定向操作符来改变数据流:

  • >:重定向操作符,将程序的输出重定向到文件(覆盖)。
  • >>:重定向操作符,将程序的输出重定向到文件(追加)。
  • |:管道操作符,使一个程序的输出成为另一个程序的输入。

命令行界面基础:P73:3:命令、参数与选项

现在我们已经了解了终端是如何进化的以及它是如何工作的,让我们看看在终端内部运行的程序:命令行界面(CLI)。

“命令行界面”、“应用程序”、“程序”、“工具”这些词经常互换使用,但在大多数时候,它们指的是同一件事。CLI使得通过Shell脚本自动化重复任务变得容易。

使用CLI的一般模式如下:

  1. Shell显示提示符,作为准备接受输入的标志。
  2. 用户输入要运行的命令,以及一些选项和参数。
  3. 这构成了命令行文本,然后命令被执行。
  4. 输出打印在终端上。

但是,这些程序所需的参数和选项是什么呢?

  • 参数:程序运行所必需的信息。没有它们,程序通常无法工作。它们通常是位置性的,这意味着参数在命令行中的位置决定了其含义。
    • 例如,cp(复制)命令需要源和目标参数。第一个位置的参数永远是源,第二个位置的永远是目标。
    • 代码示例:cp source_file.txt destination_folder/
  • 选项(或标志):用于修改命令的操作。顾名思义,它们是可选的,并且可能有一些默认值。一般惯例是在字符或单词前面使用连字符(-)或双连字符(--)来标识选项。
    • 例如,在 cp 命令中,-r 选项可以改变其操作,要求它递归地复制源目录中的所有文件到目标。
    • 代码示例:cp -r source_folder/ destination_folder/

对CLI的一个批评是,它没有向用户提供关于其所有可用操作的提示,这与图形用户界面(GUI)形成对比,GUI通常通过菜单、图标或其他视觉线索来通知用户这些操作。

为了克服这个局限,许多CLI程序会围绕它们所支持的参数和选项显示一些简短的文档。可以通过使用 --help 选项调用CLI来查看此文档。

其中一些CLI还有手册页(man pages),是“manual pages”的简称。默认情况下,man 命令使用一个终端分页程序(例如 lessmore)来显示CLI的详细手册,这使得用户很容易滚动和搜索它。

你可能会想,这里有很多活动部件,每个程序员可以以不同的方式编写CLI。例如,他们可能使用 -x 而不是 -h 来显示帮助文本。有没有什么标准来确保CLI遵循一些基本惯例?

是的,有标准。例如,符合POSIX(可移植操作系统接口)标准的命令行接口需要遵循一些规范。还有基于XDG的目录规范,它规定了应用程序应该如何存储其功能所需的不同类型的文件(如配置文件、数据文件或程序缓存),这样大家就不会把文件到处存放。这些文件应该进入用户文件系统上的特定目录。


使用Python构建CLI:P73:4:标准库与第三方方案

现在,让我们看看如何使用Python实现命令行界面。有几种选择,包括Python标准库和第三方包(PyPI)。我们举一个小例子,称之为 smallpip,看看我们如何使用所有这些不同的选项来实现它。

smallpip 只有一个子命令 install,我们可以用它从PyPI安装包。它还有一个 --upgrade 选项。

1. 使用 sys.argvgetopt(标准库)
Python标准库有 sys 模块,它带有 sys.argv 变量。sys.argv 是一个列表,包含调用CLI时传递给它的命令行参数。

getopt 模块用于解析并创建命令行选项列表。其API被设计成类似于Unix getopt 函数,它遵循POSIX标准。

让我们看看一些代码:

import sys
import getopt

def main():
    # sys.argv[0] 是脚本本身的名称
    args = sys.argv[1:]

    try:
        opts, args = getopt.getopt(args, "hv", ["help", "version"])
    except getopt.GetoptError as err:
        print(err)
        sys.exit(2)

    for opt, arg in opts:
        if opt in ("-h", "--help"):
            print_help()
            sys.exit()
        elif opt in ("-v", "--version"):
            print_version()
            sys.exit()

    # 检查被调用的子命令
    if len(args) > 0 and args[0] == "install":
        handle_install(args[1:])  # 分发控制权
    else:
        print("Unknown command")
        sys.exit(1)

def handle_install(install_args):
    # 处理 install 子命令的逻辑
    # 需要手动解析 install_args 中的 --upgrade 等选项
    pass

def print_help():
    print("Help text here")

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/c54951fe84a9560c859d349c31f0c254_10.png)

def print_version():
    print("Version 1.0")

if __name__ == "__main__":
    main()

直到Python 3.2,标准库还有 optparse 模块。从那时起,它被 argparse 取代。optparse 只支持解析选项,而不支持位置参数。argparse 更强大,能够自动生成更好的帮助信息,还允许自定义用于标识选项的字符(例如使用加号而不是减号),甚至支持斜杠(/)。argparse 还增加了对子命令的支持,这是一种常见模式(例如 pip installpip freeze)。

2. 使用 argparse(标准库)
让我们看看 smallpip 代码如何使用 argparse

import argparse

def main():
    parser = argparse.ArgumentParser(description="A small pip clone")
    parser.add_argument("-v", "--version", action="version", version="1.0")

    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # install 子命令
    install_parser = subparsers.add_parser("install", help="Install a package")
    install_parser.add_argument("package", help="Package name to install")
    install_parser.add_argument("--upgrade", action="store_true", help="Upgrade the package")

    args = parser.parse_args()

    if args.command == "install":
        # 控制权自动分发到相关代码
        handle_install(args.package, args.upgrade)
    else:
        parser.print_help()

def handle_install(package, upgrade):
    print(f"Installing {package}, upgrade={upgrade}")
    # 实际的安装逻辑

if __name__ == "__main__":
    main()

argparse 自动生成了帮助信息。

3. 使用 docopt(第三方库)
docopt 由Vladimir Keleshev编写,它采用“文档优先”的方法来编写CLI。它只需要一个符合POSIX惯例的帮助字符串作为输入,并从中推断出子命令、选项和参数。

这次我们首先创建一个头部字符串,它显示了我们CLI的描述和用法。当CLI被调用时,我们调用 docopt,传入头部字符串和版本。

"""Smallpip.

Usage:
  smallpip install <package> [--upgrade]
  smallpip (-h | --help)
  smallpip (-v | --version)

Options:
  -h --help     Show this screen.
  -v --version  Show version.
  --upgrade     Upgrade the package.
"""

from docopt import docopt

def main():
    args = docopt(__doc__, version="Smallpip 1.0")

    if args["install"]:
        handle_install(args["<package>"], args["--upgrade"])

def handle_install(package, upgrade):
    print(f"Installing {package}, upgrade={upgrade}")
    # 实际的安装逻辑

if __name__ == "__main__":
    main()

在所有示例中,我们看到除了解析结果,我们还必须编写一些样板代码来将控制权分发到相关的安装和升级代码。如果我们要验证这些参数,还需要再增加一些样板。对于大型应用程序,这个样板可能非常庞大。我们可能还需要添加一些共同特性,例如进度条和颜色。


使用Click构建高级CLI:P73:5:装饰器与常见用例

Click 是由Armin Ronacher编写的第三方库,最初是为了支持Flask项目。Click 被设计成嵌套和可组合的,这意味着它支持任意嵌套的命令(例如 python setup.py sdist bdist_wheel,其中 bdist_wheelsdist 的子命令)。Click 也会根据子命令自动将控制权分发到相关代码。它支持回调函数,可用于验证解析后的命令行选项。

让我们看看使用 Clicksmallpip 代码是什么样子的:

import click

@click.group()
@click.version_option("1.0")
def cli():
    """A small pip clone."""
    pass

@cli.command()
@click.argument("package")
@click.option("--upgrade", is_flag=True, help="Upgrade the package.")
def install(package, upgrade):
    """Install a package."""
    click.echo(f"Installing {package}, upgrade={upgrade}")
    # 实际的安装逻辑

if __name__ == "__main__":
    cli()

Click 使用基于装饰器的方法。@click.group() 装饰器使函数成为一个可以添加子命令的命令组。@cli.command() 装饰器将函数转换为子命令。Click 自动生成帮助信息,基于函数文档字符串和选项帮助文本。

Click 承诺,当使用 Click 编写的多个应用程序被串联在一起时,它们将无缝地工作。这对于快速迭代和大型项目协作非常有用。

现在,让我们看看一些常见的用例,以及如何使用 Click 实现它们。我们将使用另一个小例子,叫做 smallgit,顾名思义,是一个小的Git克隆,有六个子命令:cloneconfiglogstatuscommitpush

首先,我们用 @click.group() 装饰器定义一个CLI函数。

用例1:显示进度条
当用户调用 clone 子命令时,我们应该让他们知道克隆了多少文件的进度。Click 提供了一个进度条实用工具。

@cli.command()
@click.argument("source")
@click.argument("destination")
def clone(source, destination):
    """Clone a repository."""
    files_to_clone = ["file1.txt", "file2.txt", "file3.txt"]  # 假设的文件列表
    with click.progressbar(files_to_clone, label="Cloning files") as bar:
        for file in bar:
            # 模拟下载每个文件
            time.sleep(0.1)
            # 实际下载逻辑
    click.echo(f"Cloned from {source} to {destination}")

用例2:使用特定配置文件
我们应该在应用程序文件夹中保存用户名和电子邮件之类的配置。Click 提供了 click.get_app_dir() 函数来帮助完成此操作。

@cli.command()
@click.argument("key")
@click.argument("value")
def config(key, value):
    """Set configuration."""
    app_dir = click.get_app_dir("smallgit")
    config_path = os.path.join(app_dir, "config")
    os.makedirs(app_dir, exist_ok=True)
    # 存储配置 (简化示例)
    with open(config_path, "w") as f:
        f.write(f"{key}={value}")
    click.echo(f"Set {key} to {value}")

用例3:分页输出
对于 log 命令打印的大型提交日志,我们可以通过调用终端分页程序来支持分页输出。

@cli.command()
def log():
    """Show commit logs."""
    log_output = "commit 1\ncommit 2\n" * 50  # 模拟长日志
    click.echo_via_pager(log_output)

用例4:为输出添加颜色
在使用 status 子命令打印时,我们应该为添加或修改的文件添加颜色。Click 支持为文本添加颜色。

@cli.command()
def status():
    """Show the working tree status."""
    added_files = ["new_file.txt"]
    modified_files = ["changed_file.py"]
    output = []
    for file in added_files:
        output.append(click.style(f"added:    {file}", fg="green"))
    for file in modified_files:
        output.append(click.style(f"modified: {file}", fg="yellow", bold=True))
    click.echo("\n".join(output))

用例5:获取多行输入(启动编辑器)
当用户调用 commit 子命令时,可能需要输入多行提交消息。Click 可以为此启动编辑器。

@cli.command()
@click.option("-m", "--message", help="Commit message.")
def commit(message):
    """Record changes to the repository."""
    if not message:
        message = click.edit("\n\n# Enter your commit message above.")
        if message is None:
            click.echo("Commit aborted.")
            return
        # 清理注释行
        message = "\n".join([line for line in message.split("\n") if not line.startswith("#")]).strip()
    click.echo(f"Committing with message: {message}")
    # 实际提交逻辑

用例6:交互式提示(如输入密码)
对于 push 子命令,可能需要用户提供凭据。Click 提供了 click.prompt 功能。

@cli.command()
@click.argument("remote")
@click.argument("branch")
def push(remote, branch):
    """Update remote refs along with associated objects."""
    username = click.prompt("Username")
    password = click.prompt("Password", hide_input=True)
    click.echo(f"Pushing to {remote}/{branch} as {username}")
    # 实际推送逻辑

Click 内部使用 getpass 模块或通过 termios 模块关闭回显来处理密码输入。

Click 还允许我们测试所编写的CLI。我们可以使用 CliRunner 在代码中调用CLI的每个子命令,并根据预期输出检查结果。

要分发CLI,我们需要创建一个 setup.py 文件,并向其添加 console_scripts 入口点。console_scripts 允许将Python函数注册为命令行程序。然后可以将其打包并上传到PyPI,以便其他人安装和使用。


CLI设计原则与总结:P73:6:保持简单与用户体验

既然我们知道如何用Python编写CLI,我们需要明白我们在一个相对有限的设计空间里运作,与图形用户界面(GUI)相比,GUI为用户提供了更多的视觉线索和指导。

有一些原则可以帮助我们为编写的CLI创建一个良好的用户体验(UX):

  1. 保持简单,遵循Unix哲学:程序应该只做一件事,并把它做好。编写程序,以便它们可以一起工作(通过管道和重定向操作符处理文本流)。遵循Unix哲学,确保当用户与CLI交互时没有意外。

  2. 让功能可以被发现:通过对功能的坦率展示,类似于GUI提供的功能。我们可以通过存储用户的命令行历史记录,并让他们在其中搜索来实现这一点。也许根据CLI所支持的功能,给他们一些关于自动完成的建议。J. R. Ramanujam在2017年的一个演讲中详细讨论了这一点。

有一些资源可以帮助你实现我们刚才谈到的一些历史和自动完成功能,例如 prompt_toolkit 库,它被IPython和许多其他CLI工具使用。

总结

在本教程中,我们一起学习了:

  1. CLI的起源:从电传打字机到现代终端模拟器的演变历程。
  2. 终端的工作原理:包括规范模式、回显、信号以及输入/输出流重定向等核心概念。
  3. CLI的基本构成:命令、位置参数和可选标志的区别与用法。
  4. 使用Python构建CLI:探索了从标准库的 sys.argv/argparse 到第三方库 docoptClick 的多种方案。
  5. 高级CLI特性实现:使用 Click 库实现了进度条、彩色输出、分页、配置文件管理、编辑器集成和交互式提示等常见功能。
  6. CLI设计原则:强调了保持简单、遵循Unix哲学以及提高功能可发现性的重要性。

希望你现在对CLI生态系统有了更深入的了解,并且能够使用Python自信地构建自己的命令行工具。你现在可以进一步探索 Click 等库的文档,以发现更多强大的功能。

074:五年级科学博览会项目实战教程

概述

在本教程中,我们将学习如何将树莓派与Python编程结合,用于构建一个五年级科学博览会项目——测量弹珠在轨道上的速度。我们将从安全须知开始,逐步介绍硬件准备、电路搭建、软件编程,并分享项目开发中的实用技巧与经验教训。


安全须知 ⚠️

在开始任何电路项目前,安全是第一要务。电是看不见的危险源,遵循良好实践能保护你和你的设备。

以下是必须遵守的安全准则:

  • 每次修改电路前,务必关闭树莓派电源。
  • 确保工作区域附近没有食物或饮料。
  • 在干燥的室内环境中工作。
  • 避免在金属桌面等导电表面上工作。
  • 如果你是电路新手,请严格遵循说明书操作,注意所有警告。
  • 考虑使用像“Snap Circuits”这样的学习工具包来安全地理解电路原理和隐患。

项目背景与简介 🎯

上一节我们强调了安全规范,本节我们来了解项目的起源。这个项目源于一个夏季科学博览会,孩子们通过不同形状的木制轨道比较弹珠速度。我儿子希望更进一步,精确测量每个弹珠的速度,因此我们决定利用树莓派来完成测量。

本教程将总结如何成功完成此类项目,无论你是独立完成还是协助他人。我们将涵盖:

  • 如何开始使用树莓派。
  • 如何在硬件项目中使用GPIO(通用输入输出)引脚连接外部电路。
  • 项目过程中遇到的有趣挑战及其解决方案。

核心项目提示 💡

进行一个树莓派项目时,合理的规划至关重要。以下是确保项目顺利推进的一些关键建议。

需要注意的事项列表:

  • 警惕项目规模:树莓派项目容易让人上瘾并不断扩展,可能导致无法在规定时间内完成。适时指导学生,如果项目过大,可以承诺后续添加功能。
  • 寻求外部帮助:不要害怕向本地大学教授或创客社区寻求工具或传感器方面的建议。没有人能知晓一切,交流本身也是学习。
  • 分解任务:不要试图在一个下午完成所有工作。将项目分解,在连续几个周末各花几小时进行,能有效减轻压力。
  • 积极调试:每个项目都会遇到问题。当进展停滞时,可以休息一下或睡一觉。专注于有效的部分,逐步缩小范围来定位问题。
  • 记录过程:在整个项目中拍摄照片和视频,这对回顾和展示非常有帮助。

项目构思与光电门原理 🔦

我们有了轨道,但如何测量速度呢?经过咨询,我们决定使用光电门

光电门由两部分组成:一侧是光源(如LED或激光),另一侧是光敏晶体管。光敏晶体管是一种特殊开关:当被光照亮时,开关闭合,电流可以通过;当光线被阻挡时,开关断开,电流无法通过。

在电路层面,当光束未被阻挡时,输出为低电压;当光束被阻挡时,输出为高电压。对于树莓派,我们通常将低电压视为数字信号 0,高电压视为 1

电压状态: 有光 -> 低电压 (0) | 无光 -> 高电压 (1)

我们通过在线搜索,找到了一个关于为树莓派搭建光电门的教程视频,这成为了我们项目的基础。


树莓派入门指南 🖥️

如果你从未使用过树莓派,可以从树莓派基金会官网开始。他们列出了许多供应商,提供各种套件。

以下是开始步骤:

  • 选择套件:像“Canakit”这样的入门套件非常不错,通常包含预装NOOBS(新开箱即用软件)的Micro SD卡。NOOBS是一个包含Debian Linux操作系统的系统,拥有图形界面,并预装了Python和一些IDE。
  • 准备SD卡:如果无法获得套件,你需要自行准备Micro SD卡并安装操作系统。
  • 使用虚拟环境:如果你熟悉Python虚拟环境,建议为项目创建独立的虚拟环境。这能有效管理依赖。如果不熟悉,网上有大量相关教程。

树莓派顶部有一排突出的引脚,那就是GPIO连接器,用于连接外部电路。


推荐硬件与连接技巧 🔌

为了更方便地连接电路,推荐使用一些硬件工具。

必备工具包列表:

  • Freenove Ultimate Starter Kit:这个套件提供了带标签的分线板,可以轻松插到面包板上,并通过排线连接树莓派。它还包含LED、电机等多种元件,并附有详细的PDF教程和示例代码。
  • 面包板连接技巧:连接排线时要格外小心。可以将树莓派翻过来,背面引脚中有一个是方形的(Pin 1)。确保排线上有红色标记的一侧与Pin 1在同一方向对齐,并确认每个孔都对应一个引脚,没有偏移。

其他实用工具:

  • 面包板连接线套件:包含各种长度、已剥线的彩色跳线,能极大提高搭建效率。
  • 鳄鱼夹连接线:用于连接面包板与外部元件。
  • 优质万用表:用于调试和检查电路。如果预算有限,可以考虑购买两个廉价万用表相互校验。
  • 穿孔板(洞洞板):当你完成面包板上的电路并想永久保存时,可以学习焊接技能,将电路转移到洞洞板上。

电路搭建基础 ⚡

现在我们来了解如何在面包板上搭建基础电路。

首先,需要将树莓派提供的电压连接到面包板的电源轨。树莓派提供 5V3.3V 两种电压。通常,将5V连接到面包板一侧的红色电源轨,将3.3V连接到另一侧(或根据需要)。同时,必须将树莓派的地(GND) 引脚连接到面包板的黑色地线轨,以确保电路有共同的参考点。

面包板中间的孔是垂直连接的,适合插入双列直插封装(DIP)的芯片,如定时器或逻辑门。芯片的引脚可以直接插入这些孔中。

搭建电路时,尽量保持线路整洁,这有助于后续调试。电路搭建完成后、通电前,建议用万用表的电阻档(欧姆档)检查:将黑表笔接地,红表笔分别接触5V和3.3V电源轨,确保读数不是接近 0 欧姆。如果读数极低,说明可能存在短路,通电会损坏元件。


软件环境设置与GPIO基础 🐍

硬件准备就绪后,我们来设置软件环境。树莓派通常预装Python 2和Python 3。请注意,Python 2已停止支持,建议使用Python 3。

对于树莓派GPIO编程,我们需要 RPi.GPIO 库。在较新的系统(如使用Canakit套件)中,这个库可能已经预装。你可以通过以下命令检查或安装:

# 检查Python 3和pip
python3 --version
pip3 --version

# 安装RPi.GPIO库 (如果未安装)
pip3 install RPi.GPIO

此外,Freenove套件的GitHub仓库提供了所有示例项目的Python代码,是极佳的学习资源。


深入电路元件:二极管与光电门升级 💎

我们的项目用到了发光二极管(LED)。二极管只允许电流单向流动(从阳极到阴极)。一个使用万用表的小技巧是:将其调到二极管测试档,用红黑表笔接触LED的两脚,如果LED微亮,则红表笔接触的是阳极。

我们的项目需要红外(IR)LED。红外光人眼不可见,但许多手机摄像头可以探测到。我们最初使用的小型红外LED亮度不足,导致光电门始终认为光线被阻挡。解决方案是将其替换为使用纽扣电池供电的指尖手电筒作为光源,效果显著提升。

我们搭建了两个光电门电路(起点门和终点门),分别连接到树莓派的GPIO 17和18号引脚。我们还增加了一个“验证回路”——一个简单的LED电路,用于手动遮挡光线时快速验证光电门是否工作正常。

你可以使用在线工具如 circuit-diagram.org 来绘制自己的电路图。


GPIO输入编程与状态管理 🧮

GPIO输出编程相对简单(如点亮LED),但输入编程更具挑战性,因为你如何知道引脚状态变化了呢?

首先,你需要确认使用的GPIO引脚编号。Freenove套件的分线板有清晰的彩色标签。你也可以在终端使用 gpio readall 命令查看所有引脚状态。

接下来,我们可以编写一段简单的测试代码来读取引脚状态:

import RPi.GPIO as GPIO
import time

# 设置引脚编号模式为BOARD
GPIO.setmode(GPIO.BOARD)

# 设置引脚(例如物理引脚11)为输入
input_pin = 11
GPIO.setup(input_pin, GPIO.IN)

try:
    while True:
        # 读取引脚状态
        pin_state = GPIO.input(input_pin)
        print(f"Pin state: {pin_state}")
        time.sleep(0.1) # 短暂延迟
except KeyboardInterrupt:
    print("Program stopped.")
finally:
    GPIO.cleanup() # 清理GPIO设置

运行这段代码,当你遮挡或移开光电门光束时,应该能在终端看到状态在 01 之间变化。


实现速度测量逻辑 ⏱️

有了基础输入检测,我们需要实现完整的测量逻辑。我们需要追踪以下状态:

  1. 等待开始:弹珠在起点门处,门被阻挡(状态为1)。
  2. 第一颗弹珠出发:弹珠离开起点门,门打开(状态变为0),此时启动计时器。
  3. 等待第一颗到达终点:持续监测终点门,当状态从0变为1时,记录第一颗弹珠的到达时间。
  4. 等待第一颗离开终点:持续监测,当终点门状态从1变回0时,表示第一颗弹珠已通过。
  5. 等待第二颗到达终点:再次监测终点门,当状态从0变为1时,记录第二颗弹珠的到达时间。

核心思路是使用一个状态机和一系列标志变量在循环中管理这些状态迁移。代码会高速循环(每秒约千次读取),确保能捕捉到短暂的遮挡信号。

我们为这个项目创建了GitHub仓库,包含了测试代码和最终的测量代码,供大家参考。


总结与最终成果 🏆

在本教程中,我们一起学习了如何将树莓派和Python应用于一个真实的科学项目。我们从安全规范出发,逐步完成了硬件选型、电路搭建、软件环境配置、GPIO编程,并最终实现了弹珠速度的自动测量。

回顾关键步骤:

  • 安全第一:始终遵循电路操作安全准则。
  • 小步前进:从点亮一个LED开始,逐步增加复杂度。
  • 明确寻址:事先确定并测试好GPIO引脚编号。
  • 善用测试:编写小型测试代码来验证硬件和基础逻辑。
  • 状态管理:对于复杂的输入序列,使用状态机或标志变量来清晰地管理逻辑。

我儿子用这个系统进行了十组实验,将数据导入Jupyter Notebook进行分析,并最终在地区科学博览会上获得了第三名!我们为他感到无比自豪。

希望这个教程能激发你或你的学生开始自己的树莓派探索之旅。记住,最重要的是保持好奇,乐于动手,并从过程中享受乐趣。

(参考资料与链接请参见原视频描述或幻灯片)

075:使用 Python 与机器学习 🎭

概述

在本教程中,我们将学习如何利用 Python 和机器学习模型来创作和探索“无意义诗”。我们将从理解声音诗歌的美学基础开始,逐步深入到使用 pronouncing 库分析单词发音,以及使用 Pinsulate 机器学习模型来生成和操控虚构单词的拼写与发音。通过本教程,你将掌握一套计算工具,用于探索语言的声音层面,并创作出具有独特音韵效果的诗歌。


第 1 节:声音诗歌与音韵美学 🎶

声音诗歌是一种强调语言音响效果,而忽视传统语法和语义的诗歌形式。它让我们关注词语本身的声音质感,而非其字典含义。

路易斯·卡罗尔的《杰伯沃基》是著名的无意义诗范例。诗中如“Brilig”和“Gimbal”这样的虚构词汇,虽然不在字典中,但通过其上下文和声音,依然能传达出某种意义和情感。

研究表明,声音本身能引发特定的联觉反应。例如,当被要求将“kiki”和“booba”这两个词与尖锐或圆润的形状配对时,来自不同语言背景的人几乎总将“kiki”与尖锐形状、“booba”与圆润形状联系起来。这被称为音韵美学,即研究词语声音如何引发情感和感官反应。

奇幻作家索非亚·萨马塔在创造虚构语言时,也着重于组合“听起来美妙”的音节。将这种为音韵特性发明词汇的理念推向极致,就产生了声音诗。这种诗歌形式将语言的声学材料作为核心,例如达达主义诗人艾尔塞万·弗里塔克和俄罗斯未来主义诗人亚历克谢·克鲁恰尼克的作品。

克鲁恰尼克支持的“Zam”诗歌形式,旨在通过解构和重构传统诗歌,创造出基于声学逻辑而非指称逻辑的新文本。这个过程被称为“Zam-nification”。

声音诗歌学者史蒂夫·麦卡弗里指出,声音诗能“绕过皮层,直接作用于中枢神经系统”。这启发了我们使用计算机程序来创作此类诗歌,以探讨其美学机制,并为我们提供创作新声音诗的工具。


第 2 节:挑战与工具——从拼写到发音 🔤

上一节我们探讨了声音诗的理念。本节中,我们来看看在实践层面面临的具体挑战:如何用程序处理虚构词汇的拼写和发音。

传统工具(如拼写检查器)会将《杰伯沃基》中的词标记为错误。然而,即使这些词不在字典中,我们依然知道如何发音并感知其意义。因此,我们需要一个能双向工作的程序:

  1. 将声音(音素)转换为字母(正字法)。
  2. 将字母(正字法)转换为声音(音素)。

一个基础资源是 CMU 发音词典。它包含了超过10万个英语单词及其音标(使用ARPABET音标系统)。我们可以用它来查询已知单词的发音。

然而,CMU词典的局限在于它是固定的,无法处理不在其中的虚构词汇(如声音诗中的词)。因此,我们需要一个统计模型来预测任意字母序列的发音,以及任意音素序列的拼写。

这就是 Pinsulate 库的作用。它是一个基于序列到序列机器学习模型的Python库,能够实现音素与拼写之间的双向转换,即使对于词典中不存在的词也能给出合理的猜测。


第 3 节:模型原理与核心功能概览 🤖

Pinsulate 的核心是基于递归神经网络(RNN)的序列到序列模型。

序列到序列模型 包含两个主要部分:

  • 编码器:将一种语言(如拼写)的序列编码为一个固定长度的特征向量。
  • 解码器:基于该特征向量,预测另一种语言(如音素)序列中的下一个标记。

在 Pinsulate 中,我们训练了两个这样的模型:

  1. 拼写 -> 音素模型(编码器:拼写,解码器:音素)
  2. 音素 -> 拼写模型(编码器:音素,解码器:拼写)

这两个模型连接起来,形成了一个循环:拼写 -> 音素 -> 拼写。这个架构的关键在于,我们可以获取并操控中间的特征向量(即单词声音的压缩表示),然后再将其解码回拼写,从而创造出各种音韵效果。

Pinsulate 将音素进一步分解为音韵特征(如:双唇音、塞音、浊音)。这种表示为我们提供了更精细的控制能力。

以下是 Pinsulate 能实现的一些核心功能演示:

  • 拼写与发音虚构词:对如“Pikachu”、“MIMSI”等词进行发音和拼写。
  • 操控解码概率:通过滑块降低或提高特定字母(如‘E’)或音韵特征(如“鼻音化”)在生成过程中出现的概率。
  • 调整“温度”:控制模型预测时的随机性,从而生成更保守或更出人意料的拼写变体。
  • 单词插值:取两个单词(如“paper”和“plastic”)的音素状态向量,计算其中点,并生成一个在发音上介于两者之间的新词。
  • 拉伸与压缩:将单词的音素特征数组像音频一样拉长或缩短,从而改变其拼写长度。

这些功能让我们能够将语言的声音视为一种可塑的材料进行实验和创作。


第 4 节:实战开始——环境配置与基础分析 💻

现在,让我们开始动手实践。首先需要设置好编程环境。

环境配置步骤:

  1. 克隆教程仓库:git clone https://github.com/aparish/nonsense-pycon-2020
  2. 进入目录并创建虚拟环境:python -m venv venv
  3. 激活虚拟环境(Linux/Mac: source venv/bin/activate,Windows: venv\Scripts\activate)。
  4. 安装依赖包:pip install -r requirements.txt
  5. 启动 Jupyter Notebook:jupyter notebook

我们将首先使用 pronouncing 库进行基础的音韵分析。

使用 pronouncing 库:
pronouncing 库提供了访问 CMU 发音词典的简单接口。

import pronouncing as pr

# 获取单词“programming”的发音(音素列表)
phones = pr.phones_for_word("programming")
print(phones[0])  # 输出第一个发音
# 输出类似:P R AA1 G R AE2 M IH0 NG

# 计算音节数
syllable_count = pr.syllable_count(phones[0])
print(f"Syllables: {syllable_count}")  # 输出:3

# 获取重音模式
stress_pattern = pr.stresses(phones[0])
print(f"Stress: {stress_pattern}")  # 输出:1 2 0

# 查找与“cheese”押韵的词
rhymes = pr.rhymes("cheese")
print(rhymes[:5])  # 输出前5个押韵词,如 ['fleece', 'gees', 'greece', ...]

# 根据声音模式搜索单词(例如,包含“AY1 Z”音素的词)
search_results = pr.search("AY1 Z")
print(search_results[:5])

分析文本的音韵特征:
我们可以对一段文本进行简单的音素频率分析。

from collections import Counter
import re

text = "April is the cruelest month breeding lilacs out of the dead"
words = re.findall(r'\b\w+\b', text.lower())  # 提取单词

phoneme_counter = Counter()
for word in words:
    pronunciations = pr.phones_for_word(word)
    if pronunciations:
        # 取第一个发音,拆分成音素并计数
        phonemes = pronunciations[0].split()
        phoneme_counter.update(phonemes)

print(phoneme_counter.most_common(5))  # 打印最常见的5个音素

第 5 节:使用 Pinsulate 进行高级操控 🧩

上一节我们使用了静态词典。本节中,我们来看看如何用 Pinsulate 模型处理任意字符串。

首先,导入并初始化 Pinsulate。

from pinsulate import Pinsulate
import numpy as np

pin = Pinsulate()  # 加载预训练模型(首次使用可能需要一点时间)

基础功能:发音与拼写

# 1. 为已知或未知单词发音
print(pin.sound_out("allison"))  # -> ['AH0', 'L', 'IH0', 'S', 'AH0', 'N']
print(pin.sound_out("pikachu"))  # 不在CMU词典中,但模型会预测

# 2. 根据音素列表拼写单词
spelling = pin.spell(['M', 'IH1', 'M', 'Z', 'IY0'])
print(spelling)  # 可能输出:'mimsy'

# 3. 拼写一个即兴创造的词
print(pin.spell(['B', 'L', 'AA1', 'R', 'F']))  # 可能输出:'blarf'

操控音韵特征
Pinsulate 在内部使用音韵特征数组。我们可以直接修改这些特征。

# 获取单词“pug”的音韵特征数组
features = pin.phoneme_features("pug")
print(features.shape)  # 例如 (5, 32),5个音素(含起止),32个特征维度

# 将首音素的特征从清音改为浊音(p -> b)
from pinsulate.featurephoneme import FEATURE_INDEX
voiced_idx = FEATURE_INDEX['voi']  # 浊音特征索引
unvoiced_idx = FEATURE_INDEX['cns']  # 清音特征索引(示例,需确认具体特征名)

features[1, voiced_idx] = 1.0   # 设置为浊音
features[1, unvoiced_idx] = 0.0 # 取消清音

# 从修改后的特征拼写单词
new_spelling = pin.spell_features(features)
print(new_spelling)  # 可能输出:'bug'

使用 manipulate 函数进行便捷操控
manipulate 函数封装了常见操作。

# 调整温度:增加随机性
print(pin.manipulate("spelling", temperature=1.5))

# 抑制某些字母的出现
print(pin.manipulate("spelling", letters={'e': 5.0, 'i': 5.0})) # 降低e和i的概率

# 增强某些音韵特征(如使所有音更“鼻音化”)
print(pin.manipulate("spelling", features={'nas': 2.0})) # 增加鼻音特征权重

单词插值与生成
利用音素状态向量进行创作。

# 获取两个单词的状态向量
state_a = pin.phoneme_state("paper")
state_b = pin.phoneme_state("plastic")

# 计算中点向量
mid_state = (state_a + state_b) / 2

# 从中点状态生成新词
new_word = pin.spell_state(mid_state)
print(f"Between 'paper' and 'plastic': {new_word}")

# 从随机噪声生成新词
random_state = np.random.normal(size=256)  # 256维向量
random_word = pin.spell_state(random_state)
print(f"Random word: {random_word}")

第 6 节:创作实践——在语料库中寻找诗歌 📜

掌握了工具后,我们可以将其应用于现有文本,进行“发现式”创作。

示例1:在散文中寻找“俳句”
俳句是一种三行诗,音节模式为5-7-5。我们可以在长文本中寻找恰好能按此模式分割的17音节句子。

import nltk
nltk.download('punkt')  # 下载分词数据
from nltk.tokenize import sent_tokenize, word_tokenize

def find_haikus(text):
    sentences = sent_tokenize(text)
    haikus = []
    for sent in sentences:
        words = [w.lower() for w in word_tokenize(sent) if w.isalpha()]
        syllable_counts = []
        for w in words:
            prons = pr.phones_for_word(w)
            if prons:
                syllable_counts.append(pr.syllable_count(prons[0]))
            else:
                # 如果词典没有,用Pinsulate估算(略)
                break
        # 检查音节总数是否为17,并能按5-7-5分割(此处需实现分割逻辑)
        # ... (分割逻辑实现)
        # if is_haiku(syllable_counts):
        #     haikus.append((sent, break_indices))
    return haikus

# 加载《弗兰肯斯坦》文本并运行
with open('frankenstein.txt', 'r', encoding='utf-8') as f:
    novel_text = f.read()
potential_haikus = find_haikus(novel_text[:5000])  # 在前5000字符中查找
for h in potential_haikus[:3]:
    print(h[0])
    print("---")

示例2:重构十四行诗的韵脚
莎士比亚十四行诗有严格的押韵格式。我们可以从诗集中随机抽取行,并匹配押韵的尾词,生成新的对句。

from collections import defaultdict
import random

# 加载十四行诗,每行一个列表项
with open('sonnets.txt', 'r') as f:
    sonnet_lines = [line.strip() for line in f if line.strip()]

# 构建韵脚索引:{韵脚部分: {尾词1: [行索引1, ...], 尾词2: ...}}
rhyme_index = defaultdict(lambda: defaultdict(list))

for idx, line in enumerate(sonnet_lines):
    words = [w.lower() for w in word_tokenize(line) if w.isalpha()]
    if words:
        last_word = words[-1]
        prons = pr.phones_for_word(last_word)
        if prons:
            rhyme_part = pr.rhyming_part(prons[0])
            rhyme_index[rhyme_part][last_word].append(idx)

# 过滤掉只有一种尾词的韵脚
rhyme_index = {rp: d for rp, d in rhyme_index.items() if len(d) > 1}

# 生成几对押韵的对句
for _ in range(3):
    rp = random.choice(list(rhyme_index.keys()))
    word_dict = rhyme_index[rp]
    word_a, word_b = random.sample(list(word_dict.keys()), 2)
    line_idx_a = random.choice(word_dict[word_a])
    line_idx_b = random.choice(word_dict[word_b])
    print(sonnet_lines[line_idx_a])
    print(sonnet_lines[line_idx_b])
    print()

总结 🏁

在本教程中,我们一起学习了:

  1. 声音诗歌的理念:理解了无意义诗和音韵美学,认识到语言声音本身的表现力。
  2. 核心工具:掌握了 pronouncing 库(用于CMU发音词典查询)和 Pinsulate 库(用于基于机器学习的拼写-发音转换与操控)。
  3. 模型原理:了解了序列到序列模型和音韵特征的基本概念,这是 Pinsulate 实现灵活操控的基础。
  4. 实践技能
    • 进行基础的音素和韵律分析。
    • 使用 Pinsulate 为虚构词汇发音和拼写。
    • 通过调整概率、温度、音韵特征来操控单词的输出。
    • 实现单词插值、状态向量拉伸等高级创作技巧。
    • 在现有文本语料库中发掘符合特定格律(如俳句)的诗句或重构押韵模式。

这些工具和技术为你打开了一扇新的大门,让你能够以计算和实验的方式探索语言的音乐性,并创作出属于自己的、注重声音质感的新颖文本。

076:教程概述 🚀

在本教程中,我们将学习如何使用Python和Selenium WebDriver从头开始构建一个Web应用测试自动化解决方案。我们将以DuckDuckGo搜索引擎为例,编写一个端到端的测试用例,涵盖从项目搭建、编写测试、使用页面对象模式到处理配置和并行测试的全过程。本教程旨在让初学者能够理解并实践Web UI测试自动化的核心概念。


动手进行Web应用测试自动化:1:Web UI测试入门 🎯

在开始编写代码之前,让我们先了解什么是Web UI测试以及为什么它很重要。

测试是一种旨在识别软件产品特性中的优点和缺点以保持质量的活动。测试可以通过手动操作完成,也可以通过自动化脚本完成。

测试活动有多种类型,人们有时会误解“测试”一词,认为单元测试是唯一存在的测试类型。实际上,测试可以分为两大类:

  • 白盒测试:直接与代码交互,测试代码单元(如函数或方法)是否按预期工作。单元测试和集成测试(测试代码模块间的协作)属于此类。
  • 黑盒测试:不与代码直接交互,而是与软件的功能或产品本身交互,验证其是否符合需求并为最终用户提供价值。端到端测试和系统测试属于此类。

为了平衡测试的覆盖范围和成本,我们使用测试金字塔模型。金字塔底部是大量快速、廉价的单元测试(白盒测试)。金字塔顶部是数量较少但更复杂、更昂贵、执行更慢的黑盒测试(如集成测试和端到端测试)。我们的目标是拥有一个坚实的测试基础,同时谨慎地使用高层级测试。

Web UI测试是一种通过真实浏览器对Web应用程序进行的黑盒测试。它模拟真实用户与网页的交互(如点击、输入文本),属于功能测试和端到端测试的范畴。一个现代Web应用包含前端(HTML, CSS, JavaScript)、服务层(如REST APIs)、持久层(数据库)和基础设施,Web UI测试能验证所有这些部分协同工作的结果。

Web UI测试有其优缺点:

  • 优点:提供良好的端到端覆盖;与产品交互方式更接近真实用户;能发现明显的功能问题。
  • 缺点:比单元测试更复杂;执行速度慢;容易因网络延迟、页面加载等因素导致测试结果不稳定(片状化);问题根因分析更困难。

一个好的Web UI测试应具备以下特点:

  • 关注应用程序的一个主要行为(如登录、搜索)。
  • 具有清晰、直观的步骤流程。
  • 覆盖重要的核心功能或基本错误场景,避免罕见的边缘情况。
  • 具有高投资回报率——如果测试失败,团队会立即意识到问题的严重性。

在本教程中,我们将把测试自动化视为软件开发的一个专门领域,采用最佳实践来构建我们的解决方案。我们的技术栈包括:

  • Python:实现语言。
  • pytest:核心测试框架,用于组织和运行测试用例。
  • 页面对象模式:设计模式,用于对网页交互进行建模,提高代码可维护性和复用性。
  • Selenium WebDriver:底层库,用于自动化与真实浏览器的交互。

我们的架构如下图所示:测试用例(pytest)调用页面对象,页面对象调用Selenium WebDriver绑定,WebDriver通过一个代理(如本地WebDriver可执行文件或Selenium Grid)与浏览器通信。

现在,让我们开始设置项目并编写第一个测试。


动手进行Web应用测试自动化:2:用第一个测试设置pytest 🛠️

上一节我们介绍了Web UI测试的基本概念,本节我们将开始动手搭建测试项目并编写第一个测试用例。

我们将测试的产品是DuckDuckGo搜索引擎。我们编写的第一个测试用例是一个基本的搜索测试,步骤如下:

  1. 导航到 duckduckgo.com
  2. 在搜索框中输入一个搜索短语(例如“panda”)。
  3. 验证结果页面:
    • 页面标题包含搜索短语。
    • 结果页面的搜索输入框中的值等于搜索短语。
    • 至少有一个结果链接的标题与搜索短语相关。

我们将使用pytest作为测试框架。pytest是Python中最流行、最简洁的测试框架之一。一个pytest测试用例可以简单到一个带有assert语句的函数。

以下是设置步骤:

  1. 克隆本教程的GitHub仓库,其中包含了所有示例代码和安装说明。
  2. 按照README完成环境安装(包括Python、依赖包和浏览器驱动)。
  3. 项目已包含一个虚拟的pytest测试模块 test_fw.py,运行 python -m pytest 命令可以验证pytest是否正常工作。

现在,轮到您动手了。请暂停视频,完成README中第一部分(Part 1)的教程说明。您需要:

  • tests 目录下创建一个新的测试模块(例如 test_search.py)。
  • 在其中添加一个测试函数 test_basic_duckduckgo_search
  • 在函数体内,用注释或简单的pass语句勾勒出上述三个测试步骤,并在最后添加一个 raise Exception(“Incomplete test”) 语句,表示测试尚未实现。
  • 运行 python -m pytest,您应该看到这个新测试因为抛出异常而失败。

这符合“测试驱动开发”(TDD)的理念:先编写一个会失败的测试,再逐步实现功能使其通过。


动手进行Web应用测试自动化:3:设置Selenium WebDriver 🌐

上一节我们编写了测试用例的轮廓,本节我们将引入Selenium WebDriver,它是实现浏览器自动化的核心工具。

Selenium WebDriver 是一个W3C标准,用于自动化Web浏览器交互。它支持点击、输入文本、获取元素文本等各种操作。每个主要的浏览器(Chrome, Firefox, Safari, Edge等)都需要一个特定的驱动程序(如ChromeDriver, GeckoDriver)作为WebDriver和浏览器之间的代理。

重要注意事项:

  • 确保已将所需的浏览器驱动程序(如chromedriver, geckodriver)下载并放置在系统的PATH环境变量中。
  • 注意浏览器版本与驱动程序版本的兼容性,不匹配会导致测试失败。
  • 每个测试用例应该初始化自己的WebDriver实例,并在测试结束后正确退出(调用quit()方法),以避免资源泄漏和测试间相互影响。

在pytest中,我们可以使用fixture来处理测试的初始化和清理工作。Fixture是pytest的一个强大功能,它提供了一种可重用的方式来设置测试所需的状态和环境。

以下是一个WebDriver fixture的示例框架:

# conftest.py
import pytest
from selenium import webdriver

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/274e64ac5dd581b44ee82ed902c54130_16.png)

@pytest.fixture
def browser():
    # 1. 初始化:启动浏览器
    driver = webdriver.Chrome() # 或 webdriver.Firefox()
    # 2. (可选)设置一些全局等待时间
    driver.implicitly_wait(10)
    # 3. 将driver对象提供给测试用例
    yield driver
    # 4. 清理:测试结束后退出浏览器
    driver.quit()

在这个fixture中:

  • yield 之前的代码是“安装”阶段,在测试用例运行前执行。
  • yield driverdriver 对象提供给测试函数。
  • yield 之后的代码是“清理”阶段,在测试用例运行后(无论成功与否)执行。

要在测试用例中使用这个fixture,只需将fixture的名称作为测试函数的参数即可:

# test_search.py
def test_basic_duckduckgo_search(browser): # `browser` 参数会注入fixture返回的driver对象
    # ... 测试步骤 ...
    raise Exception(“Incomplete test”)

现在,轮到您动手了。请暂停视频,完成README中第二部分(Part 2)的教程说明。您需要:

  1. 在项目根目录或tests目录下创建 conftest.py 文件。
  2. 在其中实现上述的 browser fixture。
  3. 更新您的 test_basic_duckduckgo_search 函数,添加 browser 参数。
  4. 运行测试。您应该看到浏览器窗口短暂打开然后关闭,并且测试仍然因为“Incomplete test”异常而失败。这表明fixture已成功运行。

动手进行Web应用测试自动化:4:定义页面对象 📄

上一节我们成功启动了浏览器,本节我们将引入页面对象模式来组织我们的测试代码,使其更清晰、更易维护。

页面对象是一个代表网页或页面组件的类。它主要包含两部分:

  1. 定位器:用于在页面上查找元素的查询语句(如CSS选择器、ID)。
  2. 交互方法:封装了与页面元素交互的高级操作(如“登录”、“搜索”)。

使用页面对象的好处包括:

  • 提高可读性:测试用例读起来像用户故事,而不是一堆技术性的WebDriver调用。
  • 最大化代码复用:相同的页面交互逻辑可以在多个测试中复用。
  • 便于维护:当页面UI发生变化时,通常只需要更新对应的页面对象类,而不是修改所有测试用例。

我们的测试涉及两个页面:

  1. 搜索页面 (DuckDuckGoSearchPage):包含load()(加载页面)和search(phrase)(输入短语并搜索)方法。
  2. 结果页面 (DuckDuckGoResultPage):包含result_link_titles()(获取所有结果链接的标题)、search_input_value()(获取搜索框中的值)和title()(获取页面标题)方法。

页面对象类的框架如下:

# pages/search.py
class DuckDuckGoSearchPage:
    def __init__(self, browser):
        self.browser = browser # 接收来自测试用例的browser对象

    def load(self):
        # TODO: 实现加载页面
        pass

    def search(self, phrase):
        # TODO: 实现搜索操作
        pass
# pages/result.py
class DuckDuckGoResultPage:
    def __init__(self, browser):
        self.browser = browser

    def result_link_titles(self):
        # TODO: 实现获取结果标题
        return []

    def search_input_value(self):
        # TODO: 实现获取搜索框值
        return “”

    def title(self):
        # TODO: 实现获取页面标题
        return “”

然后,我们可以在测试用例中使用这些页面对象:

# test_search.py
from pages.search import DuckDuckGoSearchPage
from pages.result import DuckDuckGoResultPage

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/274e64ac5dd581b44ee82ed902c54130_26.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/274e64ac5dd581b44ee82ed902c54130_27.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/274e64ac5dd581b44ee82ed902c54130_29.png)

def test_basic_duckduckgo_search(browser):
    search_page = DuckDuckGoSearchPage(browser)
    result_page = DuckDuckGoResultPage(browser)
    PHRASE = “panda”

    # 1. 加载搜索页面
    search_page.load()
    # 2. 执行搜索
    search_page.search(PHRASE)
    # 3. 验证结果
    assert PHRASE in result_page.title()
    assert PHRASE == result_page.search_input_value()
    titles = result_page.result_link_titles()
    matches = [t for t in titles if PHRASE.lower() in t.lower()]
    assert len(matches) > 0
    # 移除之前的异常抛出
    # raise Exception(“Incomplete test”)

现在,测试用例的逻辑非常清晰,完全基于业务行为,隐藏了底层的WebDriver细节。

现在,轮到您动手了。请暂停视频,完成README中第三部分(Part 3)的教程说明。您需要:

  1. 创建 pages 目录和相应的Python模块(search.py, result.py)。
  2. 在其中定义页面对象类及其存根方法(如上面框架所示)。
  3. 更新您的测试用例,导入并使用这些页面对象。
  4. 运行测试。它仍然会失败,因为页面对象的方法还未实现,但失败信息会不同(例如,断言失败而不是异常)。这表明我们的测试结构正在逐步完善。


动手进行Web应用测试自动化:5:定位页面元素 🔍

上一节我们定义了页面对象的接口,本节我们将学习如何找到页面上的元素,这是实现页面对象方法的第一步。

与网页元素交互通常需要三个步骤:

  1. 等待:等待目标元素出现在页面上。
  2. 查找:使用定位器找到该元素,并获得一个代表该元素的WebElement对象。
  3. 交互:向该WebElement对象发送命令(如.click(), .send_keys())或获取其属性(如.text, .get_attribute(‘value’))。

定位器是在页面上查找元素的查询语句。Selenium支持多种定位器类型:

  • By.ID:通过元素的id属性定位(最优先选择,通常唯一)。
  • By.NAME:通过name属性定位。
  • By.CSS_SELECTOR:通过CSS选择器定位(功能强大且灵活)。
  • By.XPATH:通过XPath表达式定位(功能最强大,但也最复杂)。
  • By.LINK_TEXT / By.PARTIAL_LINK_TEXT:通过链接文本定位。
  • By.CLASS_NAME / By.TAG_NAME:通过类名或标签名定位。

示例

from selenium.webdriver.common.by import By

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/274e64ac5dd581b44ee82ed902c54130_37.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/274e64ac5dd581b44ee82ed902c54130_39.png)

# ID定位器
locator_id = (By.ID, “search_form_input_homepage”)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/274e64ac5dd581b44ee82ed902c54130_41.png)

# CSS选择器定位器
locator_css = (By.CSS_SELECTOR, “input#search_form_input_homepage”)

# XPath定位器 (尽量避免复杂XPath)
locator_xpath = (By.XPATH, “//input[@id=‘search_form_input_homepage’]”)

如何找到元素的这些属性?我们可以使用浏览器的开发者工具(如Chrome DevTools)。

  1. 打开目标网页(如 duckduckgo.com)。
  2. 右键点击要检查的元素(如搜索框),选择“检查”。
  3. 开发者工具会高亮显示对应的HTML代码,你可以从中找到idnameclass等属性。

我们的测试需要为以下元素找到定位器:

  1. 搜索页面上的搜索输入框。
  2. 结果页面上的搜索输入框。
  3. 结果页面上的结果链接。

我们将这些定位器定义为页面对象类的属性:

# pages/search.py
from selenium.webdriver.common.by import By

class DuckDuckGoSearchPage:
    # 定位器
    SEARCH_INPUT = (By.ID, ‘search_form_input_homepage’)

    def __init__(self, browser):
        self.browser = browser
    # ... 其他方法 ...

# pages/result.py
from selenium.webdriver.common.by import By

class DuckDuckGoResultPage:
    # 定位器
    RESULT_LINKS = (By.CSS_SELECTOR, ‘a.result__a’)
    SEARCH_INPUT = (By.ID, ‘search_form_input’)

    def __init__(self, browser):
        self.browser = browser
    # ... 其他方法 ...

注意:实际的定位器可能因网站更新而变化,请使用开发者工具自行验证。

现在,轮到您动手了。请暂停视频,完成README中第四部分(Part 4)的教程说明。您需要:

  1. 使用Chrome DevTools检查DuckDuckGo的搜索页面和结果页面,找到上述三个元素的合适定位器。
  2. 将这些定位器作为类属性添加到您的页面对象类中。
  3. 运行测试。测试仍然会失败,因为页面对象方法还未调用WebDriver,但我们已经为下一步做好了准备。


动手进行Web应用测试自动化:6:调用WebDriver API 🤖

上一节我们找到了页面元素的定位器,本节我们将实现页面对象中的方法,通过调用Selenium WebDriver API来完成与浏览器的实际交互。

Selenium WebDriver API提供了丰富的方法来模拟用户操作。以下是一些最常用的调用:

WebDriver实例(即browser对象)的常用方法

  • browser.get(url):导航到指定URL。
  • browser.title:获取当前页面的标题。
  • browser.find_element(locator):查找单个元素,返回WebElement对象。
  • browser.find_elements(locator):查找多个元素,返回WebElement对象列表。
  • browser.quit():关闭浏览器并结束WebDriver会话。

WebElement对象(通过find_element获得)的常用方法

  • element.click():点击元素。
  • element.send_keys(text):向元素(通常是输入框)输入文本。
  • element.text:获取元素的可见文本。
  • element.get_attribute(‘attr_name’):获取元素的HTML属性值(对于输入框的值,需要用get_attribute(‘value’))。

现在,让我们实现页面对象的方法。关键点在于使用我们之前定义的定位器,并通过find_elementfind_elements来获取元素对象。

搜索页面 (search.py)

from selenium.webdriver.common.keys import Keys

class DuckDuckGoSearchPage:
    URL = ‘https://duckduckgo.com/‘
    SEARCH_INPUT = (By.ID, ‘search_form_input_homepage’)

    def __init__(self, browser):
        self.browser = browser

    def load(self):
        self.browser.get(self.URL)

    def search(self, phrase):
        # 1. 查找搜索输入框元素
        search_input = self.browser.find_element(*self.SEARCH_INPUT) # 注意 * 用于解包元组
        # 2. 输入搜索短语并模拟按下回车键
        search_input.send_keys(phrase + Keys.RETURN)
  • *self.SEARCH_INPUT 将元组 (By.ID, ‘search_form_input_homepage’) 解包为两个参数传递给 find_element

结果页面 (result.py)

class DuckDuckGoResultPage:
    RESULT_LINKS = (By.CSS_SELECTOR, ‘a.result__a’)
    SEARCH_INPUT = (By.ID, ‘search_form_input’)

    def __init__(self, browser):
        self.browser = browser

    def result_link_titles(self):
        # 查找所有结果链接元素
        links = self.browser.find_elements(*self.RESULT_LINKS)
        # 提取每个链接的文本标题,返回列表
        titles = [link.text for link in links]
        return titles

    def search_input_value(self):
        # 查找搜索输入框元素
        search_input = self.browser.find_element(*self.SEARCH_INPUT)
        # 获取其 ‘value’ 属性的值(输入框的文本)
        value = search_input.get_attribute(‘value’)
        return value

    def title(self):
        # 页面标题是WebDriver的属性,不是页面元素
        return self.browser.title

现在,轮到您动手了。请暂停视频,完成README中第五部分(Part 5)的教程说明。您需要:

  1. 根据上面的示例,在您的页面对象类中实现所有方法。
  2. 确保在文件顶部导入必要的模块(如from selenium.webdriver.common.keys import Keys)。
  3. 运行测试!如果一切正确,您将看到浏览器自动打开,执行搜索,然后关闭,并且测试应该通过(显示绿色的.PASSED)。恭喜你,你已经完成了第一个端到端的Web UI自动化测试!

动手进行Web应用测试自动化:7:处理配置和浏览器选项 ⚙️

上一节我们成功运行了第一个自动化测试,本节我们将改进我们的框架,使其能够灵活地配置浏览器类型、超时时间等参数,并支持无头模式运行。

一个健壮的测试框架应该能够轻松适应不同的运行环境。我们需要处理诸如:

  • 浏览器选择:在Chrome、Firefox或无头Chrome之间切换。
  • 配置参数:如隐式等待时间、基础URL等,这些不应硬编码在代码中。
  • 敏感信息:如密码、API密钥,应通过安全的方式传递,避免提交到代码仓库。

我们将使用一个JSON配置文件来管理这些输入,并使用pytest fixture来读取和验证配置。

1. 创建配置文件 (config.json)

{
  “browser”: “headless chrome”,
  “implicit_wait”: 10
}

支持的browser值可以是 “chrome”, “firefox”, “headless chrome”

2. 创建读取配置的fixture (conftest.py)

import json
import pytest

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/274e64ac5dd581b44ee82ed902c54130_67.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/274e64ac5dd581b44ee82ed902c54130_69.png)

@pytest.fixture(scope=‘session’) # scope=‘session’ 表示整个测试会话只运行一次
def config():
    # 读取配置文件
    with open(‘config.json’) as f:
        config = json.load(f)
    # 验证配置值
    assert config[‘browser’] in [‘chrome’, ‘firefox’, ‘headless chrome’]
    assert isinstance(config[‘implicit_wait’], int)
    assert config[‘implicit_wait’] > 0
    # 返回配置字典
    return config

3. 更新浏览器fixture以使用配置 (conftest.py)

from selenium import webdriver
from selenium.webdriver.chrome.options import Options as ChromeOptions

@pytest.fixture
def browser(config): # fixture可以依赖其他fixture
    # 根据配置初始化浏览器
    browser_name = config[‘browser’]
    if browser_name == ‘chrome’:
        driver = webdriver.Chrome()
    elif browser_name == ‘firefox’:
        driver = webdriver.Firefox()
    elif browser_name == ‘headless chrome’:
        opts = ChromeOptions()
        opts.add_argument(‘headless’)
        driver = webdriver.Chrome(options=opts)
    else:
        raise Exception(f’Browser “{browser_name}” is not supported‘)

    driver.implicitly_wait(config[‘implicit_wait’])
    yield driver
    driver.quit()
  • 无头模式不会打开浏览器GUI窗口,运行更快,适合在持续集成(CI)环境中使用。

4. 测试用例无需修改,因为它仍然通过browser参数接收WebDriver实例。

现在,轮到您动手了。请暂停视频,完成README中第六部分(Part 6)的教程说明。您需要:

  1. 创建 config.json 文件。
  2. 更新 conftest.py,添加 config fixture并修改 browser fixture。
  3. 尝试修改 config.json 中的 browser 值,分别用 “chrome”“firefox”“headless chrome” 运行测试,确保它们都能正常工作。
  4. 观察无头模式运行时,浏览器窗口不会弹出,测试在后台执行。


动手进行Web应用测试自动化:8:处理等待与竞态条件 ⏳

上一节我们实现了灵活的配置,但在运行多浏览器测试时,你可能会发现测试在Firefox上失败,而在Chrome上却成功。这通常是由竞态条件引起的,也是Web UI测试片状化的主要原因之一。本节我们将学习如何处理它。

竞态条件发生在自动化脚本和网页加载/渲染速度不同步时。例如,脚本在页面标题更新之前就试图去获取它,导致断言失败。

我们有两种主要的等待策略:

  1. 隐式等待:在初始化WebDriver时设置一次(如 driver.implicitly_wait(10))。它会在查找任何元素时,如果元素没有立即出现,WebDriver会轮询DOM最多10秒。它适用于整个会话,但不够精确。
  2. 显式等待:针对某个特定条件(如元素可见、标题包含特定文本)进行等待。它更精确、更健壮,但代码更冗长。使用 WebDriverWaitexpected_conditions

重要警告:不要混合使用隐式和显式等待,这可能导致不可预知的超时行为。

在我们的例子中,测试在Firefox上失败是因为 result_page.title() 在页面标题完全更新前就被调用了。我们使用了隐式等待,但隐式等待只作用于 find_element 查找元素,而不作用于 browser.title 属性。

有几种修复方法:

  • (不推荐)硬性等待time.sleep(5)。这会使测试变慢且不可靠。
  • (推荐但复杂)显式等待:等待标题发生变化。但这需要重写我们的等待策略。
  • (简单有效)调整断言顺序:将检查标题的断言移到检查元素之后。因为检查元素(result_link_titles, search_input_value)会触发隐式等待,确保页面加载更充分,此时标题很可能已经更新。

让我们采用第三种方法,修改测试用例中断言的顺序:

def test_basic_duckduckgo_search(browser):
    # ... 前面的步骤不变 ...
    # 验证结果 - 先验证元素,再验证标题
    titles = result_page.result_link_titles()
    matches = [t for t in titles if PHRASE.lower() in t.lower()]
    assert len(matches) > 0
    assert PHRASE == result_page.search_input_value()
    assert PHRASE in result_page.title() # 最后检查标题

现在,轮到您动手了。请暂停视频,完成README中第七部分(Part 7)的教程说明。您需要:

  1. 调整 test_basic_duckduckgo_search 函数中断言的顺序。
  2. 再次使用三种浏览器配置(chrome, firefox, headless chrome)运行测试,确保现在全部通过。
  3. 思考:为什么调整顺序能解决问题?这利用了隐式等待的副作用,是一种务实的解决方案。但对于更复杂的场景,学习并使用显式等待是更好的长期投资。


动手进行Web应用测试自动化:9:并行运行测试 🚄

上一节我们解决了测试的稳定性问题,本节我们将关注如何提升测试的执行速度。Web UI测试通常执行较慢(约1分钟/个),当测试套件增长时,总运行时间会变得难以接受。并行运行测试是加速的关键。

假设我们有1000个Web UI测试,每个耗时1分钟,串行运行需要超过16小时。通过并行,我们可以将这个时间大幅缩短。

使用 pytest-xdist 插件可以轻松实现并行测试。它允许在多个CPU核心上并行运行测试,甚至可以将测试分发到多台机器上。

前提条件

  1. 测试独立性:每个测试必须能够独立运行,不依赖共享状态(如全局变量、数据库的特定记录),避免测试间相互干扰。
  2. 良好的性能:每个测试本身应避免不必要的延迟(如硬性等待)。

安装与使用

pip install pytest-xdist

运行测试时,使用 -n 参数指定并行进程数:

python -m pytest -n 3 # 使用3个worker并行运行

为了演示,我们可以使用 @pytest.mark.parametrize 装饰器快速创建多个测试变体:

import pytest

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/274e64ac5dd581b44ee82ed902c54130_89.png)

@pytest.mark.parametrize(‘phrase’, [‘panda’, ‘python’, ‘polar bear’])
def test_basic_duckduckgo_search(browser, phrase):
    # 测试体不变,现在会对每个phrase运行一次
    # ...

运行三个串行测试可能需要十几秒,而并行运行可能只需几秒。

超越单机:Selenium Grid
对于更大规模的并行测试(跨浏览器、跨操作系统),可以使用 Selenium Grid。它是一个开源工具,由一个中心枢纽(Hub)和多个节点(Node)组成。测试向Hub请求特定配置(如“Windows 10上的Chrome 90”),Hub会分配一个空闲的Node来执行。

  • 自建Grid:可以使用Docker等工具搭建。
  • 云服务:也可以使用Sauce Labs、BrowserStack、LambdaTest等提供的云端Selenium Grid服务,它们管理了基础设施并提供额外功能(如视频录制、日志)。

现在,轮到您动手了。请暂停视频,完成README中第八部分(Part 8)的教程说明。您需要:

  1. 安装 pytest-xdist
  2. 使用 @pytest.mark.parametrize 为您的搜索测试添加多个搜索短语。
  3. 分别用串行 (python -m pytest) 和并行 (python -m pytest -n 3) 方式运行测试,观察执行时间的变化。
  4. 体验并行测试带来的速度提升。


动手进行Web应用测试自动化:10:总结与扩展 🎉

恭喜你完成了本教程的所有核心部分!我们一起学习了如何从零开始构建一个基于Python、pytest、页面对象模式和Selenium WebDriver的Web应用测试自动化解决方案。

本节课中我们一起学习了

  1. Web UI测试的概念、优缺点及测试金字塔。
  2. 使用pytest框架组织和运行测试用例。
  3. 使用Selenium WebDriver进行浏览器自动化。
  4. 应用页面对象模式提高代码的可维护性和复用性。
  5. 定位网页元素的不同策略。
  6. 调用WebDriver API与页面交互。
  7. 使用配置文件管理测试参数和浏览器选项。
  8. 理解并处理竞态条件。
  9. 使用pytest-xdist插件并行运行测试以提升效率。

你的测试自动化项目现在已经是一个结构良好、可配置、可并行执行的坚实基础。熟能生巧,你可以在此基础上进行扩展:

扩展想法

  • 为DuckDuckGo添加更多测试:通过点击按钮搜索、点击特定搜索结果、测试搜索设置、验证更多结果属性等。
  • 尝试为其他更复杂的Web应用(如登录、表单提交、购物流程)编写测试。
  • 集成更高级的报告工具(如Allure)。
  • 将自动化套件集成到CI/CD管道(如Jenkins, GitLab CI, GitHub Actions)中。

测试自动化是一个充满挑战但回报丰厚的领域。希望本教程为你打开了这扇门,并提供了实用的起点。祝你在此旅程中一切顺利!

077:教程 Eric J. Ma - 为数据科学家解密深度学习

概述

在本教程中,我们将学习深度学习的基础框架,即模型损失函数优化器。我们将从线性回归开始,逐步过渡到逻辑回归和前馈神经网络,通过动手编码来理解这三个核心组件如何协同工作。


课程准备与目标

在开始教程之前,请确保您已准备好学习环境。您可以通过两种方式设置环境:

  1. 按照教程存储库中的 Conda 环境说明,在本地托管 Jupyter 服务器。
  2. 点击存储库中的 Binder 徽章,启动一个云托管的 Docker 容器化环境。请注意,Binder 有 15 分钟不活动暂停机制,请确保定期在笔记本中执行代码以避免连接中断。

环境就绪后,请打开 notebooks/ 目录下的学生版笔记本。

本课程的目标是理解并能够定义模型损失函数优化器,并提供示例。课程结构如下:

  • 首先在线性回归的背景下探索这些概念。
  • 然后进行逻辑回归。
  • 最后构建前馈神经网络。

课程结束时,您将看到这三个组件在实践中的应用。

学习本课程需要您熟悉 NumPy API、基础的线性代数知识(如点积)以及 Python 数据结构和流程控制。


线性回归:重温基础模型

上一节我们介绍了课程目标,本节中我们来看看第一个模型:线性回归。

线性回归是我们在高中数学和物理中学到的第一个模型,其公式为 y = wx + b。这个模型编码了一个结构假设:输出是输入的加权和加上一个偏置项。

线性模型非常有用,但世界并非完全是线性的。然而,如果我们假设观测到的数据是由一个具有未知参数 wb 的线性模型生成的,那么我们的任务就是找出能最好地解释这些数据的参数值。

让我们通过模拟数据来探索这一点。首先,我们生成一些由真实参数 w_true = 3.14b_true = 2.72 生成的数据,并添加一些噪声。

import numpy as np
# 生成数据
np.random.seed(42)
x = np.random.randn(100)
noise = np.random.randn(100) * 0.5
y_true = 3.14 * x + 2.72 + noise

接下来,我们选择一个很差的参数估计(例如 w=6.2, b=-4.2)并绘制其预测。从视觉上可以看出这个模型很糟糕。为了量化模型的糟糕程度,我们引入一个损失函数

一个常用的损失函数是均方误差。其定义为误差平方的平均值。

公式
MSE = (1/n) * Σ(y_true_i - y_pred_i)^2

视觉上,MSE 计算的是每个数据点的预测值(红色线)与真实值(蓝色点)之间垂直距离(残差)的平方的平均值。

让我们在 NumPy 中实现它:

def mse_loss(y_true, y_pred):
    """
    计算均方误差损失。
    参数:
        y_true (np.ndarray): 真实值数组。
        y_pred (np.ndarray): 预测值数组。
    返回:
        loss (np.ndarray): 标量损失值。
    """
    d = y_true - y_pred          # 计算残差
    squared_d = np.power(d, 2)   # 计算平方
    loss = np.mean(squared_d)    # 计算平均值
    return loss
    # 更简洁的写法: return np.mean((y_true - y_pred)**2)

计算 MSE 会得到一个正数,因为它衡量的是模型预测与数据之间的差异。通过量化损失,我们打开了优化模型的大门,即调整参数 wb 以最小化损失函数。

您可以尝试手动调整参数来感受优化过程。您会注意到,当接近最佳拟合时,损失会减少;但如果调整过度,损失又会增加。为了系统地理解这个过程,我们需要绕道进入基于梯度的优化


基于梯度的优化:寻找最小值

上一节我们通过手动调整体验了优化,本节中我们来看看其背后的数学原理:基于梯度的优化。

梯度与导数密切相关。简单来说,导数描述了当输入发生微小变化时,输出变化的大小。对于一个函数 f(w),其导数 df/dw 给出了 w 变化时 f 的变化率。

当我们谈论优化一个函数时,通常指的是找到其最小值(或最大值)。在微积分中,我们知道函数在斜率为零的点可能达到极值。通过令一阶导数 df/dw = 0 并求解 w,我们可以解析地找到最小值(还需检查二阶导数确认是最小值)。

然而,对于复杂函数,解析求解可能很困难。这时,我们可以使用计算优化,即利用函数的导数信息来迭代地寻找最小值。这种方法称为梯度下降

其核心思想是:如果我们站在函数曲线上的某一点,梯度(导数)告诉我们哪个方向是“上坡”。为了找到最小值,我们需要向“下坡”走,即负梯度方向。我们沿着这个方向迈出一小步(步长,通常记为 learning_rate),反复迭代,最终逼近最小值。

让我们用代码实现一个简单函数 f(w) = w^2 + 3w - 5 的梯度下降:

def f(w):
    return w**2 + 3*w - 5

def df(w): # 手动计算的导数
    return 2*w + 3

# 梯度下降
w = 3 * np.pi # 任意起始点
learning_rate = 0.01
history = []
for i in range(1000):
    w = w - learning_rate * df(w) # 向负梯度方向更新
    history.append(w)
# 最终 w 应接近解析解 -1.5

恭喜,您刚刚手动实现了梯度下降!它是一个优化器,一个迭代修改参数以最小化目标函数的程序。

在上面的例子中,我们手动定义了导数函数 df。对于复杂函数,这很麻烦。幸运的是,有工具可以自动计算导数,这就是自动微分。本教程将使用 JAX 库,它允许我们对 NumPy 代码自动求导。

from jax import grad

def f(w):
    return w**2 + 3*w - 5

df_auto = grad(f) # 自动生成梯度函数
# 现在 df_auto(w) 的行为与手动定义的 df(w) 相同

整合框架:模型、损失与优化器

上一节我们学习了梯度下降,现在让我们把基于梯度的优化和线性回归模型联系起来。

在线性回归中:

  • 我们要优化的函数是损失函数 L(即 MSE)。
  • 我们要优化的参数wb(线性模型中的参数)。
  • 我们没有直接优化线性模型函数本身,而是优化衡量模型好坏的损失函数。

至此,我们已经掌握了深度学习的核心框架,它由三部分组成:

  1. 模型:一个数学函数 f,将输入 x 映射到输出 y。它由一组参数 θ 控制。在线性模型中,θ = {w, b},函数为 y = w*x + b
  2. 损失函数:另一个数学函数 L,它量化模型预测 y_pred 与真实数据 y_true 之间的差异。它告诉我们模型有多“糟糕”。在线性回归中,我们使用 MSE。
  3. 优化器:一个基于梯度的程序(如梯度下降),它迭代地更新参数 θ,以最小化损失函数 L。即寻找 argmin_θ L(θ)

现在,让我们用代码将这个框架整合起来,用于线性回归:

# 1. 定义模型
def linear_model(p, x):
    """线性模型 y = w*x + b"""
    return p['w'] * x + p['b']

# 2. 初始化参数 (随机起点)
np.random.seed(42)
params = {'w': np.random.randn(), 'b': np.random.randn()}

# 3. 定义损失函数 (已实现的 mse_loss)
# 4. 使用 JAX 获取损失函数的梯度
from jax import grad
def loss_fn(params, x, y_true):
    y_pred = linear_model(params, x)
    return mse_loss(y_true, y_pred)

grad_loss_fn = grad(loss_fn) # 自动微分得到梯度函数

# 5. 优化器 (梯度下降训练循环)
learning_rate = 0.05
losses = []
for i in range(200):
    # 计算当前参数下的梯度
    grads = grad_loss_fn(params, x, y_true)
    # 更新每个参数:向负梯度方向移动一小步
    for key in params:
        params[key] = params[key] - learning_rate * grads[key]
    # 记录损失
    current_loss = loss_fn(params, x, y_true)
    losses.append(current_loss)
# 训练后,params 应接近真实值 w_true, b_true

绘制损失曲线会看到损失下降,最终参数接近生成数据的真实参数。您已经成功使用模型、损失、优化器框架拟合了一个线性回归模型!


逻辑回归:从回归到分类

上一节我们在线性回归中应用了框架,本节中我们将其扩展到逻辑回归,处理分类问题。

逻辑回归可以看作是线性回归的自然延伸。它在线性分量 z = w*x + b 之后,添加了一个激活函数——逻辑函数(或称 Sigmoid 函数)g(z) = 1 / (1 + exp(-z))。这个函数将线性输出压缩到 (0, 1) 区间,可以解释为概率。

逻辑函数有两个参数:

  • w 控制曲线的斜率,即从 0 到 1 转换的陡峭程度。
  • b 控制转换发生的临界点位置。

对于分类问题,我们使用不同的损失函数:交叉熵损失(或称对数损失)。对于二分类问题(标签为 0 或 1),其公式为:

公式
L = - [y_true * log(y_pred) + (1 - y_true) * log(1 - y_pred)]

这个损失函数衡量了预测概率分布与真实分布之间的差异。当预测概率接近真实标签时,损失较小。

现在,让我们将逻辑回归纳入我们的框架:

# 1. 定义模型:线性部分 + 逻辑激活
def logistic_model(p, x):
    z = p['w'] * x + p['b'] # 线性部分
    y_pred = 1 / (1 + np.exp(-z)) # 逻辑(Sigmoid)激活
    return y_pred

# 2. 定义损失函数:交叉熵损失
def logistic_loss(y_true, y_pred):
    # 添加微小值防止 log(0) 导致 NaN
    eps = 1e-15
    y_pred = np.clip(y_pred, eps, 1 - eps)
    loss = - (y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))
    return np.mean(loss)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/bf6cd821a34d13b4ab5f0ab741a0bb12_31.png)

# 3. 整合损失函数(供自动微分使用)
def loss_fn(params, x, y_true):
    y_pred = logistic_model(params, x)
    return logistic_loss(y_true, y_pred)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/bf6cd821a34d13b4ab5f0ab741a0bb12_32.png)

from jax import grad
grad_loss_fn = grad(loss_fn)

# 4. 初始化参数和优化器循环(与线性回归类似)
np.random.seed(42)
params = {'w': np.random.randn(), 'b': np.random.randn()}
learning_rate = 0.1
losses = []

for i in range(1000):
    grads = grad_loss_fn(params, x, y_true)
    for key in params:
        params[key] = params[key] - learning_rate * grads[key]
    current_loss = loss_fn(params, x, y_true)
    losses.append(current_loss)
# 训练后,模型应能区分两类数据

通过这个练习,您可以看到,只需改变模型(添加激活函数)和损失函数(从 MSE 改为交叉熵),同一个优化器框架就能应用于分类任务。


前馈神经网络:堆叠的威力

上一节我们掌握了逻辑回归,本节中我们来看看如何将其思想扩展成强大的前馈神经网络

神经网络本质上是多个“层”的堆叠,每一层都包含线性变换和激活函数。因此,一个简单的神经网络可以看作是多个逻辑回归单元的堆叠与组合,这使其成为通用的函数逼近器。

让我们构建一个简单的两层神经网络,用于预测分子是否可生物降解(二分类问题)。输入有 4 个特征,我们设计一个包含 20 个神经元的隐藏层。

以下是构建步骤:

# 辅助函数:用噪声初始化参数矩阵
def init_param(shape):
    return np.random.randn(*shape) * 0.1

# 1. 初始化参数
params = {
    'w1': init_param((4, 20)), # 从4维输入到20维隐藏层
    'b1': init_param((20,)),
    'w2': init_param((20, 1)), # 从20维隐藏层到1维输出
    'b2': init_param((1,))
}

# 2. 定义前馈神经网络模型
def neural_net(p, x):
    # 第一层: 线性变换 + Tanh激活
    # x shape: (n_samples, 4)
    # p['w1'] shape: (4, 20)
    # p['b1'] shape: (20,)
    z1 = np.dot(x, p['w1']) + p['b1'] # 结果 shape: (n_samples, 20)
    a1 = np.tanh(z1) # 激活函数

    # 第二层: 线性变换 + Sigmoid激活 (输出概率)
    # a1 shape: (n_samples, 20)
    # p['w2'] shape: (20, 1)
    # p['b2'] shape: (1,)
    z2 = np.dot(a1, p['w2']) + p['b2'] # 结果 shape: (n_samples, 1)
    a2 = 1 / (1 + np.exp(-z2)) # Sigmoid激活
    return a2.flatten() # 输出 shape: (n_samples,)

# 3. 使用之前定义的 logistic_loss 和 grad
def loss_fn(params, x, y_true):
    y_pred = neural_net(params, x)
    return logistic_loss(y_true, y_pred)

from jax import grad
grad_loss_fn = grad(loss_fn)

# 4. 训练循环 (与之前结构一致)
learning_rate = 0.05
losses = []
for epoch in range(2000):
    grads = grad_loss_fn(params, x_train, y_train)
    for key in params:
        params[key] = params[key] - learning_rate * grads[key]
    current_loss = loss_fn(params, x_train, y_train)
    losses.append(current_loss)
    if epoch % 500 == 0:
        print(f"Epoch {epoch}, Loss: {current_loss:.4f}")

训练完成后,您可以评估模型在测试集上的性能,例如绘制混淆矩阵。您会看到,通过堆叠层,神经网络能够学习更复杂的模式来拟合数据。


总结与核心要点

本节课中,我们一起学习了深度学习的核心框架:模型、损失函数和优化器

让我们回顾并总结这个框架:

  1. 模型:一个将输入 x 映射到输出 y 的数学函数 f(x; θ),具有可调参数 θ。它是我们最常更改的部分,针对不同任务(如图像、序列)有不同的标准架构(如 CNN、RNN)。
  2. 损失函数:一个衡量模型预测 f(x; θ) 与真实目标 y 之间差异的函数 L。它由问题类型决定(如回归用 MSE,分类用交叉熵)。我们可以根据需要定制或修改损失函数。
  3. 优化器:一个迭代算法(如梯度下降),它计算损失函数相对于参数 θ 的梯度,并沿负梯度方向更新 θ 以最小化损失 L。我们通常从一个简单、稳定的优化器(如学习率为 0.05 的普通梯度下降)开始。

这个框架具有强大的通用性。从线性回归到深度神经网络,我们只是改变了模型的复杂度和损失函数的形式,而优化器的核心思想保持不变。

希望本教程对您有所启发,并帮助您在职业发展中更好地理解和应用深度学习。感谢您的学习!

078:装饰器简介,增强你的Python代码

在本教程中,我们将学习Python装饰器的核心概念。从基础开始,逐步深入到创建高级装饰器。我们将通过一系列练习来巩固所学知识,最终你将能够创建自己的装饰器来增强代码功能。

第一部分:装饰器简介 🧱

上一节我们概述了课程内容,本节中我们将介绍装饰器的几个核心构建模块。

函数作为一等对象

在Python中,函数是一等对象。这意味着你可以像处理变量一样处理函数:为函数分配多个名称、将函数作为参数传递给其他函数、从函数中返回函数。

以下是一个简单示例,展示了如何将函数作为参数传递:

def say_hello(logger):
    logger("Hello, World!")

# 使用不同的记录器函数
say_hello(print)  # 使用 print 函数

内部函数

内部函数是定义在其他函数内部的函数。它们可以访问其外部(封闭)作用域中的变量。

def outer():
    pi_con = 2020
    def inner():
        print(f"Python is {pi_con}")
    inner()
    return inner

inside = outer()  # 调用 outer,返回 inner 函数
inside()          # 可以调用返回的内部函数

操作函数

我们可以通过添加属性等方式来操作函数对象。

def hello(func):
    print(f"Hello {func.__name__}")
    return func

def outer():
    print("Hi from outer")

new_outer = hello(outer)  # 打印函数名并返回原函数
new_outer()               # 调用原函数

创建装饰器

装饰器的本质是:一个接受函数作为参数并返回一个(通常是包装过的)函数的函数。

以下是一个简单的装饰器模板,它在调用函数前后执行一些操作:

import functools

def wrapper(func):
    @functools.wraps(func)  # 保留原函数的元数据
    def _wrapper(*args, **kwargs):
        # 在调用函数前做一些事情
        print("Before calling function")
        # 调用原函数
        result = func(*args, **kwargs)
        # 在调用函数后做一些事情
        print("After calling function")
        # 返回原函数的返回值
        return result
    return _wrapper

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/13dc3ed458136a286215705708b44e8c_4.png)

@wrapper
def say_hello(name):
    print(f"Hello {name}")

say_hello("World")

@wrapper 语法是 say_hello = wrapper(say_hello) 的语法糖。

第二部分:基础练习 🏋️

上一节我们介绍了装饰器的基本模板,本节中我们通过三个练习来实践。

练习1:计时器装饰器

以下是实现一个计时器装饰器的步骤,该装饰器用于测量函数运行时间。

  1. 导入 time 模块。
  2. 在包装函数内部,调用函数前记录开始时间。
  3. 调用原函数。
  4. 调用函数后记录结束时间。
  5. 计算并打印耗时。
import time
import functools

def timer(func):
    @functools.wraps(func)
    def _timer(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"Elapsed time: {end - start:.6f} seconds")
        return result
    return _timer

@timer
def waste_time(num):
    total = 0
    for i in range(num):
        for j in range(i):
            total += j
    return total

print(waste_time(1000))

练习2:追踪装饰器

以下是实现一个追踪装饰器的步骤,该装饰器用于打印函数的调用和返回信息。

  1. 在包装函数内部,打印函数名和传入的参数。
  2. 调用原函数并保存结果。
  3. 打印函数的返回值。
  4. 返回结果。
import functools

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/13dc3ed458136a286215705708b44e8c_14.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/13dc3ed458136a286215705708b44e8c_15.png)

def trace(func):
    name = func.__name__
    @functools.wraps(func)
    def _trace(*args, **kwargs):
        # 格式化参数和关键字参数
        args_repr = [repr(a) for a in args]
        kwargs_repr = [f"{k}={repr(v)}" for k, v in kwargs.items()]
        signature = ", ".join(args_repr + kwargs_repr)
        print(f"Calling {name}({signature})")
        result = func(*args, **kwargs)
        print(f"{name}() returned {repr(result)}")
        return result
    return _trace

@trace
def greet(name, greeting="Hello"):
    return f"{greeting} {name}"

greet("World", greeting="Hi")

练习3:注册装饰器

以下是实现一个注册装饰器的步骤,该装饰器将函数注册到一个全局字典中。

  1. 创建一个模块级的空字典。
  2. 装饰器函数接受一个函数作为参数。
  3. 将函数以其名称作为键,函数对象本身作为值,添加到字典中。
  4. 返回原函数(不进行包装)。
REGISTRY = {}

def register(func):
    REGISTRY[func.__name__] = func
    return func

@register
def true_or_false(text):
    if text.lower() in ("yes", "true", "1"):
        return True
    elif text.lower() in ("no", "false", "0"):
        return False
    return None

print(REGISTRY)  # 查看已注册的函数

第三部分:高级装饰器概念 🚀

上一节我们完成了三个基础练习,本节中我们来看看更高级的装饰器概念。

保持状态的装饰器

装饰器可以通过函数属性或类来保持状态。

以下是一个使用函数属性来统计函数调用次数的装饰器:

import functools

def count_calls(func):
    @functools.wraps(func)
    def _count_calls(*args, **kwargs):
        _count_calls.num_calls += 1  # 递增调用计数
        return func(*args, **kwargs)
    _count_calls.num_calls = 0  # 初始化调用计数
    return _count_calls

@count_calls
def fibonacci(n):
    if n < 2:
        return 1
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(7))
print(f"fibonacci was called {fibonacci.num_calls} times")

将类用作装饰器

任何可调用对象(如实现了 __call__ 方法的类)都可以用作装饰器。

以下是将 CountCalls 实现为类的示例:

import functools

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.num_calls = 0

    def __call__(self, *args, **kwargs):
        self.num_calls += 1
        return self.func(*args, **kwargs)

@CountCalls
def fibonacci(n):
    if n < 2:
        return 1
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(7))
print(f"fibonacci was called {fibonacci.num_calls} times")

装饰类

你也可以装饰类。通常,你需要确保装饰后的类仍然保持其类型。

以下是一个装饰类的简单示例,它在类实例化时打印一条消息:

def hello(cls):
    original_init = cls.__init__
    def new_init(self, *args, **kwargs):
        print(f"Instance of {cls.__name__} created.")
        original_init(self, *args, **kwargs)
    cls.__init__ = new_init
    return cls

@hello
class Thing:
    def __init__(self, value):
        self.value = value

t = Thing(42)

第四部分:带参数的装饰器 🛠️

上一节我们探讨了状态和类装饰器,本节中我们学习如何创建接受参数的装饰器。

练习5:使用单位的装饰器

带参数的装饰器实际上是一个“装饰器工厂”,它返回真正的装饰器。

以下是实现一个 use_unit 装饰器的步骤,该装饰器为函数返回值附加物理单位。

  1. 定义装饰器工厂 use_unit,它接受单位字符串作为参数。
  2. 在工厂内部,定义真正的装饰器 decorator
  3. 在装饰器内部,使用 pint 库将函数的返回值与指定单位相乘。
  4. 返回装饰器。
import pint
import functools

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/13dc3ed458136a286215705708b44e8c_29.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/13dc3ed458136a286215705708b44e8c_31.png)

# 创建全局单位注册表
_unit_registry = pint.UnitRegistry()

def use_unit(unit):
    """装饰器工厂,返回一个为函数返回值附加单位的装饰器。"""
    def decorator(func):
        @functools.wraps(func)
        def _decorator(*args, **kwargs):
            result = func(*args, **kwargs)
            # 将结果与单位相乘
            return result * _unit_registry(unit)
        return _decorator
    return decorator

@use_unit("meters per second")
def average_speed(distance, duration):
    return distance / duration

bolt_speed = average_speed(100, 9.58)
print(bolt_speed)  # 输出带单位的值
print(bolt_speed.to("km per hour"))  # 转换单位

练习6:带可选参数的超级追踪装饰器

以下是实现一个 super_trace 装饰器的步骤,该装饰器可以带参数(指定记录器)或不带参数(使用默认记录器)使用。

  1. 定义 super_trace 函数,其参数为 func=Nonelogger=print,并使用 * 强制后续参数为关键字参数。
  2. 判断 func 参数。如果 funcNone,说明装饰器被以 @super_trace(logger=...) 形式调用,此时应返回一个装饰器。
  3. 如果 func 不是 None,说明装饰器被以 @super_trace 形式调用,此时应直接装饰该函数。
  4. 内部定义真正的追踪装饰器逻辑。
import functools

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/13dc3ed458136a286215705708b44e8c_35.png)

def super_trace(func=None, *, logger=print):
    def decorator(f):
        @functools.wraps(f)
        def _decorator(*args, **kwargs):
            args_repr = [repr(a) for a in args]
            kwargs_repr = [f"{k}={repr(v)}" for k, v in kwargs.items()]
            signature = ", ".join(args_repr + kwargs_repr)
            logger(f"Calling {f.__name__}({signature})")
            result = f(*args, **kwargs)
            logger(f"{f.__name__}() returned {repr(result)}")
            return result
        return _decorator

    if func is None:
        # 被以 @super_trace(logger=...) 形式调用
        return decorator
    else:
        # 被以 @super_trace 形式调用
        return decorator(func)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/13dc3ed458136a286215705708b44e8c_37.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/13dc3ed458136a286215705708b44e8c_38.png)

# 使用方式1:不带参数
@super_trace
def greet(name):
    return f"Hello {name}"

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/13dc3ed458136a286215705708b44e8c_40.png)

# 使用方式2:带参数
import logging
@super_trace(logger=logging.warning)
def random_greet():
    # ... 函数实现
    pass

总结 📝

在本教程中,我们一起学习了Python装饰器的核心概念和应用。

我们首先了解了装饰器的基本构建模块:函数作为一等对象、内部函数和函数操作。接着,我们学习了装饰器的标准模板,它使用 *args**kwargs 来传递参数,并使用 functools.wraps 来保留原函数的元数据。

通过三个基础练习(计时器、追踪器、注册器),我们实践了创建基本装饰器。然后,我们探索了更高级的主题,包括使用函数属性或类来保持状态的装饰器、将类本身用作装饰器,以及如何装饰类。

最后,我们深入研究了带参数的装饰器,这通过“装饰器工厂”模式实现。我们完成了两个相关练习:为函数返回值附加单位,以及创建支持可选参数的超级追踪装饰器。

装饰器是Python中强大而优雅的工具,广泛用于日志记录、权限检查、性能测试、注册插件等场景。掌握装饰器将极大地提升你编写简洁、可复用和声明式代码的能力。

079:教程

在本教程中,我们将学习如何使用 Python 进行有效的数据可视化。我们将从数据探索开始,逐步深入到密度估计、高维数据可视化、交互式图表以及如何通过图表设计有效传达信息。本教程假设您熟悉基本的 Python 语法和编程,以及使用 Python 进行基础绘图的经验。

有效数据可视化:1:数据探索 🕵️

上一节我们介绍了本教程的概述,本节中我们来看看数据探索。数据探索是接触新数据集时的首要步骤,通过可视化可以直观地了解数据的结构和特征。

我们将首先使用 Anscombe 的四组数据集。这四组数据具有相同的均值和标准差,但分布却截然不同。

import seaborn as sns
anscombe = sns.load_dataset("anscombe")
print(anscombe.groupby('dataset').describe())

以下是绘制第一个数据集的散点图:

import seaborn as sns
anscombe = sns.load_dataset("anscombe")
dataset_1 = anscombe[anscombe['dataset'] == 'I']
sns.scatterplot(x='x', y='y', data=dataset_1)

我们可以为数据集拟合线性回归模型:

sns.lmplot(x='x', y='y', data=dataset_1, height=8)

对于第二个数据集,线性模型不适用,我们可以尝试二次拟合:

dataset_2 = anscombe[anscombe['dataset'] == 'II']
sns.lmplot(x='x', y='y', data=dataset_2, order=2, height=8)

第三个数据集包含一个异常值,我们可以使用稳健回归来减少其影响:

dataset_3 = anscombe[anscombe['dataset'] == 'III']
sns.lmplot(x='x', y='y', data=dataset_3, robust=True, ci=None, height=8)

接下来,我们使用鸢尾花数据集进行多维度探索。

iris = sns.load_dataset("iris")
print(iris.head())

以下是绘制花瓣长度与宽度的散点图,并按物种着色:

sns.scatterplot(x='petal_length', y='petal_width', hue='species', data=iris)

我们可以为每个物种分别拟合线性模型:

sns.lmplot(x='petal_length', y='petal_width', hue='species', data=iris, height=8)

为了同时查看边缘分布和联合分布,我们可以使用联合图:

sns.jointplot(x='petal_length', y='petal_width', data=iris, kind='kde')

对于多维数据,配对图可以展示所有维度两两之间的关系:

sns.pairplot(iris, hue='species', height=2.5)

对于分类数据,我们可以使用分类图。例如,绘制不同物种的花瓣宽度:

sns.catplot(x='species', y='petal_width', data=iris, kind='point', height=6)

小提琴图可以展示分布的更多细节:

sns.catplot(x='species', y='petal_width', data=iris, kind='violin', height=6)

有效数据可视化:2:密度估计 📊

上一节我们介绍了如何探索数据,本节中我们来看看密度估计。密度估计旨在理解生成数据的潜在分布。

最简单的方法是绘制数据的直方图。

import matplotlib.pyplot as plt
import seaborn as sns
iris = sns.load_dataset("iris")
sepal_length = iris['sepal_length']
plt.hist(sepal_length, bins=5, density=True)
plt.show()

直方图的分箱数量会影响其外观:

fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].hist(sepal_length, bins=5, density=True)
axes[0].set_title('5 Bins')
axes[1].hist(sepal_length, bins=100, density=True)
axes[1].set_title('100 Bins')
plt.show()

直方图存在偏差-方差权衡。核密度估计(KDE)是一种更平滑的替代方法。

sns.kdeplot(sepal_length, bw_adjust=0.5)

KDE 通过将每个数据点替换为一个核函数(如高斯函数)来工作。核的带宽是一个重要参数。

sns.kdeplot(sepal_length, bw_adjust=0.2, label='Bandwidth=0.2')
sns.kdeplot(sepal_length, bw_adjust=1.0, label='Bandwidth=1.0')
plt.legend()

对于二维数据,我们可以绘制二维 KDE 图:

sns.kdeplot(x='petal_length', y='petal_width', data=iris, fill=True)

distplot 函数可以同时绘制直方图和 KDE:

sns.histplot(sepal_length, kde=True)

有效数据可视化:3:高维数据可视化与降维 📉

上一节我们讨论了密度估计,本节中我们来看看如何可视化高维数据。降维技术可以帮助我们将高维数据投影到低维空间进行可视化。

我们将使用手写数字数据集。

from sklearn.datasets import load_digits
digits = load_digits()
images = digits.images
data = digits.data
print(data.shape)

首先,我们查看一张图像:

import matplotlib.pyplot as plt
plt.imshow(images[0], cmap='gray')
plt.show()

主成分分析(PCA)是一种线性降维方法,它寻找方差最大的投影方向。

import numpy as np
from sklearn.decomposition import PCA
# 创建一个简单的二维数据集
x = np.arange(10)
y = np.arange(10)
data_2d = np.column_stack((x, y))
# 应用PCA降至1维
pca = PCA(n_components=1)
components = pca.fit_transform(data_2d)
print(components.flatten())

现在,我们将PCA应用于数字数据集,将其从64维降至2维。

pca = PCA(n_components=2)
components = pca.fit_transform(data)
plt.scatter(components[:, 0], components[:, 1], c=digits.target, cmap='tab10', alpha=0.5)
plt.colorbar()
plt.show()

PCA对于非线性数据可能失效。t-SNE是一种处理非线性数据集的降维方法。

from sklearn.manifold import TSNE
tsne = TSNE(n_components=2, random_state=42)
components_tsne = tsne.fit_transform(data)
plt.scatter(components_tsne[:, 0], components_tsne[:, 1], c=digits.target, cmap='tab10', alpha=0.5)
plt.colorbar()
plt.show()

有效数据可视化:4:交互式可视化 🎮

上一节我们介绍了降维,本节中我们来看看交互式可视化。交互式图表允许用户探索数据,获取更多细节。

我们将使用 plotly 库来可视化金融市场数据。

import pandas as pd
import plotly.graph_objects as go
from pandas_datareader import data as pdr
import datetime
import yfinance as yf
yf.pdr_override()
# 获取标普500 ETF数据
start = datetime.datetime(2020, 1, 1)
end = datetime.datetime(2020, 12, 31)
spy = pdr.get_data_yahoo('SPY', start, end)
spy.reset_index(inplace=True)
# 创建散点图
fig = go.Figure(data=go.Scatter(x=spy['Date'], y=spy['Close'], mode='markers'))
fig.show()

我们还可以创建烛台图:

fig = go.Figure(data=[go.Candlestick(x=spy['Date'][:90],
                open=spy['Open'][:90],
                high=spy['High'][:90],
                low=spy['Low'][:90],
                close=spy['Close'][:90])])
fig.show()

可以在一个图表中绘制多个数据序列并添加控件:

aapl = pdr.get_data_yahoo('AAPL', start, end)
aapl.reset_index(inplace=True)
fig = go.Figure()
fig.add_trace(go.Scatter(x=spy['Date'], y=spy['Close'], name='SPY'))
fig.add_trace(go.Scatter(x=aapl['Date'], y=aapl['Close'], name='AAPL'))
fig.update_layout(title='Yahoo Finance Data',
                  updatemenus=[...]) # 按钮定义省略
fig.show()

plotly 还可以用于创建地图。例如,可视化 COVID-19 确诊病例数据。

import plotly.express as px
# 假设 `df` 是一个包含 FIPS 代码和病例数的 DataFrame
fig = px.choropleth(df,
                    locations='fips',
                    color='cases',
                    color_continuous_scale=px.colors.sequential.Blues,
                    scope='usa')
fig.show()

有效数据可视化:5:通过图表进行交流 📢

上一节我们介绍了交互式可视化,本节是最后一节,我们将学习如何通过图表设计有效地传达信息。不当的设计可能导致误解。

首先讨论颜色。Jet 色彩映射是历史上常用的,但它在感知上不一致。

import seaborn as sns
import numpy as np
data = np.tile(np.arange(100), (10, 1))
sns.heatmap(data, cmap='jet', square=True)

更有效的色彩映射类型包括:

  1. 顺序色彩映射:用于表示从低到高的数据。
  2. 发散色彩映射:用于突出显示与中间值的偏差。
  3. 分类色彩映射:用于区分不同类别的数据。

以下是使用感知均匀的顺序色彩映射 viridis 的示例:

flights = sns.load_dataset("flights").pivot("month", "year", "passengers")
sns.heatmap(flights, cmap='viridis')

标记形状的选择也很重要。当需要区分不同类别的数据点时,应使用明显不同的形状。

import matplotlib.pyplot as plt
import numpy as np
np.random.seed(0)
data1 = np.random.randn(100, 2)
data2 = np.random.randn(5, 2) + 3
plt.scatter(data1[:, 0], data1[:, 1], marker='s', label='Dataset 1')
plt.scatter(data2[:, 0], data2[:, 1], marker='*', s=100, label='Dataset 2')
plt.legend()
plt.show()

在二维图表中,我们可以利用颜色、大小和形状来编码额外的维度。

最后,确保为整个项目或笔记本设置一个合适的、色盲友好的调色板。

sns.set_palette("colorblind")
sns.set_context("notebook", font_scale=1.2)

本节课中我们一起学习了数据可视化的五个核心方面:数据探索、密度估计、高维数据降维、交互式可视化以及通过设计进行有效沟通。掌握这些技能将帮助您更清晰、更有力地通过数据讲述故事。

080:教程概述

在本教程中,我们将学习如何在无服务器基础设施上部署一个Django应用程序。我们将使用Google Cloud Platform作为云服务提供商,通过手动和自动化两种方式,将一个名为“Unicodex”的示例Django应用部署到云端。教程将涵盖从本地环境准备、手动配置云资源,到使用Terraform实现基础设施即代码(IaC)自动化的完整流程。


无服务器基础设施部署Django:1:本地环境准备与项目介绍

上一节我们概述了教程内容,本节中我们来看看如何准备本地开发环境并了解我们的示例项目。

首先,我们需要获取示例Django应用程序。这是一个名为“Unicodex”的应用,用于展示不同供应商和版本的表情符号(Emoji)及其Unicode码点信息。

以下是获取和运行本地项目的步骤:

  1. 下载项目:从提供的GitHub链接下载项目ZIP文件。
  2. 解压文件:将下载的ZIP文件解压到本地文件夹。
  3. 使用Docker Compose启动服务:项目包含一个docker-compose.yml文件,用于定义和运行PostgreSQL数据库和Django Web服务。
    # docker-compose.yml 示例结构
    version: '3'
    services:
      db:
        image: postgres
        environment:
          POSTGRES_PASSWORD: localpassword
      web:
        build: .
        command: python manage.py runserver 0.0.0.0:8000
        volumes:
          - .:/code
        ports:
          - "8000:8000"
        depends_on:
          - db
    
  4. 应用数据库迁移:启动容器后,运行Django的迁移命令来创建数据库表结构。
    docker-compose exec web python manage.py migrate
    
  5. 加载初始数据:运行命令加载示例数据(fixtures)。
    docker-compose exec web python manage.py loaddata sampledata
    
  6. 访问应用:在浏览器中打开 http://localhost:8000,你将看到一个基础的Django应用界面。
  7. 访问管理后台:创建超级用户后,可以登录Django管理后台(/admin)查看和管理数据模型,并执行“生成设计”等管理操作来获取表情符号图片。

至此,我们已经在本地成功运行了“Unicodex”应用。这个应用包含了数据库模型、管理操作、静态文件和媒体文件处理,是一个功能相对完整的Django项目,适合作为部署案例。


无服务器基础设施部署Django:2:手动部署到Google Cloud Run

上一节我们介绍了如何在本地运行项目,本节中我们来看看如何手动将其部署到Google Cloud Run无服务器环境。

部署到生产环境需要解决几个问题:服务器托管、媒体文件存储、敏感信息(如数据库密码)管理。我们将使用以下Google Cloud服务:

  • Cloud Run:用于托管和运行Django应用容器。
  • Cloud SQL:提供托管的PostgreSQL数据库实例。
  • Cloud Storage:用于存储用户上传的媒体文件。
  • Secret Manager:用于安全地存储和管理数据库连接字符串、密码等机密信息。

以下是手动部署的核心步骤:

  1. 启用计费与API:在Google Cloud控制台中为项目启用计费功能,并启用Cloud Run、Cloud SQL、Cloud Build、Secret Manager等所需服务的API。
  2. 创建服务账户并分配权限:创建专门的服务账户来运行应用,并为其分配最小必要权限(如Cloud Run管理员、Cloud SQL客户端)。
  3. 创建Cloud SQL数据库实例:使用gcloud命令创建一个PostgreSQL实例、数据库和用户,并生成强密码。
    gcloud sql instances create [INSTANCE_NAME] --database-version=POSTGRES_11 --region=[REGION]
    gcloud sql databases create [DATABASE_NAME] --instance=[INSTANCE_NAME]
    gcloud sql users create [USER_NAME] --instance=[INSTANCE_NAME] --password=[GENERATED_PASSWORD]
    
  4. 创建Cloud Storage存储桶:创建一个存储桶用于存放媒体文件,并授予服务账户写入权限。
    gsutil mb -l [REGION] gs://[BUCKET_NAME]
    
  5. 在Secret Manager中存储机密:将数据库连接字符串(DATABASE_URL)、存储桶名称等敏感信息存储为Secret。
    echo -n $DATABASE_URL | gcloud secrets create DATABASE_URL --data-file=-
    
  6. 构建Docker镜像:使用Cloud Build服务,根据项目中的Dockerfile构建容器镜像。
  7. 首次部署Cloud Run服务:使用上一步构建的镜像部署到Cloud Run,但先不设置数据库连接等环境变量。
  8. 配置环境变量并重新部署:获取首次部署生成的服务URL,将其设置为ALLOWED_HOSTS。然后更新Cloud Run服务,注入从Secret Manager读取机密所需的环境变量(如DATABASE_URLGS_BUCKET_NAME)。
  9. 运行数据库迁移:通过Cloud Build运行一个专门的构建步骤,该步骤配置了Cloud SQL代理,能够安全地连接到数据库并执行python manage.py migratecollectstatic等命令。
  10. 访问应用:部署完成后,通过Cloud Run提供的URL访问应用。登录管理后台(需从Secret Manager获取管理员密码)并执行“生成设计”操作,验证媒体文件能否正确存储到Cloud Storage并显示。

通过以上步骤,我们成功手动将Django应用部署到了无服务器环境。这个过程涉及多个服务的配置和交互,确保了应用的安全性、可扩展性和12-Factor应用原则的遵循。


无服务器基础设施部署Django:3:使用Cloud Build触发器实现自动部署

上一节我们完成了复杂的手动部署,本节中我们来看看如何利用Cloud Build触发器,实现代码提交后的自动构建与部署。

为了实现持续部署(CD),我们将把项目代码托管在GitHub上,并配置Cloud Build触发器。这样,每当向指定的分支(如main)推送代码时,就会自动触发构建、迁移和部署流程。

以下是配置自动部署的步骤:

  1. Fork项目仓库:将演示用的“Unicodex”仓库Fork到自己的GitHub账户下。
  2. 在Cloud Build中连接仓库:在Google Cloud控制台的Cloud Build设置中,连接你的GitHub账户和Fork后的仓库。
  3. 创建触发器:创建一个新的触发器,配置其监听main分支的推送事件,并使用我们之前手动部署时创建的cloudbuild.yaml配置文件。
    # 使用gcloud命令创建触发器的示例
    gcloud beta builds triggers create github \
      --name="unicodex-deploy-trigger" \
      --repo-name="[YOUR_REPO_NAME]" \
      --repo-owner="[YOUR_GITHUB_USERNAME]" \
      --branch-pattern="^main$" \
      --build-config="cloudbuild.yaml"
    
  4. 测试自动部署:在本地克隆你的仓库,对代码进行修改(例如,更改模板文件中的标题颜色),然后将修改提交并推送到GitHub的main分支。
  5. 观察自动流程:推送后,在Cloud Build控制台可以看到一个新的构建任务被自动触发。该任务会执行构建镜像、运行数据库迁移、收集静态文件、重新部署到Cloud Run等一系列操作。
  6. 验证更新:构建部署完成后,刷新浏览器中已部署的应用页面,确认代码更改已生效。

通过设置Cloud Build触发器,我们将部署流程自动化。开发者只需关注代码开发,提交后即可自动完成上线,极大地提升了开发效率和部署的一致性。


无服务器基础设施部署Django:4:使用Terraform自动化基础设施配置

上一节我们实现了代码部署的自动化,本节中我们来看看如何使用Terraform实现基础设施即代码(IaC),将云资源的创建和管理也自动化。

手动创建服务账户、数据库、存储桶等资源虽然可行,但难以复制和版本控制。Terraform允许我们使用声明式代码(.tf文件)来定义所需的基础设施,并能一键创建或销毁。

以下是使用Terraform自动化配置的步骤:

  1. 安装Terraform和Google Cloud SDK:在本地机器上安装Terraform和gcloud命令行工具。
  2. 创建新项目:在Google Cloud上创建一个全新的项目,用于Terraform演示,避免影响之前的手动部署。
  3. 为Terraform创建服务账户:在新项目中创建一个服务账户,并授予其足够的权限(如项目所有者),以便Terraform能够代表我们创建资源。同时为该账户生成密钥文件。
  4. 编写Terraform配置:项目代码库中已包含Terraform配置文件(main.tfvariables.tf等),它们定义了Cloud SQL实例、数据库用户、存储桶、Secret Manager机密、Cloud Run服务等资源。
    # main.tf 示例片段:创建随机密码和SQL实例
    resource "random_password" "db_root_password" {
      length = 64
    }
    resource "google_sql_database_instance" "postgres" {
      name = var.instance_name
      database_version = "POSTGRES_11"
      region = var.region
      settings {
        tier = "db-f1-micro"
      }
    }
    
  5. 初始化与规划:在Terraform配置目录下运行terraform init初始化,然后运行terraform plan查看将要创建的资源计划。
  6. 应用配置:运行terraform apply,Terraform将自动按照配置创建所有定义好的云资源。这个过程会重现我们之前手动执行的大部分步骤。
  7. 构建镜像与运行迁移:Terraform主要管理基础设施资源,不负责构建应用镜像和运行数据库迁移。因此,在apply完成后,我们仍需使用gcloud命令构建Docker镜像,并运行一次性的数据库迁移脚本。
  8. 访问与验证:Terraform输出Cloud Run服务的URL。访问该URL,验证全新的基础设施和应用已成功部署并运行。

使用Terraform后,整个云环境可以通过版本控制的代码文件来定义和重现。这带来了环境一致性、可审计性以及快速搭建/销毁环境的能力,是管理现代云基础设施的最佳实践。


无服务器基础设施部署Django:5:教程总结与资源清理

本节课中我们一起学习了在无服务器基础设施上部署Django应用的完整流程。

我们首先在本地运行了“Unicodex”示例应用。然后,我们详细演练了如何手动在Google Cloud上配置Cloud SQL、Cloud Storage、Secret Manager等服务,并将应用部署到Cloud Run。接着,我们通过设置Cloud Build触发器,实现了代码提交即部署的持续交付流程。最后,我们引入了基础设施即代码的概念,使用Terraform自动化了所有云资源的创建和管理,使整个部署过程可重复、可版本化。

核心要点总结

  • 无服务器架构:利用Cloud Run等服务,无需管理服务器,按需伸缩。
  • 安全实践:使用服务账户最小权限原则、Secret Manager管理机密、环境变量配置。
  • 自动化流水线:结合Cloud Build和GitHub触发器,实现CI/CD。
  • 基础设施即代码:使用Terraform等工具,将基础设施定义为可版本控制的代码。

重要提示:资源清理
教程中创建的资源(尤其是Cloud SQL实例)可能会产生持续费用。完成学习后,请务必清理资源:

  1. 对于手动部署和Terraform创建的项目,最彻底的方式是直接删除整个Google Cloud项目
  2. 在Google Cloud控制台,进入“管理资源”页面,选择要删除的项目,点击“删除”。
  3. 删除项目后,所有计费和资源都将停止,数据会在30天后永久清除。

感谢你学习本教程。通过掌握这些步骤,你已经能够将复杂的Django应用安全、高效地部署到现代化的无服务器云平台之上。

081:从零到精通

在本教程中,我们将学习Python自然语言处理的基础知识,从传统的文本向量化方法开始,逐步深入到最先进的模型。我们将涵盖词袋模型、词向量、正则表达式、词干提取与词形还原、停用词移除、拼写纠正、词性标注以及基于Transformer架构的现代模型。通过本教程,你将掌握一系列实用的NLP技术,并能够将它们应用到实际项目中。

1:文本向量化基础:词袋模型

上一节我们介绍了本教程的概览,本节中我们来看看如何将文本转换为计算机可以处理的数字形式。词袋模型是最简单的方法之一。

词袋模型的核心思想是忽略文本中单词的顺序和语法,只关注单词是否出现。具体做法是,首先创建一个包含所有训练文本中出现的唯一单词的词汇表,然后将每个句子表示为一个向量。向量中的每个位置对应词汇表中的一个单词,如果该单词在句子中出现,则对应位置为1(或单词出现的次数),否则为0。

以下是使用Python的scikit-learn库实现词袋模型的示例代码:

from sklearn.feature_extraction.text import CountVectorizer

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_19.png)

# 定义训练文本
train_x = ["I love this book", "This is a great book", "The fit is great", "I love these shoes"]
# 定义类别标签
train_y = ["book", "book", "clothing", "clothing"]

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_21.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_23.png)

# 创建CountVectorizer对象,使用二进制模式(只记录是否出现)
vectorizer = CountVectorizer(binary=True)
# 拟合词汇表并转换训练文本
train_vectors = vectorizer.fit_transform(train_x)

# 查看词汇表
print(vectorizer.get_feature_names_out())
# 查看第一个句子的向量表示
print(train_vectors[0].toarray())

接下来,我们可以使用这个向量表示来训练一个简单的分类器,例如支持向量机。

from sklearn import svm

# 创建SVM分类器
classifier = svm.SVC(kernel='linear')
# 使用向量和标签训练模型
classifier.fit(train_vectors, train_y)

# 预测新句子
test_sentence = ["I love this book"]
test_vector = vectorizer.transform(test_sentence)
prediction = classifier.predict(test_vector)
print(prediction)  # 输出:['book']

词袋模型简单有效,但它有两个主要缺点:

  1. 它无法处理训练时未出现过的单词。
  2. 它忽略了单词的顺序和上下文关系(但可以通过使用n-gram特征部分缓解)。

2:捕捉语义:词向量模型

上一节我们介绍了词袋模型,本节中我们来看看一种能更好捕捉单词语义的表示方法:词向量。

词向量(Word Embeddings)的目标是将单词映射到一个高维向量空间中,使得语义相似的单词在空间中的位置也相近。例如,“书”和“故事”的向量表示应该比较接近。流行的训练方法有连续词袋模型和Skip-gram模型。

在实践中,我们通常使用预训练好的词向量模型。spaCy库提供了方便易用的接口。

以下是使用spaCy加载预训练词向量并计算句子向量的步骤:

# 首先需要安装spaCy和下载模型
# !pip install spacy
# !python -m spacy download en_core_web_md

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_31.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_33.png)

import spacy

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_35.png)

# 加载中等大小的英文词向量模型
nlp = spacy.load('en_core_web_md')

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_37.png)

# 定义训练文本
train_x = ["I love this book", "This is a great book", "The fit is great", "I love these shoes"]
train_y = ["book", "book", "clothing", "clothing"]

# 将每个句子转换为词向量(取句子中所有词向量的平均值)
train_vectors = [nlp(text).vector for text in train_x]

# 训练SVM分类器
from sklearn import svm
classifier_wv = svm.SVC(kernel='linear')
classifier_wv.fit(train_vectors, train_y)

# 预测新句子
test_phrases = ["I love this story", "I love these hats"]
for phrase in test_phrases:
    test_vector = nlp(phrase).vector.reshape(1, -1)
    prediction = classifier_wv.predict(test_vector)
    print(f"'{phrase}' -> {prediction[0]}")

词向量的优势在于它能理解语义。即使模型在训练时没见过“story”这个词,它也能根据“story”和“book”在向量空间中的相似性,正确地将“I love this story”分类为“book”。

然而,词向量也有局限性。当句子很长时,简单的平均操作可能会丢失重要信息。此外,对于具有多重含义的单词(如“check”),标准的词向量无法根据上下文区分其不同含义。

3:文本预处理与模式匹配

上一节我们探讨了词向量,本节中我们来看看一些基础的文本预处理技术和模式匹配工具,它们对于清理和规范化文本数据非常有用。

以下是几种常用的文本处理技术:

1. 正则表达式
正则表达式用于在字符串中进行复杂的模式匹配。例如,可以用来验证电话号码格式或提取特定信息。

import re

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_47.png)

# 定义一个匹配“以'ab'开头,中间无空格,以'cd'结尾”的正则表达式
pattern = re.compile(r'^ab[^\s]+cd$')
test_strings = ['abcd', 'ab123cd', 'ab cd', 'xxab123cdxx']
matches = [s for s in test_strings if pattern.match(s)]
print(matches)  # 输出:['abcd', 'ab123cd']

2. 词干提取与词形还原
两者都是将单词还原为基本形式的技术。词干提取基于规则,可能产生非真实单词;词形还原则基于词典,确保输出是有效的单词。

import nltk
from nltk.stem import PorterStemmer
from nltk.stem import WordNetLemmatizer
nltk.download('wordnet')

stemmer = PorterStemmer()
lemmatizer = WordNetLemmatizer()

word = "reading"
print(f"Stem of '{word}': {stemmer.stem(word)}")  # 输出:read
print(f"Lemma of '{word}': {lemmatizer.lemmatize(word, pos='v')}")  # 输出:read

3. 停用词移除
停用词是常见但信息量少的词(如“the”,“is”)。移除它们可以降低数据维度,有时能提升模型性能。

from nltk.corpus import stopwords
nltk.download('stopwords')

stop_words = set(stopwords.words('english'))
sentence = "This is an example sentence demonstrating stop words removal"
words = nltk.word_tokenize(sentence)
filtered_words = [word for word in words if word.lower() not in stop_words]
print(filtered_words)

4. 使用TextBlob进行综合处理
TextBlob库基于NLTK,提供了更简洁的API来完成拼写纠正、词性标注和情感分析等任务。

from textblob import TextBlob

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_49.png)

# 拼写纠正
text = TextBlob("I havv goood speling!")
print(text.correct())  # 输出:I have good spelling!

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_51.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_53.png)

# 词性标注
blob = TextBlob("I read that book")
print(blob.tags)  # 输出:[('I', 'PRP'), ('read', 'VBP'), ('that', 'DT'), ('book', 'NN')]

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_55.png)

# 情感分析
blob1 = TextBlob("I love this book!")
blob2 = TextBlob("This book is terrible.")
print(blob1.sentiment.polarity)  # 输出:正数
print(blob2.sentiment.polarity)  # 输出:负数

这些预处理步骤是构建高质量NLP管道的基础,能够显著提升后续模型的性能。

4:从RNN到Transformer:现代NLP架构

上一节我们介绍了一些基础的文本处理技术,本节中我们将目光投向更强大的现代NLP模型,了解它们如何解决传统方法的局限。

循环神经网络(RNN)
RNN通过按顺序处理单词,并将前一个步骤的隐藏状态传递到下一个步骤,从而能够考虑上下文信息。这解决了词向量“一成不变”的问题,使得单词的表示能根据其所在句子动态变化。

然而,RNN存在两个主要问题:

  1. 长程依赖问题:当相关单词距离较远时,RNN难以有效捕捉它们之间的关系。
  2. 训练效率低:由于其顺序性,难以利用GPU进行并行计算,训练速度慢。

注意力机制与Transformer
注意力机制的提出是NLP领域的重大突破。它允许模型在处理某个单词时,“关注”句子中所有其他单词,并动态决定哪些单词更重要。这有效解决了长程依赖问题。

基于注意力机制构建的Transformer架构完全摒弃了循环结构,可以并行处理整个序列,极大地提升了训练效率。BERT和GPT等最先进的模型都基于Transformer。

使用spaCy调用BERT模型
我们可以轻松地使用spaCy库来利用预训练的BERT模型获取文本的上下文相关表示。

# 安装必要的库和模型
# !pip install spacy-transformers
# !python -m spacy download en_trf_bertbaseuncased_lg

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_74.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_76.png)

import spacy
import torch

# 加载预训练的BERT模型
nlp = spacy.load('en_trf_bertbaseuncased_lg')

# 准备数据:区分“书”和“银行”相关文本
train_x = ["borrow a book", "great story", "interesting characters",
           "deposit money", "check balance", "save money"]
train_y = ["book", "book", "book", "bank", "bank", "bank"]

# 获取句子的BERT向量(取所有token向量的平均值)
train_vectors = [nlp(text).vector for text in train_x]

# 训练分类器
from sklearn import svm
classifier = svm.SVC(kernel='linear')
classifier.fit(train_vectors, train_y)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_78.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/b3ffce8be3ade096797cd8ba5d9dde80_80.png)

# 测试模型理解上下文的能力
test_phrases = ["write a check", "check out this book"]
for phrase in test_phrases:
    test_vec = nlp(phrase).vector.reshape(1, -1)
    print(f"'{phrase}' -> {classifier.predict(test_vec)[0]}")
# 输出可能:'write a check' -> bank, 'check out this book' -> book

尽管“check”这个词在训练数据中只出现在“book”类别里,但BERT模型凭借其强大的上下文理解能力,能够正确判断“write a check”应与“bank”相关,而“check out this book”应与“book”相关。

对于想要更深入定制或微调Transformer模型的用户,可以探索Hugging Face Transformers库,它提供了丰富的预训练模型和灵活的接口。

总结

在本教程中,我们一起学习了Python自然语言处理的完整路径。我们从最基础的词袋模型开始,学习了如何将文本转化为数字向量。接着,我们探讨了能捕捉语义的词向量模型。然后,我们介绍了一系列文本预处理技术,包括正则表达式、词干提取、词形还原和停用词移除,这些是清理和准备数据的关键步骤。最后,我们了解了现代NLP的核心——注意力机制Transformer架构,并通过spaCy库实践了如何使用强大的预训练BERT模型。

这些技术构成了NLP的实用工具箱。你可以根据具体任务,组合使用这些方法,例如在词袋模型中加入文本预处理,或对预训练的BERT模型进行微调,以构建出更加强大和精准的自然语言处理应用。

082:概述与动机

在本节课中,我们将要学习什么是网络抓取,以及为什么它是一个强大且有用的技能。

互联网上充满了有价值的数据。假设你想分析2019年最受欢迎的歌曲。维基百科上有一篇文章列出了这些歌曲,每首歌的页面都包含发行日期、流派、时长和词曲作者等信息。手动将这些信息复制粘贴到电子表格中会非常耗时,尤其是当你有上百个页面需要处理时。

网络抓取可以自动化这个过程。它指的是从网站上收集数据,并将其解析成有意义的格式(如CSV文件)。通过学习网络抓取,你可以:

  • 节省时间:自动化数据收集,避免繁琐的手动操作。
  • 扩展数据源:不再依赖他人整理好的数据集或有限的API,可以直接从任何公开的网站获取数据。

网络抓取的基本工作原理是:我们感兴趣的网页由HTML(以及CSS和JavaScript)代码驱动。我们将提取页面的HTML代码,然后利用其结构化的特点,定位并提取出我们需要的特定数据片段。

在本教程中,我们将使用两个Python库:

  • requests:用于从互联网请求并获取网页的HTML代码。
  • Beautiful Soup:用于解析HTML结构,并从中提取所需信息。

关于网络抓取的合法性:近期(2019年)的法律裁决(如hiQ Labs诉LinkedIn案)表明,抓取公开可用的数据通常不违反《计算机欺诈与滥用法》(CFAA)。但请注意,本教程不构成法律建议,在实际操作前应自行核实相关法律法规和网站的使用条款。


网络抓取教程:2:学习目标与HTML基础

上一节我们介绍了网络抓取的动机和基本概念,本节中我们来看看本教程的具体学习目标,并学习解读基本的HTML代码。

学习目标

通过本教程,你将学会:

  1. 解读基本的HTML代码。
  2. 从互联网检索信息(使用requests库)。
  3. 解析网络数据(使用Beautiful Soup库)。
  4. 系统地收集和准备数据,构建数据管道。

HTML基础

HTML(超文本标记语言)是构成网页骨架的代码。浏览器读取HTML代码并将其渲染成我们看到的网页。

一个基本的HTML元素由以下部分组成:

<p id="intro">这是一段文本。</p>
  • 标签<p></p>p 代表“段落”。标签用尖括号<>括起来,结束标签带有斜杠/
  • 属性id="intro"。属性提供关于元素的额外信息,位于开始标签内。一个元素可以有多个属性。
  • 内部HTML文本这是一段文本。。这是在浏览器中实际显示的内容。

以下是常见的HTML标签和属性:

常见标签:

  • h1, h2, h3...:标题,数字越小字体通常越大。
  • p:段落。
  • a:锚点,用于创建超链接。
  • div:分区,用于组合其他元素的通用容器。
  • span:跨度,用于对行内元素进行分组。
  • img:图像。
  • li:列表项。

常见属性:

  • id:元素的唯一标识符。
  • class:类名,可用于标识多个元素。
  • href:超链接引用,指定a标签链接的目标地址。
  • src:源,指定img标签图像的来源。

文档对象模型

HTML元素以树状结构嵌套,称为文档对象模型。理解DOM有助于我们通过元素之间的关系(父元素、子元素、兄弟元素)来定位数据。

例如,以下HTML:

<body>
    <div>
        <h3>标题</h3>
    </div>
    <div>
        <p>段落</p>
        <a>链接</a>
    </div>
</body>

其DOM树结构可以理解为:body 有两个子元素 div。第二个 div 又有两个子元素:pa


网络抓取教程:3:抓取基础——解析本地HTML

上一节我们学习了HTML的基础知识,本节中我们将开始使用Beautiful Soup来解析HTML并提取数据。我们将首先在本地HTML字符串上进行练习。

我们将使用Google Colab环境进行交互式编程。请访问链接 bit.ly/pycon2020_scrapingbasics 获取代码笔记本。

引入Beautiful Soup

Beautiful Soup是一个用于从HTML文件中提取数据的Python库。它本身不获取网页,只负责解析HTML结构。我们将使用requests库来获取HTML。

首先,我们从一个简单的HTML字符串开始:

simple_html = """
<html>
    <head>
        <title>学习目标</title>
    </head>
    <body>
        <h1>今天的学习目标</h1>
        <ul>
            <li>学习HTML基础</li>
            <li>学习使用Requests</li>
            <li>学习使用Beautiful Soup</li>
            <li>构建数据管道</li>
        </ul>
    </body>
</html>
"""

使用Beautiful Soup解析和查找

  1. 导入库并创建Soup对象
    from bs4 import BeautifulSoup as bs
    soup = bs(simple_html, ‘html.parser‘) # ‘html.parser‘是解析器
    
  2. 按标签查找元素
    • find():返回第一个匹配的标签。
      first_h1 = soup.find(‘h1‘) # 返回整个<h1>标签元素
      h1_text = first_h1.text   # 提取标签内的文本:‘今天的学习目标‘
      
    • find_all():返回所有匹配的标签,结果是一个类似列表的ResultSet对象。
      all_li = soup.find_all(‘li‘) # 返回所有<li>标签
      
  3. 从ResultSet中提取文本:不能直接对ResultSet使用.text,需要遍历每个元素。
    goals = []
    for item in all_li:
        goals.append(item.text)
    # 或使用列表推导式
    goals = [item.text for item in all_li]
    

常见错误提醒

  • 使用find时,以为会得到所有匹配项,但实际上只得到第一个。
  • 尝试对find_all返回的ResultSet直接使用.text属性,这会导致错误。

练习:解析复杂HTML

以下是更复杂的HTML,模拟真实网页的一部分:

workshop_html = """
<html>
    <body>
        <h1>Python数据科学研讨会</h1>
        <p>欢迎参加本次研讨会。</p>
        <p>我们将学习有用的工具。</p>
        <h2>今日议程</h2>
        <ul>
            <li>网络抓取介绍</li>
            <li>数据清洗</li>
            <li>数据分析</li>
        </ul>
        <h2>工具</h2>
        <ul>
            <li>Python</li>
            <li>Pandas</li>
            <li>Beautiful Soup</li>
        </ul>
    </body>
</html>
"""

任务

  1. 提取标题(h1)文本。
  2. 找到所有段落(p)并打印其文本。
  3. 挑战:创建一个只包含“今日议程”项目(前三个li)的列表。

解决方案思路

  1. soup.find(‘h1‘).text
  2. 遍历soup.find_all(‘p‘),对每个元素使用.text
  3. 找到所有li,然后使用切片取前三个:[li.text for li in soup.find_all(‘li‘)[:3]]。更好的方法是通过定位父元素来精确查找,我们将在下一节学习。

网络抓取教程:4:抓取基础——精确定位与属性提取

上一节我们学会了使用标签名进行查找,本节中我们来看看如何利用属性和DOM关系进行更精确的定位,并提取链接等属性值。

我们将使用一个更复杂的HTML文件(pyconinfo.html),它包含了多个教程活动的信息。

通过属性精确定位

仅靠标签名(如a)通常不够精确,因为页面中可能有太多同类标签。我们可以利用元素的idclass等属性。

假设HTML中有一个div包含了今天的所有活动,其idtoday

# 首先找到这个特定的div
today_div = soup.find(‘div‘, id=‘today‘)
# 然后在这个div内部查找所有链接
today_links = today_div.find_all(‘a‘)

findfind_all方法可以接受属性参数:

  • idsoup.find(id=‘today‘)
  • classsoup.find(class_=‘events‘) (注意class后有个下划线,因为class是Python关键字)
  • 多个属性:使用字典 soup.find_all(attrs={‘class‘: ‘events‘, ‘id‘: ‘tomorrow‘})

提取属性值

我们经常需要提取链接的URL(href属性值)或图片的源(src属性值)。

first_link = today_links[0]
link_url = first_link[‘href‘]  # 提取href属性的值
link_text = first_link.text     # 提取链接的文本

要获取今天所有活动的链接列表,可以使用列表推导式:

all_urls = [link[‘href‘] for link in today_links]

练习:构建数据结构

任务:为“明天”的活动创建一个元组列表,每个元组包含(活动标题,活动链接)。

解决方案思路

  1. 找到idtomorrowdiv
  2. 在其中找到所有a标签。
  3. 遍历这些标签,用(link.text, link[‘href‘])构建元组。

网络抓取教程:5:抓取真实网页

上一节我们掌握了解析本地HTML的核心技巧,本节中我们将从互联网上抓取真实的网页。我们将以宾夕法尼亚州的维基百科页面为例。

请访问新的Google Colab笔记本:bit.ly/pycon2020_scrapingpipeline

使用Requests获取网页

Beautiful Soup只负责解析,获取网页需要requests库。

import requests
from bs4 import BeautifulSoup

url = ‘https://en.wikipedia.org/wiki/Pennsylvania‘
response = requests.get(url) # 发送GET请求

# 检查请求是否成功
print(response.status_code) # 200表示成功

# 获取页面的HTML内容
page_html = response.text

解析并提取信息

现在,我们可以像处理本地HTML一样使用Beautiful Soup

soup = BeautifulSoup(page_html, ‘html.parser‘)
# 例如,提取页面主标题
page_title = soup.find(‘h1‘).text
print(page_title) # 输出:Pennsylvania

制定抓取策略

关键在于“检查”网页元素。在浏览器中右键点击感兴趣的内容(如坐标、链接),选择“检查”(Inspect),查看其对应的HTML代码结构,然后设计Beautiful Soup查找策略。

示例1:提取消歧义链接

  1. 右键点击“Pennsylvania (disambiguation)”链接,选择检查。
  2. 发现其a标签有一个独特的classmw-disambig
  3. 策略:soup.find(‘a‘, class_=‘mw-disambig‘)[‘href‘]

示例2:提取经纬度

  1. 右键点击坐标,选择检查。
  2. 发现坐标在一个span标签内,classgeo-dms
  3. 策略:soup.find(‘span‘, class_=‘geo-dms‘).text

方法链与DOM遍历

我们可以将多个查找操作连接起来,或者利用DOM关系进行定位。

# 方法链:找到第一个表格,然后找到其中的第一个表头
first_table_header = soup.find(‘table‘).find(‘th‘).text

# 通过文本定位相邻元素:找到“Admitted to the Union”文本,然后获取下一个兄弟元素(即日期)
admitted_text = soup.find(text=‘Admitted to the Union‘)
admission_date = admitted_text.find_next(‘td‘).text

其他有用的导航方法:.find_parent(), .find_next_sibling(), .find_previous_sibling()等。

练习

  1. 提取宾夕法尼亚州的首府(尝试使用文本匹配策略,而不是依赖固定位置)。
  2. 提取页面底部的三个参考文献的文本及其外部链接。

网络抓取教程:6:数据清洗与存储

上一节我们学会了从单个网页抓取信息,本节中我们来看看如何清洗抓取到的字符串数据,并将其存储为结构化格式,为分析做准备。

数据清洗

从网页上抓取的数据几乎都是字符串。我们需要将其转换为适合分析的类型(如整数、浮点数、日期)。

# 示例:清洗“Admitted to the Union”的日期
date_string = “December 12, 1787 (2nd)“
# 1. 移除括号内的序数词
clean_date_string = date_string.split(‘ (‘)[0] # ‘December 12, 1787‘
# 2. 转换为datetime对象
from dateutil import parser
date_obj = parser.parse(clean_date_string)
print(date_obj.year) # 输出:1787

# 示例:清洗人口数字字符串
pop_string = “12,801,989“
pop_int = int(pop_string.replace(‘,‘, ‘‘)) # 移除逗号后转换
print(pop_int) # 输出:12801989

建议将常用的清洗逻辑封装成函数,例如clean_number(text)clean_date(text)

数据存储

将清洗后的数据组织起来,通常使用字典。

pennsylvania_data = {
    ‘name‘: ‘Pennsylvania‘,
    ‘population‘: 12801989,
    ‘admission_date‘: date_obj,
    ‘area_sq_mi‘: 46055
}

为什么用字典?因为它可以方便地转换为pandas DataFrame,这是数据分析的利器。

import pandas as pd
# 将字典放入列表,再创建DataFrame
states_list = [pennsylvania_data, ...] # 可以添加更多州的数据
df = pd.DataFrame(states_list)
# 保存为CSV文件
df.to_csv(‘us_states_data.csv‘, index=False)

练习

任务:从宾夕法尼亚州页面提取“Median household income”的值,将其转换为整数,并更新到你的pennsylvania_data字典和DataFrame中。

提示:使用文本匹配找到“Median household income”,然后定位其数值,清洗字符串(移除$,),最后转换为int


网络抓取教程:7:构建抓取管道

上一节我们处理了单个页面的数据,本节中我们将把这些技能整合起来,构建一个完整的抓取管道,系统地收集多个网页的数据。

核心步骤

一个典型的网络抓取管道包含以下步骤:

  1. 获取链接列表:找到一个包含所有目标页面链接的索引页。
  2. 遍历链接:循环访问列表中的每个链接。
  3. 抓取单个页面:对每个页面执行抓取和清洗操作。
  4. 存储数据:将每个页面的数据保存下来(如添加到列表)。
  5. 最终输出:将所有数据转换为分析友好的格式(如CSV)。

实战:抓取美国50个州的信息

  1. 获取所有州的链接
    • 索引页:https://en.wikipedia.org/wiki/List_of_states_and_territories_of_the_United_States
    • 抓取该页面,提取指向各州维基百科页面的所有链接。
    index_url = ‘...‘
    response = requests.get(index_url)
    index_soup = BeautifulSoup(response.text, ‘html.parser‘)
    # 假设链接在第二个表格中
    states_table = index_soup.find_all(‘table‘)[1]
    state_links = []
    for row in states_table.find_all(‘tr‘)[1:]: # 跳过表头
        link_tag = row.find(‘a‘)
        if link_tag:
            relative_url = link_tag[‘href‘]
            full_url = ‘https://en.wikipedia.org‘ + relative_url
            state_links.append(full_url)
    
  2. 封装单页抓取逻辑为函数:将之前为宾夕法尼亚州编写的抓取和清洗代码整合到一个函数scrape_state_info(state_url)中,该函数返回一个数据字典。
  3. 遍历并抓取
    all_states_data = []
    for state_url in state_links[:10]: # 先测试前10个州
        state_data = scrape_state_info(state_url)
        if state_data: # 确保抓取成功
            all_states_data.append(state_data)
        time.sleep(1) # 重要:请求间暂停,避免给服务器造成压力
    
  4. 错误处理与健壮性:使用try...except块包裹抓取代码,防止因某个页面结构异常而导致整个程序崩溃。对于缺失的数据,可以返回None
    def robust_scrape(state_url):
        try:
            # ... 抓取代码 ...
            return state_info_dict
        except Exception as e:
            print(f“抓取 {state_url} 时出错: {e}“)
            return None
    
  5. 保存数据
    df = pd.DataFrame(all_states_data)
    df.to_csv(‘collected_states_data.csv‘, index=False)
    

负责任地抓取

  • 尊重robots.txt:在网站根目录下查看此文件(如https://en.wikipedia.org/robots.txt),了解该网站允许和禁止抓取的范围。
  • 添加延迟:在请求之间使用time.sleep(),避免高频请求。维基百科建议每秒不超过1次请求。
  • 识别自己:有些网站要求在请求头中设置用户代理(User-Agent),以标识你的爬虫。
  • 处理异常:网络可能不稳定,代码需能处理超时、404错误等情况。

网络抓取教程:8:总结与进阶

本节课中我们一起学习了网络抓取的完整流程,从HTML基础到构建自动化数据管道。

教程总结

  1. HTML基础:理解了标签、属性、文本以及DOM树结构,这是抓取的基石。
  2. 抓取工具
    • requests:用于从网络获取HTML内容。
    • Beautiful Soup:用于解析HTML,并通过findfind_all、属性查找、DOM遍历等方法精准定位数据。
  3. 数据处理:学会了清洗字符串(转换为数字、日期等)并将数据存储到字典和pandas DataFrame中。
  4. 构建管道:掌握了系统化抓取多个页面的模式:获取链接列表 -> 遍历抓取 -> 清洗存储 -> 最终输出。强调了错误处理(try...except)和道德规范(添加延迟、遵守robots.txt)。

下一步建议

  • 实践项目:尝试教程提供的项目想法(如分析各国饮茶数据),或自己寻找感兴趣的数据源进行抓取。实践是巩固技能的最佳方式。
  • 处理动态内容:如果遇到用requests抓取不到的数据(页面由JavaScript动态加载),需要学习SeleniumPlaywright等工具来控制浏览器,渲染页面后再抓取。
  • 深入探索
    • 学习正则表达式,用于更复杂的文本匹配和清洗。
    • 探索Beautiful Souppandas的更高级功能。
    • 考虑将抓取任务自动化、定时化(如使用cron job或云函数)。

网络抓取是一项强大的技能,它能为你打开无数数据宝库的大门。请始终牢记负责任和道德地使用这项技术。祝你抓取愉快!


教程内容源自 PyCon 2020 教程《It’s Officially Legal, So Let’s Web Scrape》 by Kimberly Fessel

083:通过机器人表达感谢! 🤖

在本教程中,我们将学习如何构建一个GitHub机器人。这个机器人将能够自动感谢安装它的仓库维护者,并对新贡献者提交的拉取请求表达感谢。我们将使用Python 3.7、gidgethub库和aiohttp网络框架来完成这个项目。


课程概述 📋

在本节课中,我们将要学习:

  1. GitHub机器人的基本概念及其用途。
  2. 如何使用gidgethub库与GitHub API进行交互。
  3. 如何创建和配置一个GitHub应用。
  4. 如何部署一个Web服务到Heroku来处理GitHub的Webhook事件。
  5. 编写代码让机器人自动响应特定事件,如应用安装和拉取请求。

1:准备工作与环境搭建 🛠️

在开始构建机器人之前,我们需要准备好开发环境。本节将介绍所需的工具和账户,并指导你完成初始设置。

你需要准备以下内容:

  • Python 3.7:确保你的环境中安装了正确版本的Python。
  • GitHub账户:用于创建应用和测试。
  • Heroku账户:用于免费部署我们的Web服务。
  • Heroku CLI:一个有用的命令行工具,用于管理和调试Heroku应用。

以下是具体的设置步骤:

  1. 安装Python 3.7:请根据你的操作系统,参考相关教程进行安装。在终端输入python3.7 --version来验证安装是否成功。
  2. 创建GitHub个人访问令牌:登录GitHub,进入Settings -> Developer settings -> Personal access tokens,点击Generate new token。为令牌命名(例如“tutorial”),并勾选repo权限范围,然后生成令牌。请务必妥善保存此令牌字符串,因为它只会显示一次
  3. 设置环境变量:将生成的令牌设置为环境变量,以便在代码中安全使用。例如,在Mac/Linux的终端中,可以运行:
    export GH_TOKEN=你的令牌字符串
    


2:初识GitHub API与gidgethub库 📚

上一节我们准备好了基础环境,本节中我们来看看如何与GitHub进行程序化交互。我们将使用gidgethub库来简化API调用。

GitHub机器人是利用GitHub的Webhooks和API实现自动化的应用程序。它们可以自动回复用户、添加标签、管理Issue和Pull Request等,从而帮助维护者节省时间。

gidgethub是一个用于与GitHub API交互的Python异步库。它抽象了许多底层细节,例如自动构建请求头(如AuthorizationUser-Agent),让你能更专注于业务逻辑。

让我们通过一个简单的命令行脚本来体验如何使用gidgethub

首先,安装必要的库并创建一个脚本文件:

pip install gidgethub aiohttp

然后,创建一个Python脚本(例如demo.py),使用你的个人访问令牌来创建一个Issue:

import asyncio
import os
from gidgethub.aiohttp import GitHubAPI

async def main():
    async with aiohttp.ClientSession() as session:
        gh = GitHubAPI(session, “你的GitHub用户名”, oauth_token=os.getenv(“GH_TOKEN”))
        # 在指定仓库创建一个Issue
        response = await gh.post(
            “/repos/{owner}/{repo}/issues“,
            data={“title”: “我们发现了一个问题”, “body”: “这是一个通过API创建的Issue。”}
        )
        print(f“Issue创建成功: {response[‘html_url’]}“)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/3c3588b77123e82a29264958f5086ce4_34.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/3c3588b77123e82a29264958f5086ce4_36.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/3c3588b77123e82a29264958f5086ce4_38.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/3c3588b77123e82a29264958f5086ce4_40.png)

asyncio.run(main())

运行此脚本,它会在你指定的仓库中创建一个新的Issue。这演示了如何以编程方式与GitHub交互。


3:理解Webhook与GitHub应用 🔗

在上一节,我们通过主动执行脚本与GitHub交互。但一个真正的“机器人”应该能自动响应事件。本节我们将学习WebhookGitHub应用的概念。

Webhook是GitHub在特定事件(如Issue创建、Pull Request打开等)发生时,向你的服务器发送的HTTP POST请求。你的服务器接收并处理这些请求,从而实现自动化。

由于GitHub需要向一个公开的URL发送Webhook,我们不能在本地电脑上运行服务。因此,我们需要将服务部署到云端,例如Heroku

GitHub应用是一种特殊的集成方式。它可以被安装到多个仓库,并且通过更安全的机制进行身份认证。与应用交互时,我们使用安装访问令牌,而不是个人访问令牌。获取这个令牌的过程稍复杂,但gidgethub库提供了工具函数来帮助我们。


4:创建Web服务与GitHub应用 🌐

上一节我们介绍了核心概念,本节中我们开始动手搭建。我们将创建一个基础的Web服务,并将其部署到Heroku,然后配置一个GitHub应用。

以下是需要完成的步骤:

  1. 获取基础代码:为了加快进度,可以使用一个预先准备好的Web服务模板。你可以Fork并克隆这个仓库到本地。
  2. 部署到Heroku
    • 登录Heroku,创建一个新的应用。
    • 将你的代码仓库连接到Heroku,并启用自动部署。
    • 部署成功后,你会获得一个类似 https://your-app-name.herokuapp.com 的URL,这就是你的Web服务地址。
  3. 创建GitHub应用
    • 登录GitHub,进入 Settings -> Developer settings -> GitHub Apps -> New GitHub App
    • 填写应用名称。
    • Webhook URL:填写你的Heroku应用URL,并加上 /webhook 路径,例如 https://your-app-name.herokuapp.com/webhook
    • Webhook secret:设置一个密钥(如随机生成的字符串),用于验证Webhook请求的来源。
    • 配置权限:为本教程,我们需要 IssuesPull requestsRead & write 权限。
    • 订阅事件:勾选 IssuePull request 事件。
    • 最后,生成一个私钥文件并下载保存。
  4. 配置环境变量:在Heroku应用的设置页面,添加以下配置变量(Config Vars):
    • GITHUB_APP_ID: 你的GitHub应用ID。
    • GITHUB_PRIVATE_KEY: 你下载的私钥文件内容。
    • GITHUB_WEBHOOK_SECRET: 你之前设置的Webhook密钥。

完成以上步骤后,你的基础设施就搭建好了。


5:编写机器人逻辑——感谢维护者 ❤️

现在,我们的Web服务和GitHub应用都已就绪。本节我们将编写第一个机器人功能:当有人安装应用时,自动在对应仓库创建一个感谢Issue。

我们将在Web服务的 /webhook 端点处理 installation 事件。以下是实现思路:

  1. 在代码中,为 installation 事件创建路由处理器。
  2. 当收到 installation 事件且动作为 created 时,获取负载中的 installation_id 和仓库列表。
  3. 使用 gidgethub 提供的工具,通过 installation_id 获取一个安装访问令牌
  4. 使用这个令牌,在每个安装了此应用的仓库中创建一个感谢Issue。
  5. (可选)为了保持仓库整洁,可以立即关闭这个感谢Issue。

核心代码逻辑如下:

from gidgethub import routing, sansio
from gidgethub.aiohttp import GitHubAPI
from gidgethub.apps import get_installation_access_token

router = routing.Router()

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/3c3588b77123e82a29264958f5086ce4_70.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/3c3588b77123e82a29264958f5086ce4_72.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/3c3588b77123e82a29264958f5086ce4_74.png)

@router.register(“installation”, action=“created”)
async def thank_maintainer(event, gh, *args, **kwargs):
    “”“当应用被安装时,感谢维护者。”“”
    installation_id = event.data[“installation”][“id”]
    # 1. 获取安装访问令牌
    access_token = await get_installation_access_token(
        gh,
        installation_id=installation_id,
        app_id=os.getenv(“GITHUB_APP_ID”),
        private_key=os.getenv(“GITHUB_PRIVATE_KEY”),
    )
    # 2. 为每个被安装的仓库创建Issue
    for repo in event.data[“repositories”]:
        repo_name = repo[“full_name”]
        issue_url = f“/repos/{repo_name}/issues”
        # 创建Issue
        await gh.post(issue_url,
                     data={“title”: “谢谢安装!”, “body”: “感谢您安装我的机器人!”},
                     oauth_token=access_token)
        # 可以在这里添加关闭该Issue的代码

将代码部署后,当你安装这个GitHub应用到你的仓库时,机器人就会自动运行并创建感谢Issue。


6:编写机器人逻辑——欢迎新贡献者 🎉

上一节我们让机器人学会了感谢维护者,本节中我们来看看如何让它欢迎新贡献者。我们将处理 pull_request 事件。

我们希望机器人在有人新开一个Pull Request时,自动在PR下留言欢迎。如果是第一次贡献,则给予特别鼓励。

实现步骤如下:

  1. 订阅 pull_request 事件,并关注 opened 动作。
  2. 在事件负载中,检查 author_association 字段。如果其值为 NONE,通常意味着这是贡献者的第一次PR。
  3. 获取安装访问令牌(方式同上)。
  4. 使用令牌在对应的Pull Request下发布一条评论。

核心代码逻辑如下:

@router.register(“pull_request”, action=“opened”)
async def welcome_new_contributor(event, gh, *args, **kwargs):
    “”“当有新的Pull Request被打开时,欢迎贡献者。”“”
    installation_id = event.data[“installation”][“id”]
    pr_author = event.data[“pull_request”][“user”][“login”]
    pr_number = event.data[“pull_request”][“number”]
    repo_name = event.data[“repository”][“full_name”]
    author_association = event.data[“pull_request”][“author_association”]

    # 获取安装访问令牌
    access_token = await get_installation_access_token(...) # 同上

    comment_url = f“/repos/{repo_name}/issues/{pr_number}/comments”

    if author_association == “NONE”:
        message = f“@{pr_author},感谢你的第一次贡献!我们非常欢迎。”
    else:
        message = f“@{pr_author},感谢你的贡献!”

    await gh.post(comment_url,
                 data={“body”: message},
                 oauth_token=access_token)

此外,你还可以扩展功能,例如自动为新PR添加“待审核”标签,或者当有人在Issue下评论时自动添加表情反应。这些都可以通过订阅相应事件并调用对应的GitHub API来实现。


总结 🎓

在本教程中,我们一起学习了如何从零开始构建一个GitHub机器人。

我们首先了解了GitHub机器人的概念和gidgethub库的优势。然后,我们通过命令行脚本熟悉了GitHub API的基本调用。接着,我们深入探讨了Webhook和GitHub应用的工作原理,并成功在Heroku上部署了Web服务、创建并配置了GitHub应用。

最后,我们编写了核心的机器人逻辑:

  • 当应用被安装时,自动创建感谢Issue。
  • 当有新贡献者提交Pull Request时,自动留言欢迎。

你现在已经掌握了构建一个自动化GitHub机器人的基本技能。你可以在此基础上,探索更多GitHub API和事件,打造出功能更强大的机器人,来优化你的工作流程或开源项目管理。

084:制作COVID-19图表 📊

在本节课中,我们将学习如何使用Python从GitHub下载COVID-19数据,解析CSV文件,筛选特定州的数据,并利用Matplotlib库绘制图表。这是一个高度互动的实践教程,我们将从命令行开始,逐步编写代码,并学习调试、测试和创建命令行工具。

概述

我们将通过一个实际项目来学习Python编程。项目目标是下载美国各州的COVID-19历史数据,筛选出特定州(如犹他州)的信息,并绘制阳性病例、住院人数等指标随时间变化的图表。在这个过程中,我们将涵盖Python环境设置、基本数据结构、函数定义、错误处理、代码测试以及创建命令行界面。


环境准备与命令行基础 💻

在开始编写代码之前,我们需要准备好Python环境,并熟悉命令行操作。这是高效使用Python和各种开发工具的基础。

检查Python安装

首先,确保你的系统已安装Python 3。打开终端(Mac/Linux)或命令提示符(Windows),输入以下命令进行检查:

  • Mac/Linux: python3 --version
  • Windows: python --version

如果命令返回了Python 3.x的版本号,说明安装成功。如果提示“命令未找到”,你需要从python.org下载并安装Python。

对于Windows用户的特别提示:
在安装Python时,请务必勾选“Add Python to PATH”选项。如果没有勾选,你将无法从命令行直接运行python命令。解决方法是运行安装程序并选择“修复”选项,在过程中勾选该选项。

为什么使用命令行?

许多Python工具(如包管理器pip、测试运行器、代码覆盖工具等)都通过命令行运行。掌握命令行操作能让你更深入地理解工具的工作原理,并更高效地进行开发。

创建虚拟环境

虚拟环境可以将项目的依赖包隔离开来,避免不同项目之间的包版本冲突。以下是创建和激活虚拟环境的方法。

在Mac/Linux上:

# 创建名为‘env‘的虚拟环境
python3 -m venv env
# 激活虚拟环境
source env/bin/activate

在Windows上:

# 创建名为‘env‘的虚拟环境
python -m venv env
# 激活虚拟环境
env\Scripts\activate

激活后,你的命令行提示符通常会显示环境名称(如(env))。现在,所有通过pip安装的包都将只存在于这个环境中。

启动IDLE编辑器

我们将使用Python自带的IDLE编辑器,因为它集成了REPL(交互式解释器),便于快速测试代码片段。请从已激活的虚拟环境中启动IDLE,以确保能访问环境中安装的第三方库。

  • Mac/Linux: python3 -m idlelib.idle
  • Windows: python -m idlelib.idle

IDLE启动后,你会看到一个带有>>>提示符的窗口,这就是REPL。你可以在这里输入单行代码并立即看到结果。


获取与处理数据 📥

上一节我们准备好了环境,本节中我们来看看如何获取数据并进行初步处理。我们将从一个公开的GitHub仓库下载CSV格式的COVID-19数据。

下载数据文件

我们将使用Python标准库中的urllib.request模块来下载数据。在IDLE的REPL中,输入以下代码:

import urllib.request as req

# 数据文件的URL
url = ‘https://raw.githubusercontent.com/nytimes/covid-19-data/master/us-states.csv‘

# 打开URL并读取数据
with req.urlopen(url) as f:
    data = f.read()

print(f‘下载的数据大小:{len(data)} 字节‘)

这段代码会将远程CSV文件的内容以二进制形式下载到变量data中。

将数据保存到本地文件

为了方便后续处理,我们将下载的二进制数据保存到本地的一个CSV文件中。这里我们使用with语句来管理文件,它可以确保文件在使用后被正确关闭。

# 将数据写入本地文件
with open(‘covid.csv‘, ‘wb‘) as f_out:
    f_out.write(data)
print(‘数据已保存到 covid.csv‘)

重构代码:创建可重用的函数

将直接在REPL中执行的代码重构为函数,可以提高代码的复用性和可读性。我们创建一个fetch_url函数。

def fetch_url(url, fname):
    “““
    将一个URL的内容下载并保存到本地文件。
    参数:
        url: 要下载的URL地址
        fname: 要保存的本地文件名
    “““
    import urllib.request as req
    with req.urlopen(url) as f:
        data = f.read()
    with open(fname, ‘wb‘) as f_out:
        f_out.write(data)
    print(f‘已将 {url} 的内容保存到 {fname}‘)

在IDLE中,你可以通过运行 -> 运行模块来加载这个函数,然后在REPL中调用它:fetch_url(url, ‘test.csv‘)


解析CSV数据与数据结构 📊

现在我们已经有了本地的CSV文件,本节我们将学习如何读取它,并使用Python的列表和字典这两种核心数据结构来组织和处理数据。

读取CSV文件

我们将编写一个read_csv函数来读取文件。这里我们手动解析CSV,以深入理解字符串处理和数据结构,而在实际项目中,你可能会使用csv模块或pandas库。

def read_csv(fname):
    “““
    读取CSV文件,将每一行解析为一个字典。
    字典的键来自CSV文件的第一行(标题行)。
    返回一个字典的列表。
    “““
    with open(fname, encoding=‘utf-8‘) as f:
        rows = []
        for i, line in enumerate(f):
            # 去除行尾的换行符,并按逗号分割
            values = line.strip().split(‘,‘)
            if i == 0:
                # 第一行是标题行
                headers = values
            else:
                # 将标题和值组合成字典
                row_dict = dict(zip(headers, values))
                rows.append(row_dict)
    return rows

代码解释:

  • enumerate(f) 在遍历文件行时同时获取索引i和内容line
  • line.strip().split(‘,‘) 先去掉换行符,再按逗号分割成列表。
  • dict(zip(headers, values)) 是Python的一个常用技巧。zip将两个列表对应位置元素配对,dict将其转换为字典。

筛选特定州的数据

数据包含所有州的信息,我们需要筛选出感兴趣的那个州。我们创建一个filter_rows函数。

def filter_rows(rows, state):
    “““
    从数据行中筛选出指定州的数据。
    参数:
        rows: 字典的列表,每个字典代表一行数据
        state: 要筛选的州名缩写(如 ‘UT‘)
    返回:
        筛选后的字典列表
    “““
    result = []
    for row in rows:
        if row[‘state‘] == state:
            result.append(row)
    return result

在REPL中测试:utah_data = filter_rows(all_data, ‘UT‘),然后查看len(utah_data)utah_data[0]来确认。

数据转换与清洗

观察数据,你会发现数字(如‘positive‘)是以字符串形式存储的。为了后续计算和绘图,我们需要将它们转换为整数。同时,某些字段可能为空字符串,直接转换会出错。

def convert_row_types(row):
    “““
    尝试将字典中代表数字的字符串值转换为整数。
    如果转换失败(例如值为空),则保留原字符串。
    “““
    for key, value in row.items():
        try:
            row[key] = int(value)
        except ValueError:
            # 如果value不是有效的整数格式,则跳过,保留原值
            pass
    return row

# 应用转换到所有数据行
all_data = [convert_row_types(row) for row in all_data]

代码解释:

  • try...except ValueError 是异常处理机制。尝试执行int(value),如果引发ValueError异常(例如value‘AK‘或空字符串),则执行except块内的代码(这里pass表示什么也不做)。
  • 列表推导式 [convert_row_types(row) for row in all_data] 是一种简洁的构建新列表的方式。

数据排序与提取 📈

处理完数据后,我们需要按日期排序,并提取出需要绘图的特定数据序列(如每日阳性病例数)。

按日期排序

数据默认可能是按州排列的,为了按时间序列绘图,我们需要按日期排序。

def sort_by(rows, column_name):
    “““
    根据指定的列名对数据行进行排序。
    参数:
        rows: 字典的列表
        column_name: 作为排序依据的列名(如 ‘date‘)
    返回:
        排序后的新列表
    “““
    def get_key(row):
        return row[column_name]
    return sorted(rows, key=get_key)

# 对犹他州数据按日期排序
utah_data_sorted = sort_by(utah_data, ‘date‘)

sorted函数的key参数接受一个函数,该函数从每个元素中提取用于比较的值。

提取数值序列

为了绘图,我们需要从排序后的数据中提取出数值序列(列表)。

def get_values(rows, column_name):
    “““
    从数据行中提取指定列的值,组成一个列表。
    参数:
        rows: 字典的列表
        column_name: 要提取的列名
    返回:
        值列表
    “““
    values = []
    for row in rows:
        values.append(row[column_name])
    return values

# 提取犹他州的阳性病例数和死亡数
positive_cases = get_values(utah_data_sorted, ‘positive‘)
deaths = get_values(utah_data_sorted, ‘deaths‘)

使用Matplotlib绘制图表 🎨

数据准备就绪,本节我们将使用Matplotlib库来创建图表,直观展示数据变化趋势。

安装Matplotlib

Matplotlib不是Python标准库的一部分,需要使用pip安装。在已激活虚拟环境的命令行中运行:

pip install matplotlib

绘制简单折线图

回到IDLE,导入Matplotlib并绘制阳性病例数的折线图。

import matplotlib.pyplot as plt

# 创建图形和坐标轴
fig, ax = plt.subplots()
# 绘制阳性病例曲线, ‘b-‘ 表示蓝色实线
ax.plot(positive_cases, ‘b-‘, label=‘Positive Cases‘)
# 添加图例
ax.legend()
# 添加标题和坐标轴标签
ax.set_title(‘COVID-19 Positive Cases in Utah‘)
ax.set_xlabel(‘Day‘)
ax.set_ylabel(‘Number of Cases‘)
# 显示图表
plt.show()

运行后,会弹出一个窗口显示图表。你可以尝试在同一张图上叠加绘制死亡人数曲线。

创建绘图函数

我们将绘图步骤封装成一个函数,便于为不同州生成图表。

def plot_state(state_abbr, csv_fname, output_fname):
    “““
    为指定州生成COVID-19数据图表并保存。
    参数:
        state_abbr: 州名缩写 (如 ‘UT‘)
        csv_fname: 输入的CSV文件名
        output_fname: 输出的图片文件名 (如 ‘utah_plot.png‘)
    “““
    # 1. 读取并处理数据
    all_data = read_csv(csv_fname)
    all_data = [convert_row_types(row) for row in all_data]
    state_data = filter_rows(all_data, state_abbr)
    state_data_sorted = sort_by(state_data, ‘date‘)

    # 2. 提取需要绘制的数据
    positives = get_values(state_data_sorted, ‘positive‘)
    hospitalized = get_values(state_data_sorted, ‘hospitalized‘)
    deaths = get_values(state_data_sorted, ‘deaths‘)

    # 3. 绘图
    fig, ax = plt.subplots(figsize=(10, 6))
    ax.plot(positives, ‘b-‘, label=‘Positive‘, linewidth=2)
    ax.plot(hospitalized, ‘g--‘, label=‘Hospitalized‘, linewidth=2)
    ax.plot(deaths, ‘r:‘, label=‘Deaths‘, linewidth=2)

    ax.set_title(f‘COVID-19 Trends in {state_abbr}‘)
    ax.set_xlabel(‘Days‘)
    ax.set_ylabel(‘Count‘)
    ax.legend()
    ax.grid(True, linestyle=‘--‘, alpha=0.7)

    # 4. 保存图表
    plt.tight_layout()
    fig.savefig(output_fname)
    print(f‘图表已保存至 {output_fname}‘)
    plt.close(fig) # 关闭图形,释放内存

现在,你可以调用 plot_state(‘NY‘, ‘covid.csv‘, ‘new_york.png‘) 来为纽约州生成图表。


代码测试与质量保障 ✅

编写可工作的代码很重要,但编写可测试、健壮的代码更重要。本节我们将为代码添加单元测试,并检查测试的代码覆盖率。

编写单元测试

Python标准库提供了unittest模块来编写测试。我们创建一个单独的测试文件test_covid.py

import unittest
import covid # 导入我们编写的主模块

class TestCovid(unittest.TestCase):
    “““测试covid模块中的函数。“““

    def test_read_csv(self):
        “““测试CSV文件读取功能。“““
        result = covid.read_csv(‘covid.csv‘)
        # 断言结果不为空,并且长度大于0
        self.assertIsInstance(result, list)
        self.assertGreater(len(result), 0)
        # 断言第一行包含预期的键
        first_row = result[0]
        self.assertIn(‘date‘, first_row)
        self.assertIn(‘state‘, first_row)

    def test_filter_rows(self):
        “““测试数据筛选功能。“““
        sample_data = [
            {‘state‘: ‘UT‘, ‘cases‘: 100},
            {‘state‘: ‘NY‘, ‘cases‘: 200},
            {‘state‘: ‘UT‘, ‘cases‘: 150}
        ]
        filtered = covid.filter_rows(sample_data, ‘UT‘)
        self.assertEqual(len(filtered), 2)
        for row in filtered:
            self.assertEqual(row[‘state‘], ‘UT‘)

    def test_get_values(self):
        “““测试数据提取功能。“““
        sample_data = [
            {‘state‘: ‘UT‘, ‘cases‘: 100},
            {‘state‘: ‘UT‘, ‘cases‘: 150}
        ]
        values = covid.get_values(sample_data, ‘cases‘)
        self.assertEqual(values, [100, 150])

if __name__ == ‘__main__‘:
    unittest.main()

在命令行运行测试:python -m unittest test_covid.py。如果所有测试通过,你会看到OK

检查代码覆盖率

代码覆盖率工具可以告诉我们测试执行了源代码的哪些部分。我们使用coverage库。

  1. 安装: pip install coverage
  2. 运行测试并收集覆盖率数据: coverage run -m unittest test_covid.py
  3. 生成文本报告: coverage report
  4. 生成更详细的HTML报告: coverage html

打开生成的htmlcov/index.html文件,你可以直观地看到哪些代码行被测试覆盖了(绿色),哪些没有(红色)。这有助于你发现未被测试的代码分支。


创建命令行界面 🖥️

最后,我们将为程序添加一个命令行界面,这样用户可以直接通过终端命令来生成图表,而无需进入Python交互环境。

使用argparse解析参数

Python的argparse模块可以轻松地解析命令行参数。我们在主脚本covid.py底部添加以下代码:

import argparse
import sys

def main(cli_args):
    “““命令行主函数。“““
    parser = argparse.ArgumentParser(description=‘生成指定州的COVID-19数据图表。‘)
    parser.add_argument(‘-s‘, ‘--state‘, required=True,
                        help=‘州名缩写,例如 UT, NY, CA‘)
    parser.add_argument(‘-c‘, ‘--csv‘, default=‘covid.csv‘,
                        help=‘输入的CSV数据文件路径 (默认: covid.csv)‘)
    parser.add_argument(‘-o‘, ‘--output‘, required=True,
                        help=‘输出的图表文件名 (例如 utah_plot.png)‘)

    args = parser.parse_args(cli_args)

    # 调用绘图函数
    plot_state(args.state, args.csv, args.output)

if __name__ == ‘__main__‘:
    # sys.argv[1:] 去掉了脚本名本身
    main(sys.argv[1:])

通过命令行使用程序

现在,你可以在命令行中像使用其他工具一样使用你的程序了:

# 查看帮助
python covid.py -h

# 为犹他州生成图表
python covid.py -s UT -c covid.csv -o utah_trends.png

# 为加利福尼亚州生成图表
python covid.py --state CA --csv covid.csv --output california.png

总结 🎉

在本节课中,我们一起完成了一个完整的Python小项目:制作COVID-19数据图表。我们从头开始,学习了:

  1. 环境搭建:使用虚拟环境隔离项目依赖,从命令行操作Python。
  2. 数据处理:下载网络数据,读取并解析CSV文件,使用列表字典组织数据,进行筛选、排序和转换。
  3. 数据可视化:安装并使用第三方库Matplotlib绘制折线图。
  4. 代码质量:编写单元测试来验证函数逻辑,使用coverage工具检查测试覆盖率
  5. 产品化:利用argparse库为脚本创建友好的命令行界面

这个项目涵盖了Python编程的多个核心概念和实用技能。记住,学习编程的最佳方式就是动手实践。尝试修改代码,绘制其他州的数据,或者添加新的功能(比如计算7日平均线)。祝你学习愉快!

085:教程概述 🐍

在本教程中,我们将学习如何将代码从 Python 2 迁移到 Python 3。我们将首先了解两个版本之间的主要差异,然后学习如何清理和现代化 Python 2 代码,最后探讨不同的迁移策略和自动化工具。本教程旨在让初学者能够理解并实践迁移过程。


Python 2 迁移到 3:1:环境设置与工具介绍 🔧

为了学习迁移,我们需要一个同时包含 Python 2 和 Python 3 的开发环境。我们将使用 Conda 来创建两个独立的环境,并使用 Jupyter Lab 作为我们的实验工具。

创建 Python 环境

以下是使用 Conda 创建环境的步骤。如果你已经设置好环境,可以跳过此部分。

  1. 创建 Python 2.7 环境
    conda create -n py27 python=2.7
    

  1. 创建 Python 3.8 环境

    conda create -n py38 python=3.8
    
  2. 激活环境并安装 Jupyter Lab
    分别激活两个环境并安装 Jupyter Lab,以便进行交互式编程。

    conda activate py27
    conda install jupyterlab
    
    conda activate py38
    conda install jupyterlab
    

启动 Jupyter Lab

在激活的环境下,启动 Jupyter Lab 并指定端口(例如 8888 和 8889)以避免冲突。

jupyter lab --port 8888

在浏览器中打开 Jupyter Lab 后,你可以创建新的 Notebook,并选择对应的 Python 内核(2.7 或 3.8)进行编码。


Python 2 迁移到 3:2:Python 2 与 Python 3 的核心差异 🔄

上一节我们设置了开发环境,本节中我们来看看 Python 2 和 Python 3 之间有哪些最重要的区别。理解这些差异是成功迁移的基础。

1. print 函数

在 Python 2 中,print 是一个语句;而在 Python 3 中,它是一个函数。

  • Python 2:
    print "Hello World"
    
  • Python 3:
    print("Hello World")
    
    为了使 Python 2 代码兼容,可以在文件顶部添加:
    from __future__ import print_function
    

2. 整数除法

Python 2 中的整数除法会进行地板除,而 Python 3 会得到浮点数结果。

  • Python 2:
    1 / 2  # 结果是 0
    
  • Python 3:
    1 / 2  # 结果是 0.5
    1 // 2 # 地板除,结果是 0
    
    在 Python 2 中可以使用 from __future__ import division 来启用 Python 3 的除法行为。

3. Unicode 字符串

字符串处理是最大的变化之一。Python 3 默认使用 Unicode 字符串。

  • Python 2:
    type('hello')      # <type 'str'> (字节字符串)
    type(u'hello')     # <type 'unicode'>
    
  • Python 3:
    type('hello')      # <class 'str'> (Unicode 字符串)
    type(b'hello')     # <class 'bytes'>
    
    在 Python 2 中,可以使用 from __future__ import unicode_literals 使所有字符串字面量变为 Unicode。

4. rangexrange

在 Python 2 中,range() 返回列表,xrange() 返回迭代器。在 Python 3 中,range() 的行为类似于 Python 2 的 xrange()

  • Python 2:
    range(5)   # 返回列表 [0, 1, 2, 3, 4]
    xrange(5)  # 返回 xrange 对象(迭代器)
    
  • Python 3:
    range(5)   # 返回 range 对象(迭代器)
    list(range(5)) # 转换为列表 [0, 1, 2, 3, 4]
    

5. 字典视图

字典的 .keys(), .values(), .items() 方法在 Python 3 中返回“视图”,而不是列表。

  • Python 2:
    d = {'a': 1}
    keys = d.keys() # 返回列表 ['a']
    
  • Python 3:
    d = {'a': 1}
    keys = d.keys() # 返回 dict_keys(['a']) 视图
    

6. 异常处理

捕获异常并访问异常实例的语法发生了变化。

  • Python 2:
    try:
        1 / 0
    except ZeroDivisionError, e:
        print e
    
  • Python 3:
    try:
        1 / 0
    except ZeroDivisionError as e:
        print(e)
    

7. 迭代器与生成器

许多内置函数在 Python 3 中返回迭代器以提高效率。

  • Python 2:
    map(str, [1, 2]) # 返回列表 ['1', '2']
    zip([1,2], [3,4]) # 返回列表 [(1, 3), (2, 4)]
    
  • Python 3:
    map(str, [1, 2]) # 返回 map 对象(迭代器)
    zip([1,2], [3,4]) # 返回 zip 对象(迭代器)
    

Python 2 迁移到 3:3:清理和现代化 Python 2 代码 🧹

了解了核心差异后,本节我们来看看如何在不破坏 Python 2 兼容性的前提下,将旧代码现代化,为迁移到 Python 3 做好准备。

以下是一些让 Python 2 代码更现代、更接近 Python 3 风格的最佳实践:

停止使用过时的特性

  1. 使用新式类:始终从 object 继承。

    # 旧式类 (不要用)
    class OldClass:
        pass
    # 新式类
    class NewClass(object):
        pass
    
  2. 使用 as 语法捕获异常

    # 旧语法 (不要用)
    try:
        ...
    except ValueError, e:
        ...
    # 新语法
    try:
        ...
    except ValueError as e:
        ...
    
  3. 使用 // 进行显式地板除

    # 更明确
    result = 7 // 2
    
  4. 使用 next() 函数:而不是迭代器的 .next() 方法。

    it = iter([1, 2])
    # 旧方法
    value = it.next()
    # 新方法
    value = next(it)
    

使用 __future__ 导入

__future__ 模块允许你在当前版本中使用未来版本的功能。

  • print_function:使 print 成为函数。
  • division:启用 Python 3 的除法规则。
  • unicode_literals:使字符串字面量默认为 Unicode。
  • absolute_import:启用绝对导入,避免与本地模块命名冲突。

在文件顶部添加这些导入:

from __future__ import print_function, division, unicode_literals, absolute_import

处理文本和二进制数据

明确区分文本(str/unicode)和二进制数据(bytes)。

  • 使用 io.open() 替代内置的 open(),因为它能更好地处理编码。
  • 在需要字节的地方显式使用 b 前缀,在需要文本的地方使用 u 前缀(在 Python 2 中)。

Python 2 迁移到 3:4:迁移策略与自动化工具 🛠️

现在我们已经准备好了更现代的 Python 2 代码,本节我们来探讨将代码库迁移到 Python 3 的不同策略和自动化工具。

主要迁移策略

  1. 单代码库,双版本兼容:维护一份同时能在 Python 2 和 Python 3 上运行的代码。这通常需要借助兼容层库。
  2. 并行分支:为 Python 2 和 Python 3 维护两个独立的代码分支。长期维护成本高。
  3. 一次性迁移:将整个项目直接升级到 Python 3,放弃对 Python 2 的支持。对于新项目或小项目是可行的。

兼容层库

对于策略1,有两个主流的库可以帮助我们:

  • six:一个轻量级的库,提供了简单的函数来包装版本差异。你需要修改代码,使用 six 提供的函数(如 six.text_type, six.moves)。
  • future:一个更强大的工具,它不仅提供了类似 six 的兼容层,还附带了 futurizepasteurize 这样的自动化代码转换工具。

使用 futurize 进行自动化迁移

futurizefuture 包的一部分,它能自动将 Python 2 代码转换为兼容 Python 2 和 Python 3 的代码。

基本用法

futurize 通常分两个阶段运行:

  1. 阶段一:进行“安全的”更改,使代码更现代化,但仍完全兼容 Python 2。

    futurize --stage1 my_script.py
    

    这个阶段会做诸如添加 __future__ 导入、将 print 改为函数、将 .next() 改为 next() 等操作。

  2. 阶段二:进行更激进的更改,使代码在 Python 3 中能原生运行,同时通过 future 标准库在 Python 2 中保持兼容。

    futurize --stage2 my_script.py
    

    这个阶段会处理诸如将 unicode 改为 str,修改导入语句(如 import urlparse 改为 from future.moves.urllib import parse)等。

更安全的方式是使用 --write 或指定输出目录,而不是直接覆盖原文件:

futurize --stage1 -o stage1_output my_script.py
futurize --stage2 -o stage2_output stage1_output/my_script.py

使用 pasteurize 进行反向兼容

如果你是从 Python 3 开始,但需要支持 Python 2,可以使用 pasteurize 工具。

pasteurize my_py3_script.py

它会添加必要的导入和包装,使代码能在 Python 2 上运行。

迁移工作流程建议

  1. 确保测试覆盖:在开始迁移前,确保你有良好的测试套件。迁移过程中要频繁运行测试。
  2. 版本控制:使用 Git 等工具,在每次重大更改前提交代码。
  3. 逐步迁移:对于大项目,可以逐个模块或逐个文件进行迁移和测试。
  4. 最终清理:当项目完全迁移到 Python 3 并放弃 Python 2 支持后,可以移除 futuresix 的兼容代码,使用纯 Python 3 语法。

Python 2 迁移到 3:5:总结与后续步骤 🎯

本节课中我们一起学习了从 Python 2 迁移到 Python 3 的完整路径。

我们首先设置了一个包含 Python 2.7 和 Python 3.8 的双环境,用于对比和测试。接着,我们深入探讨了两个版本之间的核心差异,包括 print 函数、整数除法、Unicode 字符串、迭代器行为等关键变化。

然后,我们学习了如何清理和现代化现有的 Python 2 代码,例如使用新式类、更新异常语法、利用 __future__ 导入等,这为迁移打下了良好基础。最后,我们介绍了不同的迁移策略,并重点演示了如何使用 futurize自动化工具来辅助迁移过程。

核心建议

  1. 不要拖延:Python 2 已停止支持,迁移越早开始越好。
  2. 测试驱动:强大的自动化测试是迁移成功的保障。
  3. 利用工具:像 futurize 这样的工具可以处理大量机械性工作,但理解其背后的原理至关重要。
  4. 逐个击破:对于大型项目,采用增量式迁移策略。

迁移完成后,你将能享受到 Python 3 带来的更多现代特性、性能改进和持续的社区支持。祝你迁移顺利!

086:从入门到精通 🐍

概述

在本教程中,我们将系统地学习 Python 并发编程。我们将从计算机架构和操作系统的基础知识开始,逐步深入到多线程、多处理、线程同步等核心概念,并最终学习如何使用高级库来简化并发代码的编写。我们的目标是让你理解何时以及如何使用合适的并发工具,并能够编写正确、高效的并发程序。


第 1 章:基础概念 🧱

1.1:计算机架构与并发需求

上一节我们概述了本教程的内容,本节中我们来看看为什么需要并发编程。

计算机 CPU 的发展趋势是核心数量不断增加,而非单个核心的速度提升。为了充分利用现代计算机的多核能力,我们需要将工作分配到多个核心中并行执行,这就是并发编程的核心目标。

一个重要的概念是不同资源的访问时间差异巨大。例如,相对于一个 CPU 周期(假设为1秒):

  • 访问内存需要约 4 分钟
  • 访问固态硬盘需要 1.5 到 4 天
  • 访问机械硬盘需要 1 到 9 个月
  • 进行一次网络请求可能需要 5 到 11 年

因此,识别代码是 CPU 密集型(大量计算)还是 I/O 密集型(大量等待网络、磁盘)对于决定如何优化和并行化至关重要。

1.2:操作系统与进程模型

操作系统是硬件资源的守护者,它管理着 CPU、内存和 I/O 设备。用户程序不能直接访问硬件,必须通过操作系统。

当我们运行一个 Python 脚本时,操作系统会创建一个 进程。进程是一个包含代码、内存、文件描述符等资源的容器。你可以同时运行同一个程序的多个进程实例,每个都有独立的进程 ID。

并发并行 的区别:

  • 并发:同时处理多个任务,但在单核 CPU 上,这些任务是通过操作系统快速切换(时间片轮转)来实现的,并非真正同时运行。
  • 并行:在多核 CPU 上,两个或多个任务真正在同一时刻运行。

在单核时代,我们只能实现并发。在多核时代,我们可以实现真正的并行。

1.3:程序内部的并发:线程

理想情况下,我们希望在一个程序内部实现并发,而不是启动多个独立的进程。这就是 线程 的作用。

线程是进程内的执行单元。一个进程可以包含多个线程,它们共享进程的内存空间和资源,但拥有独立的执行流。

例如,顺序从三个网站获取数据(每个耗时2秒)需要至少6秒。如果使用三个线程并发执行,理想情况下总时间可以缩短到约2秒。


第 2 章:Python 多线程编程 🧵

2.1:线程的创建与基本操作

我们将使用 Python 标准库中的 threading 模块(而非底层的 _thread 模块)来创建和管理线程。

以下是创建和运行线程的基本步骤:

import threading
import time

def simple_worker():
    print(f"Hello from {threading.current_thread().name}")
    time.sleep(2)
    print(f"Goodbye from {threading.current_thread().name}")

# 1. 实例化线程(此时线程尚未运行)
t = threading.Thread(target=simple_worker, name=‘MyThread‘)

# 2. 启动线程
t.start()

# 3. 主线程可以继续执行其他任务
print("Main thread is doing something else...")

# 4. 等待线程完成
t.join()
print("Thread has finished.")

关键方法

  • Thread(target=func, args=(), kwargs={}, name=‘...‘): 创建线程。
  • start(): 启动线程,开始执行 target 函数。
  • join(timeout=None): 主线程阻塞,等待该线程结束。
  • threading.current_thread(): 获取当前正在执行的线程对象。
  • threading.get_ident(): 获取当前线程的唯一标识符。

2.2:线程间共享数据与竞争条件

线程共享其所属进程的全局变量。这虽然方便,但引入了 竞争条件 的风险。

竞争条件是指程序的结果依赖于线程执行指令的特定顺序,导致非确定性的、通常是错误的行为。

示例:不安全的计数器

import threading

counter = 0
ITERATIONS = 1000

def unsafe_increment():
    global counter
    for _ in range(ITERATIONS):
        # 这个操作不是原子的:读取 -> 加1 -> 写回
        counter += 1

threads = []
for _ in range(10):
    t = threading.Thread(target=unsafe_increment)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(f‘Expected: {10 * ITERATIONS}, Got: {counter}‘) # 结果通常小于 10000

2.3:线程同步:锁

为了解决竞争条件,我们需要 线程同步。最基础的同步原语是 (互斥锁)。

锁就像一个房间的钥匙。线程想进入“临界区”(访问共享资源)必须先获取锁。如果锁已被占用,线程必须等待。使用完毕后,线程释放锁,让其他线程可以获取。

import threading

counter = 0
ITERATIONS = 1000
lock = threading.Lock() # 创建一个共享的锁

def safe_increment():
    global counter
    for _ in range(ITERATIONS):
        with lock: # 使用上下文管理器自动获取和释放锁
            counter += 1
        # 非共享数据的操作应放在锁外,以提高性能

threads = []
for _ in range(10):
    t = threading.Thread(target=safe_increment)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(f‘Expected: {10 * ITERATIONS}, Got: {counter}‘) # 正确输出 10000

重要提示:始终使用 with lock: 语句来管理锁,这可以确保即使在发生异常时锁也能被正确释放,避免死锁。

2.4:死锁

死锁是指两个或多个线程互相等待对方持有的资源,导致所有线程都无法继续执行。

经典死锁场景(转账问题):
线程 A:锁住账户1,尝试锁住账户2。
线程 B:锁住账户2,尝试锁住账户1。
结果:两者互相等待,形成死锁。

避免死锁的策略

  1. 按固定顺序获取锁:所有线程都按相同顺序(如账户ID从小到大)请求锁。
  2. 使用超时lock.acquire(timeout=2),如果超时未获取到锁,则释放已持有的锁并重试或报错。
  3. 尽可能减少同步:使用更高级的并发模型。

第 3 章:高级线程模型与 GIL 🚧

3.1:生产者-消费者模型与线程安全队列

手动管理锁和同步非常容易出错。对于大量任务,更好的模式是 生产者-消费者模型,配合使用线程安全的 队列 (queue.Queue)。

  • 生产者线程:生成任务,并将其放入 任务队列
  • 消费者线程(线程池):从任务队列中获取任务并执行,将结果放入 结果队列
  • 队列 是线程安全的,内部实现了必要的锁,我们无需手动同步。

这种模式解决了两个问题:

  1. 任务过多:无需为每个任务创建一个线程,而是使用固定数量的工作线程处理任务队列。
  2. 同步复杂:队列操作是线程安全的,避免了直接的共享数据访问。
import threading
import queue
import requests
import time

def worker(task_queue, result_queue):
    """消费者线程的工作函数"""
    while True:
        try:
            # 从队列获取任务,block=False 表示队列空时立即抛出异常
            task = task_queue.get(block=False)
            # 执行任务(例如:网络请求)
            response = requests.get(task[‘url‘])
            result_queue.put({‘task‘: task, ‘result‘: response.text})
            # 通知队列该任务已完成
            task_queue.task_done()
        except queue.Empty:
            # 队列为空,没有更多任务,线程退出
            break

# 创建队列
task_queue = queue.Queue()
result_queue = queue.Queue()

# 填充任务
urls = [‘http://example.com/1‘, ‘http://example.com/2‘, ...]
for url in urls:
    task_queue.put({‘url‘: url})

# 创建工作线程池
num_workers = 10
threads = []
for _ in range(num_workers):
    t = threading.Thread(target=worker, args=(task_queue, result_queue))
    t.start()
    threads.append(t)

# 等待所有任务完成
task_queue.join()

# 处理结果
while not result_queue.empty():
    result = result_queue.get()
    print(result)

3.2:全局解释器锁 (GIL)

Python(特指 CPython 实现)有一个 全局解释器锁。它规定,在任何时刻,只有一个线程可以执行 Python 字节码。

这意味着什么?

  • 对于 I/O 密集型任务(如网络请求、文件读写),线程在等待 I/O 时会释放 GIL,其他线程可以运行,因此多线程能有效提升速度。
  • 对于 CPU 密集型任务(如数学计算、图像处理),线程会一直持有 GIL 进行计算,多线程无法实现真正的并行,甚至因为线程切换的开销而比单线程更慢。

如何绕过 GIL?

  1. 使用 多进程 (multiprocessing),每个进程有独立的 Python 解释器和内存空间,因此有各自的 GIL,可以实现真正的并行。
  2. 将计算密集型部分用 C/C++ 扩展实现,并在其中释放 GIL。
  3. 使用 concurrent.futures.ProcessPoolExecutormultiprocessing.Pool 等高级接口。

第 4 章:Python 多处理编程 ⚙️

4.1:多进程基础

multiprocessing 模块提供了类似于 threading 的 API,但创建的是进程而非线程。进程间内存不共享,通信需要通过队列、管道等机制。

import multiprocessing
import time

def cpu_bound_task(number):
    """一个模拟的CPU密集型任务"""
    result = sum(i * i for i in range(number))
    return result

if __name__ == ‘__main__‘: # 多进程编程必须保护主模块
    numbers = [1000000 + x for x in range(5)]

    # 顺序执行
    start = time.time()
    results = [cpu_bound_task(n) for n in numbers]
    print(f‘Sequential time: {time.time() - start:.2f}s‘)

    # 多进程执行
    start = time.time()
    with multiprocessing.Pool(processes=4) as pool:
        results_parallel = pool.map(cpu_bound_task, numbers)
    print(f‘Parallel time: {time.time() - start:.2f}s‘)

4.2:进程间通信 (IPC)

由于进程内存独立,共享数据必须使用特殊机制。

1. 队列 (multiprocessing.Queue)
类似于 queue.Queue,但是为多进程设计。

2. 管道 (multiprocessing.Pipe)
创建一个双向或单向的通信通道。

from multiprocessing import Process, Pipe

def worker(conn):
    conn.send([‘hello‘, ‘world‘]) # 发送数据
    conn.close()

parent_conn, child_conn = Pipe()
p = Process(target=worker, args=(child_conn,))
p.start()
print(parent_conn.recv()) # 接收数据: [‘hello‘, ‘world‘]
p.join()

3. 共享内存 (multiprocessing.Value, multiprocessing.Array)
在进程间共享基本数据类型或数组,但通常仍需配合锁使用。


第 5 章:高级并发库 🚀

5.1:concurrent.futures

这是 Python 标准库中的高级并发模块。它提供了 ThreadPoolExecutorProcessPoolExecutor,抽象了线程池/进程池的细节,并引入了 Future 对象来表示异步计算的结果。

核心优势

  • 接口统一:线程和进程的 API 几乎相同,切换容易。
  • 无需手动管理:无需直接创建、启动、同步线程/进程。
  • Future 模式:可以方便地查询任务状态、获取结果、添加回调。
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests

def fetch_price(exchange, symbol, date):
    # 模拟网络请求
    time.sleep(0.5)
    return {‘exchange‘: exchange, ‘price‘: 100} # 模拟返回

tasks = [(‘bitfinex‘, ‘BTC‘, ‘2023-01-01‘), (‘kraken‘, ‘BTC‘, ‘2023-01-01‘)]

# 使用线程池
with ThreadPoolExecutor(max_workers=5) as executor:
    # 提交任务,得到 Future 对象列表
    future_to_task = {executor.submit(fetch_price, *task): task for task in tasks}

    # 按完成顺序获取结果
    for future in as_completed(future_to_task):
        task = future_to_task[future]
        try:
            result = future.result() # 获取结果(会阻塞直到完成)
            print(f‘Task {task} completed with result: {result}‘)
        except Exception as exc:
            print(f‘Task {task} generated an exception: {exc}‘)

# 使用 map 方法(结果顺序与输入顺序一致)
with ThreadPoolExecutor(max_workers=5) as executor:
    results = executor.map(lambda t: fetch_price(*t), tasks)
    for result in results:
        print(result)

选择指南

  • I/O 密集型 -> ThreadPoolExecutor
  • CPU 密集型 -> ProcessPoolExecutor

5.2:paralil 库简介

paralil 是一个第三方库,旨在提供更简洁、灵活的高级并发接口,特别是在错误处理和参数传递方面进行了增强。

# 示例:使用 paralil 进行并行映射
from paralil import map as parallel_map

def task(x):
    return x * x

inputs = list(range(10))
# 默认使用多线程执行器
results = parallel_map(task, inputs, executor=‘thread‘, max_workers=4)
print(list(results))

# 可以轻松切换到多进程
results_mp = parallel_map(task, inputs, executor=‘process‘, max_workers=4)
print(list(results_mp))

总结与决策路径 🧭

本节课中我们一起学习了 Python 并发编程的完整知识体系。以下是选择并发工具的决策路径,可作为实践指南:

  1. 是否需要并发? 你的任务是否真的可以从并行中受益(I/O 等待长、计算可拆分)?如果不是,顺序执行最简单。
  2. 任务类型是什么?
    • I/O 密集型(网络、磁盘):首选 多线程
    • CPU 密集型(计算):首选 多进程 以绕过 GIL。
  3. 选择实现层次(从高到低)
    • 首选高级库:尝试使用 concurrent.futures。它安全、简洁、功能强大,能满足大多数需求。
    • 中级模型:如果 concurrent.futures 的灵活性不足(如复杂的任务依赖),考虑使用 生产者-消费者模型 配合 queue.Queue(线程)或 multiprocessing.Queue(进程)。
    • 底层控制:只有在极少数需要精细控制的情况下,才直接使用 threadingmultiprocessing 模块,并务必小心处理同步和死锁问题。
  4. 始终牢记
    • 避免共享状态:尽可能设计无共享数据的并发模型(如使用队列)。
    • 理解 GIL:知道它的存在和影响,这是选择线程还是进程的关键。
    • 测试与度量:并发会引入复杂性,务必进行充分测试,并实际度量性能提升是否达到预期。

并发编程是一把双刃剑,用得好可以大幅提升程序性能,用得不好则会引入难以调试的 bug。希望本教程能帮助你建立起清晰的知识框架,在实践中做出明智的选择。

087:教程塞巴斯蒂安·维托夫斯基 - 程序员百科书

在本教程中,我们将学习如何优雅地配置 Python 开发环境,包括设置代码编辑器、管理不同的 Python 版本和 Python 依赖项。教程结束时,你将能够自信地编写 Python 项目。

概述

我们将从设置代码编辑器开始,然后讨论如何管理 Python 版本和依赖项。接着,我们会进行一个简短的练习,并探讨项目结构、代码风格、REPL 的使用、测试和文档的编写。最后,我们将整合所有知识,构建一个小型待办事项应用程序,并使用 Docker 进行部署。


准备工作

要跟随本教程,你需要安装以下工具:

  • Visual Studio Code 编辑器。
  • Docker。
  • Python 3.6 或更高版本。

你还需要了解 Python 的基本语法。


现代 Python 开发者工具包:1:设置代码编辑器

上一节我们介绍了本教程的概览和准备工作,本节中我们来看看如何设置你的代码编辑器。代码编辑器是每个程序员的基本工具,我们将重点介绍 Visual Studio Code (VS Code),因为它非常流行且对初学者友好。

安装 VS Code 和 Python 扩展

首先,访问 VS Code 官网下载并安装编辑器。安装完成后,打开 VS Code,你需要做的第一件事是安装 Python 扩展,因为 VS Code 默认不包含任何编程语言的扩展。

以下是安装 Python 扩展的步骤:

  1. 打开 VS Code。
  2. 点击左侧活动栏的扩展图标(或按 Ctrl+Shift+X)。
  3. 在搜索框中输入 “Python”。
  4. 找到由 Microsoft 发布的 “Python” 扩展,点击 “安装”。

安装 Python 扩展后,你将获得代码补全、语法高亮、代码导航、代码检查和格式化等工具。

配置 Linter(代码检查工具)

打开一个 Python 文件时,VS Code 可能会提示你安装一个 linter(如 pylint)。本教程推荐使用 flake8

以下是配置 flake8 的步骤:

  1. 打开命令面板(Ctrl+Shift+P)。
  2. 输入并选择 “Python: Select Linter”。
  3. 从列表中选择 “flake8”。
  4. VS Code 会提示你安装 flake8,点击 “安装”。

注意:如果系统没有安装 pip,你需要先安装 Python 和 pip,或者使用后续介绍的 pyenv 工具。

code 命令添加到终端

为了方便地从终端打开文件或文件夹到 VS Code,可以将 code 命令添加到系统路径。

操作步骤如下:

  1. 打开命令面板(Ctrl+Shift+P)。
  2. 搜索 “Shell Command: Install ‘code’ command in PATH”。
  3. 运行该命令,并根据提示输入密码。

完成后,你就可以在终端使用 code <文件或文件夹名> 命令在 VS Code 中打开它们了。

常用快捷键

掌握快捷键可以提升编码效率。以下是 VS Code 中一些常用的快捷键:

  • 命令面板Ctrl+Shift+P - 显示所有可用命令的列表。
  • 转到文件Ctrl+P - 快速打开当前项目中的任何文件。
  • 转到符号Ctrl+Shift+O - 跳转到当前文件中的函数、方法或变量。
  • 触发建议Ctrl+SpaceAlt+Esc - 显示代码自动补全。
  • 转到定义F12Ctrl+单击 - 跳转到函数或变量的定义处。
  • 查看引用Shift+F12 - 显示某个符号在何处被使用。

你可以在 VS Code 的键盘快捷键设置(Ctrl+K Ctrl+S)中查看或自定义所有快捷键。


现代 Python 开发者工具包:2:使用 VS Code 运行和调试代码

上一节我们介绍了如何安装和配置 VS Code,本节中我们来看看如何使用它来运行和调试 Python 代码。

运行 Python 脚本

对于独立的 Python 脚本,你有多种运行方式:

  • 在终端中运行整个文件
    1. 打开命令面板(Ctrl+Shift+P)。
    2. 搜索并选择 “Python: Run Python File in Terminal”。
      或者,直接点击编辑器右上角的绿色运行按钮。

  • 在 Python 终端中运行选中代码
    1. 在编辑器中选择一段代码。
    2. 右键点击并选择 “Run Selection/Line in Python Terminal”。
      或者,选中代码后按 Shift+Enter

运行 Web 应用程序(以 Flask 为例)

对于像 Flask 这样的 Web 应用程序,除了从终端启动,还可以使用 VS Code 的调试功能。

以下是配置 Flask 调试的步骤:

  1. 点击左侧活动栏的 “运行和调试” 图标(或按 Ctrl+Shift+D)。
  2. 点击 “创建一个 launch.json 文件”。
  3. 从环境列表中选择 “Flask”。
  4. VS Code 会生成一个 launch.json 配置文件。确保其中的 "program" 路径指向你的应用启动文件(例如 app.py)。
  5. 点击绿色的开始调试按钮(或按 F5)启动 Flask 服务器。

优势:以调试模式启动服务器允许你设置断点并进行交互式调试。

使用调试器

在调试模式下,你可以:

  1. 设置断点:点击编辑器行号左侧的空白区域,会出现一个红点。
  2. 检查变量:当代码执行到断点时,左侧的 “变量” 面板会显示当前作用域内的所有变量及其值。
  3. 控制执行:使用调试工具栏(继续、单步跳过、单步进入、单步跳出、重启、停止)控制代码执行。
  4. 执行代码:在底部的 “调试控制台” 中可以执行任意的 Python 代码。

运行测试

VS Code 与测试框架(如 pytest)集成良好。

以下是启用测试界面的步骤:

  1. 打开命令面板(Ctrl+Shift+P)。
  2. 运行 “Python: Configure Tests”。
  3. 选择测试框架(例如 pytest)。
  4. 选择测试目录(例如项目根目录)。
  5. 安装提示安装 pytest(如果尚未安装)。

启用后,侧边栏会出现一个烧杯图标。点击它可以查看、运行和调试所有测试。

使用代码片段

代码片段可以快速生成常用代码结构。Python 扩展自带了一些片段。

使用方法:

  1. 在 Python 文件中开始输入(例如 def)。
  2. 自动补全建议中会出现代码片段,按 Tab 键插入。
  3. 插入后,按 Tab 键可以在片段中的不同位置间跳转。

你还可以创建自定义片段:

  1. 打开命令面板,选择 “Preferences: Configure User Snippets”。
  2. 选择 “python.json” 来为 Python 文件定义片段。
  3. 按照文件中的示例格式添加你自己的片段。

定义任务

如果你的项目使用 Makefile 或类似的任务运行器,VS Code 可以检测并运行其中的任务。

操作步骤:

  1. 打开命令面板(Ctrl+Shift+P)。
  2. 运行 “Tasks: Run Task”。
  3. 选择你想要运行的任务。

你也可以在 .vscode/tasks.json 文件中自定义任务。


现代 Python 开发者工具包:3:推荐的 VS Code 扩展

上一节我们探讨了 VS Code 的核心功能,本节中我们来看看一些能进一步提升开发体验的扩展插件。

以下是一些非常实用的 VS Code 扩展:

  • Python:编写 Python 代码的基础扩展,必装。
  • IntelliCode:提供 AI 辅助的智能代码补全建议。
  • Emmet:(已内置)快速编写 HTML/CSS 代码。
  • AutoDocstring:快速为函数生成文档字符串骨架。
  • Bookmarks:在代码中标记位置,便于快速跳转。
  • Error Lens:在出错代码行的行内直接显示错误信息,更直观。
  • GitLens:增强 Git 功能,如显示代码作者、提交历史等。
  • Jumpy / MetaGo:通过快捷键快速将光标跳转到屏幕上的任意位置。
  • Paste and Indent:粘贴代码时自动进行正确的缩进。
  • Project Manager:方便地在多个项目之间切换。
  • Settings Sync:在不同设备间同步 VS Code 的设置。
  • Todo Highlight:高亮代码注释中的 TODOFIXME 等标记。
  • Spell Right:检查代码注释和字符串中的拼写错误。

其他提示与技巧

  • Docker 扩展:如果你使用 Docker,可以安装 Docker 扩展来管理容器和镜像。
  • Live Share:与同事实时共享编辑会话,进行结对编程。
  • 重启 VS Code:使用命令 “Developer: Reload Window” 快速重启编辑器,无需完全关闭。
  • 更改自动补全引擎:如果 “转到定义” 等功能失效,可以尝试在设置中将 python.jediEnabledtrue(使用 Jedi)和 false(使用 Microsoft Python Language Server)之间切换。
  • 在任何文件设置断点:如果想在 HTML 模板等非 Python 文件中设置断点,可以修改设置 "debug.allowBreakpointsEverywhere": true

现代 Python 开发者工具包:4:管理 Python 版本和依赖

上一节我们配置好了编辑器,本节中我们来看看如何在你的计算机上管理不同的 Python 版本以及项目的依赖包。

使用 Pyenv 管理 Python 版本

系统自带的 Python 版本可能过时,且直接更新系统 Python 可能破坏其他软件。pyenv 可以让你轻松安装和切换多个 Python 版本。

安装 Pyenv(以 macOS/Linux 为例):
运行安装脚本(或使用 Homebrew):

curl https://pyenv.run | bash

按照提示将 pyenv 初始化命令添加到你的 shell 配置文件(如 ~/.bashrc~/.zshrc)中,然后重启终端。

使用 Pyenv

  • 查看可安装版本:pyenv install -l
  • 安装特定版本:pyenv install 3.8.2
  • 查看已安装版本:pyenv versions
  • 设置全局版本:pyenv global 3.8.2
  • 为当前目录设置本地版本:pyenv local 3.7.6 (会创建 .python-version 文件)
  • 为当前 shell 会话设置版本:pyenv shell 3.6.10

使用虚拟环境隔离项目依赖

不同项目可能需要同一包的不同版本。虚拟环境可以为每个项目创建独立的 Python 环境,避免依赖冲突。

使用内置 venv 模块

# 创建虚拟环境
python -m venv myenv

# 激活虚拟环境 (Linux/macOS)
source myenv/bin/activate
# 激活虚拟环境 (Windows)
myenv\Scripts\activate

# 在虚拟环境中安装包
pip install flask

# 停用虚拟环境
deactivate

使用 pyenv-virtualenv 插件(如果你用 pyenv):

# 创建虚拟环境(基于特定Python版本)
pyenv virtualenv 3.8.2 myproject-env

# 激活/停用虚拟环境
pyenv activate myproject-env
pyenv deactivate

使用 Pipx 安装全局工具

有些工具(如代码格式化工具 black、linter flake8)你希望在所有项目中都能使用,但又不应该全局安装(以免污染环境)。pipx 可以为每个工具创建独立的虚拟环境,同时将其命令行暴露为全局可用。

安装和使用 Pipx

# 安装 pipx (macOS 可使用 Homebrew)
pip install pipx
pipx ensurepath

# 使用 pipx 安装工具
pipx install black
pipx install flake8

# 列出已安装的工具
pipx list

# 在临时环境中运行一次某个工具而不安装
pipx run flake8 --version

配置 VS Code 使用 Pipx 安装的 Flake8

  1. 获取 flake8 二进制路径:pipx listpipx run which flake8
  2. 在 VS Code 设置中搜索 “Flake8 Path”。
  3. 将路径设置为从 pipx 获取的完整路径(例如 /Users/username/.local/pipx/venvs/flake8/bin/flake8)。

现代 Python 开发者工具包:5:项目结构与代码风格

上一节我们解决了环境和依赖管理的问题,本节中我们来看看如何组织 Python 项目结构以及保持一致的代码风格。

使用 CookieCutter 生成项目骨架

手动创建项目结构容易出错。CookieCutter 是一个项目模板工具,可以根据预定义的模板快速生成项目脚手架。

安装和使用 CookieCutter

# 使用 pipx 全局安装
pipx install cookiecutter

# 使用一个模板(例如:用于Python包的模板)
cookiecutter https://github.com/audreyr/cookiecutter-pypackage.git

运行命令后,CookieCutter 会询问一系列问题(如项目名、作者、许可证等),然后根据你的回答生成包含标准文件(如 setup.pyREADME、测试目录、文档目录等)的项目结构。

使用 Black 自动格式化代码

关于代码风格的争论(如空格 vs 制表符)会浪费大量时间。Black 是一个“有主见”的代码格式化工具,它能自动将你的代码格式化为符合 PEP 8 风格的统一格式。

安装和使用 Black

# 使用 pipx 全局安装
pipx install black

# 格式化单个文件
black my_script.py

# 格式化整个目录
black my_project/

Black 的配置选项极少(主要是行长度),这迫使团队接受统一的风格,从而将代码审查的焦点放在逻辑而非格式上。

使用 Flake8 进行代码检查

Flake8 是一个流行的代码检查工具,它结合了 PyFlakes(检查逻辑错误)、pycodestyle(检查 PEP 8 风格违规)和 McCabe(检查代码复杂度)。它不会修改你的代码,但会给出警告和建议。

安装和使用 Flake8

# 使用 pipx 全局安装
pipx install flake8

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_74.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_76.png)

# 检查目录
flake8 my_project/

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_78.png)

# 如果需要通过 pipx 安装 flake8 插件
pipx inject flake8 flake8-docstrings

Flake8 拥有丰富的插件生态系统,可以添加更多检查规则(如文档字符串、推导式用法等)。

其他静态分析工具

  • Pylint:另一个非常严格且功能强大的 linter。
  • Bandit:专注于查找 Python 代码中的安全漏洞。
  • pydocstyle:检查文档字符串是否符合 PEP 257。
  • pre-commit:一个框架,用于将 blackflake8 等工具设置为 Git 提交钩子,在提交代码前自动运行。

现代 Python 开发者工具包:6:REPL、测试与文档

上一节我们讨论了项目结构和代码风格,本节中我们来看看如何交互式地测试代码、为代码编写测试以及生成项目文档。

使用增强的 REPL:IPython

Python 自带的交互式 shell(REPL)功能有限。IPython 提供了更强大的功能,是数据科学领域 Jupyter Notebook 的基础。

安装和使用 IPython

# 使用 pipx 全局安装
pipx install ipython

# 启动 IPython
ipython

IPython 的主要特性

  • Tab 自动补全:对变量、对象属性、模块名等进行智能补全。
  • 对象内省:在变量后加 ? 查看其文档,加 ?? 查看其源代码。
  • 魔法命令:以 %%% 开头的特殊命令,例如 %timeit 测量代码执行时间,%run 运行外部脚本。
  • 更好的历史记录和编辑功能

使用 Pytest 编写测试

pytest 是目前最流行的 Python 测试框架之一。它简单易用,功能强大。

基本用法

  1. 在项目虚拟环境中安装:pip install pytest
  2. 创建 tests 目录,并在其中创建以 test_ 开头的 .py 文件。
  3. 在文件中编写以 test_ 开头的函数。
  4. 在项目根目录运行 pytest

Pytest 高级特性

  • 夹具:使用 @pytest.fixture 装饰器创建可重用的测试数据或环境设置。
    import pytest
    
    @pytest.fixture
    def sample_user():
        return User(name="test", email="test@example.com")
    
    def test_user_creation(sample_user):
        assert sample_user.name == "test"
    
  • 参数化测试:使用 @pytest.mark.parametrize 用多组数据运行同一个测试。
    @pytest.mark.parametrize("a,b,expected", [(1,2,3), (4,5,9)])
    def test_addition(a, b, expected):
        assert a + b == expected
    
  • Mock/猴子补丁:使用 unittest.mockpytest-mock 来模拟外部依赖(如 API 调用、数据库连接)。

使用 Sphinx 生成文档

良好的文档对项目至关重要。Sphinx 是 Python 生态中最流行的文档生成工具,它可以从代码中的文档字符串自动生成 API 文档。

快速开始

# 在项目虚拟环境中安装
pip install sphinx

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_116.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_118.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_120.png)

# 初始化文档项目
sphinx-quickstart docs

按照提示回答问题,Sphinx 会生成一个包含 conf.py(配置文件)和 index.rst(主页)的文档结构。

编写文档

  • 使用 reStructuredText(.rst)格式在 source 目录下编写文档。
  • index.rst 中使用 toctree 指令组织文档结构。

自动生成 API 文档

  1. conf.py 文件的 extensions 列表中添加 'sphinx.ext.autodoc'
  2. 创建一个 .rst 文件(如 api.rst),并使用 automodule 指令:
    API 参考
    ========
    
    .. automodule:: mymodule
       :members:
    
  3. 运行 make html(在 docs 目录下)生成 HTML 文档。

阅读文档:你可以将 Sphinx 文档免费托管在 Read the Docs 上。


现代 Python 开发者工具包:7:实战:构建待办事项应用

上一节我们学习了测试和文档,本节中我们将整合前面学到的所有工具和概念,构建一个完整的 Flask 待办事项应用程序。

项目初始化

我们将使用一个预制的 CookieCutter 模板来生成项目骨架,专注于实现业务逻辑。

  1. 创建项目目录并进入
  2. 创建虚拟环境并激活
  3. 使用 CookieCutter 生成项目
    cookiecutter https://github.com/some-flask-template-url.git
    
    根据提示回答问题(选择 Flask、空骨架、使用 Bootstrap 等)。
  4. 安装依赖pip install -r requirements.txt

实现应用功能

生成的项目包含了一些骨架文件。我们需要实现以下核心功能:

  • 模型:在 models.py 中定义 Task 模型,包含 idbody(任务内容)、done(完成状态)字段。
  • API 层:在 api.py 中编写函数,用于处理任务的创建、读取、更新(标记完成)、删除。这些函数将操作数据库模型。
  • 视图层:在 views.py 中编写 Flask 视图函数,处理 HTTP 请求,调用 API 层的函数,并渲染模板或返回重定向。
  • 模板:使用 Bootstrap 的 HTML 模板(已由模板生成)来显示任务列表、添加新任务的表单以及完成/删除按钮。

编写测试

tests 目录下创建测试文件(如 test_api.pytest_views.py)。

  • 使用 pytestpytest-flask 插件。
  • 为每个 API 函数编写测试(测试任务创建、列表、完成、删除)。
  • 使用夹具来为测试创建临时的测试数据库(如 SQLite 内存数据库),避免污染开发数据库。

生成文档

  1. 在项目根目录运行 sphinx-quickstart docs 初始化文档。
  2. 修改 docs/source/conf.py,将项目路径添加到 sys.path,并启用 autodoc 扩展。
  3. docs/source/api.rst 中使用 automodule 指令来自动生成 api.pymodels.py 的 API 文档。
  4. 编写 usage.rst 介绍如何使用该应用。
  5. 运行 cd docs && make html 生成 HTML 文档。

代码风格与检查

  • 在项目虚拟环境中安装开发依赖:pip install black flake8
  • 配置 VS Code 使用项目虚拟环境中的 blackflake8
  • 运行 black . 格式化所有代码。
  • 运行 flake8 检查代码问题。


现代 Python 开发者工具包:8:使用 Docker 部署应用

上一节我们完成了一个功能完整的待办事项应用,本节中我们来看看如何使用 Docker 将应用容器化并部署。

创建 Dockerfile

在项目根目录创建一个名为 Dockerfile 的文件:

# 指定基础镜像和版本
FROM python:3.8-slim

# 设置工作目录
WORKDIR /app

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_171.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_173.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_175.png)

# 先复制依赖文件,利用Docker缓存层
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 再复制应用代码
COPY . .

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_177.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_179.png)

# 设置环境变量,让Flask服务监听所有接口
ENV FLASK_APP=run.py
ENV FLASK_RUN_HOST=0.0.0.0

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_181.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_183.png)

# 暴露端口
EXPOSE 5000

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_184.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_186.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_188.png)

# 启动命令
CMD ["flask", "run"]

构建和运行 Docker 镜像

在包含 Dockerfile 的目录下执行:

# 构建镜像,并指定标签
docker build -t my-todo-app:latest .

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_198.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2020/img/7eb1842d3c14a35b4d4915acfc4044ad_200.png)

# 运行容器,将容器的5000端口映射到主机的5000端口
docker run -p 5000:5000 my-todo-app:latest

现在,你可以在浏览器中访问 http://localhost:5000 来查看运行在 Docker 容器中的应用。

推送镜像到 Docker Hub

为了能在其他机器上运行,可以将镜像推送到 Docker Hub(类似于 GitHub 的镜像仓库)。

  1. 注册 Docker Hub 账号
  2. 登录docker login
  3. 标记镜像(将 yourusername 替换为你的 Docker Hub 用户名):
    docker tag my-todo-app:latest yourusername/my-todo-app:latest
    
  4. 推送镜像
    docker push yourusername/my-todo-app:latest
    

在其他地方运行你的应用

在任何安装了 Docker 的机器上(如云服务器),现在只需一条命令即可运行你的应用:

docker run -p 5000:5000 yourusername/my-todo-app:latest

Dockerfile 优化建议

  • 锁定基础镜像版本:使用 python:3.8-slim 而非 python:latest,确保构建的一致性。
  • 利用缓存:先复制 requirements.txt 并安装依赖,再复制代码。这样只有当依赖变更时才会重新执行耗时的 pip install
  • 使用 .dockerignore 文件:排除不需要复制到镜像中的文件(如 __pycache__.gitvenv 等),以减小镜像体积和加速构建。
  • 考虑多阶段构建:对于生产环境,可以使用多阶段构建来创建更小、更安全的最终镜像。


总结

在本教程中,我们一起学习了现代 Python 开发的全套工作流程:

  1. 配置开发环境:安装并设置了 VS Code 编辑器及其 Python 扩展。
  2. 管理 Python 和依赖:使用 pyenv 管理多版本 Python,使用虚拟环境和 pipx 隔离项目依赖和全局工具。
  3. 构建项目:使用 CookieCutter 生成标准项目结构,使用 BlackFlake8 保证代码风格和质量。
  4. 开发与调试:使用增强的 IPython REPL 进行探索,利用 VS Code 强大的运行和调试功能。
  5. 保证质量:使用 pytest 编写全面的测试,使用 Sphinx 生成专业的项目文档。
  6. 实战演练:构建了一个包含模型、API、视图、测试和文档的完整 Flask 待办事项应用。
  7. 部署上线:使用 Docker 将应用容器化,并推送到 Docker Hub,实现了简单可靠的部署。

掌握这些工具和最佳实践,将帮助你从一个会写 Python 脚本的人,成长为能够构建、维护和部署健壮 Python 项目的专业开发者。

088:使用 GeoPandas 🗺️

概述

在本教程中,我们将学习如何使用 Python 库 GeoPandas 进行地理空间公共政策分析。我们将通过一个具体案例,分析美国高等教育机构的地理分布,并探讨其与人口特征(如种族构成)之间的关系。教程将涵盖从数据获取、清理、空间分析到可视化的完整流程。


章节 1:工具与数据来源介绍 🛠️

上一节我们介绍了本教程的目标,本节中我们来看看我们将要使用的工具和数据来源。

我们将使用以下工具:

  • JupyterLab:作为交互式开发环境。
  • Pandas:用于基础数据分析。
  • GeoPandas:用于处理地理空间数据。
  • Geoplot:用于创建美观的地图可视化。
  • Census Data Downloader:一个命令行工具,用于自动化下载美国人口普查数据。

我们将使用以下数据来源:

  • IPEDS (综合高等教育数据系统):提供美国大学和学院的详细信息。
  • 美国社区调查 (ACS):提供详细的人口统计数据。
  • TIGER 形状文件:由美国人口普查局提供的官方地理边界文件。

章节 2:项目结构与数据准备 📁

上一节我们介绍了工具和数据,本节中我们来看看如何组织项目并准备数据。

项目结构

一个清晰的项目结构有助于保持工作流程的条理性。以下是推荐的结构:

项目根目录/
├── data/               # 所有数据文件
│   ├── external/       # 原始外部数据(如压缩文件)
│   ├── interim/        # 中间处理数据
│   ├── processed/      # 最终用于分析的数据
│   └── raw/            # 原始未处理数据
├── docs/               # 项目文档
├── notebooks/          # Jupyter 笔记本
├── reports/            # 生成的分析报告
│   └── figures/       # 报告中的图表
└── src/                # 源代码(Python脚本)

准备机构数据

首先,我们需要从 IPEDS 数据集中提取我们感兴趣的变量。

以下是处理机构数据的步骤:

  1. 导入必要的库:pandaspathlib
  2. 设置数据路径。
  3. 读取处理过的机构数据 CSV 文件。
  4. 选择我们感兴趣的列,例如机构名称、经纬度、州、总注册人数等。
  5. sector 列拆分为 control(公立/私立)和 level(两年制/四年制)两列。
  6. 删除原始的 sector 列。
  7. 将处理好的数据保存为新的 CSV 文件,供后续分析使用。

核心代码示例:拆分列

# 拆分 sector 列
split_series = institutions_df['sector'].str.split(', ', expand=True)
institutions_df['control'] = split_series[0]
institutions_df['level'] = split_series[1]
# 删除原列
institutions_df = institutions_df.drop(columns=['sector'])

准备县级人口普查数据

接下来,我们使用 Census Data Downloader 获取县级人口统计数据,并进行处理。

以下是处理县级数据的步骤:

  1. 在终端中使用 Census Data Downloader 下载数据(例如,种族构成数据)。
  2. 在笔记本中读取下载的 CSV 文件。
  3. 使用列表推导式筛选出我们需要的列(例如,总人口、各种族人口数),排除误差列和注释列。
  4. 将处理好的数据保存为新的 CSV 文件。

核心代码示例:筛选列

# 筛选不需要‘moe’和‘annotation’的列
columns_of_interest = [col for col in county_df.columns
                       if ('moe' not in col) and ('annotation' not in col)]
working_county_df = county_df[columns_of_interest].copy()

章节 3:研究问题一:多数代表性不足群体县的机构分析 🏫

上一节我们准备好了数据,本节中我们开始回答第一个研究问题:在多数人口为历史上在高等教育中代表性不足的群体(如黑人、拉丁裔)的县中,有多少高等教育机构?这些机构有何特征?

分析步骤

  1. 导入与加载数据:导入 geopandas, geoplot, matplotlib。加载处理好的机构数据、县级人口数据和县级形状文件。
  2. 数据预处理
    • 将机构数据的州名转换为 FIPS 代码,以便与形状文件匹配。
    • 过滤出美国本土(连续48州)的县和机构数据。
  3. 识别“多数代表性不足”的县
    • 在县级数据中,计算黑人、拉丁裔、美国印第安人和阿拉斯加原住民、夏威夷原住民和其他太平洋岛民的人口总和占总人口的比例。
    • 创建一个新列 share_underrepresented 存储这个比例。
    • 设定一个阈值(例如50%),创建布尔掩码来标识哪些县属于“多数代表性不足”。
  4. 空间连接:使用 geopandas.sjoin 进行空间连接,找出位于这些“多数代表性不足”县内的所有高等教育机构点。
    • 操作参数设为 op=‘within’,检查点是否在多边形内部。
  5. 描述性统计分析:对位于这些县内的机构进行统计分析。
    • 比较这些机构与全国机构在 control(公立/私立)和 level(两年制/四年制)上的分布差异。

核心代码示例:空间连接

# 找出位于‘多数代表性不足’县内的机构
institutions_in_majority_counties = gpd.sjoin(
    geo_institutions,           # 点数据(机构)
    majority_counties_gdf,      # 多边形数据(县)
    how='inner',                # 内连接,只保留相交的
    op='within'                 # 空间操作:点 within 多边形
)

初步发现

分析可能显示,在代表性不足群体占多数的县中,营利性私立机构的比例高于全国平均水平,而两年制机构的比例也相对较高。这为教育公平性讨论提供了数据视角。


章节 4:研究问题二:识别“教育荒漠” 🏜️

上一节我们分析了特定县的机构,本节我们来看看是否存在完全没有高等教育机构的县,即“教育荒漠”。

分析步骤

  1. 空间连接(反向):再次使用空间连接,找出所有包含至少一个高等教育机构的县。
    • 这次,结果会列出每个县及其包含的机构信息,一个县可能对应多行。
  2. 获取有机构的县列表:从连接结果中提取唯一的县名称或ID列表。
  3. 找出“教育荒漠”县:通过列表筛选,从所有县中排除“有机构的县”,剩下的就是没有机构的县。
  4. 对比分析:比较“有机构县”和“无机构县”在人口特征(如代表性不足群体比例、中位年龄、中位家庭收入)上的差异。

核心公式/逻辑

所有县的集合 - 有机构的县的集合 = 无机构的县的集合

初步发现

分析可能表明,没有高等教育机构的县,其人口的中位年龄更高,中位家庭收入更低。这揭示了教育资源分布与地区社会经济状况之间的潜在关联。


章节 5:研究问题三:绘制“教育荒漠”地图 🗺️

上一节我们识别了“教育荒漠”,本节我们通过地图可视化,更直观地展示哪些区域远离高等教育资源。

分析步骤

  1. 创建缓冲区:围绕每个高等教育机构的地理点,创建一定距离的缓冲区(如10英里、25英里、50英里),代表机构的“服务范围”。
    • 关键:需要将数据的坐标参考系统(CRS)转换为以米为单位的投影(如 EPSG:3857),才能使用米制单位创建缓冲区。
    • 公式缓冲区距离(米) = 英里数 × 1609.34
  2. 计算未覆盖区域:使用 geopandas.overlay 函数,设置 how=‘difference’
    • 这相当于从美国县级地图中“减去”所有缓冲区覆盖的区域,得到未被任何机构缓冲区覆盖的区域。
  3. 可视化
    • 使用 geoplot 绘制美国县级底图。
    • 将计算出的“未覆盖区域”叠加在底图上,并用颜色映射(如中位收入、代表性不足群体比例)来显示这些区域的特征。
    • 可以分别绘制10、25、50英里缓冲区下的“教育荒漠”地图,以观察不同通勤容忍度下的情况。

核心代码示例:创建缓冲区和差异叠加

# 转换CRS以便以米为单位工作
geo_institutions_meters = geo_institutions.to_crs(epsg=3857)
# 创建50英里缓冲区(50 * 1609.34 米)
institutions_50mi = geo_institutions_meters.copy()
institutions_50mi['geometry'] = institutions_50mi.buffer(50 * 1609.34)
# 计算50英里缓冲区未覆盖的区域
uncovered_50mi = gpd.overlay(contiguous_us_gdf, institutions_50mi, how='difference')

地图解读

生成的地图可以清晰显示,在美国中部和西部部分地区,存在大片区域不在任何高等教育机构50英里半径范围内。结合这些区域的人口经济数据,可以进一步探讨教育可及性的公平问题。


总结 🎯

在本教程中,我们一起学习了使用 GeoPandas 进行地理空间公共政策分析的完整流程:

![我们可以看看如果——。

  1. 项目搭建与数据准备:我们建立了清晰的项目结构,并使用 Pandas 和命令行工具清洗、准备了机构数据和人口普查数据。
  2. 空间数据分析:我们利用 GeoPandas 的核心功能:
    • 通过空间连接(sjoin)将点(机构)与多边形(县)关联起来。
    • 通过创建缓冲区和叠加分析(overlay)来量化地理可达性。
  3. 问题解答与可视化:我们逐步回答了三个研究问题,从描述性统计到地图可视化,揭示了美国高等教育机构分布与人口特征之间的空间关系。
  4. 核心技能:你学会了如何操作几何图形、处理坐标参考系统(CRS)、执行空间查询以及制作信息丰富的地图。

通过这个案例,你将能够把类似的工作流程应用于其他公共政策领域,如医疗设施可及性、交通网络分析或环境正义研究。记住,清晰的数据管理和对地理空间概念的理解是成功的关键。

posted @ 2026-03-29 09:24  布客飞龙II  阅读(6)  评论(0)    收藏  举报