Jiahong 的个人博客

凡事预则立,不预则废


  • Home

  • Tags

  • Archives

  • Navigation

  • Search

Python——Pydantic库简单学习


整体说明

  • Pydantic 库可利用 Python 类型提示对数据进行验证、解析、转换以及管理,确保数据的完整性、规范性和一致性
  • Pydantic 库还方便数据在不同格式间的序列化与反序列化

安装

  • 使用以下命令安装 Pydantic:
    1
    pip install pydantic

基本使用

定义数据模型

  • 通过继承BaseModel来定义数据模型类,类中的字段使用类型注解来指定数据类型
    1
    2
    3
    4
    5
    6
    7
    from pydantic import BaseModel

    class User(BaseModel):
    id: int
    name: str
    age: int
    is_active: bool = True

数据验证和解析

  • 创建数据模型的实例时,Pydantic 会自动进行数据验证和解析
    • 如果数据符合模型定义,就可以正常创建实例;
    • 如果数据无效,会抛出ValidationError异常
  • 示例如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    data = {
    "id": 1,
    "name": "Alice",
    "age": 30
    }
    user = User(**data)
    print(user)

    invalid_data = {
    "id": "invalid", # id应该是int类型
    "age": "thirty" # age应该是int类型
    }
    try:
    user = User(**invalid_data)
    except ValidationError as e:
    print(e)

高级特性

  • 可选字段 :使用Optional类型来定义可选字段。例如:

    1
    2
    3
    4
    5
    6
    7
    from typing import Optional
    from pydantic import BaseModel

    class User(BaseModel):
    id: int
    name: str
    age: Optional[int]
  • 默认值 :可以在定义字段时直接设置默认值。例如:

    1
    2
    3
    4
    5
    6
    from pydantic import BaseModel

    class User(BaseModel):
    id: int
    name: str = "Jane Doe"
    age: int = 18
  • 允许多种数据类型 :通过类型提示允许字段接受多种数据类型。例如:

    1
    2
    3
    4
    5
    6
    from typing import Union
    from pydantic import BaseModel

    class User(BaseModel):
    id: Union[int, str]
    name: str
  • 枚举类型 :Pydantic支持枚举类型,用于限制字段的值只能是预定义的一组值之一。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    from enum import Enum
    from pydantic import BaseModel

    class Gender(str, Enum):
    MALE = "male"
    FEMALE = "female"

    class User(BaseModel):
    id: int
    name: str
    gender: Gender
  • 嵌套模型 :可以定义嵌套的模型来表示复杂的数据结构。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    from pydantic import BaseModel

    class Address(BaseModel):
    street: str
    city: str
    zip_code: str

    class User(BaseModel):
    id: int
    name: str
    address: Address
  • 自定义验证器 :使用validator装饰器可以在数据被解析后进行额外的验证。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    from pydantic import BaseModel, validator

    class User(BaseModel):
    age: int

    @validator('age')
    def check_age(cls, value):
    if value < 0:
    raise ValueError('Age must be a non - negative integer')
    return value

其他功能

  • 数据模型实例具有一些属性和方法,如dict()返回模型字段和值的字典,json()返回JSON字符串表示,copy()创建模型的副本等
  • 使用create_model方法可以动态创建模型(不常用):
    1
    2
    3
    from pydantic import create_model

    DynamicModel = create_model('DynamicModel', foo=(str, ...), bar=123)

Python——Python3相对Python2的异同点总结

Python 3 和 Python 2 存在许多显著差异,下面为你详细介绍主要的不同之处:


语法层面

  • 打印函数 :
    • Python 2 里,print 属于语句,使用时无需括号。例如:print "Hello, World!"
    • Python 3 中,print 变为函数,必须使用括号。例如:print("Hello, World!")
  • 除法运算 :
    • Python 2 中,整数相除结果为整数,小数部分会被截断。例如:3 / 2 结果是 1
    • Python 3 里,整数相除结果为浮点数。例如:3 / 2 结果是 1.5。若想得到整数结果,需使用 // 运算符,如 3 // 2 结果是 1
  • Unicode 编码 :
    • Python 2 对 Unicode 支持欠佳,字符串默认是 ASCII 编码,若要使用 Unicode 字符串,需在字符串前加 u,如 u"你好"
    • Python 3 中,字符串默认是 Unicode 编码,可直接处理多种语言文字,无需额外指定

异常处理

  • 异常捕获语法 :
    • Python 2 可使用 except Exception, e 来捕获异常
    • Python 3 要求使用 except Exception as e 这种语法

迭代器与生成器

  • range 函数 :
    • Python 2 有 range 和 xrange 两个函数。range 返回列表,xrange 返回迭代器对象
    • Python 3 里,range 函数等同于 Python 2 的 xrange,返回迭代器对象,节省内存
  • 字典方法 :
    • Python 2 中,dict.keys()、dict.values() 和 dict.items() 返回列表
    • Python 3 里,这些方法返回视图对象,是迭代器,并非列表

模块和库

  • 标准库变动 :
    • Python 3 中部分模块的名称和位置有所改变。例如,urllib 模块被拆分成多个子模块,像 urllib.request、urllib.parse 等
    • 一些 Python 2 的库在 Python 3 中需要重新安装或更新以确保兼容

其他Python3特性

  • Python3还提供了比如 f-string 等新特性(允许使用print(f'{a}{b}{c+d}')这种代码来组织字符串)

示例代码对比

  • Python 2 代码示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    # 打印语句
    print "Hello, Python 2!"

    # 除法运算
    result = 3 / 2
    print result

    # 异常处理
    try:
    num = 1 / 0
    except ZeroDivisionError, e:
    print "Error:", e

    # range 函数
    for i in range(5):
    print i

    # 字典方法
    my_dict = {'a': 1, 'b': 2}
    print my_dict.keys()
  • Python 3 代码示例 :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    # 打印函数
    print("Hello, Python 3!")

    # 除法运算
    result = 3 / 2
    print(result)

    # 异常处理
    try:
    num = 1 / 0
    except ZeroDivisionError as e:
    print("Error:", e)

    # range 函数
    for i in range(5):
    print(i)

    # 字典方法
    my_dict = {'a': 1, 'b': 2}
    print(my_dict.keys())
    • 在捕捉多个异常时可以使用
      1
      except (ZeroDivisionError, ABCError) as e:

附录:Python 3 可以指定参数类型

  • Python 3 可以指定参数类型(Python2不可以),不过这属于类型提示(Type Hints),它只是一种提示,并不会在运行时强制检查参数类型
  • 虽然类型提示不会在运行时进行强制检查,但它能提升代码的可读性和可维护性,同时还能让 IDE 提供更精准的代码提示和错误检查。若要在运行时进行类型检查,可以使用第三方库,例如 pydantic
  • 下面从不同方面介绍 Python 3 中指定参数类型的方式

