零基础AI入门指南

本文以工程师的视角从零开始搭建并运行一个AI小模型,并把它完全运行起来以理解AI的工作原理,非常接地气。

AI模型是如何工作的

神经网络是AI的一种重要的计算模型,深度学习是通过神经网络实现特征学习和模式分析,大量用于图像识别等领域。我们以最基础的手写数字识别为例,看看一个神经网络的AI模型是如何工作的。

MNIST(Modified National Institute of Stands and Technology)是一个开源的数据集,它包含了6万个手写的数字图像,每个图像都是28×28黑底白字:

mnist-preview

有了这个开源的数据集,我们就可以训练一个识别手写数字的AI模型,这个练习堪称AI界的“Hello, world”。

要编写这个AI模型,我们需要使用一种称为卷积神经网络(CNN:Convolutional Neural Network)的神经网络结构,具体到代码层面,则需要使用PyTorch这样的训练框架。PyTorch底层用C++开发,外层用Python调用,非常方便易用。先确保机器安装了Python3,然后,安装PyTorch 2:

pip install torch torchvision torchaudio

如果本机有CUDA环境,也可以安装GPU版本,训练速度更快。

编写模型

准备好环境后,我们开始编写模型。先让AI写一个用CNN识别MNIST数据集的PyTorch代码:

import torch.nn as nn

class NeuralNetwork(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 32, 3, 1)
        self.conv2 = nn.Conv2d(32, 64, 3, 1)
        self.fc1 = nn.Linear(in_features=64 * 5 * 5, out_features=128)
        self.fc2 = nn.Linear(in_features=128, out_features=10)

    def forward(self, x):
        x = nn.functional.relu(self.conv1(x))
        x = nn.functional.max_pool2d(x, kernel_size=2)
        x = nn.functional.relu(self.conv2(x))
        x = nn.functional.max_pool2d(x, kernel_size=2)
        x = x.view(-1, 64 * 5 * 5)
        x = nn.functional.relu(self.fc1(x))
        x = self.fc2(x)
        return x

看不懂不要紧,可以接着问AI,它会告诉我们,这个神经网络定义了两个CNN卷积层和两个全连接层,总的来说就是,这个模型定义了2层卷积网络加2层全连接层,输入为1通道图片,经过卷积和池化后进入全连接层,最后输出10个分类结果,分别代表0~9这10个数字。

训练

接下来我们要使用MNIST数据集来训练这个模型。受益于PyTorch这个框架,我们连下载和读取数据集都省了,因为PyTorch已经集成了这个数据集,直接下载、加载、训练,一步到位:

from time import time

import torch
import torch.nn as nn
import torch.optim as optim

from torchvision import datasets
from torch.utils.data import DataLoader
from torchvision.transforms import ToTensor

from model import NeuralNetwork

def train(dataloader, device, model, loss_fn, optimizer):
    model.train()
    running_loss = 0.0
    for batch, (inputs, labels) in enumerate(dataloader):
        inputs = inputs.to(device)
        labels = labels.to(device)
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = loss_fn(outputs, labels)
        loss.backward()
        optimizer.step()
        running_loss += loss.item()
    print(f'loss: {running_loss/len(dataloader):>0.3f}')

def test(dataloader, device, model):
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for inputs, labels in dataloader:
            inputs = inputs.to(device)
            labels = labels.to(device)
            outputs = model(inputs)
            _, predicted = torch.max(outputs.data, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()
    print(f'accuracy: {100.0*correct/total:>0.2f} %')

def main():
    print('loading training data...')
    train_data = datasets.MNIST(
        root='./data', train=True, download=True, transform=ToTensor())
    print('loading test data...')
    test_data = datasets.MNIST(
        root='./data', train=False, download=True, transform=ToTensor())

    train_dataloader = DataLoader(train_data, batch_size=64)
    test_dataloader = DataLoader(test_data, batch_size=64)

    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    print(f'using {device}')
    model = NeuralNetwork().to(device)
    print(model)

    loss_fn = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=0.001)
    epochs = 5
    for t in range(epochs):
        start_time = time()
        print(f'epoch {t+1} / {epochs}\n--------------------')
        train(train_dataloader, device, model, loss_fn, optimizer)
        test(test_dataloader, device, model)
        end_time = time()
        print(f'time: {end_time-start_time:>0.2f} seconds')
    print('done!')
    path = 'mnist.pth'
    torch.save(model.state_dict(), path)
    print(f'model saved: {path}')

if __name__ == '__main__':
    main()

数据集分两部分:一个用于训练,一个用于测试训练效果,用PyTorch的datasets.MNIST()自动下载、解压并加载数据集(解压后约55M数据,仅第一次需要下载)。然后,定义损失函数和优化器,用train()做训练,用test()测试训练效果,训练5次,运行结果如下:

$ python3 train.py
loading training data...
Downloading http://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz
...第一次运行会自动下载数据到data目录并解压...

loading test data...
using cpu
NeuralNetwork(
  (conv1): Conv2d(1, 32, kernel_size=(3, 3), stride=(1, 1))
  (conv2): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1))
  (fc1): Linear(in_features=1600, out_features=128, bias=True)
  (fc2): Linear(in_features=128, out_features=10, bias=True)
)
epoch 1 / 5
--------------------
loss: 0.177
accuracy: 97.21 %
time: 30.96 seconds
epoch 2 / 5
--------------------
loss: 0.053
accuracy: 98.62 %
time: 32.24 seconds
epoch 3 / 5
--------------------
loss: 0.035
accuracy: 98.70 %
time: 33.70 seconds
epoch 4 / 5
--------------------
loss: 0.025
accuracy: 98.90 %
time: 35.10 seconds
epoch 5 / 5
--------------------
loss: 0.018
accuracy: 98.95 %
time: 32.02 seconds
done!
model saved: mnist.pth

经过5轮训练,每轮耗时约30秒(这里用CPU训练,如果是GPU则可以大大提速),准确率可以达到99%。训练结束后,将模型保存至mnist.pth文件。

使用模型

有了预训练的模型后,我们就可以用实际的手写图片测试一下。用PS手绘几张手写数字图片,测试代码如下:

import torch
from torchvision import transforms

from PIL import Image, ImageOps
from model import NeuralNetwork

device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'using {device}')
model = NeuralNetwork().to(device)
path = './mnist.pth'
model.load_state_dict(torch.load(path))
print(f'loaded model from {path}')
print(model)

def test(path):
    print(f'test {path}...')
    image = Image.open(path).convert('RGB').resize((28, 28))
    image = ImageOps.invert(image)

    trans = transforms.Compose([
        transforms.Grayscale(1),
        transforms.ToTensor()
    ])
    image_tensor = trans(image).unsqueeze(0).to(device)
    model.eval()
    with torch.no_grad():
        output = model(image_tensor)
        probs = torch.nn.functional.softmax(output[0], 0)
    predict = torch.argmax(probs).item()
    return predict, probs[predict], probs

def main():
    for i in range(10):
        predict, prob, probs = test(f'./input/test-{i}.png')
        print(f'expected {i}, actual {predict}, {prob}, {probs}')


if __name__ == '__main__':
    main()

因为训练时输入的图片是黑底白字,而测试图片是白底黑字,所以先用PIL把图片处理成28×28的黑底白字,再测试,结果如下:

$ python3 test.py 
using cpu
loaded model from ./mnist.pth
NeuralNetwork(
  (conv1): Conv2d(1, 32, kernel_size=(3, 3), stride=(1, 1))
  (conv2): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1))
  (fc1): Linear(in_features=1600, out_features=128, bias=True)
  (fc2): Linear(in_features=128, out_features=10, bias=True)
)
test ./input/test-0.png...
expected 0, actual 0, 0.9999996423721313, tensor([1.0000e+00, 2.3184e-10, 1.7075e-08, 7.6250e-16, 1.2966e-12, 5.7179e-11,
        2.1766e-07, 1.8820e-12, 1.1260e-07, 2.2463e-09])
...

以图片0为例,我们要使用模型,需要把输入图片变成模型可接受的参数,实际上是一个Tensor(张量),可以理解为任意维度的数组,而模型的输出也是一个Tensor,它是一个包含10个元素的1维数组,分别表示每个输出的概率。对图片0的输出如下:

  • 1.0000e+00
  • 2.3184e-10
  • 1.7075e-08
  • 7.6250e-16
  • 1.2966e-12
  • 5.7179e-11
  • 2.1766e-07
  • 1.8820e-12
  • 1.1260e-07
  • 2.2463e-09

除了第一个输出为1,其他输出都非常接近于0,可见模型以99.99996423721313%的概率认为图片是0,是其他数字的概率低到接近于0。

因此,这个MNIST模型实际上是一个图片分类器,或者说预测器,它针对任意图片输入,都会以概率形式给出10个预测,我们找出接近于1的输出,就是分类器给出的预测。

产品化

虽然我们已经有了预训练模型,也可以用模型进行手写数字识别,但是,要让用户能方便地使用这个模型,还需要进一步优化,至少需要提供一个UI。我们让AI写一个简单的页面,允许用户在页面用鼠标手写数字,然后,通过API获得识别结果:

mnist-ui

因此,最后一步是把模型的输入输出用API封装一下。因为模型基于PyTorch,所以使用Python的Flask框架是最简单的。API实现如下:

import base64
import torch
from io import BytesIO
from PIL import Image
from flask import Flask, request, redirect, jsonify
from torchvision import transforms
from model import NeuralNetwork

device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'using {device}')
model = NeuralNetwork().to(device)
path = './mnist.pth'
model.load_state_dict(torch.load(path))
print(f'loaded model from {path}')
print(model)
params = model.state_dict()
print(params)

app = Flask(__name__)

@app.route('/')
def index():
    return redirect('/static/index.html')

@app.route('/api', methods=['POST'])
def api():
    data = request.get_json()
    image_data = base64.b64decode(data['image'])
    image = Image.open(BytesIO(image_data))
    trans = transforms.Compose([
        transforms.Grayscale(1),
        transforms.ToTensor()
    ])
    image_tensor = trans(image).unsqueeze(0).to(device)
    model.eval()
    with torch.no_grad():
        output = model(image_tensor)
        probs = torch.nn.functional.softmax(output[0], 0)
    predict = torch.argmax(probs).item()
    prob = probs[predict]
    print(f'predict: {predict}, prob: {prob}, probs: {probs}')
    return jsonify({
        'result': predict,
        'probability': prob.item()
    })

if __name__ == '__main__':
    app.run(port=5000)

上述代码实现了一个简单的API服务,注意尚未对并发访问做处理,所以只能算一个可用的DEMO。

思考

对于AI程序,我们发现,模型定义非常简单,一共也就20行代码。训练代码也很少,不超过100行。它和传统的程序最大的区别在哪呢?

无论是传统的程序,还是AI程序,在计算机看来,本质上是一样的,即给定一个输入,通过一个函数计算,获得输出。不同点在于,对于传统程序,输入是非常简单的,例如用户注册,仅仅需要几个字段,而处理函数少则几千行,多则几十万行。虽然代码量很大,但执行路径却非常清晰,通过跟踪执行,能轻易获得一个确定的执行路径,从而最终获得一个确定性的结果。确定性就是传统程序的特点,或者说,传统程序的代码量虽然大,但输入简单,路径清晰:

f(x1, x2, x3)
  │
  ▼
 ┌─┐◀─┐
 └─┘  │
  │   │
  ▼   │
 ┌─┐  │
 └─┘  │
  │   │
  ▼   │
 ┌─┐──┘
 └─┘
  │
  ▼
 ┌─┐
 └─┘

AI程序则不同,它只经过几层计算,复杂的大模型也就100来层,就可以输出结果。但是,它的输入数据量大,每一层的数据量更大,就像一个扁平的巨大函数:

       f(x1, x2, x3, ... , x998, x999, x1000)
         │   │   │   │   │   │   │   │   │
         ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼
        ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
        └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘
         │   │   │   │   │   │   │   │   │
 ┌───┬───┼───┼───┼───┼───┼───┼───┼───┼───┼───┬───┐
 ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼
┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
└─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘
 │   │   │   │   │   │   │   │   │   │   │   │   │
 └───┴───┴───┴───┼───┼───┼───┼───┼───┴───┴───┴───┘
                 ▼   ▼   ▼   ▼   ▼
                ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
                └─┘ └─┘ └─┘ └─┘ └─┘

这个函数的计算并不复杂,每一层都是简单的矩阵计算,但并行程度很高,所以需要用GPU加速。复杂度在于每一层都有大量的参数,这些参数不是开发者写死的,而是通过训练确定的,每次对参数进行微调,然后根据效果是变得更好还是更坏决定微调方向。我们这个简单的神经网络模型参数仅几万个,训练的目的实际上就是为了把这几万个参数确定下来,目标是使得识别率最高。训练这几万个参数就花了几分钟时间,如果是几亿个甚至几百亿个参数,可想而知训练所需的时间和算力都需要百万倍的增长,所以,AI模型的代码并不复杂,模型规模大但本身结构并不复杂,但为了确定模型中每一层的成千上万个参数,时间和算力主要消耗在训练上。

比较一下传统程序和AI程序的差异:

传统程序AI程序
代码量
输入参数
输出结果精确输出不确定性输出
代码参数由开发设定由训练决定
执行层次可达数百万行仅若干层网络
执行路径能精确跟踪无法跟踪
并行串行或少量并行大规模并行
计算以CPU为主以GPU为主
开发时间主要消耗在编写代码主要消耗在训练
数据主要存储用户产生的数据需要预备大量训练数据
程序质量取决于设计架构、代码优化等取决于神经网络模型和训练数据质量

传统程序的特点是精确性:精确的输入可以实现精确地执行路径,最终获得精确的结果。而AI程序则是一种概率输出,由于模型的参数是训练生成的,因此,就连开发者自己也无法知道训练后的某个参数比如0.123究竟是什么意义,调大或者调小对输出有什么影响。传统程序的逻辑是白盒,AI程序的逻辑就是黑盒,只能通过调整神经网络的规模、层次、训练集和训练方式来评估输出结果,无法事先给出一个准确的预估。

源码下载

本文源码可通过GitHub下载

贝叶斯定理(原理篇)