函数参数类型提示

  • 在定义函数时,可以为参数和返回值指定类型,示例如下:

    1
    2
    def add_numbers(a: int, b: int) -> int:
    return a + b
    • 在上述代码里,a: int 和 b: int 表明 a 和 b 应当是整数类型,-> int 表示该函数返回值为整数类型

类属性类型提示

  • 在类中,也能够为属性指定类型,示例如下:

    1
    2
    3
    4
    5
    6
    7
    class Person:
    name: str
    age: int

    def __init__(self, name: str, age: int):
    self.name = name
    self.age = age
    • 在这个示例中,name: str 和 age: int 分别指定了 Person 类的 name 和 age 属性的类型

复杂类型提示

  • Python 还提供了 typing 模块,借助该模块可以进行更复杂的类型提示,例如列表、字典等。示例如下:

    1
    2
    3
    4
    5
    6
    7
    from typing import List, Dict

    def process_list(numbers: List[int]) -> int:
    return sum(numbers)

    def process_dict(data: Dict[str, int]) -> int:
    return sum(data.values())
    • 在上述代码中,List[int] 表示列表中的元素应当是整数类型,Dict[str, int] 表示字典的键为字符串类型,值为整数类型

可选类型和联合类型

  • 使用 typing 模块的 Optional 和 Union 可以表示可选类型和联合类型。示例如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    from typing import Optional, Union

    def get_length(s: Optional[str]) -> int:
    if s is None:
    return 0
    return len(s)
    print(get_length("Alice")) # OK
    print(get_length(None)) # OK
    print(get_length([1,2,3])) # 运行正常,但编译器会有提示

    def convert_to_number(value: Union[int, str]) -> int:
    if isinstance(value, str):
    return int(value)
    return value
    print(convert_to_number("112")) # OK
    print(convert_to_number(112)) # OK
    print(convert_to_number(112.0)) # 运行正常,但编译器会有提示

    # 5
    # 0
    # 3
    # 112
    # 112
    # 112.0
    • 在 get_length 函数里,Optional[str] 意味着参数 s 可以是字符串类型,也可以是 None
    • 在 convert_to_number 函数中,Union[int, str] 表示参数 value 可以是整数类型或者字符串类型

Python——正则表达式


将文本分行

  • text.split("\n")
    • 适用于普通文本,这种文本的特点是写到文件或者print输出后看不到\n字符串,自动转义成换行符,显示出来就是分行的
  • text.split("\\n")
    • 适用于被编码后的文本,该文本的特点是经过编码,所以写到文件或者输出时还能看到\n字符串,本质上需要二次转义才能显示为换行符号
  • text.split(r"\n")
    • 同上,等价与告诉别人不需要转义r"\n"本身代表着"\n"是原始文本,无需转义,体现在分
  • text.decode("unicode-escape").split("\n")
    • 效果同上
  • 第一种文本是一次转义就能按行显示的文本,比如一次print和write操作都会转义
  • 后三种文本需要两次转义才能按行显示,中间两种分割方式等价,最后一种是先转义再分割

正则表达式匹配完整字符串

  • 必须使用^和$, 否则部分匹配也会返回结果
    1
    2
    3
    4
    5
    import re
    def totally_match(pattern, string):
    if re.match(pattern, string) is not None:
    return True
    totally_match(r"^cat$", "cat")

正则表达式替换指定范式的字符

  • 要求:替换问题[数字1]:[数字2]为公式[数字1]。[数字2],即其中问题和:替换为公式和。,并保留[数字]部分
    • 比如:替换问题1:100为公式1。100
  • 实现方式:
    1
    2
    3
    4
    5
    import re
    text = "问题1:100 这是一个示例"
    # r'问题(\d+):(\d+)',(\d+) 是两个捕获组,分别用于捕获数字1和数字2,替换为:r'公式\1。\2',解释:\1 表示第一个捕获组的内容(数字1),\2 表示第二个捕获组的内容(数字2)
    result = re.sub(r'问题(\d+):(\d+)', r'公式\1。\2', text)
    print(result)

附录:高阶正则表达式

  • 整体来说,比较常用的正则表达式如下:
    语法 名称 功能
    (?=...) 正向前瞻 后面必须是指定的模式
    (?!...) 负向前瞻 后面不能是指定的模式
    (?<=...) 正向后瞻 前面必须是指定的模式
    (?<!...) 负向后瞻 前面不能是指定的模式
    (?:...) 非捕获组 组合但不捕获
    (?P<name>...) 命名捕获组 捕获并命名
    (?>...) 原子组 匹配后不回溯
    (?(...)...) 条件匹配 根据条件匹配不同模式
  • 其中(?...)是一种特殊的模式,其他字符表示如下:
    • =表示匹配(对应为正),相反的是!表示不匹配(对应为负)
    • <表示向后匹配,向后看(后瞻),相反的是不使用<,则表示向前看(前瞻)

正向前瞻(Positive Lookahead)

  • 语法 :(?=...)
  • 功能 :匹配某个位置,要求该位置后面必须是指定的模式
  • 示例 :
    • 正则表达式:\d(?=px)
    • 匹配:数字后面必须是 px,但只匹配数字
    • 输入:10px 20em
    • 匹配结果:1(在 10px 中),2(在 20em 中不匹配,因为后面是 em)

负向前瞻(Negative Lookahead)

  • 语法 :(?!...)
  • 功能 :匹配某个位置,要求该位置后面不能是指定的模式
  • 示例 :
    • 正则表达式:\d(?!px)
    • 匹配:数字后面不能是 px,但只匹配数字
    • 输入:10px 20em
    • 匹配结果:0(在 10px 中不匹配,因为后面是 px),2(在 20em 中)

正向后瞻(Positive Lookbehind)

  • 语法 :(?<=...)
  • 功能 :匹配某个位置,要求该位置前面必须是指定的模式
  • 示例 :
    • 正则表达式:(?<=\$)\d+
    • 匹配:前面是 $ 的数字
    • 输入:$100 €200
    • 匹配结果:100($100 中的数字),200 不匹配,因为前面是 €

负向后瞻(Negative Lookbehind)

  • 语法 :(?<!...)
  • 功能 :匹配某个位置,要求该位置前面不能是指定的模式
  • 示例 :
    • 正则表达式:(?<!\$)\d+
    • 匹配:前面不是 $ 的数字
    • 输入:$100 200
    • 匹配结果:200($100 中的 100 不匹配,因为前面是 $)