转自https://liaoxuefeng.com/blogs/all/2023-08-27-bayes-explain/index.html
可以不用看文章,直接看油管上视频Bayes’ Theorem 贝叶斯定理

托马斯·贝叶斯(Thomas Bayes)是18世纪的英国数学家,也是一位虔诚的牧师。据说他为了反驳对上帝的质疑而推导出贝叶斯定理。贝叶斯定理是一个由结果倒推原因的概率算法,在贝叶斯提出这个条件概率公式后,很长一段时间,大家并没有觉得它有什么作用,并一直受到主流统计学派的排斥。直到计算机诞生后,人们发现,贝叶斯定理可以广泛应用在数据分析、模式识别、统计决策,以及最火的人工智能中,结果,贝叶斯定理是如此有用,以至于不仅应用在计算机上,还广泛应用在经济学、心理学、博弈论等各种领域,可以说,掌握并应用贝叶斯定理,是每个人必备的技能。

这里推荐两个视频,深入浅出地解释了贝叶斯定理:

Bayes’ Theorem 贝叶斯定理

Bayes theorem, the geometry of changing beliefs

如果你不想花太多时间看视频,可以继续阅读,我把视频内容编译成文字,以便快速学习贝叶斯定理。

为了搞明白贝叶斯定理究竟要解决什么问题,我们先看一个现实生活的例子:

已知有一种疾病,发病率是0.1%。针对这种疾病的测试非常准确:

  • 如果有病,则准确率是99%(即有1%未检出阳性);
  • 如果没有病,则误报率是2%(即有2%误报为阳性)。

现在,如果一个人测试显示阳性,请问他患病的概率是多少?

如果我们从大街上随便找一个人,那么他患病的概率就是0.1%,因为这个概率是基于历史统计数据的先验概率。

现在,他做了一次测试,结果为阳性,我们要计算他患病的概率,就是计算条件概率,即:在测试为阳性这一条件下,患病的概率是多少。

从直觉上这个人患病的概率大于0.1%,但也肯定小于99%。究竟是多少,怎么计算,我们先放一放。

为了理解条件概率,我们换一个更简单的例子:掷两次骰子,一共可能出现的结果有6×6=36种:

sample space

这就是所谓的样本空间,每个样本的概率均为1/36,这个很好理解。

如果我们定义事件A为:至少有一个骰子是2,那么事件A的样本空间如下图红色部分所示:

Event A

事件A一共有11种情况,我们计算事件A的概率P(A):

P(A)

我们再定义事件B:两个骰子之和为7,那么事件B的样本空间如下图绿色部分所示:

Event B

事件B一共有6种情况,我们计算事件B的概率P(B):

P(B)

接下来我们用P(A∩B)表示A和B同时发生的概率,A∩B就是A和B的交集,如下图蓝色部分所示:

P(A∩B)

显然A∩B只有两种情况,因此,计算P(A∩B):

P(A∩B)

接下来我们就可以讨论条件概率了。我们用P(A|B)表示在B发生的条件下,A发生的概率。由于B已经发生,所以,样本空间就是B的样本数量6,而要发生A则只能是A、B同时发生,即A∩B,有两种情况。

因此,计算P(A|B)如下:

P(A|B)

同理,我们用P(B|A)表示在A发生的条件下,B发生的概率。此时,分子仍然是A∩B的样本数量,但分母变成A的样本数量:

P(B|A)

可见,条件概率P(A|B)和P(B|A)是不同的。

我们再回到A、B同时发生的概率,观察P(A∩B)可以改写为:

P(B|A)xP(A)

同理,P(A∩B)还可以改写为:

P(A|B)xP(B)

因此,根据上述两个等式,我们推导出下面的等式:

P(AB)=P(ABP(B)=P(BAP(A)

把左边的P(A∩B)去掉,我们得到等式:

P(ABP(B)=P(BAP(A)

最后,整理一下等式,我们推导出贝叶斯定理如下:

P(AB)=P(B)P(BAP(A)​

这就是著名的贝叶斯定理,它表示,当出现B时,如何计算A的概率。

很多时候,我们把A改写为H,把B改写为E

P(HE)=P(E)P(EHP(H)​

H表示Hypothesis(假设),E表示Evidence(证据),贝叶斯定理的意义就在于,给定一个先验概率P(H),在出现了证据E的情况下,计算后验概率P(H|E)。

计算

有了贝叶斯定理,我们就可以回到开头的问题:

已知有一种疾病,发病率是0.1%。针对这种疾病的测试非常准确:

  • 如果有病,则准确率是99%(即有1%未检出阳性);
  • 如果没有病,则误报率是2%(即有2%误报为阳性)。

现在,如果一个人测试显示阳性,请问他患病的概率是多少?

用H表示患病,E表示测试为阳性,那么,我们要计算在测试为阳性的条件下,一个人患病的概率,就是计算P(H|E)。根据贝叶斯定理,计算如下:

P(HE)=P(E)P(EHP(H)​

P(H)表示患病的概率,根据发病率可知,P(H)=0.1%;

P(E|H)表示在患病的情况下,测试为阳性的概率,根据“如果有病,则准确率是99%”可知,P(E|H)=99%;

P(E)表示测试为阳性的概率。这个概率就稍微复杂点,因为它是指对所有人(包含病人和健康人)进行测试,结果阳性的概率。

我们可以把检测人数放大,例如放大到10万人,对10万人进行检测,根据发病率可知:

  • 有100人是病人,另外99900是健康人;
  • 对100个病人进行测试,有99人显示阳性,另有1人未检出(阴性);
  • 对99900个健康人进行测试,有2%=1998人显示阳性(误报),另有98%=97902人为阴性。

下图显示了检测为阳性的结果的分布:

           ┌───────┐
           │100000 │
           └───────┘
               │
       ┌───────┴───────┐
       ▼               ▼
   ┌───────┐       ┌───────┐
   │  100  │       │ 99900 │
   └───────┘       └───────┘
       │               │
   ┌───┴───┐       ┌───┴───┐
   ▼       ▼       ▼       ▼
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ 99  │ │  1  │ │1998 │ │97902│
└─────┘ └─────┘ └─────┘ └─────┘
   │               │
   ▼               ▼
   +               +

所以,对于10万人的样本空间来说,事件E=显示阳性的概率为(99+1998)/100000=2.097%。

带入贝叶斯定理,计算P(H|E):

P(HE)=P(E)P(EHP(H)​=2.097%99%×0.1%​=0.020970.99×0.001​=0.04721=4.721%

计算结果为患病的概率为4.721%,这个概率远小于99%,且与大多数人的直觉不同,原因在于庞大的健康人群导致的误报数量远多于病人,当出现“检测阳性”的证据时,患病的概率从先验概率0.1%提升到4.721%,还远不足以确诊。

贝叶斯定理的另一种表示

在上述计算中,我们发现计算P(E)是比较困难的,很多时候,甚至无法知道P(E)。此时,我们需要贝叶斯定理的另一种表示形式。

我们用P(H)表示H发生的概率,用H表示H不发生,P(H)表示H不发生的概率。显然P(H)=1-P(H)。

下图红色部分表示H,红色部分以外则表示H:

P(H)

事件E用绿色表示:

P(E)

可见,P(E)可以分为两部分,一部分是E和H的交集,另一部分是E和H的交集:

P(E)=P(EH)+P(EH)

根据上文的公式P(A∩B)=P(A|B)xP(B),代入可得:

P(E)=P(EH)+P(EH)=P(EHP(H)+P(EHP(H)

把P(E)替换掉,我们得到贝叶斯定理的另一种写法:

P(HE)=P(EHP(H)+P(EHP(H)P(EHP(H)​

用这个公式来计算,我们就不必计算P(E)了。再次回到开头的问题:

已知有一种疾病,发病率是0.1%。针对这种疾病的测试非常准确:

  • 如果有病,则准确率是99%(即有1%未检出阳性);
  • 如果没有病,则误报率是2%(即有2%误报为阳性)。

现在,如果一个人测试显示阳性,请问他患病的概率是多少?

  • P(E|H)表示患病时检测阳性的概率=99%;
  • P(H)表示患病的概率=0.1%;
  • P(E|H)表示没有患病但检测阳性的概率=2%;
  • P(H)表示没有患病的概率=1-P(H)=99.9%。

代入公式,计算:

P(HE)=P(EHP(H)+P(EHP(H)P(EHP(H)​=99%×0.1%+2%×99.9%99%×0.1%​=0.04721=4.721%

检测为阳性这一证据使得患病的概率从0.1%提升到4.721%。假设这个人又做了一次检测,结果仍然是阳性,那么他患病的概率是多少?

我们仍然使用贝叶斯定理计算,只不过现在先验概率P(H)不再是0.1%,而是4.721%,P(E|H)和P(E|H)仍保持不变,计算新的P(H|E):

P(HE)=P(EHP(H)+P(EHP(H)P(EHP(H)​=99%×4.721%+2%×(1−4.721%)99%×4.721%​=0.71=71%

结果为71%,两次检测为阳性的结果使得先验概率从0.1%提升到4.721%再提升到71%,继续第三次检测如果为阳性则概率将提升至99.18%。

可见,贝叶斯定理的核心思想就是不断根据新的证据,将先验概率调整为后验概率,使之更接近客观事实。

关于信念

我们再回顾一下贝叶斯定理:

P(HE)=P(E)P(EHP(H)​

稍微改一下,变为:

P(HE)=P(HP(E)P(EH)​

P(H)是先验概率,P(H|E)是后验概率,P(E|H)/P(E)被称为调整因子,先验概率乘以调整因子就得到后验概率。

我们发现,如果P(H)=0,则P(H|E)=0;如果P(H)=1,则P(E|H)=P(E),P(H|E)=1。

也就是说,如果先验概率为0%或100%,那么,无论出现任何证据E,都无法改变后验概率P(H|E)。这对我们看待世界的认知有重大指导意义,因为贝叶斯概率的本质是信念,通过一次次事件,我们可能加强某种信念,也可能减弱某种信念,但如果信念保持100%或0%,则可以做到对外界输入完全“免疫”。

举个例子,十年前许多人都认为比特币是庞氏骗局,如果100%坚定地持有这种信念,那么他将无视用户越来越多、价格上涨、交易量扩大、机构入市等诸多证据,至今仍然会坚信比特币是骗局而错过无数次机会。(注:此处示例不构成任何投资建议)

对于新生事物,每个人都可以有非常主观的先验概率,但只要我们不把先验概率定死为0或100%,就有机会改变自己的信念,从而更有可能接近客观事实,这也是贝叶斯定理的精髓:

你相信什么并不重要,重要的是你别完全相信它。

JavaScript再复习

https://liaoxuefeng.com/books/javascript/introduction/index.html摘自

下面的一行代码包含两个语句,每个语句用;表示语句结束

var x = 1; var y = 2; // 定义了两个变量,不建议一行写多个语句!
用等号=对变量进行赋值。可以把任意数据类型赋值给变量,同一个变量可以反复赋值,而且可以是不同类型的变量,但是要注意只能用var申明一次,例如:

var a = 123; // a的值是整数123
a = 'ABC'; // a变为字符串
这种变量本身类型不固定的语言称之为动态语言

如果一个变量没有通过var申明就被使用,那么该变量就自动被申明为全局变量:

i = 10; // i现在是全局变量

在同一个页面的不同的JavaScript文件中,如果都不用var申明,恰好都使用了变量i,将造成变量i互相影响,产生难以调试的错误结果。这是要避免的,可以在JavaScript代码的第一行写上:’use strict’;强制要求通过var申明变量

const来定义常量,比如const PI = 3.1415926要求数值一定后面不能改,可以用这个定义

语句块是一组语句的集合,例如,下面的代码先做了一个判断,如果判断成立,将执行{...}中的所有语句:

if (2 > 1) {
    x = 1;
    y = 2;
    z = 3;
}

注意花括号{...}内的语句具有缩进,通常是4个空格。缩进不是JavaScript语法要求必须的

//开头直到行末的字符被视为行注释, /*...*/把多行字符包裹起来

JavaScript不区分整数和浮点数,统一用Number表示,以下都是合法的Number类型:

123; // 整数123
0.456; // 浮点数0.456
1.2345e3; // 科学计数法表示1.2345x1000,等同于1234.5
-99; // 负数
NaN; // NaN表示Not a Number,当无法计算结果时用NaN表示
Infinity; // Infinity表示无限大,当数值超过了JavaScript的Number所能表示的最大值时,就表示为Infinity

计算机由于使用二进制,所以,有时候用十六进制表示整数比较方便,十六进制用0x前缀和0-9,a-f表示,例如:0xff000xa5b4c3d2,等等,它们和十进制表示的数值完全一样。

Number可以直接做四则运算,规则和数学一致:

1 + 2; // 3
(1 + 2) * 5 / 2; // 7.5
2 / 0; // Infinity
0 / 0; // NaN
10 % 3; // 1
10.5 % 3; // 1.5

注意%是求余运算。

要注意,JavaScript的Number不区分整数和浮点数,也就是说,12.00 === 12。(在大多数其他语言中,整数和浮点数不能直接比较)并且,JavaScript的整数最大范围不是±263,而是±253,因此,超过253的整数就可能无法精确表示:,如果一串字符很长且不是计算则尽量要把他变成字符串,否则会把后面截去影响结果

// 计算圆面积:
var r = 123.456;
var s = 3.14 * r * r;
console.log(s); // 47857.94555904001

字符串是以单引号’或双引号”括起来的任意文本,比如'abc'"xyz"等等。请注意,''""本身只是一种表示方式,不是字符串的一部分,因此,字符串'abc'只有abc这3个字符

&&运算是与运算,只有所有都为true&&运算结果才是true

true && true; // 这个&&语句计算结果为true
true && false; // 这个&&语句计算结果为false
false && true && false; // 这个&&语句计算结果为false

||运算是或运算,只要其中有一个为true||运算结果就是true

false || false; // 这个||语句计算结果为false
true || false; // 这个||语句计算结果为true
false || true || false; // 这个||语句计算结果为true

!运算是非运算,它是一个单目运算符,把true变成falsefalse变成true

! true; // 结果为false

实际上,JavaScript允许对任意数据类型做比较:

false == 0; // true
false === 0; // false
要特别注意相等运算符==。JavaScript在设计时,有两种比较运算符:

第一种是==比较,它会自动转换数据类型再比较,很多时候,会得到非常诡异的结果;

第二种是===比较,它不会自动转换数据类型,如果数据类型不一致,返回false,如果一致,再比较。

由于JavaScript这个设计缺陷,不要使用==比较,始终坚持使用===比较。

另一个例外是NaN这个特殊的Number与所有其他值都不相等,包括它自己:

NaN === NaN; // false
唯一能判断NaN的方法是通过isNaN()函数:

isNaN(NaN); // true
最后要注意浮点数的相等比较:

1 / 3 === (1 - 2 / 3); // false
这不是JavaScript的设计缺陷。浮点数在运算过程中会产生误差,因为计算机无法精确表示无限循环小数。要比较两个浮点数是否相等,只能计算它们之差的绝对值,看是否小于某个阈值:

Math.abs(1 / 3 - (1 - 2 / 3)) < 0.0000001; // true

null和undefined
null表示一个“空”的值,它和0以及空字符串''不同,0是一个数值,''表示长度为0的字符串,而null表示“空”。

在其他语言中,也有类似JavaScript的null的表示,例如Java也用null,Swift用nil,Python用None表示。但是,在JavaScript中,还有一个和null类似的undefined,它表示“未定义”。

JavaScript的设计者希望用null表示一个空的值,而undefined表示值未定义。事实证明,这并没有什么卵用,区分两者的意义不大。大多数情况下,我们都应该用null。undefined仅仅在判断函数参数是否传递的情况下有用

数组是一组按顺序排列的集合,集合的每个值称为元素。JavaScript的数组可以包括任意数据类型。例如:

[1, 2, 3.14, 'Hello', null, true];
上述数组包含6个元素。数组用[]表示,元素之间用,分隔。

另一种创建数组的方法是通过Array()函数实现:

new Array(1, 2, 3); // 创建了数组[1, 2, 3]


JavaScript的对象是一组由键-值组成的无序集合,例如:

var person = {
    name: 'Bob',
    age: 20,
    tags: ['js', 'web', 'mobile'],
    city: 'Beijing',
    hasCar: true,
    zipcode: null
};
JavaScript对象的键都是字符串类型,值可以是任意数据类型。

要获取一个对象的属性,我们用对象变量.属性名的方式:

person.name; // 'Bob'
person.zipcode; // null

JavaScript的字符串就是用''""括起来的字符表示。

如果'本身也是一个字符,那就可以用""括起来,比如"I'm OK"

如果字符串内部既包含'又包含"怎么办?可以用转义字符\来标识,比如:

'I\'m \"OK\"!'; // I'm "OK"!

表示的字符串内容是:I'm "OK"!

转义字符\可以转义很多字符,比如\n表示换行,\t表示制表符,字符\本身也要转义,所以\\表示的字符就是\

ASCII字符可以以\x##形式的十六进制表示,例如:

'\x41'; // 完全等同于 'A'

还可以用\u####表示一个Unicode字符:

'\u4e2d\u6587'; // 完全等同于 '中文'

多行字符串用\n写起来比较费事,用反引号`...`表示

多个字符串连接起来,可以用+号连接

如果有很多变量需要连接,用+号就比较麻烦。ES6新增了一种模板字符串,它会自动替换字符串中的变量:

let name = '小明';
let age = 20;
let message = `你好, ${name}, 你今年${age}岁了!`;
alert(message);

获取字符串长度:

let s = 'Hello, world!';
s.length; // 13
要获取字符串某个指定位置的字符,使用类似Array的下标操作,索引号从0开始:

let s = 'Hello, world!';

s[0]; // 'H'
s[6]; // ' '
s[7]; // 'w'
s[12]; // '!'
s[13]; // undefined 超出范围的索引不会报错,但一律返回undefined
需要特别注意的是,字符串是不可变的,如果对字符串的某个索引赋值,不会有任何错误,但是,也没有任何效果:

let s = 'Test';
s[0] = 'X';
console.log(s); // s仍然为'Test'
JavaScript为字符串提供了一些常用方法,注意,调用这些方法本身不会改变原有字符串的内容,而是返回一个新字符串:

toUpperCase
toUpperCase()把一个字符串全部变为大写:

let s = 'Hello';
s.toUpperCase(); // 返回'HELLO'
toLowerCase
toLowerCase()把一个字符串全部变为小写:

let s = 'Hello';
let lower = s.toLowerCase(); // 返回'hello'并赋值给变量lower
lower; // 'hello'
indexOf
indexOf()会搜索指定字符串出现的位置:

let s = 'hello, world';
s.indexOf('world'); // 返回7
s.indexOf('World'); // 没有找到指定的子串,返回-1
substring
substring()返回指定索引区间的子串:

let s = 'hello, world'
s.substring(0, 5); // 从索引0开始到5(不包括5),返回'hello'
s.substring(7); // 从索引7开始到结束,返回'world'

Array可以包含任意数据类型,并通过索引来访问每个元素

indexOf

与String类似,Array也可以通过indexOf()来搜索一个指定的元素的位置:

let arr = [10, 20, '30', 'xyz'];
arr.indexOf(10); // 元素10的索引为0
arr.indexOf(20); // 元素20的索引为1
arr.indexOf(30); // 元素30没有找到,返回-1
arr.indexOf('30'); // 元素'30'的索引为2

注意了,数字30和字符串'30'是不同的元素。

slice

slice()就是对应String的substring()版本,它截取Array的部分元素,然后返回一个新的Array

let arr = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
arr.slice(0, 3); // 从索引0开始,到索引3结束,但不包括索引3: ['A', 'B', 'C']
arr.slice(3); // 从索引3开始到结束: ['D', 'E', 'F', 'G']

注意到slice()的起止参数包括开始索引,不包括结束索引。

如果不给slice()传递任何参数,它就会从头到尾截取所有元素。利用这一点,我们可以很容易地复制一个Array

let arr = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
let aCopy = arr.slice();
aCopy; // ['A', 'B', 'C', 'D', 'E', 'F', 'G']
aCopy === arr; // false

push和pop

push()Array的末尾添加若干元素,pop()则把Array的最后一个元素删除掉:

let arr = [1, 2];
arr.push('A', 'B'); // 返回Array新的长度: 4
arr; // [1, 2, 'A', 'B']
arr.pop(); // pop()返回'B'
arr; // [1, 2, 'A']
arr.pop(); arr.pop(); arr.pop(); // 连续pop 3次
arr; // []
arr.pop(); // 空数组继续pop不会报错,而是返回undefined
arr; // []

unshift和shift

如果要往Array的头部添加若干元素,使用unshift()方法,shift()方法则把Array的第一个元素删掉:

let arr = [1, 2];
arr.unshift('A', 'B'); // 返回Array新的长度: 4
arr; // ['A', 'B', 1, 2]
arr.shift(); // 'A'
arr; // ['B', 1, 2]
arr.shift(); arr.shift(); arr.shift(); // 连续shift 3次
arr; // []
arr.shift(); // 空数组继续shift不会报错,而是返回undefined
arr; // []

sort

sort()可以对当前Array进行排序,它会直接修改当前Array的元素位置,直接调用时,按照默认顺序排序:

let arr = ['B', 'C', 'A'];
arr.sort();
arr; // ['A', 'B', 'C']

能否按照我们自己指定的顺序排序呢?完全可以,我们将在后面的函数中讲到。

reverse

reverse()把整个Array的元素给调个个,也就是反转:

let arr = ['one', 'two', 'three'];
arr.reverse(); 
arr; // ['three', 'two', 'one']

splice

splice()方法是修改Array的“万能方法”,它可以从指定的索引开始删除若干元素,然后再从该位置添加若干元素:

let arr = ['Microsoft', 'Apple', 'Yahoo', 'AOL', 'Excite', 'Oracle'];
// 从索引2开始删除3个元素,然后再添加两个元素:
arr.splice(2, 3, 'Google', 'Facebook'); // 返回删除的元素 ['Yahoo', 'AOL', 'Excite']
arr; // ['Microsoft', 'Apple', 'Google', 'Facebook', 'Oracle']
// 只删除,不添加:
arr.splice(2, 2); // ['Google', 'Facebook']
arr; // ['Microsoft', 'Apple', 'Oracle']
// 只添加,不删除:
arr.splice(2, 0, 'Google', 'Facebook'); // 返回[],因为没有删除任何元素
arr; // ['Microsoft', 'Apple', 'Google', 'Facebook', 'Oracle']

concat

concat()方法把当前的Array和另一个Array连接起来,并返回一个新的Array

let arr = ['A', 'B', 'C'];
let added = arr.concat([1, 2, 3]);
added; // ['A', 'B', 'C', 1, 2, 3]
arr; // ['A', 'B', 'C']

请注意concat()方法并没有修改当前Array,而是返回了一个新的Array

实际上,concat()方法可以接收任意个元素和Array,并且自动把Array拆开,然后全部添加到新的Array里:

let arr = ['A', 'B', 'C'];
arr.concat(1, 2, [3, 4]); // ['A', 'B', 'C', 1, 2, 3, 4]

join

join()方法是一个非常实用的方法,它把当前Array的每个元素都用指定的字符串连接起来,然后返回连接后的字符串:

let arr = ['A', 'B', 'C', 1, 2, 3];
arr.join('-'); // 'A-B-C-1-2-3'

JavaScript的对象有个小问题,就是键必须是字符串。但实际上Number或者其他数据类型作为键也是非常合理的。

为了解决这个问题,最新的ES6规范引入了新的数据类型Map

Map是一组键值对的结构,具有极快的查找速度。

举个例子,假设要根据同学的名字查找对应的成绩,如果用Array实现,需要两个Array

let names = ['Michael', 'Bob', 'Tracy'];
let scores = [95, 75, 85];

给定一个名字,要查找对应的成绩,就先要在names中找到对应的位置,再从scores取出对应的成绩,Array越长,耗时越长。

如果用Map实现,只需要一个“名字”-“成绩”的对照表,直接根据名字查找成绩,无论这个表有多大,查找速度都不会变慢。用JavaScript写一个Map如下:

let m = new Map([['Michael', 95], ['Bob', 75], ['Tracy', 85]]);
m.get('Michael'); // 95

初始化Map需要一个二维数组,或者直接初始化一个空MapMap具有以下方法:

let m = new Map(); // 空Map
m.set('Adam', 67); // 添加新的key-value
m.set('Bob', 59);
m.has('Adam'); // 是否存在key 'Adam': true
m.get('Adam'); // 67
m.delete('Adam'); // 删除key 'Adam'
m.get('Adam'); // undefined

SetMap类似,也是一组key的集合,但不存储value。由于key不能重复,所以,在Set中,没有重复的key。

要创建一个Set,需要提供一个Array作为输入,或者直接创建一个空Set

let s1 = new Set(); // 空Set
let s2 = new Set([1, 2, 3]); // 含1, 2, 3

重复元素在Set中自动被过滤:

let s = new Set([1, 2, 3, 3, '3']);
s; // Set {1, 2, 3, "3"}

注意数字3和字符串'3'是不同的元素。

通过add(key)方法可以添加元素到Set中,可以重复添加,但不会有效果:

s.add(4);
s; // Set {1, 2, 3, 4}
s.add(4);
s; // 仍然是 Set {1, 2, 3, 4}

通过delete(key)方法可以删除元素

遍历Array可以采用下标循环,遍历MapSet就无法使用下标。为了统一集合类型,ES6标准引入了新的iterable类型,ArrayMapSet都属于iterable类型。

具有iterable类型的集合可以通过新的for ... of循环来遍历

let a = ['A', 'B', 'C'];
let s = new Set(['A', 'B', 'C']);
let m = new Map([[1, 'x'], [2, 'y'], [3, 'z']]);
for (let x of a) { // 遍历Array
    console.log(x);
}
for (let x of s) { // 遍历Set
    console.log(x);
}
for (let x of m) { // 遍历Map
    console.log(x[0] + '=' + x[1]);
}

我们建议用for … of,而不是for … in 更好的方式是直接使用iterable内置的forEach方法,它接收一个函数,每次迭代就自动回调该函数。以Array为例:

let a = [‘A’, ‘B’, ‘C’];
a.forEach(function (element, index, array) {
// element: 指向当前元素的值
// index: 指向当前索引
// array: 指向Array对象本身
console.log(${element}, index = ${index});
});

关键字arguments,它只在函数内部起作用,并且永远指向当前函数的调用者传入的所有参数

实际上arguments最常用于判断传入参数的个数。你可能会看到这样的写法:

// foo(a[, b], c)
// 接收2~3个参数,b是可选参数,如果只传2个参数,b默认为null:
function foo(a, b, c) {
    if (arguments.length === 2) {
        // 实际拿到的参数是a和b,c为undefined
        c = b; // 把b赋给c
        b = null; // b变为默认值
    }
    // ...
}

由于JavaScript函数允许接收任意个参数,于是我们就不得不用arguments来获取所有参数:

function foo(a, b) {
    let i, rest = [];
    if (arguments.length > 2) {
        for (i = 2; i<arguments.length; i++) {
            rest.push(arguments[i]);
        }
    }
    console.log('a = ' + a);
    console.log('b = ' + b);
    console.log(rest);
}

为了获取除了已定义参数ab之外的参数,我们不得不用arguments,并且循环要从索引2开始以便排除前两个参数,这种写法很别扭,只是为了获得额外的rest参数,有没有更好的方法?

ES6标准引入了rest参数,上面的函数可以改写为:

function foo(a, b, ...rest) {
    console.log('a = ' + a);
    console.log('b = ' + b);
    console.log(rest);
}

foo(1, 2, 3, 4, 5);
// 结果:
// a = 1
// b = 2
// Array [ 3, 4, 5 ]

foo(1);
// 结果:
// a = 1
// b = undefined
// Array []

rest参数只能写在最后,前面用...标识,从运行结果可知,传入的参数先绑定ab,多余的参数以数组形式交给变量rest,所以,不再需要arguments我们就获取了全部参数。

如果传入的参数连正常定义的参数都没填满,也不要紧,rest参数会接收一个空数组(注意不是undefined

JavaScript默认有一个全局对象window,全局作用域的变量实际上被绑定到window的一个属性,以变量方式var foo = function () {}定义的函数实际上也是一个全局变量,因此,顶层函数的定义也被视为一个全局变量,并绑定到window对象:

function foo() {
    alert('foo');
}

foo(); // 直接调用foo()
window.foo(); // 通过window.foo()调用

进一步大胆地猜测,我们每次直接调用的alert()函数其实也是window的一个变量

高阶函数

map

举例说明,比如我们有一个函数f(x)=x2,要把这个函数作用在一个数组[1, 2, 3, 4, 5, 6, 7, 8, 9]上,就可以用map实现如下:

            f(x) = x * x

                  │
                  │
  ┌───┬───┬───┬───┼───┬───┬───┬───┐
  │   │   │   │   │   │   │   │   │
  ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼

[ 1   2   3   4   5   6   7   8   9 ]

  │   │   │   │   │   │   │   │   │
  │   │   │   │   │   │   │   │   │
  ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼   ▼

[ 1   4   9  16  25  36  49  64  81 ]

由于map()方法定义在JavaScript的Array中,我们调用Arraymap()方法,传入我们自己的函数,就得到了一个新的Array作为结果:

function pow(x) {
return x * x;
}

let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
let results = arr.map(pow); //结果 [1, 4, 9, 16, 25, 36, 49, 64, 81]

要区别于上页的Map,上面的Map是一个数据结构,一些键值对,这里是一个高阶函数

reduce

再看reduce的用法。Array的reduce()把一个函数作用在这个Array[x1, x2, x3...]上,这个函数必须接收两个参数,reduce()把结果继续和序列的下一个元素做累积计算,其效果就是:

[x1, x2, x3, x4].reduce(f) = f(f(f(x1, x2), x3), x4)

比方说对一个Array求和,就可以用reduce实现:

let arr = [1, 3, 5, 7, 9];
arr.reduce(function (x, y) {
    return x + y;
}); // 25

filter也是一个常用的操作,它用于把Array的某些元素过滤掉,然后返回剩下的元素。

filter()把传入的函数依次作用于每个元素,然后根据返回值是true还是false决定保留还是丢弃该元素。

例如,在一个Array中,删掉偶数,只保留奇数,可以这么写:

let arr = [1, 2, 4, 5, 6, 9, 10, 15];
let r = arr.filter(function (x) {
    return x % 2 !== 0;
});
r; // [1, 5, 9, 15]

sort()方法是用于排序的, 要注意的是默认把所有元素先转换为String再排序,所以'10'排在了'2'的前面,因为字符'1'比字符'2'的ASCII码小,幸运的是,sort()方法也是一个高阶函数,它还可以接收一个比较函数来实现自定义的排序。

要按数字大小排序,我们可以这么写:

let arr = [10, 20, 1, 2];

arr.sort(function (x, y) {
if (x < y) { return -1; } if (x > y) {
return 1;
}
return 0;
});

console.log(arr); // [1, 2, 10, 20]

every()方法可以判断数组的所有元素是否满足测试条件

find()方法用于查找符合条件的第一个元素,如果找到了,返回这个元素,否则,返回undefined

findIndex()find()类似,也是查找符合条件的第一个元素,不同之处在于findIndex()会返回这个元素的索引,如果没有找到,返回-1

forEach()map()类似,它也把每个元素依次作用于传入的函数,但不会返回新的数组。forEach()常用于遍历数组,因此,传入的函数不需要返回值:

let arr = [‘Apple’, ‘pear’, ‘orange’];
arr.forEach(x=>console.log(x)); // 依次打印每个元素

高阶函数除了可以接受函数作为参数外,还可以把函数作为结果值返回

箭头函数


x => x * x

上面的箭头函数相当于:

function (x) {
    return x * x;
}

还有一种可以包含多条语句,这时候就不能省略{ ... }return

x => {
    if (x > 0) {
        return x * x;
    }
    else {
        return - x * x;
    }
}

如果参数不是一个,就需要用括号()括起来

标签函数

const email = “test@example.com”;
const password = ‘hello123’;

function sql(strings, …exps) {
console.log(SQL: ${strings.join('?')});
console.log(SQL parameters: ${JSON.stringify(exps)});
return {
name: ‘小明’,
age: 20
};
}

const result = sqlSELECT * FROM users WHERE email=${email} AND password=${password};

console.log(JSON.stringify(result));

这里出现了一个奇怪的语法:

sql`SELECT * FROM users WHERE email=${email} AND password=${password}`

模板字符串前面以sql开头,实际上这是一个标签函数,上述语法会自动转换为对sql()函数的调用。我们关注的是,传入sql()函数的参数是什么。

sql()函数实际上接收两个参数:

第一个参数strings是一个字符串数组,它是["SELECT * FROM users WHERE email=", " AND password=", ""],即除去${xxx}剩下的字符组成的数组;

第二个参数...exps是一个可变参数,它接收的也是一个数组,但数组的内容是由模板字符串里所有的${xxx}的实际值组成,即["test@example.com", "hello123"],因为解析${email}得到"test@example.com",解析${password}得到"hello123"

生成器 generator由function*定义(注意多出的*号)

调用generator对象有两个方法,一是不断地调用generator对象的next()方法

第二个方法是直接用for ... of循环迭代generator对象

function* fib(max) {
    let
        a = 0,
        b = 1,
        n = 0;
    while (n < max) {
        yield a;
        [a, b] = [b, a + b];
        n ++;
    }
    return;
}

for (let x of fib(10)) {
    console.log(x); // 依次输出0, 1, 1, 2, 3, ...
}

在JavaScript中,Date对象用来表示日期和时间。

要获取系统当前时间,用:

let now = new Date();
now; // Wed Jun 24 2015 19:49:22 GMT+0800 (CST)
now.getFullYear(); // 2015, 年份
now.getMonth(); // 5, 月份,注意月份范围是0~11,5表示六月
now.getDate(); // 24, 表示24号
now.getDay(); // 3, 表示星期三
now.getHours()

JavaScript有两种方式创建一个正则表达式:

第一种方式是直接通过/正则表达式/写出来,第二种方式是通过new RegExp('正则表达式')创建一个RegExp对象。

两种写法是一样的:

let re1 = /ABC\-001/;
let re2 = new RegExp('ABC\\-001');

re1; // /ABC\-001/
re2; // /ABC\-001/

注意,如果使用第二种写法,因为字符串的转义问题,字符串的两个\\实际上是一个\

先看看如何判断正则表达式是否匹配:

let re = /^\d{3}\-\d{3,8}$/;
re.test('010-12345'); // true
re.test('010-1234x'); // false
re.test('010 12345'); // false

RegExp对象的test()方法用于测试给定的字符串是否符合条件

JSON是JavaScript Object Notation的缩写,它是一种数据交换格式,JSON实际上是JavaScript的一个子集。在JSON中,一共就这么几种数据类型:

  • number:和JavaScript的number完全一致;
  • boolean:就是JavaScript的truefalse
  • string:就是JavaScript的string
  • null:就是JavaScript的null
  • array:就是JavaScript的Array表示方式——[]
  • object:就是JavaScript的{ ... }表示方式。

以及上面的任意组合。

并且,JSON还定死了字符集必须是UTF-8,表示多语言就没有问题了。为了统一解析,JSON的字符串规定必须用双引号"",Object的键也必须用双引号""

拿到一个JSON格式的字符串,我们直接用JSON.parse()把它反序列化变成一个JavaScript对象

JSON.stringify()把一个JavaScript对象序列化为JSON

对象编程

创建一个Array对象:

let arr = [1, 2, 3];

当我们创建一个函数时:

function foo() {
    return 0;
}

函数也是一个对象,

构造函数

除了直接用{ ... }创建一个对象外,JavaScript还可以用一种构造函数的方法来创建对象。它的用法是,先定义一个构造函数:

function Student(name) {
    this.name = name;
    this.hello = function () {
        alert('Hello, ' + this.name + '!');
    }
}

你会问,咦,这不是一个普通函数吗?

这确实是一个普通函数,但是在JavaScript中,可以用关键字new来调用这个函数,并返回一个对象:

let xiaoming = new Student('小明');
xiaoming.name; // '小明'
xiaoming.hello(); // Hello, 小明!

注意,如果不写new,这就是一个普通函数,它返回undefined。但是,如果写了new,它就变成了一个构造函数,它绑定的this指向新创建的对象,并默认返回this,也就是说,不需要在最后写return this;

JavaScript可以获取浏览器提供的很多对象,并进行操作。

window

window对象不但充当全局作用域,而且表示浏览器窗口。

window对象有innerWidthinnerHeight属性,可以获取浏览器窗口的内部宽度和高度的内部宽度和高度

navigator

navigator对象表示浏览器的信息,最常用的属性包括:

  • navigator.appName:浏览器名称;
  • navigator.appVersion:浏览器版本;
  • navigator.language:浏览器设置的语言;
  • navigator.platform:操作系统类型;
  • navigator.userAgent:浏览器设定的User-Agent字符串

screen对象表示屏幕的信息

location对象表示当前页面的URL信息。例如,一个完整的URL,可以用location.href获取

document对象表示当前页面。由于HTML在浏览器中以DOM形式表示为树形结构,document对象就是整个DOM树的根节点。

documenttitle属性是从HTML文档中的<title>xxx</title>读取的

要查找DOM树的某个节点,需要从document对象开始查找。最常用的查找是根据ID和Tag Name。

JavaScript可以通过document.cookie读取到当前页面的Cookie

由于HTML文档被浏览器解析后就是一棵DOM树,要改变HTML的结构,就需要通过JavaScript来操作DOM。

始终记住DOM是一个树形结构。操作一个DOM节点实际上就是这么几个操作:

  • 更新:更新该DOM节点的内容,相当于更新了该DOM节点表示的HTML的内容;
  • 遍历:遍历该DOM节点下的子节点,以便进行进一步操作;
  • 添加:在该DOM节点下新增一个子节点,相当于动态增加了一个HTML节点;
  • 删除:将该节点从HTML中删除,相当于删掉了该DOM节点的内容以及它包含的所有子节点。

在操作一个DOM节点前,我们需要通过各种方式先拿到这个DOM节点。最常用的方法是document.getElementById()document.getElementsByTagName(),以及CSS选择器document.getElementsByClassName()

由于ID在HTML文档中是唯一的,所以document.getElementById()可以直接定位唯一的一个DOM节点。document.getElementsByTagName()document.getElementsByClassName()总是返回一组DOM节点。要精确地选择DOM,可以先定位父节点,再从父节点开始选择,以缩小范围。

第二种方法是使用querySelector()querySelectorAll(),需要了解selector语法,然后使用条件来获取节点,更加方便:

// 通过querySelector获取ID为q1的节点:
let q1 = document.querySelector('#q1');

// 通过querySelectorAll获取q1节点内的符合条件的所有节点:
let ps = q1.querySelectorAll('div.highlighted > p');

拿到一个DOM节点后,我们可以对它进行更新。

可以直接修改节点的文本,方法有两种:

一种是修改innerHTML属性,这个方式非常强大,不但可以修改一个DOM节点的文本内容,还可以直接通过HTML片段修改DOM节点内部的子树:

// 获取<p id="p-id">...</p>
let p = document.getElementById('p-id');
// 设置文本为abc:
p.innerHTML = 'ABC'; // <p id="p-id">ABC</p>
// 设置HTML:
p.innerHTML = 'ABC <span style="color:red">RED</span> XYZ';
// <p>...</p>的内部结构已修改

innerHTML时要注意,是否需要写入HTML。如果写入的字符串是通过网络拿到的,要注意对字符编码来避免XSS攻击。

第二种是修改innerTexttextContent属性

当我们获得了某个DOM节点,想在这个DOM节点内插入新的DOM,应该如何做?

如果这个DOM节点是空的,例如,<div></div>,那么,直接使用innerHTML = '<span>child</span>'就可以修改DOM节点的内容,相当于“插入”了新的DOM节点。

如果这个DOM节点不是空的,那就不能这么做,因为innerHTML会直接替换掉原来的所有子节点。

有两个办法可以插入新的节点。一个是使用appendChild,把一个子节点添加到父节点的最后一个子节点。例如:

<!-- HTML结构 -->
<p id="js">JavaScript</p>
<div id="list">
    <p id="java">Java</p>
    <p id="python">Python</p>
    <p id="scheme">Scheme</p>
</div>

<p id="js">JavaScript</p>添加到<div id="list">的最后一项:

let
    js = document.getElementById('js'),
    list = document.getElementById('list');
list.appendChild(js);

如果我们要把子节点插入到指定的位置怎么办?可以使用parentElement.insertBefore(newElement, referenceElement);,子节点会插入到referenceElement之前

删除一个DOM节点就比插入要容易得多。

要删除一个节点,首先要获得该节点本身以及它的父节点,然后,调用父节点的removeChild把自己删掉:

// 拿到待删除节点:
let self = document.getElementById('to-be-removed');
// 拿到父节点:
let parent = self.parentElement;
// 删除:
let removed = parent.removeChild(self);
removed === self; // true

操作表单直接看https://liaoxuefeng.com/books/javascript/browser/form/index.html

AJAX

一、AJAX概述

AJAX是Asynchronous JavaScript and XML的缩写,即异步JavaScript和XML。它是一种在不重新加载整个页面的情况下,通过JavaScript和服务器进行数据交互和页面更新的技术。AJAX技术可以使得网页交互更加流畅和快速,同时也可以减少网络流量和服务器资源的占用。

在AJAX技术中,当用户执行某个操作时,例如点击按钮或输入数据,JavaScript代码会通过XMLHttpRequest对象向服务器发送异步请求,并接收服务器返回的数据。在接收到数据后,JavaScript代码可以对页面进行动态更新,例如更新部分页面内容、添加新的内容、或者执行特定的操作等。

二、XMLHttpRequest对象

XMLHttpRequest对象是AJAX技术的核心,它是一个内置对象,可以在JavaScript中使用。该对象可以通过JavaScript向服务器发送HTTP请求,并接收服务器返回的数据。以下是XMLHttpRequest对象的基本用法:

  1. 创建XMLHttpRequest对象
    在JavaScript中,可以使用以下代码创建一个XMLHttpRequest对象:
    “`javascript
    let xhr = new XMLHttpRequest();
    “`
  2. 发送HTTP请求

使用XMLHttpRequest对象发送HTTP请求的基本步骤如下:

  1. 调用open()方法,指定请求的方法、URL和是否使用异步方式。例如,以下代码指定了一个GET方法的请求,请求URL为”/api/data”,并使用异步方式发送请求:
    “`javascript
    xhr.open(“GET”, “/api/data”, true);
    “`
  2. 添加请求头。可以使用setRequestHeader()方法添加请求头,例如:
    “`javascript
    xhr.setRequestHeader(“Content-Type”, “application/json”);
    “`
  3. 发送请求。使用send()方法发送HTTP请求,例如:

“`javascript

xhr.send();

“`

3. 处理服务器响应

当服务器响应XMLHttpRequest对象的请求时,可以通过以下属性和方法来获取响应数据:

  1. responseText:响应的文本数据。
  2. responseXML:响应的XML数据。
  3. status:响应的状态码。
  4. statusText:响应状态码对应的文本信息。
  5. getAllResponseHeaders():获取所有响应头。
  6. getResponseHeader(header):获取指定响应头。

在接收到响应后,我们可以通过回调函数来处理响应数据。例如,以下代码定义了一个回调函数,当XMLHttpRequest对象接收到响应时,会调用该函数:

“`javascript

xhr.onload = function() {

if (xhr.status === 200) {

console.log(xhr.responseText);

} else {

console.log(“请求失败:” + xhr.statusText);

}

};

“`

如果不考虑早期浏览器的兼容性问题,现代浏览器还提供了原生支持的Fetch API,以Promise方式提供。使用Fetch API发送HTTP请求代码如下:

async function get(url) {
let resp = await fetch(url);
let result = await resp.text();
return result;
}

// 发送异步请求:
get(‘./content.html’).then(data => {
let textarea = document.getElementById(‘fetch-response-text’);
textarea.value = data;
});

CORS

CORS全称Cross-Origin Resource Sharing,是HTML5规范定义的如何跨域访问资源。

了解CORS前,我们先搞明白概念:

Origin表示本域,也就是浏览器当前页面的域。当JavaScript向外域(如sina.com)发起请求后,浏览器收到响应后,首先检查Access-Control-Allow-Origin是否包含本域,如果是,则此次跨域请求成功,如果不是,则请求失败,JavaScript将无法获取到响应的任何数据。

用一个图来表示就是:

         GET /res/abc.data
         Host: sina.com
┌──────┐ Origin: http://my.com                      ┌────────┐
│my.com│───────────────────────────────────▶│sina.com│
│      │◀───────────────────────────────────│        │
└──────┘ HTTP/1.1 200 OK                            └────────┘
         Access-Control-Allow-Origin: http://my.com
         Content-Type: text/xml

         <xml data...>

假设本域是my.com,外域是sina.com,只要响应头Access-Control-Allow-Originhttp://my.com,或者是*,本次请求就可以成功。

可见,跨域能否成功,取决于对方服务器是否愿意给你设置一个正确的Access-Control-Allow-Origin,决定权始终在对方手中。

上面这种跨域请求,称之为“简单请求”。简单请求包括GET、HEAD和POST(POST的Content-Type类型 仅限application/x-www-form-urlencodedmultipart/form-datatext/plain),并且不能出现任何自定义头(例如,X-Custom: 12345),通常能满足90%的需求。

无论你是否需要用JavaScript通过CORS跨域请求资源,你都要了解CORS的原理。最新的浏览器全面支持HTML5。在引用外域资源时,除了JavaScript和CSS外,都要验证CORS。例如,当你引用了某个第三方CDN上的字体文件时:

/* CSS */
@font-face {
  font-family: 'FontAwesome';
  src: url('http://cdn.com/fonts/fontawesome.ttf') format('truetype');
}

如果该CDN服务商未正确设置Access-Control-Allow-Origin,那么浏览器无法加载字体资源。

对于PUT、DELETE以及其他类型如application/json的POST请求,在发送AJAX请求之前,浏览器会先发送一个OPTIONS请求(称为preflighted请求)到这个URL上,询问目标服务器是否接受:

OPTIONS /path/to/resource HTTP/1.1
Host: bar.com
Origin: http://my.com
Access-Control-Request-Method: POST

服务器必须响应并明确指出允许的Method:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://my.com
Access-Control-Allow-Methods: POST, GET, PUT, OPTIONS
Access-Control-Max-Age: 86400

浏览器确认服务器响应的Access-Control-Allow-Methods头确实包含将要发送的AJAX请求的Method,才会继续发送AJAX,否则,抛出一个错误。

由于以POSTPUT方式传送JSON格式的数据在REST中很常见,所以要跨域正确处理POSTPUT请求,服务器端必须正确响应OPTIONS请求。

Promise

“承诺将来会执行”的对象在JavaScript中称为Promise对象。如下test是一个判断函数,用一个Promise对象来执行它,并在将来某个时刻获得成功或失败的结果:

let p1 = new Promise(test);
let p2 = p1.then(function (result) {
    console.log('成功:' + result);
});
let p3 = p2.catch(function (reason) {
    console.log('失败:' + reason);
});

变量p1是一个Promise对象,它负责执行test函数。由于test函数在内部是异步执行的,当test函数执行成功时,我们告诉Promise对象:

// 如果成功,执行这个函数:
p1.then(function (result) {
    console.log('成功:' + result);
});

test函数执行失败时,我们告诉Promise对象:

p2.catch(function (reason) {
    console.log('失败:' + reason);
});

Promise对象可以串联起来,所以上述代码可以简化为:

new Promise(test).then(function (result) {
    console.log('成功:' + result);
}).catch(function (reason) {
    console.log('失败:' + reason);
});

一个Promise对象在操作网络时是异步的,等到返回后再调用回调函数,执行正确就调用then(),执行错误就调用catch(),虽然异步实现了,不会让用户感觉到页面“卡住”了,但是一堆then()catch()写起来麻烦看起来也乱。

有没有更简单的写法?

可以用关键字async配合await调用Promise,实现异步操作,但代码却和同步写法类似:

async function get(url) {
    let resp = await fetch(url);
    let result = await resp.json();
    return result;
}

使用async function可以定义一个异步函数,异步函数和Promise可以看作是等价的,在async function内部,用await调用另一个异步函数,写起来和同步代码没啥区别,但执行起来是异步的。

也就是说:

let resp = await fetch(url);

自动实现了异步调用,它和下面的Promise代码等价:

let promise = fetch(url);
promise.then((resp) => {
    // 拿到resp
});

如果我们要实现catch()怎么办?用Promise的写法如下:

let promise = fetch(url);
promise.then((resp) => {
    // 拿到resp
}).catch(e => {
    // 出错了
});

await调用时,直接用传统的try { ... } catch

async function get(url) {
    try {
        let resp = await fetch(url);
        let result = await resp.json();
        return result;
    } catch (e) {
        // 出错了
    }
}

用async定义异步函数,用await调用异步函数,写起来和同步代码差不多,但可读性大大提高

Canvas是HTML5新增的组件,它就像一块幕布,可以用JavaScript在上面绘制各种图表、动画等。

一个Canvas定义了一个指定尺寸的矩形框,在这个范围内我们可以随意绘制:

<canvas id="test-canvas" width="300" height="200"></canvas>
getContext('2d')方法让我们拿到一个CanvasRenderingContext2D对象,所有的绘图操作都需要通过这个对象完成。

let ctx = canvas.getContext('2d');
如果需要绘制3D怎么办?HTML5还有一个WebGL规范,允许在Canvas中绘制3D图形:

gl = canvas.getContext("webgl")

前端库jQuery简介

jQuery能帮我们干这些事情:

  • 消除浏览器差异:你不需要自己写冗长的代码来针对不同的浏览器来绑定事件,编写AJAX等代码;
  • 简洁的操作DOM的方法:写$('#test')肯定比document.getElementById('test')来得简洁;
  • 轻松实现动画、修改CSS等各种操作。

只需要在页面的<head>引入jQuery文件即可就可使用 <script src=”https://code.jquery.com/jquery-3.7.1.min.js”></script>

$是著名的jQuery符号。实际上,jQuery把所有功能全部封装在一个全局变量jQuery中,

如果某个DOM节点有id属性,利用jQuery查找如下:

// 查找<div id="abc">:
let div = $('#abc');

注意#abc#开头。返回的对象是jQuery对象。

什么是jQuery对象?jQuery对象类似数组,它的每个元素都是一个引用了DOM节点的对象。

以上面的查找为例,如果idabc<div>存在,返回的jQuery对象如下:

[<div id="abc">...</div>]

按tag查找只需要写上tag名称就可以了:

let ps = $('p'); // 返回所有<p>节点
ps.length; // 数一数页面有多少个<p>节点
按class查找注意在class名称前加一个.:

let a = $('.red'); // 所有节点包含`class="red"`都将返回
// 例如:
// <div class="red">...</div>
// <p class="green red">...</p>
通常很多节点有多个class,我们可以查找同时包含red和green的节点:

let a = $('.red.green'); // 注意没有空格!
// 符合条件的节点:
// <div class="red green">...</div>
// <div class="blue green red">...</div>

很多时候按属性查找会非常方便,比如在一个表单中按属性来查找:

let email = $('[name=email]'); // 找出<??? name="email">
let passwordInput = $('[type=password]'); // 找出<??? type="password">
let a = $('[items="A B"]'); // 找出<??? items="A B">

添加新的DOM节点,除了通过jQuery的html()这种暴力方法外,还可以用append()方法,例如:

<div id="test-div">
    <ul>
        <li><span>JavaScript</span></li>
        <li><span>Python</span></li>
        <li><span>Swift</span></li>
    </ul>
</div>

如何向列表新增一个语言?首先要拿到<ul>节点:

let ul = $('#test-div>ul');

然后,调用append()传入HTML片段:

ul.append('<li><span>Haskell</span></li>');

浏览器在接收到用户的鼠标或键盘输入后,会自动在对应的DOM节点上触发相应的事件。如果该节点已经绑定了对应的JavaScript处理函数,该函数就会自动调用。

由于不同的浏览器绑定事件的代码都不太一样,所以用jQuery来写代码,就屏蔽了不同浏览器的差异,我们总是编写相同的代码。

举个例子,假设要在用户点击了超链接时弹出提示框,我们用jQuery这样绑定一个click事件

// 获取超链接的jQuery对象:
let a = $('#test-link');
a.click(function () {
    alert('Hello!');
});

jQuery能够绑定的事件主要包括:

鼠标事件

  • click: 鼠标单击时触发;
  • dblclick:鼠标双击时触发;
  • mouseenter:鼠标进入时触发;
  • mouseleave:鼠标移出时触发;
  • mousemove:鼠标在DOM内部移动时触发;
  • hover:鼠标进入和退出时触发两个函数,相当于mouseenter加上mouseleave。

键盘事件

键盘事件仅作用在当前焦点的DOM上,通常是<input><textarea>

  • keydown:键盘按下时触发;
  • keyup:键盘松开时触发;
  • keypress:按一次键后触发。

其他事件

  • focus:当DOM获得焦点时触发;
  • blur:当DOM失去焦点时触发;
  • change:当<input><select><textarea>的内容改变时触发;
  • submit:当<form>提交时触发;
  • ready:当页面被载入并且DOM树完成初始化后触发。

其中,ready仅作用于document对象。由于ready事件在DOM完成初始化后触发,且只触发一次

jQuery内置了几种动画样式:

如直接以无参数形式调用show()hide(),会显示和隐藏DOM元素。但是,只要传递一个时间参数进去,就变成了动画:

let div = $('#test-show-hide');
div.hide(3000); // 在3秒钟内逐渐消失

Pydantic库-数据验证和设置管理

在处理来自系统外部的数据,如API、终端用户输入或其他来源时,我们必须牢记开发中的一条基本原则:“永远不要相信用户的输入”。

因此,我们必须对这些数据进行严格的检查和验证,确保它们被适当地格式化和标准化。这样做的目的是为了确保这些数据符合我们的程序所需的输入规范,从而保障项目能够正确且高效地运行。

为什么使用 Python 的 Pydantic 库?
Pydantic 是一个在 Python 中用于数据验证和解析的第三方库,它现在是 Python 使用最广泛的数据验证库。

它利用声明式的方式定义数据模型和Python 类型提示的强大功能来执行数据验证和序列化,使您的代码更可靠、更可读、更简洁且更易于调试。。
它还可以从模型生成 JSON 架构,提供了自动生成文档等功能,从而轻松与其他工具集成。


Pydantic 的一些主要特性
易用性

Pydantic 使用起来简单直观,需要最少的样板代码和配置。它适用于许多流行的 IDE 和静态分析工具,例如 PyCharm、VS Code、mypy 等。Pydantic 可以轻松与其他流行的 Python 库(如 Flask、Django、FastAPI 和 SQLAlchemy)集成,使其易于在现有项目中使用。

类型注解

Pydantic 使用类型注解来定义模型的字段类型,以确保确保数据符合预期的类型和格式。你可以使用Python 内置的类型、自定义类型或者其他Pydantic 提供的验证类型。

数据验证,用户友好的错误

Pydantic 自动根据模型定义进行数据验证。它会检查字段的类型、长度、范围等,并自动报告验证错误,Pydantic 会提供信息丰富且可读的错误消息,包括错误的位置、类型和输入。你可以使用 ValidationError 异常来捕获验证错误。

序列化与反序列化

序列化是将复杂数据结构(如对象、数组、字典等)转换为简单数据格式(如字符串或字节流)的过程。这样做是为了便于存储或传输。反序列化是相反的过程,将简单数据格式还原为复杂数据结构。Pydantic 提供了从各种数据格式(例如 JSON、字典)到模型实例的转换功能。它可以自动将输入数据解析成模型实例,并保留类型安全性和验证规则。

性能高

Pydantic 的核心验证逻辑是用 Rust 编写的,使其成为 Python 中最快的数据验证库之一。它还支持延迟验证和缓存,以提高效率。


要使用可以直接安装

pip install pydantic

Pydantic 使用例子

from datetime import datetime

from pydantic import BaseModel, PositiveInt


class User(BaseModel):
    id: int  #id 的类型是 int ;仅注释声明告知 Pydantic 该字段是必需的。如果可能,字符串、字节或浮点数将被强制转换为整数;否则将引发异常。
    name: str = 'John Doe'  #name 是一个字符串;因为它有默认值,所以不必需。
    signup_ts: datetime | None  #signup_ts 是一个必填的 datetime 字段,但值 None 可以提供;Pydantic 将处理 Unix 时间戳整数(例如 1496498400 )或表示日期和时间的字符串。
    tastes: dict[str, PositiveInt]  #tastes 是一个键为字符串且值为正整数的字典。 PositiveInt 类型是 Annotated[int, annotated_types.Gt(0)] 的简写。


external_data = {
    'id': 123,
    'signup_ts': '2019-06-01 12:22',  #这里的输入是一个 ISO8601 格式的日期时间,Pydantic 将把它转换为一个 datetime 对象。
    'tastes': {
        'wine': 9,
        b'cheese': 7,  #关键在这里是 bytes ,但 Pydantic 会负责将其强制转换为字符串。
        'cabbage': '1',  #同样地,Pydantic 会将字符串 '1' 强制转换为整数 1
    },
}

user = User(**external_data)  #这里通过将外部数据作为关键字参数传递给 User 来创建 User 的实例

print(user.id)  #我们可以将字段作为模型的属性来访问
#> 123
print(user.model_dump())  #我们可以将模型转换为带有 model_dump() 的字典
"""
{
    'id': 123,
    'name': 'John Doe',
    'signup_ts': datetime.datetime(2019, 6, 1, 12, 22),
    'tastes': {'wine': 9, 'cheese': 7, 'cabbage': 1},
}
"""

如果验证失败,Pydantic 会引发一个错误并详细说明哪里出错了:

# continuing the above example...

from pydantic import ValidationError


class User(BaseModel):
    id: int
    name: str = 'John Doe'
    signup_ts: datetime | None
    tastes: dict[str, PositiveInt]


external_data = {'id': 'not an int', 'tastes': {}}  

try:
    User(**external_data)  
except ValidationError as e:
    print(e.errors())
    """
    [
        {
            'type': 'int_parsing',
            'loc': ('id',),
            'msg': 'Input should be a valid integer, unable to parse string as an integer',
            'input': 'not an int',
            'url': 'https://pydantic.com.cn/errors/validation_errors#int_parsing',
        },
        {
            'type': 'missing',
            'loc': ('signup_ts',),
            'msg': 'Field required',
            'input': {'id': 'not an int', 'tastes': {}},
            'url': 'https://pydantic.com.cn/errors/validation_errors#missing',
        },
    ]
    """

简单解释

Pydantic 允许你定义数据模型,这些模型会自动验证输入数据的结构和类型。你只需定义一个类,用 Python 的类型提示标注其字段,Pydantic 就会为你处理验证和序列化。与使用JSON Schema或OpenAPI进行手动验证相比,这大大简化了数据验证过程。同时,Pydantic 也提供了强大的数据转换能力,能将复杂数据结构(如 JSON、字典)轻易转换为Python对象。

场景:API 参数验证和转换在电子商务平台

背景

假设你正在开发一个电子商务平台的后端服务,该服务提供了一个API端点,允许客户提交订单。每个订单都有多个字段,如产品ID、数量、支付方式等。你希望验证这些输入参数的有效性并转换为内部使用的Python对象。

常见技术对比

  • 手动验证:你可以在代码中手动检查每个字段,但这样做很冗长,容易出错。
  • JSON Schema:提供一种结构化的验证方法,但需要额外的定义和解析步骤。
  • Marshmallow:也是一种常用于数据验证的库,但与Pydantic相比,它更侧重于序列化和反序列化,而不是类型安全。

Pydantic 的实际应用

使用 Pydantic,你可以定义一个 Order模型来自动完成这些工作。

from pydantic import BaseModel, Field

class Order(BaseModel):
    product_id: int = Field(..., gt=0)
    quantity: int = Field(..., gt=0, le=100)
    payment_method: str = Field(..., regex="^(credit_card|paypal)$")

功能

  1. 类型检查product_id和 quantity必须是整数。
  2. 范围验证product_id必须大于0,quantity必须在1到100之间。
  3. 正则匹配payment_method只能是 “credit_card” 或 “paypal”。

使用

当客户通过API提交一个订单时,你只需将输入数据传递给这个 Order模型。如果数据无效,Pydantic 将自动抛出一个详细的错误,指出哪个字段无效以及为什么。

order_data = {
    "product_id": 1,
    "quantity": 50,
    "payment_method": "credit_card"
}

try:
    order = Order(**order_data)
except ValidationError as e:
    print(e.json())

这种方式使得代码更简洁,更易于维护,同时提供了强类型和自动验证的优点。与手动验证或使用其他库相比,Pydantic 提供了一个更为高效和直观的解决方案。

示例:用户注册API与Pydantic的数据验证

代码设置

在这个示例中,我们使用 FastAPI 构建一个简单的用户注册 API。FastAPI 与 Pydantic 集成非常紧密,用于请求和响应模型的验证。我们将比较使用 Pydantic 和手动验证的差异。

首先,我们导入必要的模块并设置 FastAPI 应用。

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr, Field
from typing import Optional

app = FastAPI()

Pydantic 数据模型

接下来,我们使用 Pydantic 定义一个用户注册的数据模型。

class UserRegister(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    email: EmailStr
    password: str = Field(..., min_length=8)
    age: Optional[int] = Field(None, ge=18)

在这个模型中,我们定义了如下字段和验证规则:

  • username: 字符串类型,长度必须在3到50字符之间。
  • email: 必须是有效的电子邮件地址。
  • password: 字符串类型,至少包含8个字符。
  • age: 整数类型,可选,但如果提供必须大于等于18。

FastAPI 路由与验证

使用 Pydantic 模型,我们可以很容易地在 FastAPI 路由中进行数据验证。

@app.post("/register/")
def register(user: UserRegister):
    return {"username": user.username, "email": user.email}

对比:手动验证

如果不使用 Pydantic,数据验证会变得复杂和冗长。例如:

@app.post("/register_manual/")
def register_manual(username: str, email: str, password: str, age: Optional[int] = None):
    if len(username) < 3 or len(username) > 50:
        raise HTTPException(status_code=400, detail="Invalid username length")

    # ...其他字段验证

    return {"username": username, "email": email}

在这个手动验证的示例中,我们需要为每个字段写多行验证代码,这显然不如使用 Pydantic 效率高。

pydantic的核心是模型(Model)

验证数据

一旦你定义了模型,你可以使用它来验证数据。

如果要从字典实例化 User 对象,可以使用字典对象解包.model_validate().model_validate_json()类方法:

if __name__ == '__main__':

    user_data = {
        "id": 123,
        "name": "小卤蛋",
        "age": 20,
        "email": "xiaoludan@example.com",
        'signup_ts': '2024-07-19 00:22',
        'friends': ["公众号:海哥python", '小天才', b''],
        'password': '123456',
        'phone': '13800000000',
        'sex': '男'
    }

    try:
        # user = User(**user_data)
        user = User.model_validate(user_data)
        print(f"User id: {user.id}, User name: {user.name}, User email: {user.email}")
    except ValidationError as e:
        print(f"Validation error: {e.json()}")

都符合模型定义的情况下,您可以像往常一样访问模型的属性:

User id: 123, User name: 小卤蛋, User email: xiaoludan@example.com
1
如果数据不符合模型的定义(以下故意不传 id 字段),Pydantic 将抛出一个 ValidationError。

自定义验证

除了内置的验证器,还可以为模型定义自定义验证器。假设要确保用户年龄在18岁以上,可以使用@field_validator装饰器创建一个自定义验证器:

# ! -*-conding: UTF-8 -*-
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, EmailStr, field_validator, ValidationError


def check_name(v: str) -> str:
    """Validator to be used throughout"""
    if not v.startswith("小"):
        raise ValueError("must be startswith 小")
    return v


class User(BaseModel):
    id: int
    name: str = "小卤蛋"
    age: int
    email: EmailStr
    signup_ts: Optional[datetime] = None
    friends: List[str] = []

    validate_fields = field_validator("name")(check_name)
'''上面这行代码是使用field_validator装饰器来为name字段添加一个自定义的验证函数check_name。field_validator装饰器允许你为模型的字段指定一个或多个验证函数,这些函数将在模型实例化时自动调用,以确保字段值符合特定的条件。
"name":指定了要验证的字段名。
check_name:是一个自定义的验证函数,它接受一个字符串参数v,并检查这个字符串是否以"小"开头。如果不是,它将抛出一个ValueError。

下面的@field_validator("age")是一个装饰器,用于为age字段添加一个自定义的验证函数。@field_validator装饰器的工作方式与field_validator类似,但它是作为一个装饰器直接应用于方法上的,而不是作为类属性。
"age":指定了要验证的字段名。
check_age:是一个类方法,它接受一个参数age,并检查这个值是否小于18。如果是,它将抛出一个ValueError。
'''

    @field_validator("age")
    @classmethod
    def check_age(cls, age):
        if age < 18:
            raise ValueError("用户年龄必须大于18岁")
        return age

当尝试创建一个只有12岁的小朋友用户:

if __name__ == '__main__':
    user_data = {
        "id": 123,
        "name": "小卤蛋",
        "age": 12,
        "email": "xiaoludan@example.com",
        'signup_ts': '2024-07-19 00:22',
        'friends': ["公众号:海哥python", '小天才', b''],
    }
    try:
        user = User(**user_data)
    except ValidationError as e:
        print(f"Validation error: {e.json()}")

将得到一个错误:
Validation error: [{"type":"value_error","loc":["age"],"msg":"Value error, 用户年龄必须大于18岁","input":12,"ctx":{"error":"用户年龄必须大于18岁"},"url":"https://errors.pydantic.dev/2.8/v/value_error"}]

或者,当name不是小开头的话也会报错

如果要同时动态校验多个字段,还可以使用model_validator装饰器。
# ! -*-conding: UTF-8 -*-
# @公众号: 海哥python
from datetime import datetime
from typing import List, Optional
from typing_extensions import Self  # 如果python版本不低于3.11,则可以直接从typing中导入Self
from pydantic import BaseModel, ValidationError, EmailStr, field_validator, model_validator


def check_name(v: str) -> str:
    """Validator to be used throughout"""
    if not v.startswith("小"):
        raise ValueError("must be startswith 小")
    return v


class User(BaseModel):
    id: int
    name: str = "小卤蛋"
    age: int
    email: EmailStr
    signup_ts: Optional[datetime] = None
    friends: List[str] = []

    validate_fields = field_validator("name")(check_name)

    @field_validator("age")
    @classmethod
    def check_age(cls, age):
        if age < 18:
            raise ValueError("用户年龄必须大于18岁")
        return age

    @model_validator(mode="after")
    def check_age_and_name(self) -> Self:
        if self.age < 30 and self.name != "小卤蛋":
            raise ValueError("用户年龄必须小于30岁, 且名字必须为小卤蛋")

        return self


if __name__ == '__main__':
    user_data = {
        "id": 123,
        "name": "小小卤蛋",
        "age": 20,
        "email": "xiaoludan@example.com",
        'signup_ts': '2024-07-19 00:22',
        'friends': ["公众号:海哥python", '小天才', b''],
    }
    try:
        user = User(**user_data)
        print(user.model_dump())
    except ValidationError as e:
        print(f"Validation error: {e.json()}")

深入学习以下

https://blog.csdn.net/python_9k/article/details/140711001

https://blog.csdn.net/weixin_43936332/article/details/131627430

python Annotated 注释模块

什么是Annotated模块?

Annotated是Python标准库中的一个模块,它提供了一种注解(Annotation)的实现方式。注解是Python 3.0引入的一种特性,它允许在函数、类和方法的定义中添加额外的信息,这些信息可以用于类型检查、文档生成等用途。Annotated模块通过提供一些装饰器和工具函数,使得注解的使用变得更加简单和便捷。

Annotated模块的使用方法

安装Annotated模块

在使用Annotated模块之前,首先需要安装它。可以通过以下命令使用pip安装Annotated模块:

pip install Annotated

安装完成后,就可以在Python代码中导入Annotated模块了。

基本注解类型

Annotated模块提供了几种基本的注解类型,包括AnnotatedUnionOptional。这些类型可以用于对函数参数、返回值和变量进行注解。

  • Annotated[type, metadata]:用于注解对象的类型并添加元数据。
  • Union[type1, type2, ...]:表示注解对象的类型可以是多个类型中的任意一个。
  • Optional[type]:表示注解对象的类型可以是指定类型或者None

下面的例子演示了如何使用这些基本注解类型:

from Annotated import Annotated, Union, Optional

def greet(name: Annotated[str, "The name of the person"]) -> str:
    return "Hello, " + name

def add(a: int, b: int) -> int:
    return a + b

def divide(a: int, b: Annotated[int, "The divisor"], *, remainder: Optional[bool] = False) -> Union[int, float]:
    if remainder:
        return a % b
    else:
        return a / b

在上面的例子中,greet函数的参数name被注解为字符串类型,并添加了一个描述信息。add函数的参数ab被注解为整数类型,返回值被注解为整数类型。divide函数的参数ab被注解为整数类型,并添加了描述信息,remainder参数被注解为布尔类型,默认值为False,返回值可以是整数或者浮点数类型。

使用注解

在使用Annotated模块时,可以通过调用AnnotatedUnionOptional等类型来添加注解。注解可以用于函数的参数、返回值和变量的声明中。

from typing import Annotated, Union, Optional

def greet(name: Annotated[str, "The name of the person"]) -> str:
    return "Hello, " + name

def divide(a: int, b: Annotated[int, "The divisor"], *, remainder: Optional[bool] = False) -> Union[int, float]:
    if remainder:
        return a % b
    else:
        return a / b

result = greet("Alice")
print(result)  # 输出:Hello, Alice

result = divide(10, 3)
print(result)  # 输出:3.3333333333333335

result = divide(10, 3, remainder=True)
print(result)  # 输出:1

在上面的例子中,通过调用Annotated函数来为函数的参数和返回值添加注解,注解的类型可以是基本类型,也可以是自定义类型。在调用函数时,可以传入符合注解类型的实参。

获取注解信息

使用Annotated模块可以方便地获取函数、方法和类的注解信息。可以通过get_type_hints函数来获取函数中参数和返回值的注解信息。

from Annotated import get_type_hints

def greet(name: Annotated[str, "The name of the person"]) -> str:
    return "

python插件架构介绍

一、插件架构
在 Python 中,插件架构通常指的是一种软件架构模式,它允许开发者在不改变主应用程序代码的情况下,向应用程序添加新的功能或修改现有功能。这种架构使得应用程序可以通过加载外部模块或组件来扩展其功能,这些外部模块或组件通常被称为“插件”。

Python 的插件架构涉及以下几个关键点:

模块化:Python 支持模块化设计,意味着应用程序可以被分解成独立、可替换、可重用的模块。插件本质上是这些模块的一种,它们遵循预定义的接口或协议。

接口定义:为了让插件能够与主应用程序交互,通常会定义一套接口或抽象基类。插件需要实现这些接口或继承并实现这些基类,从而提供必要的功能。

插件发现:应用程序需要有某种机制来发现可用的插件。这可以通过扫描特定目录、注册表项或使用插件管理器来实现。插件发现过程可能涉及动态加载 Python 模块。

插件加载与激活:一旦发现一个插件,应用程序需要知道如何加载并激活它。在 Python 中,这通常涉及到使用标准库中的 importlib 模块动态加载插件模块,并创建插件实例。

配置和定制:插件系统应该允许插件通过配置文件或环境变量等方式进行定制,以满足不同用户或不同环境的需求。

隔离和安全性:合理的插件架构应该确保插件之间以及插件与主应用程序之间有适当的隔离,以保护应用程序的整体安全性和稳定性。
1
2
3
4
5
6
7
8
9
10
11
Python 中实现插件架构的例子包括:

使用 setuptools 的 entry points:setuptools 提供了 entry points 机制,这是一种用于发现和加载插件的方法。开发者可以在 setup.py 文件中指定 entry points,然后在应用程序中通过 pkg_resources 或 importlib.metadata(Python 3.8+)来发现和加载符合 entry points 的模块。

使用专门的插件框架:如 pluggy(pytest 用它实现了插件系统)、yapsy、pluginbase 等。这些框架提供了插件的发现、加载和管理的更高级抽象。

自定义插件架构:开发者也可以根据自己的需求实现自定义的插件系统。这可能包括定义接口、编写插件加载机制和管理工具等。

利用插件架构,Python 应用程序可以变得更加灵活和可扩展,更容易适应不断变化的需求。
1
2
3
4
5
6
7
二、以pluggy模块,给一个代码案例
pluggy 是一个插件管理框架,它是由 pytest 团队开发的,用于构建可扩展的应用程序。以下是使用 pluggy 构建一个简单插件系统的代码示例:

首先,你需要安装 pluggy。可以使用 pip 进行安装:

pip install pluggy
1
步骤 1: 定义钩子规范
钩子规范是接口的声明,它定义了插件需要实现的方法和所需的参数。这些规范是插件开发者遵循的蓝图,确保了所有插件都有一致的接口。

hookspecs.py

import pluggy

创建一个钩子规范管理器

hookspec = pluggy.HookspecMarker(“myproject”)

class MySpec:
“””一个包含所有钩子规范的类。”””

@hookspec
def my_hook(self, arg1, arg2):
    """一个简单的钩子规范,插件需要实现这个接口。"""
    pass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
步骤 2: 实现插件
开发者根据钩子规范创建插件,提供具体的实现逻辑。

plugins.py

import pluggy

hookimpl = pluggy.HookimplMarker(“myproject”)

class MyPlugin:
“””一个插件实现,它实现了 my_hook 钩子。”””

@hookimpl
def my_hook(self, arg1, arg2):
    print(f"插件被调用,参数为:{arg1}, {arg2}")
    # 在这里执行插件的功能逻辑
    return arg1 + arg2

1
2
3
4
5
6
7
8
9
10
11
12
13
步骤 3: 注册钩子规范、注册插件、调用钩子
接下来,我们需要告诉插件管理器(PluginManager)有哪些钩子规范存在。这样,管理器才能知道哪些钩子可以被调用,以及它们应该接受哪些参数。

main.py

import pluggy
import hookspecs
import plugins

创建一个插件管理器

pm = pluggy.PluginManager(“myproject”)

将钩子规范注册到插件管理器中

pm.add_hookspecs(hookspecs.MySpec)

注册插件

pm.register(plugins.MyPlugin())

调用插件

result = pm.hook.my_hook(arg1=10, arg2=20)
print(result)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在这个过程中:

钩子规范 提供了一个统一的调用接口。
插件管理器 负责维护插件和钩子的注册信息,并在需要时调用正确的插件。
插件 提供了钩子的具体实现。
1
2
3
这种模式的优点是,主程序不需要知道插件的具体实现细节,只需要按照钩子规范调用接口即可。这允许主程序和插件开发者独立工作,只要遵守共同的规范。此外,可以随时添加或移除插件,而不需要修改主程序的代码,这提高了程序的模块化和可扩展性。

你运行 main.py 文件时,它会创建一个插件管理器,向管理器注册钩子规范和插件,并调用 my_hook 钩子。插件的 my_hook 方法会被执行,并打印参数和返回结果。

这个例子非常简单,但它展示了 pluggy 的基本用法,包括钩子规范的定义、插件的实现和它们的注册与调用。在实践中,pluggy 可以用于构建复杂的插件化系统,例如 pytest 测试框架就是一个很好的例子。

三、与传统继承类的区别
本质上,钩子规范和插件系统与抽象基类(ABCs)和继承确实有一些共同之处,但也存在关键的差异。让我们来探讨一下这两种概念:

相似之处:

接口定义:

抽象基类定义了一组抽象方法,子类必须实现这些方法。

钩子规范定义了一组钩子接口,插件必须实现这些接口。
1
2
3
封装和扩展性:

抽象基类允许通过创建新的子类来扩展功能。

钩子允许通过添加新的插件来扩展功能。
1
2
3
多态性:

在基于继承的系统中,多态性允许程序在运行时根据实际的子类类型来调用相应的方法。

在插件系统中,多态性允许程序在运行时根据注册的插件来调用相应的钩子实现。
1
2
3
差异之处:

松耦合与紧耦合:

继承通常产生紧耦合的关系,因为子类依赖于其基类的定义,且在编译时就确定了类之间的关系。

钩子和插件系统提供了更加松耦合的关系,因为插件可以在运行时动态加载和卸载,不需要在编译时知道具体的实现。
1
2
3
组合和灵活性:

继承可能导致类层次结构变得复杂,而且一个子类只能继承自一个基类(在不支持多重继承的语言中)。

插件系统允许以组合的方式将多个独立的插件组合在一起,每个插件可以独立地实现一个或多个钩子,为同一个钩子提供不同的行为。
1
2
3
动态性:

继承通常在编码阶段决定。类的结构在编译或解释之前就已经固定下来。

钩子和插件系统更加动态,允许在应用程序运行时动态地添加、移除或替换插件。
1
2
3
隔离性:

继承中的子类通常可以访问基类的保护成员,这在某种程度上减少了隔离性。

插件通常只能访问它们需要实现的钩子规范,不会与其他插件或主程序产生直接的依赖关系,从而保持了较高的隔离性。
1
2
3
综上所述,抽象基类和继承机制更适合于那些类结构和层次关系相对固定的场景,而钩子和插件系统提供了更高的灵活性和动态性,更适合于需要运行时扩展和修改的应用程序。两者都是解决代码复用和抽象的有效手段,但选择哪种方式取决于具体的设计需求和上下文环境。

有价值的资源:
https://developer.aliyun.com/article/308565

原文链接:https://blog.csdn.net/ningyanggege/article/details/135663015

LangGraph Studio:可视化调试基于LangGraph构建的AI智能体

之前我们在第一时间介绍过使用LangChain的LangGraph开发复杂的RAG或者Agent应用,随着版本的迭代,LangGraph已经成为可以独立于LangChain核心,用于开发多步骤、面向复杂任务、支持循环的AI智能体的强大框架。

近期LangGraph推出了一个使得复杂AI智能体开发更加高效的工具:LangGraph Studio,一个为可视化测试与调试基于LangGraph构建的AI智能体而设计的集成环境。本文将带领大家初步探索这个新的工具。

  1. 回顾LangGraph并创建测试智能体

LangGraph是用于构建基于LLM的复杂智能体的底层框架(注意LangGraph并非类似于Coze这样的低代码Agent构建平台),它的确更复杂但也更强大(与其类似的是另一家主流框架LlamaIndex推出的Workflows)。主要特点有:

**基于图结构定义的AI工作流
**

**支持复杂的循环与条件分支
**

**细粒度的智能体控制,而非智能体“黑盒子”
**

智能体状态的持久化,可单步控制、暂停与恢复

支持多智能体开发、人机交互工作流

现在让我们参考官方应用构建一个简单的测试智能体,这个智能体的Graph图定义如下:

这是一个非常简单的智能体,流程描述如下:

用户输入问题

调用LLM获得问题答案,并决定是否需要调用搜索工具

如果需要,则调用搜索引擎获得结果,并返回给LLM继续

如果不再需要搜索,则给出答案,流程结束

现在使用LangGraph实现这个智能体:

【定义State】

定义在工作流中传递与保持的“状态”数据,可以理解为全局共享数据:


from typing import TypedDict,TypedDict, Annotated, Sequence   from langgraph.graph import StateGraph, END   from langgraph.graph import add_messages   from langchain_core.messages import BaseMessage   from langchain_openai import ChatOpenAI   from langgraph.prebuilt import ToolNode   from langchain_community.tools.tavily_search import TavilySearchResults      class AgentState(TypedDict):`        `messages: Annotated[Sequence[BaseMessage], add_messages]




【定义Node】

定义一个工作流中的处理节点,这里主要有两个:LLM调用与搜索引擎调用。另外定义一个辅助判断方法,用来决定是否需要调用搜索引擎。

# 调用搜索引擎的工具节点,利用ToolNode构建  
tools = [TavilySearchResults(max_results=1)]  
tool_node = ToolNode(tools)  
  
# 调用大模型  
def call_llm(state):  
    messages = state["messages"]  
    messages = [{"role": "system", "content": "你是一个中文智能小助手。"}] + messages  
    model = ChatOpenAI(temperature=0, model_name="gpt-4o-mini")  
    model = model.bind_tools(tools)  
    response = model.invoke(messages)  
    return {"messages": [response]}  
  
# 一个辅助方法:判断是否需要调用工具  
def should_continue(state):  
    messages = state["messages"]  
    last_message = messages[-1]  
  
    #根据大模型的反馈来决定是结束,还是调用工具  
    if not last_message.tool_calls:  
        return "end"  
    else:  
        return "continue"

【定义Graph】

现在你可以定义Graph – 也就是智能体的工作流。

# 定义一个graph  
workflow = StateGraph(AgentState)  
  
# 增加两个节点  
workflow.add_node("llm", call_llm)  
workflow.add_node("search", tool_node)  
  
# 确定入口  
workflow.set_entry_point("llm")  
  
# 一个条件边,即从llm节点出来的两个分支及条件  
workflow.add_conditional_edges(  
    "llm",  
    should_continue,  
    {  
        "continue": "search",  
        "end": END,  
    },  
)  
  
# search调用后返回llm  
workflow.add_edge("search", "llm")  
  
#编译  
graph = workflow.compile()  
  
#本地测试代码  
if __name__ == "__main__":  
    while True:  
        user_input = input("User: ")  
        print("User: "+ user_input)  
        if user_input.lower() in ["quit", "exit", "q"]:  
            print("Goodbye!")  
            break  
          
        response = graph.invoke({"messages": [("user", user_input)]})  
        print(response["messages"][-1].content)

这里加上了本地测试代码,创建完成后可以在本地运行测试。

  1. LangGraph Studio是什么?

LangGraph Studio是LangChain推出的专门用于复杂智能体与RAG应用可视化、交互与调试的桌面集成开发环境。借助于LangGraph Studio,你可以非常直观的观察所创建的AI智能体的工作流程,并与其进行交互来调试它的多步骤任务,监控各个步骤的状态与输入输出,从而更快的发现故障与修复。

需要注意的几点是:

LangGraph Studio不是一个快速创建LangGraph智能体的开发平台(至少目前还不是)。

LangGraph Studio的调试是针对LangGraph智能体的Cloud部署模式,即:将智能体部署在独立的API Server中,并通过API调用智能体。

使用时,LangGraph Studio会把你的智能体打包成docker image,并在本地启动测试的API Server。原理如下图:

LangGraph Studio使用需要LangSmith的账号,可以去LangSmith免费注册,并获得LangSmith的API_Key。

  1. 用LangGraph Studio在本地加载智能体

【准备工作:Docker安装】

由于LangGraph Studio需要启动一个Docker作为API Server,因此依赖于Docker Engine,请首先安装Docker Desktop,并确保docker-compose的版本为2.22.0或更高版本。

【步骤1:下载LangGraph Studio并安装】

进入LangGraph Studio的github项目地址(搜索langgraph-studio),下载桌面客户端(暂时只有Mac版本,很快支持其他平台)。下载完成后安装打开,并使用LangSmith账户登录(免费账户也可)。

【步骤2:配置项目目录】

为了让LangGraph Studio能够认识并在构建的API Server(Docker Image)中加载你的智能体,你的智能体项目需要满足必要的项目结构,一般类似于:

这里的agent.py为基于LangGraph的智能体代码(参考上文),此外需要三个基本的配置:

langgraph.json:基本配置文件。定义依赖项、环境变量、智能体路径等配置的文件。下图是例子配置,内容很好理解,就是一些路径和指向,请根据自己实际的目录结构进行修改。

requirements.txt:项目依赖。用来在docker中运行Agent。下图是例子配置:

.env:智能体运行时需要的环境变量,比如OPENAI_API_KEY等。这里我们的配置项包括:

【步骤三:用LangGraph Studio加载智能体】

确保Docker后台在运行。

打开LangGraph Studio,使用LangSmith账户登录。

导航到你的langgraph.json文件所在的项目目录,并选择该目录打开。

如果一切正常,一段时间后(构建docker image并启动),你将会看到代理的可视化表示。常见的问题通常和配置错误有关,比如不正确的LangSmith的API Key,或者配置中的目录名称错误等。

用LangGraph Studio调试智能体

LangGraph Studio通过调用本地API Server(docker)中的智能体服务相关接口来向使用者提供一系列跟踪与调试功能,包括:

与智能体对话:发送消息并接受反馈
在左上角菜单中选择需要调试的智能体Graph,然后在下方的Input区域,选择+Message,添加你的输入消息,然后点击Submit,就可以调用智能体:

智能体的响应会显示在右侧区域,会清晰地显示每个步骤的执行情况:

编辑消息
LangGraph Studio一个重要的调试功能是可以查看当前运行线程中的某个步骤的消息,并对其进行编辑后创建一个新的“分支”运行,后续相关的步骤会在此基础上自动重新运行。通过这种方法,你可以观察到不同情况下的不同输出结果。比如,这里我们把这里搜索的结果手工做个修改:

然后点击下方的“Fork”按钮,此时智能体会从该节点生成一个新的“分支”运行,并重新输出结果。你可以点击下方的多个Fork之间的切换箭头来查看不同的结果,这对于智能体调试中观察不同中间结果的影响非常有用:

设置中断
LangGraph Studio允许给需要调用的智能体设置中断(interrupts)来控制工作流程。这有点类似程序调试中的断点与单步执行:你可以在特点节点的前后来暂停工作流的运行以进行观察,然后决定是否继续。这可以用于对智能体的每一步行为进行细粒度观察与控制,以排除某种故障。

在左侧窗口区域右上角的菜单点击Interrupts按钮,然后选择需要中断的节点及时间点(节点前与节点后),然后运行。比如这里对所有节点设置中断,就会发现输出窗格中需要确认“Continue”后才会继续运行,否则将会一直阻塞等待:

运行线程管理
你可以在客户端管理多个智能体运行线程,而不互相影响。在右侧区域左上角菜单中选择线程进行切换,或者创建一个新的线程,打开新的窗口,这样就可以启动一个新的会话。

与LangSmith/VSCode的集成
LangGraph Studio与同属一家的LangSmith有着良好的集成,你可以在LangSmith中打开项目,可以查看到智能体的每一次运行的详细细节、输入输出、耗时、tokens使用、提示词等:

此外,你可以在LangGraph Studio中直接打开VScode对智能体代码进行编辑修改,修改后智能体会实时更新并部署到docker中,然后就可以重新调试,这对于需要反复修改迭代的智能体开发非常有用。

LangGraph Studio提供了一种可视化调试AI智能体的实用方法。它与LangGraph、LangSmith一起组成了一个构建复杂AI智能体的强大工具集。这个工具集既具有了底层开发框架的灵活(相对于低代码开发平台更强大与可控),也兼顾了智能体在跟踪与调试时所需要的简洁易用。
LangGraph Studio:可视化调试基于LangGraph构建的AI智能体

原文链接:https://blog.csdn.net/m0_63171455/article/details/142642259

http://www.360doc.com/content/24/0412/19/47115229_1120203963.shtml

Python中常用的装饰器@classmethod、@abstractmethod、@property和@staticmethod

在Python编程中,装饰器是一种强大而灵活的工具,可以在不修改源代码的情况下修改函数或类的行为。本文将介绍几个常用的装饰器,包括@classmethod@abstractmethod@property@staticmethod,并提供代码示例,以帮助你更好地理解它们的用法。

@classmethod

@classmethod装饰器用于定义类方法(classmethods)。类方法与普通方法不同,它在类层级上操作,而不是在实例层级上。通过类方法,我们可以直接通过类名调用方法,而无需创建类的实例。

以下是一个使用@classmethod装饰器定义类方法的示例:

class MathUtils:
    @classmethod
    def multiply(cls, a, b):
        return a * b

result = MathUtils.multiply(5, 3)
print(result)  # 输出: 15

在上面的示例中,MathUtils类定义了一个类方法multiply,通过@classmethod装饰器标记。类方法的第一个参数通常被命名为cls,它指向类本身。通过类方法,我们可以直接在类层级上进行操作,而无需实例化类。

@abstractmethod

@abstractmethod装饰器用于定义抽象方法(abstract methods)。抽象方法在基类中声明但没有具体实现,需要在派生类中进行实现。如果一个类中包含抽象方法,那么该类必须声明为抽象类,无法实例化。

以下是一个使用@abstractmethod装饰器定义抽象方法的示例:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        return 3.14 * self.radius ** 2

# 创建 Circle 对象
circle = Circle(5)
print(circle.area())  # 输出: 78.5

在上面的示例中,Shape类是一个抽象基类,其中包含一个抽象方法area。通过使用@abstractmethod装饰器,我们可以声明area方法为抽象方法,无需提供具体实现。派生类Circle继承了Shape类,并实现了area方法,使其具有特定的功能。

@property

@property装饰器用于将一个类方法转换为只读属性(read-only property)。通过使用@property装饰器,我们可以定义一个特殊的方法,使其在使用点符号访问时,像访问属性一样,而不是通过函数调用。

以下是一个使用@property装饰器定义属性的示例:

class Person:
    def __init__(self, name):
        self._name = name
    
    @property
    def name(self):
        return self._name

# 创建 Person 对象
person = Person("John")
print(person.name)  # 输出: John

在上面的示例中,Person类定义了一个属性name,使用@property装饰器将name方法转换为只读属性。这样,我们可以通过属性方式访问name,而无需显式调用方法。

@staticmethod

@staticmethod装饰器用于定义静态方法(staticmethods)。静态方法在类的命名空间中定义,与类的实例无关,因此不需要通过实例来调用。静态方法可以直接通过类名调用。

以下是一个使用@staticmethod装饰器定义静态方法的示例:

class MathUtils:
    @staticmethod
    def add(a, b):
        return a + b

result = MathUtils.add(5, 3)
print(result)  # 输出: 8

在上面的示例中,MathUtils类定义了一个静态方法add,通过@staticmethod装饰器标记。静态方法可以直接通过类名调用,无需实例化类。

总结

装饰器是Python中强大而灵活的工具,可以优化代码结构、提供额外功能,并提高代码的可读性。本文介绍了@classmethod@abstractmethod@property@staticmethod这几个装饰器的使用方法,并提供了相应的代码示例。

希望通过本文的介绍,你能更好地理解这些装饰器的作用,并在自己的代码中灵活应用它们。

Python设计模式-组合模式

组合模式(Composite Pattern)是一种结构型设计模式,它允许你将对象组合成树形结构来表示“部分-整体”的层次结构。组合模式使得客户端可以统一地处理单个对象和对象组合。

组合模式的结构
组合模式主要包含以下几个角色:

组件(Component):定义对象的接口,并实现一些默认行为。声明一个接口,用于访问和管理Leaf和Composite中的子组件。
叶子(Leaf):代表树的叶子节点,叶子节点没有子节点。
组合(Composite):定义有子部件的那些部件的行为,存储子部件。并在组件接口中实现与子部件有关的操作,如添加、删除等。
组合模式的示例
假设我们有一个图形绘制系统,可以绘制简单的形状如圆和方块,也可以将这些形状组合成复杂的图形。我们可以使用组合模式来实现这一需求。

定义组件

from abc import ABC, abstractmethod

class Graphic(ABC):
    @abstractmethod
    def draw(self):
        pass

    def add(self, graphic):
        raise NotImplementedError("This method is not supported")

    def remove(self, graphic):
        raise NotImplementedError("This method is not supported")

    def get_child(self, index):
        raise NotImplementedError("This method is not supported")

定义叶子

class Circle(Graphic):
    def draw(self):
        print("Drawing a circle")

class Square(Graphic):
    def draw(self):
        print("Drawing a square")


定义组合
class CompositeGraphic(Graphic):
    def __init__(self):
        self.children = []

    def draw(self):
        for child in self.children:
            child.draw()

    def add(self, graphic):
        self.children.append(graphic)

    def remove(self, graphic):
        self.children.remove(graphic)

    def get_child(self, index):
        return self.children[index]

使用组合模式
def main():
    # 创建叶子节点
    circle1 = Circle()
    circle2 = Circle()
    square1 = Square()

    # 创建组合节点
    composite1 = CompositeGraphic()
    composite2 = CompositeGraphic()

    # 组合图形
    composite1.add(circle1)
    composite1.add(circle2)

    composite2.add(square1)
    composite2.add(composite1)

    # 绘制组合图形
    composite2.draw()

if __name__ == "__main__":
    main()

在这个示例中,Graphic是抽象组件类,定义了绘制方法。Circle和Square是叶子类,分别实现了绘制方法。CompositeGraphic是组合类,实现了管理子组件的方法,并重写了绘制方法来递归绘制子组件。客户端通过组合叶子节点和组合节点来创建复杂的图形结构,并统一调用draw方法进行绘制。

组合模式的优缺点
优点
统一处理单个对象和组合对象:组合模式使得客户端可以统一地处理单个对象和对象组合,提高了代码的灵活性和可扩展性。
简化客户端代码:客户端代码可以一致地使用组件接口,而不需要关心处理的是单个对象还是组合对象。
符合开闭原则:可以通过增加新的叶子和组合类来扩展系统,而不需要修改现有代码。
缺点
增加复杂性:组合模式会增加系统中类和对象的数量,可能会使系统变得复杂。
难以限制组合层次:有时需要对组合层次进行限制,但组合模式本身没有提供这样的机制。
组合模式的适用场景
表示部分-整体层次结构:当需要表示对象的部分-整体层次结构时,可以使用组合模式。
统一处理单个对象和组合对象:当需要统一处理单个对象和组合对象时,可以使用组合模式。
构建递归结构:当需要构建递归结构(如树形结构)时,可以使用组合模式。
总结
组合模式是一种结构型设计模式,通过将对象组合成树形结构来表示“部分-整体”的层次结构,使得客户端可以统一地处理单个对象和对象组合。组合模式适用于表示部分-整体层次结构、统一处理单个对象和组合对象以及构建递归结构的场景。合理应用组合模式,可以提高系统的灵活性和可扩展性,简化客户端代码。理解并掌握组合模式,有助于在实际开发中构建高效、灵活的系统。

                        
原文链接:https://blog.csdn.net/weixin_55252589/article/details/139074443

Python设计模式-工厂方法模式

工厂方法模式(Factory Method Pattern)是一种创建型设计模式,它定义了一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法使得一个类的实例化延迟到其子类。通过使用工厂方法模式,可以将对象的创建过程与使用过程分离,从而提高代码的灵活性和可扩展性。

工厂方法模式的结构
工厂方法模式主要包括以下几个角色:

抽象产品(Product):定义产品的接口。
具体产品(ConcreteProduct):实现抽象产品接口的具体产品类。
抽象工厂(Creator):声明工厂方法,用于返回一个产品对象。可以定义一个工厂方法的默认实现。
具体工厂(ConcreteCreator):实现抽象工厂接口,重定义工厂方法以返回一个具体产品实例。
示例
假设我们有一个日志系统,可以记录日志到控制台或文件。我们可以使用工厂方法模式来实现不同日志记录方式的选择和创建。

定义抽象产品和具体产品

from abc import ABC, abstractmethod

class Logger(ABC):
    @abstractmethod
    def log(self, message: str):
        pass

class ConsoleLogger(Logger):
    def log(self, message: str):
        print(f"Console: {message}")

class FileLogger(Logger):
    def __init__(self, filename: str):
        self.filename = filename

    def log(self, message: str):
        with open(self.filename, 'a') as f:
            f.write(f"File: {message}\n")

定义抽象工厂和具体工厂

class LoggerFactory(ABC):
    @abstractmethod
    def create_logger(self) -> Logger:
        pass

class ConsoleLoggerFactory(LoggerFactory):
    def create_logger(self) -> Logger:
        return ConsoleLogger()

class FileLoggerFactory(LoggerFactory):
    def __init__(self, filename: str):
        self.filename = filename

    def create_logger(self) -> Logger:
        return FileLogger(self.filename)

使用工厂方法模式

def main():
    # 创建控制台日志记录器
    console_factory = ConsoleLoggerFactory()
    console_logger = console_factory.create_logger()
    console_logger.log("This is a console log message.")

    # 创建文件日志记录器
    file_factory = FileLoggerFactory("app.log")
    file_logger = file_factory.create_logger()
    file_logger.log("This is a file log message.")

if __name__ == "__main__":
    main()

在这个示例中,Logger是抽象产品,ConsoleLogger和FileLogger是具体产品。LoggerFactory是抽象工厂,ConsoleLoggerFactory和FileLoggerFactory是具体工厂。通过工厂方法模式,我们可以灵活地选择和创建不同类型的日志记录器,而不需要修改客户端代码。

工厂方法模式的优缺点
优点
遵循开闭原则:可以在不修改现有代码的情况下增加新产品。
提高灵活性:可以根据需要在运行时选择和创建具体的产品。
封装对象创建过程:将对象的创建过程封装在工厂类中,减少了客户端代码的复杂性。
缺点
增加代码复杂性:引入更多的类和接口,增加了代码的复杂性。
难以管理:当产品种类增多时,可能会导致工厂类的数量增加,管理起来较为困难。
工厂方法模式的适用场景
创建对象需要较复杂的过程:对象的创建过程较为复杂,包含多个步骤或涉及多个依赖时,可以使用工厂方法模式。
需要灵活地创建不同类型的对象:根据不同的条件或环境,在运行时选择和创建不同类型的对象。
遵循开闭原则:需要在不修改现有代码的情况下增加新产品。
工厂方法模式与简单工厂模式的区别
简单工厂模式:由一个工厂类负责创建所有产品,工厂类通常包含一个静态方法,根据传入的参数来创建具体产品。简单工厂模式不符合开闭原则。
工厂方法模式:将对象创建的职责分散到多个具体工厂类中,每个具体工厂类负责创建一种具体产品。工厂方法模式符合开闭原则。
总结
工厂方法模式是一种创建型设计模式,通过定义一个用于创建对象的接口,将对象的创建过程延迟到子类,从而提高代码的灵活性和可扩展性。通过使用工厂方法模式,可以在不修改现有代码的情况下增加新产品,减少了代码耦合,提高了系统的可维护性。合理应用工厂方法模式,可以显著提升代码质量和设计水平。

原文链接:https://blog.csdn.net/weixin_55252589/article/details/139072230