非捕获组(Non-capturing Group)

  • 语法 :(?:...)
  • 功能 :匹配部分符合预期的字符,但不捕获匹配的内容(即不会生成反向引用)
  • 示例 :
    • 正则表达式:(?:https?://)(\w+)
    • 匹配:http:// 或 https:// 后面的单词,但只捕获单词部分
    • 输入:http://example.com https://test.com
    • 匹配结果:example 和 test

命名捕获组(Named Capturing Group)

  • 语法 :(?P<name>...)
  • 功能 :将匹配的内容捕获到一个命名组中,可以通过名称引用
  • 示例 :
    • 正则表达式:(?P<year>\d{4})-(?P<month>\d{2})
    • 匹配:日期中的年份和月份,并分别命名为 year 和 month
    • 输入:2023-10
    • 匹配结果:year=2023,month=10

原子组(Atomic Group)

  • 语法 :(?>...)
  • 功能 :将组内的匹配作为一个原子操作,一旦匹配成功,就不会回溯
  • 示例 :
    • 正则表达式:(?>a+)ab
    • 匹配:一个或多个 a,后面必须是 ab,且不会回溯
    • 输入:aaab
    • 匹配结果:无匹配,因为(?>a+)先匹配了aaa,导致正则表达式的ab无法与b匹配,此时不再回溯
    • 注:作为对照,正则表达式 a+ab可以匹配aaab,因为a+先匹配了aaa,正则表达式的ab无法与b匹配,此时会回溯到a+先匹配了aa,正则表达式的ab与ab匹配

条件匹配(Conditional Matching)

  • 语法 :(?(condition)true-pattern|false-pattern)
  • 功能 :根据条件匹配不同的模式
  • 示例 :
    • 正则表达式:(?(?<=foo)bar|baz)
    • 匹配:如果前面是 foo,则匹配 bar,否则匹配 baz
    • 输入:foobar testbaz
    • 匹配结果:bar(在 foobar 中),baz(在 testbaz 中)。

NLP——LLDA的Gibbs采样实现


整体说明

  • 论文介绍基于 Python 的有标签隐式狄利克雷分布(Labeled Latent Dirichlet Allocation,Labeled-LDA,L-LDA,LLDA)的 Gibbs Sampling 实现
  • 论文同时还记录了项目实现中的一些思考和笔记,作为个人 Labeled LDA 实现过程中遇到的问题和论文中的思考,写得比较多,比较杂
  • 项目地址:Labeled-LDA-Python
  • 原始文章:Labeled LDA: A supervised topic model for credit attribution in multi-labeled corpora

为什么选择吉布斯采样

  • 详细问题:为什么使用Gibbs Sampling而不是变分推断实现?
  • 答:这个项目是参考 L-LDA 原始文章实现的,文章中只给出了 Gibbs 采样的实现,理论上也可以使用变分推断实现

收敛性的判断

  • 补充问题:收敛性的判断使用参数变化量还是使用困惑度?

收敛的多种判断方式

  • 采样一定次数后停止
    • 网上太多实现使用的是采样循环(对所有词的主题的完全采样一次算一个循环)一定次数后停止
    • 优点:
      • 每次循环结束无需任何额外的计算和判断,只需把迭代次数加一即可
      • 无需存储参数的中间信息
    • 缺点:
      • 难以确定采样循环次数,次数太多浪费时间,次数太少容易造成不收敛
  • 参数变化量(Gibbs 采样收敛性的判断使用这个感觉更靠谱)
    • 基本原理是: 采样收敛以后概率不变,模型参数也会收敛,两次迭代之间参数变化量会很小
    • 优点:
      • 可以通过参数变化来精确知道模型是否收敛
    • 缺点:
      • 每次采样循环结束后需要计算参数变化量
      • 需要存储之前的参数的中间结果结果(参数是所有概率矩阵,往往并不小)
  • 困惑度的变化量
    • 在采样的过程中,模型的困惑度一定是递减的(偶尔可能有微弱的增加,属于正常现象)
    • 当困惑度小到一定程度后困惑度应该趋近于收敛,几乎不变,如果困惑度不再有大的变化,我们认为模型收敛
    • 优点:
      • 无需存储参数,仅仅存储困惑度的值即可(是一个标量)
    • 缺点:
      • 每次评估困惑度时计算量大
      • 当前尚未看到有人使用这个指标作为语言模型收敛度的判断
  • 在我们的场景中,因为我们想知道每一轮的困惑度,所以就顺便把困惑度的变化量当做收敛性判断实现了
  • 实际上在训练时我们为了收敛保证,选择的是采样次数在一定范围内(不能太小,也不能太大),同时参数变化量变得很小的双重判断标准

我们的实现

  • 实现了困惑度评估收敛性的方法: 滑动窗口保留近 N 轮困惑度(每一轮针对所有词),模型困惑度都不变化,那么认为该模型收敛
    • 在不同的实际应用中收敛性的判断应该使用不同的方式,我们这里使用困惑度是因为反正都要计算困惑度的,为了充分利用困惑度的计算结果,顺便将其作为收敛性判断
  • 实际论文和工程实验中为了保险使用的是迭代次数的方式
  • 测试说明:经测试发现,模型的困惑度 10 次采样前后变化不大后,参数的确收敛了,而且模型的预测效果也收敛,继续采样 50次 循环对模型的效果无明显提升

增量更新训练数据

  • 补充问题:在向原始训练数据集(TDS)中添加新的训练数据(NewTDS)时,如何利用原始训练数据上一次的训练结果?
  • 我们实现了一种可以增量更新模型的方式:将上一次模型(TDS训练得到)已经训练好的参数作为新模型的初始参数,提升收敛速度
  • 由于 Gibbs 采样的收敛状态与初始状态无关,所以我们可以从数学理论上证明该方法的正确性
  • 测试说明:
    • 经测试发现,本方法可以很大程度提升新模型的收敛速度
    • 经测试发现,本方法训练的结果与随机初始化参数的训练结果,模型效果相同
      • 两个模型的困惑度收敛到近似相等
      • 实际场景中测试说明两个模型的精度也相同

推断(预测)

  • 补充问题:模型训练收敛后,对新来的文档,如何给出主题预测?
  • 参考文献: LDA数学八卦,L-LDA
  • 在训练阶段得到模型的主题-词矩阵 \(\beta\) 后,可以继续进行采样
  • 不用存储文档-主题 \(\theta\) 矩阵,采样时不需要这个参数,训练后这个参数无需存储
  • 流程如下:
    • 随机初始化, 对文档中的没个词随机赋一个主题
    • 重新扫描当前文档,按照吉布斯采样公式:
      $$
      \begin{align}
      P(z_{i}=k|\vec{z}_{\not{i}}, \vec{w}) &\propto E(\theta_{m,k}) E(\phi_{k,t}) \\
      &= \hat{\theta}_{m,k} \hat{\phi}_{k,t}
      \end{align}
      $$
      • \(\vec{z}_{\not{i}}\) 为除了 \(z_{i}\) 的所有当前主题 \(z_{i}\) 是词 \(w_{i}\) 的主题
      • \(\hat{\theta}_{m,k} \hat{\phi}_{k,t}\) 是样本均值,这里是用当前均值估计期望
      • 显然对每个词,重新采样它的主题(每次采样时实际上当前词的主题只与当前文档其他词的主题和主题-词矩阵相关, 这里推断和训练期间都一样)
      • 实际实现时, 我们并没有存储参数 \(\hat{\phi}_{k,t}\) 的值,而是存储一个主题-词[数量]矩阵,方便计算和采样,这种实现推断期间 \(\hat{\phi}_{k,t}\) 的值还会继续变化,实际上是更符合实际(精确)的做法,这种做法是使得当前推断和训练的采样方法一模一样(连参数 \(\hat{\phi}_{k,t}\) 的计算都一样,论文推荐的是在推断期间 \(\hat{\phi}_{k,t}\) 值不变,与训练步骤不同)
        • 这里也可以把 \(\hat{\phi}_{k,t}\) 的参数(整个矩阵)都存储下来,然后推断期间都不变,这种做法是论文中推荐的,这种做法采样速度快,不需要每次都计算一下当前的 \(\hat{\phi}_{k,t}\) 值,但是实际上并不精确(当然:当训练数据非常大时,这里近似于精确的,我们的应用场景中考虑到可能训练数据一开始并不多,所以确保精确,我们实现的是前面那种更精确的做法)
    • 重复扫描采样直至收敛
    • 统计文档中的主题分布,得到新文档的文档主题分布 \(\vec{\theta}_{new}\)

实际使用中主题数如何确定的?

  • 一般情况下可以使用困惑度,不同的主题数对应模型的困惑度不一样,k 从小到大,对应模型的困惑度应该是先减小后增加,选择困惑度最小的模型对应的主题数即可
  • 在我们论文实验中,由于主题数与标签数量一致,直接设定即可

验证集问题

  • 补充问题:实际在 SemiTagRec 实现的时候,只提到训练集和测试集,那网格搜索的时候验证集是什么数据呢?
  • 我们的算法中,如果考虑详细情况的话,网格搜索超参数其实需要每次都调一遍
  • 但是幸亏我们测试发现 Integrator 的超参数很容易调
  • 实际实验时,我们使用 80% 和 90% 样本作为训练集,然后只用随机采样 50 个左右的样本作为验证集基本就收敛了(随机采样多个都是这样,能在 0.91 到 0.09 周围得到最优值),而且后面调试几乎不变(基本上就在 0.89-0.93 和 0.11-0.07 之间且精度几乎没变化,所以我们实际上用所有测试样本测试通过得到了最优的值为0.91和0.09,这个值是取平均值得到的)
  • 最终方法 :在我们的算法中,首先经过多次训练和测试(实际上就是每次训练完,然后使用网格搜索法完成了 Integrator 的超参数设定,基本都是使用 0.91和0.09最好),然后接下来的模型迭代训练中我们没有再修改这个超参数了,所以也不用验证集了,这可以为我们之后的训练增加训练和测试数据,对我们来说是个好消息

训练集和测试集的划分

  • 为什么使用90%这么多的样本作为训练集?测试集使用10%足够了吗?
  • 实际上我们的算法中,我们一开始的分割方式是60%训练,20%验证,20%测试,这样训练的到的结果不理想(Labeled LDA的训练结果测试就非常差),分析原因其实是3000+ 的训练集太少了,对我们的训练模型Labeled LDA来说,远远不够
  • 由于可用的训练样本数量太少,为了保证训练质量,我们选择90%(5000个左右)用于训练后,10%(550个左右)用于测试,550个测试样本总数有 2000+个正确的标签,实际上够用了(多次测试证明,随机选取 300 个测试样本以后基本上增加测试样本模型的精度几乎没变化)

为什么不使用十折交叉验证法

  • 补充问题:既然分配给测试的数据太少,为了增加训练集的同时保证模型评估的精确性,是不是应该使用十折交叉验证法
  • 我们的模型使用的训练时间太长了,采样花的时间比较多,考虑到时间因素,没有使用十折交叉验证
  • PS:许多文章也都是这样直接划定测试集和训练集的

为什么使用多处理器?

  • 使用多处理器的初始想法(这是一个错误的想法)
    • 预测时文本的预测结果与初始值(当前文本每个词的主题初始分配)有很大关系,不同初始值会收敛到不同的结果,为了防止初始值不同带来的误差(错误,这里实际上是采样还没收敛才导致的),我们采用多次初始化并且多次收敛的方法,最终对结果求均值,得到最终的预测结果
  • 问题:吉布斯采样应该是能够收敛到目标分布的,为什么预测时不会收敛到相同目标分布
  • 回答:会收敛的,每次迭代次数不要太少,迭代一定次数后开始丢弃之前的一定数量的采样,去后面的采样平均值,会得到收敛的结果,核心是采样次数一定要够
  • 进一步实验证明:采样次数不足时多个不同初始值采样的结果取平均的确是有帮助的,但是采样次数非常多以后就不需要了,采样结果收敛后一定是到那个目标分布的!

多处理器依然存在的意义

  • 用于对相同文档同时采样,收敛后每个处理器返回自己的一个平稳分布的样本(可以为一个,可以为多个),然后所有样本构成最终平稳分布的代表样本
    • 实际上收敛后这些样本都是服从目标分布产生的
  • 实现上没有问题,但是这里浪费了很多时间采样到收敛的过程(多条不同的采样过程分别采样到收敛,很浪费时间)
  • 从单个处理器来看,不论怎样都需要采样到平稳分布的,在多处理器同时工作时,从平稳状态中采样平稳分布的样本需要的采样次数实际上是被均分到多个处理器上的,从这里来看不考虑内存占用等方面的问题,多处理器工作的确是能节约我们的整体时间的
  • 一般为了避免随机变量统计量(如期望等)估计的偏差,需要产生独立同分布的样本,我们这里就需要: 同时使用多条马尔可夫链可以得到独立同分布的样本,否则同一条链上的样本往往不是独立的,因为同一条链上的后一个样本由前一个样本通过某种特定的状态转移概率得到.
    • 实践中,在同一条马尔可夫链上每隔若干个样本才选取一个可以得到近似独立的样本
    • 如果仅仅是采样,不需要样本间相互独立,我们一般就直接使用一条链产生多个样本即可

为什么训练的时候不是采样多个样本来预测分布

  • 补充问题:为什么训练的时候不使用采样多个样本(每个样本代表当前所有文档的所有词的主题矩阵)?
  • 由于训练样本非常多,所以单个样本足以代表整体主题-词分布
    • 因为我们不评估每个文档的主题分布:由于对每个文档来说,不取多个采样值,无法代表文档本身的词分布
    • 而是关注每个主题的词分布:实际上对每个主题来说,词的数量已经非常多了,完全可以代表当前主题-词分布了
    • 经过测试证明的,也有论文支持

测试

  • 训练收敛后取后面 m 个样本作为训练结果计算主题-词分布与收敛后取最后一个样本得到的结果(主题-词分布矩阵)相同
  • 所以为了节约内存和减少计算量,我们只保留了最后一个样本,丢弃了前面的样本
  • 论文原文引用说明 Parameter estimation for text analysis,Gregor Heinrich

    To obtain the resulting model parameters from a Gibbs sampler, several approaches exist. One is to just use only one read out, another is to average a number of samples, and often it is desirable to leave an interval of L iteration between subsequent read-outs to obtain decorrelated states of the Markov chain. This interval is often called “thinning interval” or sampling lag.

  • 说明:上面这段话的是针对 LDA 的 Gibbs 采样方法而言的(虽然上面这段话没提到LDA)
  • 上面的引用说明选取LDA收敛后的训练样本选择有两种方式:
    • 选取一个作为样本(这里只有在LDA训练时可用这种方法,其他的采样模型还要视情况而定的)
      • 在LDA中,由于训练样本非常多,所以单个样本足以代表整体主题-词分布(由于LDA同时还关注着文档-主题分布,所以在LDA中私以为还是采样多个样本保险一些,特别是对于词数比较少的文档)
      • 但是,在我们的应用场景中(在Labeled LDA中),
        • 只关注每个主题的词分布:实际上对每个主题来说,词的数量已经非常多了,完全可以代表当前主题-词分布了,
        • 训练时我们不评估每个文档的主题分布
        • 预测时:由于对每个文档来说,不取多个采样值,无法代表文档本身的词分布(特别是当文挡中的词比较少时),所以后面的预测过程中对单个文本的预测问题我们需要采样多个收敛后的样本计算均值
    • 选取多个样本的平均值作为样本
      • 注意:选取多个样本时,为了得到马尔可夫模型不相关的状态,需要间隔L次迭代进行间隔采样
      • 一般来说,在Markov chain收敛后开始从1计数,Gibbs采样(这里不针对LDA)选取一次完整迭代后的结果作为平稳分布的样本即可
        • 也就是 \([(x_{1}^{t},x_{2}^{t},\cdots,x_{n}^{t}), (x_{1}^{t+1},x_{2}^{t+1},\cdots,x_{n}^{t+1}),\cdots, (x_{1}^{t+s},x_{2}^{t+s},\cdots,x_{n}^{t+s})]\)
          • 其中需要的平稳分布的样本数是(s+1)
          • 注意 \([(x_{1}^{t+1},x_{2}^{t},\cdots,x_{n}^{t}), (x_{1}^{t+1},x_{2}^{t+1},\cdots,x_{n}^{t})]\) 这些不完整迭代的结果都不能成为平稳分布的样本,因为这些样本之间相关度太高,不够独立,不能用来代表最终的平稳分布,容易造成局部偏差

论文中为什么选择Labeled LDA而不使用深度学习

  • 补充提问:为什么使用Labeled LDA模型,没有考虑过使用深度学习模型吗?
  • TLDR:主要原因是效果不好
  • 写论文需要可解释性,当时在大家的视角看,深度学习模型的解释性远远不如 Labeled LDA 模型的解释性
  • 本人使用深度学习模型测试:没拿到优于 Labeled LDA 模型的效果
    • 模型较简单,简单的对每个词 OneHot 编码,然后+固定分类类别数量为 1000,所以输出为 1000 维度
    • 模型是对 NNLM 的一种改进
    • 损失函数使用的是交叉熵
  • 深度学习在我们的场景中效果不好的原因可能包括以下方面:
    • 训练数据量不够,太稀疏
    • 没找到合适的神经网络模型,比如可以考虑使用一些权重共享的思想,降低由于数据太少引起的过拟合
    • 在未来的想法: 如果可以增加数据量,或者能够使用一些新的有效模型或者词嵌入的数据集,可以重新尝试使用神经网络模型

NLP——关于英文单词的处理总结


保留词根

  • 安装相关库:

    1
    pip install pattern
  • 导入和使用

    1
    2
    3
    4
    5
    from pattern.text.en import lemma
    lemma("describing")

    # output:
    # describe
    • 需要nltk中的几个语料库包, 如果没有以下包,导入时会报出zip文件相关的错,按装这几个语料库包直接使用nltk.download(“wordent”)等语句就行
      1
      "wordnet", "wordnet_ic", "sentiwordnet"

保留词干

  • 安装相关库:
    • 安装 nltk 即可
  • 导入和使用:
    1
    2
    3
    4
    5
    6
    7
    from nltk.stem.porter import PorterStemmer

    stemmer = PorterStemmer()
    stemmer.stem("describing")

    # output:
    # describ

Python——ProcessPoolExecutor和ThreadPoolExecutor

Python 中如何使用CPU的多个核


全局解释器锁(GIL)

  • 由于CPython解释器本身就不是线程安全的,所以需要一个全局解释器锁,以保证同一时刻仅有一个线程在执行Python的字节码
  • 由于GIL的存在,造成了Python多线程不是不能真正并行,尽管有多个CPU核心也不能全都用上
  • 由于标准库中执行所有阻塞型IO操作的函数,在等待操作系统返回结果时都会释放GIL,这意味着Python在这个层次上可以使用多线程,所以对于IO密集型任务来说,多线程是有作用的

ThreadPoolExecutor

  • 使用多线程,适用于IO密集型的任务
    1
    2



ProcessPoolExecutor

  • 使用多进程,突破GIL的限制,绕开GIL,成功使用多个CPU核
    1
    2


Docker——Docker访问GPU笔记


NVIDIA Docker 工具链

  • 若直接安装 docker 并启动 GPU 程序,会出现报错

  • 比如,下面使用 Megatron 官方教程的命令(会使用到 GPU):

    1
    sudo docker run --ipc=host --shm-size=512m --gpus 2 -it nvcr.io/nvidia/pytorch:24.02-py3
  • 执行上面的命令时出现下面的错误

    1
    docker: Error response from daemon: could not select device driver "" with capabilities: [[gpu]]
  • 出现上面问题的原因是,Docker 无法找到合适的 GPU 驱动程序

    • Docker 访问 GPU 需要专门为 Docker 做的工具链 NVIDIA Docker
    • 上述问题一般是:NVIDIA Docker 工具链未正确安装或配置导致的
  • NVIDIA Docker 工具链安装方式:

    • 第一步:确保 NVIDIA 驱动已经安装

      1
      nvidia-smi
      • 若未安装,则需要先安装驱动,Ubuntu系统可参考:Ubuntu——显卡驱动安装
    • 第二步:安装 NVIDIA Docker 工具链

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      # 添加NVIDIA的包仓库
      distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
      curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
      curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list

      # 安装nvidia-container-toolkit
      sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit

      # 重启Docker服务
      sudo systemctl restart docker

Docker——容器深入理解


整体说明

  • TLDR:容器的本质是封装了指定命令的进程

容器生命周期管理

  • 启动容器:
    • docker run [OPTIONS] IMAGE [COMMAND] [ARG...]:该命令用于从镜像创建并启动一个新的容器
      • [COMMAND] 是希望在容器启动时执行的程序或脚本,而 [ARG…] 就是这个程序或脚本运行所需的额外信息,比如 ls -l /
    • docker start [OPTIONS] CONTAINER [CONTAINER...]:该命令用于启动一个或多个已经停止的容器
    • 常用选项:
      • -d:后台运行容器
      • -p:端口映射,如 -p 8080:80 将宿主机的 8080 端口映射到容器的 80 端口
      • -v:数据卷挂载,如 -v /host/data:/container/data 将宿主机的目录挂载到容器内,可多次使用 -v 参数挂在多个目录
      • --name:给容器指定一个名称,如 --name my-web-app
      • -it:交互式终端,i 保持标准输入打开,t 分配一个伪终端
  • 停止容器:
    • docker stop [OPTIONS] CONTAINER [CONTAINER...]:优雅地停止一个或多个运行中的容器
    • docker kill [OPTIONS] CONTAINER [CONTAINER...]:强制停止一个或多个容器,不进行任何清理
    • docker restart [OPTIONS] CONTAINER [CONTAINER...]:重启一个或多个容器
  • 删除容器:
    • docker rm [OPTIONS] CONTAINER [CONTAINER...]:删除一个或多个已停止的容器
    • docker rm -f CONTAINER:强制删除一个正在运行的容器

查看容器信息

  • 查看容器:
    • docker ps [OPTIONS]:列出所有正在运行的容器
    • 常用选项:
      • -a:列出所有容器,包括已停止的
      • -s:显示总文件大小
      • -l:显示最新创建的容器
  • 查看容器日志:
    • docker logs [OPTIONS] CONTAINER:获取容器的日志
    • 常用选项:
      • -f:实时跟踪日志输出
      • --tail N:仅显示最新的 N 行日志
  • 查看容器详细信息:
    • docker inspect [OPTIONS] CONTAINER [CONTAINER...]:获取容器的详细配置和状态信息,以 JSON 格式输出
  • 查看容器资源使用情况:
    • docker stats [OPTIONS] [CONTAINER...]:实时显示一个或多个容器的 CPU、内存、网络 I/O 和块 I/O 使用情况

容器内部操作

  • 进入容器内部:
    • docker exec [OPTIONS] CONTAINER COMMAND [ARG...]:在运行中的容器内执行命令。这是最常用的方式,因为它不会启动新的进程
    • 常用例子:
      • docker exec -it my-container bash:在名为 my-container 的容器中启动一个 bash 终端
      • docker exec my-container ls -l /:在容器中执行 ls -l / 命令
  • 复制文件到容器或从容器复制文件:
    • docker cp [OPTIONS] SRC_PATH CONTAINER:DEST_PATH:从宿主机复制文件到容器
    • docker cp [OPTIONS] CONTAINER:SRC_PATH DEST_PATH:从容器复制文件到宿主机

容器进阶管理

  • 暂停/恢复容器:
    • docker pause CONTAINER [CONTAINER...]:暂停容器内的所有进程。容器状态变为 paused,资源仍然被占用
    • docker unpause CONTAINER [CONTAINER...]:恢复被暂停的容器
  • 清理无用容器:
    • docker container prune:删除所有已停止的容器
  • 批量管理:
    • docker stop $(docker ps -a -q):停止所有容器
    • docker rm $(docker ps -a -q):删除所有容器
    • 注意 :docker ps -a -q 会列出所有容器的 ID

docker create 和 docker run 的区别

  • TLDR:docker run = docker create + docker start

docker create

  • docker create 命令只负责从一个镜像创建一个容器
  • docker create 它会为容器分配一个 ID,设置好配置(如端口映射、数据卷等),但不会启动容器
  • 总结 docker create 命令:
    • 只创建,不启动 :容器处于“已创建”(Created)状态
    • 分步操作 :您需要先使用 docker create 创建容器,然后再使用 docker start 来启动它
    • 适用场景 :当你需要先配置好容器,但暂时不希望它运行,或者需要对容器进行额外的配置或检查后才启动时,这个命令很有用
      • 例如,在自动化脚本中,您可以先批量创建容器,然后再按需启动

docker run

  • docker run 命令是 docker create 和 docker start 的组合
  • docker run 它从一个镜像创建一个新的容器,并立即启动它
  • 总结 docker run 命令:
    • 创建并启动 :这是最常用的方式,一步到位
    • 一步到位 :大部分情况下,你希望容器创建后立即运行,所以 docker run 更方便快捷
    • 适用场景 :这是日常开发和部署中最常见的命令,因为它简化了操作流程

示例

  • 如果想创建一个 Nginx 容器并让它在后台运行,可以使用:

    1
    docker run -d --name my-nginx nginx
  • 这一个命令就完成了创建和启动

  • 但如果你想分步操作,可以这么做:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # 只创建容器,容器不会运行,注意,容器启动命令在这里传入
    docker create --name my-nginx-created nginx

    # 检查容器是否已创建(状态为 "Created")
    docker ps -a

    # 手动启动这个已创建的容器
    docker start my-nginx-created

    # 检查容器是否已启动(状态为 "Up")
    docker ps

附录:一个 docker 容器创建和启动的实例

  • 容器创建:

    1
    docker create --runtime=nvidia --gpus all --net=host --shm-size="10g" --cap-add=SYS_ADMIN -v .:/workspace/python_demo --name python_demo <image:tag> sleep infinity
    • --runtime=nvidia:使用 NVIDIA 容器运行时,支持 GPU 加速
    • --gpus all:允许容器使用所有可用 GPU
    • --net=host:使用主机网络模式,容器与主机共享网络栈
    • --shm-size="10g":设置共享内存大小为 10GB,提升进程间通信效率
    • --cap-add=SYS_ADMIN:添加系统管理员权限,允许容器内执行更多系统操作
    • -v .:/workspace/python_demo:将当前目录挂载到容器内的 /workspace/python_demo 目录
    • --name python_demo:给容器命名为 “python_demo”
    • <image:tag>:指定要使用的 Docker 镜像及标签
    • sleep infinity:容器启动后执行无限休眠命令,保持容器运行
  • 启动容器:

    1
    docker start python_demo
    • 启动名为 “python_demo” 的容器
    • 执行此命令后,之前创建的容器将开始运行
  • 进入容器终端与容器交互:

    1
    docker exec -it python_demo bash
    • 进入 “python_demo” 容器的 bash 交互终端
    • -it:以交互式终端模式进入容器
    • python_demo:目标容器名称
    • bash:在容器内启动 bash shell
  • 以上所有命令也可以使用一个 docker run 命令来实现


附录:Linux 中的 sleep infinity 命令常用于 docker 容器

  • 在 Linux 系统中,sleep infinity 命令会让进程进入无限期休眠状态
    • sleep 命令的作用是让当前进程暂停指定的时间
    • 当使用 infinity 作为参数时,它表示”无限长的时间”
    • 这会导致执行该命令的进程一直处于休眠状态,不会自动退出
    • 要终止这个进程,需要手动干预(通常使用 Ctrl+C 快捷键或 kill 命令)
  • 这种命令常用于需要保持容器运行的场景(如 Docker 容器),或者作为一种简单的方式让进程在后台持续运行而不占用过多系统资源
  • 由于进程处于休眠状态,它只会占用极少的系统资源(主要是进程表项)

终端于进程的关系

  • TLDR:终端存在是进程持续运行的一个条件(避免被 SIGHUP 终止),但进程本身还需要有“不主动退出”的逻辑(如无限循环、sleep infinity 等)才能一直运行
    • sleep infinity 是实现这种“不退出”的简单高效方式
  • 前台进程与终端的关系
    • 如果进程是在终端中直接启动的前台进程(比如直接执行 python script.py),那么当终端关闭时,该进程会收到 SIGHUP(挂断信号),通常会随之终止=
    • 这种情况下,即使不关闭终端,进程也可能因为自身逻辑结束(比如脚本执行完毕)而退出,并非“一直运行”
  • 后台进程的特殊性
    • 如果用 & 将进程放到后台(如 python script.py &),终端关闭时它仍可能被 SIGHUP 终止(取决于 shell 配置,如 huponexit 选项)
    • 即使终端保持打开,后台进程若自身逻辑执行完毕(比如循环结束),也会自动退出
  • sleep infinity 的作用
    • sleep infinity 的核心是让进程进入“无限等待”状态,自身不会主动结束,且几乎不消耗资源
    • sleep infinity 与普通进程的区别在于:
      • 普通进程需要自身逻辑(如无限循环)来维持运行,可能消耗 CPU 或内存;
      • sleep infinity 是利用系统调用让进程进入休眠,本质上是“挂起等待”,资源占用极低
  • 终端存在≠进程持续运行
    • 终端存在只是避免了 SIGHUP 导致的终止,但进程是否持续运行仍取决于其自身是否会主动退出
    • 比如执行 echo "hello" 后,终端还在,但进程已经结束;
    • 而 sleep infinity 无论终端是否关闭(若处理了 SIGHUP),都会一直休眠

Docker——访问官方镜像库-代理配置


终端科学上网说明

  • 配置好科学上网后,网页能科学上网

  • 为了在终端科学上网,需要配置代理(修改环境变量为 VPN 的指定端口)

    1
    2
    export HTTP_PROXY="http://127.0.0.1:7897/"
    export HTTPS_PROXY="http://127.0.0.1:7897/"
  • 确认一下 Ubuntu 终端已经科学上网,即已经配置下面的情况

    1
    2
    3
    4
    # echo $HTTP_PROXY
    http://127.0.0.1:7897/
    # echo $HTTPS_PROXY
    http://127.0.0.1:7897/
    • 从输出可以看到,终端已经配置了代理(HTTP_PROXY 和 HTTPS_PROXY 均指向 http://127.0.0.1:7897/)
  • 此时在终端执行 curl 能成功

    1
    curl https://www.google.com
    • 注意此时用 ping 命令不一定能成功,因为 ping 使用的是 ICMP 协议
  • 综上已经确定了终端可以科学上网

  • 特别注意:一般来说终端的代理配置仅对当前终端会话生效 ,除非把环境变量配置添加到 ~/.bashrc 文件


docker 的问题

  • 通过网页访问 docker 核心库没问题(有返回没验证的说明就是 OK 的)
  • Docker 服务默认不会继承终端的代理环境变量 ,这常常是 Docker 拉取镜像仍失败的关键原因
  • 需要将代理配置同步给 Docker 服务,确保 Docker 能通过代理访问镜像仓库

为 Docker 配置代理(确保与终端代理一致)

第一步:创建 Docker 代理配置文件

  • Docker 服务的代理配置需通过专门的系统目录(/etc/systemd/system/docker.service.d/)生效,执行以下命令创建配置文件:
    1
    2
    3
    4
    5
    # 创建配置目录(若不存在)
    sudo mkdir -p /etc/systemd/system/docker.service.d

    # 创建代理配置文件
    sudo nano /etc/systemd/system/docker.service.d/proxy.conf

第二步:写入代理配置(与终端代理保持一致)

  • 在 proxy.conf 文件中添加以下内容(注意代理地址 127.0.0.1:7897 需与你终端的 HTTP_PROXY 完全一致,包括结尾是否带 /):

    1
    2
    3
    4
    [Service]
    Environment="HTTP_PROXY=http://127.0.0.1:7897/"
    Environment="HTTPS_PROXY=http://127.0.0.1:7897/"
    Environment="NO_PROXY=localhost,127.0.0.1,docker-registry.local.golang.org,.docker.internal"
    • NO_PROXY:指定无需代理的地址(避免本地服务/容器间通信走代理,防止冲突),无需修改

第三步:重启 Docker 服务,使代理生效

1
2
3
4
5
6
7
8
# 重新加载 systemd 配置(识别新的 proxy.conf)
sudo systemctl daemon-reload

# 重启 Docker 服务
sudo systemctl restart docker

# 验证 Docker 是否加载了代理配置
sudo systemctl show --property=Environment docker
* 若输出中包含 `HTTP_PROXY=http://127.0.0.1:7897/` 和 `HTTPS_PROXY=http://127.0.0.1:7897/`,说明 Docker 代理配置成功

再次尝试拉取 Docker 镜像

  • 现在 Docker 已通过代理访问外部仓库,执行拉取命令:
    1
    2
    3
    4
    5
    # 先测试小镜像 hello-world,验证代理是否生效
    sudo docker pull hello-world

    # 若 hello-world 成功,再拉取目标镜像
    sudo docker pull xxx

Docker——镜像深入理解


整体说明

  • TLDR:镜像的本质是静态的只读模板
  • 镜像(Image)是一个静态的、只读的二进制文件集合 ,包含运行应用所需的代码、 runtime、库、环境变量、配置文件等所有依赖
  • 镜像的核心作用是:
    • 作为容器的”模板”:容器是镜像的运行实例(镜像 + 可写层)
    • 保证环境一致性:无论在哪个宿主机器上,基于同一镜像创建的容器都能运行相同的应用(”一次构建,到处运行”)
  • 镜像的核心特性包括
    • 只读性 :所有层不可修改,保证安全性和可复用性
    • 分层存储 :基于 UnionFS,层可共享,减少冗余
    • 轻量高效 :仅包含应用依赖,体积远小于虚拟机镜像
    • 可移植性 :镜像内容与宿主环境无关,实现”一次构建,到处运行”

镜像的分层结构:UnionFS 与 Copy-on-Write

  • Docker 镜像最核心的设计是分层存储 ,基于 Union File System(联合文件系统) 实现
  • 这种分层结构让镜像具备了”可复用、轻量、高效”的特性

分层的本质

  • 每个镜像由多个只读层(Layer) 叠加而成,每层对应镜像构建过程中的一个操作(如 RUN、COPY 等 Dockerfile 指令)
  • 层与层之间通过哈希值唯一标识(如 sha256:a1b2c3...),相同的层会被不同镜像共享(避免重复存储)
  • 例如,一个 nginx 镜像可能包含以下层:
    • 基础层:ubuntu:20.04 的底层文件系统(如 /bin、/etc 等)
    • 依赖层:安装 nginx 所需的库(如 libpcre3 等)
    • 应用层:nginx 二进制文件和配置文件(如 /usr/sbin/nginx、/etc/nginx/)

联合挂载(Union Mount)

  • 当镜像被用于创建容器时,Docker 会将所有只读层联合挂载为一个统一的文件系统,对容器来说,这些层看起来是一个完整的目录(透明化分层细节)

Copy-on-Write(写时复制)机制

  • Copy-on-Write(写时复制)机制是镜像分层与容器交互的核心机制
  • 镜像的所有层都是只读的,容器启动时,Docker 会在镜像顶层添加一个可写层(Writable Layer)
  • 当容器需要修改文件时:
    • 若文件在底层(镜像层),会先将文件复制到可写层 ,再修改可写层的副本(底层文件不变)
    • 若文件是新创建的,直接写入可写层
  • 这种机制保证了:
    • 镜像层不会被容器修改(只读),可安全复用
    • 容器的修改仅保存在自己的可写层,不影响其他容器或镜像

Dockerfile 构建镜像与分层

  • 镜像的构建通常通过 Dockerfile 定义(而非 docker commit,后者不推荐)

  • Dockerfile 中的每一条指令都会生成一个新的只读层 ,指令与层的对应关系是理解镜像体积和优化的关键

  • 下面是一个 Dockerfile 与分层对应的示例:

    1
    2
    3
    4
    5
    6
    FROM ubuntu:20.04       # 基础层(复用 ubuntu:20.04 的所有层)
    RUN apt-get update # 层 1:执行 update 后的文件变化
    RUN apt-get install -y nginx # 层 2:安装 nginx 后的变化
    COPY nginx.conf /etc/nginx/ # 层 3:复制配置文件的变化
    EXPOSE 80 # 元数据(不生成层,仅记录信息)
    CMD ["nginx", "-g", "daemon off;"] # 元数据(容器启动命令)
  • EXPOSE、CMD、ENV 等指令仅修改镜像的元数据(保存在镜像的配置层),不生成新的文件层

  • 多条指令会生成多个层,层越多,镜像体积可能越大(需优化)


附录:镜像 ID 与 Digest的区别

  • 镜像 ID是镜像的唯一标识符(64 位哈希,通常显示前 12 位),由镜像的所有层和元数据共同计算得出
    • f9c8f87e172b(完整 ID 为 f9c8f87e172b2a4e41a73e2d685c8f...)
  • Digest(摘要) :镜像内容的哈希值(基于所有层的内容计算),用于验证镜像的完整性(避免篡改)
    • 拉去影响完成时,给出的 Digest: sha256:abc123... 就是摘要,相同摘要的镜像内容一定相同

附录:Storage Driver

  • Docker 通过存储驱动管理镜像层和容器可写层的存储与联合挂载
  • 不同的存储驱动实现方式不同,主流的是 overlay2(Linux 推荐,性能最优)
  • overlay2 驱动的核心结构如下:
    • 镜像层 :存储在 /var/lib/docker/overlay2/ 下,每层对应一个目录(以层哈希命名)
    • 可写层 :容器启动时,overlay2 会创建一个新目录作为可写层,并通过”上下层”关系关联镜像层
    • 合并视图 :通过 overlay2 的联合挂载,将所有层合并为容器内看到的统一文件系统

附录:镜像的体积优化

  • 镜像体积过大会导致存储、传输和启动效率下降,优化核心是减少层数、删除冗余文件 :

  • 合并指令(减少层数) :多条 RUN 指令可合并为一条(用 && 连接),并清理缓存(如 apt-get clean):

    1
    2
    3
    4
    5
    6
    7
    8
    # 优化前(2 层)
    RUN apt-get update
    RUN apt-get install -y nginx

    # 优化后(1 层,且清理缓存)
    RUN apt-get update && \
    apt-get install -y nginx && \
    rm -rf /var/lib/apt/lists/* # 删除 apt 缓存
  • 多阶段构建(丢弃无用层) :用于编译型应用(如 Go、Java),仅保留运行时所需文件(下面的代码最终镜像仅包含 alpine 基础层 + 二进制文件,体积大幅减小):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 阶段 1:编译(包含编译器等冗余工具)
    FROM golang:1.20 AS builder
    WORKDIR /app
    COPY . .
    RUN go build -o myapp .

    # 阶段 2:运行(仅保留编译产物)
    FROM alpine:3.18
    COPY --from=builder /app/myapp /myapp # 仅复制编译好的二进制文件
    CMD ["/myapp"]
  • 优先选择 alpine(几 MB)、slim 版本,而非完整版(如 ubuntu 完整版约 200MB,alpine 约 5MB)

  • 删除临时文件、日志、包管理缓存(如 yum clean all、npm cache clean)


附录:执行 docker pull 时在发生什么?

  • 当执行 docker pull [镜像名] 时,终端会显示的多行输出,这是 Docker 拉取镜像过程的详细日志,每一行对应镜像的一个 层(Layer) 的下载或处理状态,包含层的信息
  • 如果本地已经有的层,不会再下载,且不同镜像是可以共享同一个层的(通过 ID 唯一识别)
  • 层 ID :每个层的唯一标识符(如 a1b2c3d4...)
  • 操作类型 :
    • Pull complete:该层已成功下载并解压
    • Already exists:本地已存在该层,无需重复下载
    • Downloading:正在下载该层,会显示进度(如 50% [=====>])
    • Verifying Checksum:验证文件完整性
    • Extracting:解压下载的层文件
  • 为什么会有这么多层?
    • Docker 镜像是由多个 只读层(Layer) 叠加而成的
    • 每个层对应镜像构建过程中的一个操作(如 RUN、COPY 等指令)
    • 层具有 可复用性 :不同镜像可能共享相同的层,避免重复存储和下载
    • 层的设计让镜像更新更高效(只需更新变化的层)

附录:镜像与容器的关系:动态 vs 静态

  • 镜像 :静态、只读、多分层,是容器的”模板”
  • 容器 :动态、可写,是镜像的”运行实例”(= 镜像所有只读层 + 容器独有的可写层)
  • 可以理解为:容器 = 镜像(只读层) + 可写层(容器私有) + 容器元数据(如网络配置、环境变量等)
  • 当容器被删除时,其可写层和元数据会被清理 ,但镜像的只读层不受影响(可继续用于创建新容器)
1…444546…63
Joe Zhou

Joe Zhou

Stay Hungry. Stay Foolish.

630 posts
53 tags
GitHub E-Mail
© 2026 Joe Zhou
Powered by Hexo
|
Theme — NexT.Gemini v5.1.4