2023年4月

@

原理

定义一个拖拽物,和它拖拽的目标,拖拽物可以理解为一个平底锅(pan),拖拽目标是一个坑(pit),当拖拽物进入坑时,拖拽物就会被吸附在坑里。可以脑补一下下图:

你问我为什么是平底锅和坑,当然了在微软官方的写法里pan是平移的意思,而不是指代平底锅。只是通过同义词来方便理解
坑就是正好是平底锅大小的炉灶。正好可以放入平底锅。

pan和pit组成平移手势的系统,在具体代码中包含了边缘检测判定和状态机维护。我们将一步步实现平移手势功能

pit很简单,是一个包含了名称属性的控件,这个名称属性是用来标识pit的。以便当pan入坑时我们知道入了哪个坑,IsEnable是一个绑定属性,它用来控制pit是否可用的。

在这个程序中,拖拽物是一个抽象的唱盘。它的拖拽目标是周围8个图标。

交互实现

这里用Grid作为pit控件基类型,因为Grid可以包含子控件,我们可以在pit控件中添加子控件,比如一个图片,一个文字,这样就可以让pit控件更加丰富。


public class PitGrid : Grid
{
    public PitGrid()
    {
        IsEnable = true;
    }

    public static readonly BindableProperty IsEnableProperty =
        BindableProperty.Create("IsEnable", typeof(bool), typeof(CircleSlider), true, propertyChanged: (bindable, oldValue, newValue) =>
        {
            var obj = (PitGrid)bindable;
            obj.Opacity = obj.IsEnable ? 1 : 0.8;

        });

    public bool IsEnable
    {
        get { return (bool)GetValue(IsEnableProperty); }
        set { SetValue(IsEnableProperty, value); }
    }

    public string PitName { get; set; }

}

使用WeakReferenceMessenger作为消息中心,用来传递pan和pit的交互信息。

定义一个平移事件PanAction,在pan和pit产生交汇时触发。其参数PanActionArgs描述了pan和pit的交互的关系和状态。

public class PanActionArgs
{
    public PanActionArgs(PanType type, PitGrid pit = null)
    {
        PanType = type;
        CurrentPit = pit;
    }
    public PanType PanType { get; set; }
    public PitGrid CurrentPit { get; set; }

}

手势状态类型PanType定义如下:

  • In:pan进入pit时触发,
  • Out:pan离开pit时触发,
  • Over:释放pan时触发,
  • ·Start:pan开始拖拽时触发
public enum PanType
{
    Out, In, Over, Start
}

MAUI为我们开发者包装好了
PanGestureRecognizer
即平移手势识别器。

平移手势更改时引发事件
PanUpdated
事件,此事件附带的 PanUpdatedEventArgs对象中包含以下属性:

  • StatusType,类型 GestureStatus为 ,指示是否为新启动的手势、正在运行的手势、已完成的手势或取消的手势引发了事件。
  • TotalX,类型 double为 ,指示自手势开始以来 X 方向的总变化。
  • TotalY,类型 double为 ,指示自手势开始以来 Y 方向的总变化。

容器控件

PanGestureRecognizer
提供了当手指在屏幕移动这一过程的描述我们需要一个容器控件来对拖拽物进行包装,以赋予拖拽物响应平移手势的能力。

创建平移手势容器控件:在Controls目录中新建PanContainer.xaml,代码如下:

<?xml version="1.0" encoding="UTF-8"?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui" 
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MatoMusic.Controls.PanContainer">
    <ContentView.GestureRecognizers>
        <PanGestureRecognizer PanUpdated="PanGestureRecognizer_OnPanUpdated"></PanGestureRecognizer>
        <TapGestureRecognizer Tapped="TapGestureRecognizer_OnTapped"></TapGestureRecognizer>

    </ContentView.GestureRecognizers>
</ContentView>

为PanContainer添加PitLayout属性,用来存放pit的集合。
打开PanContainer.xaml.cs,添加如下代码:


private IList<PitGrid> _pitLayout;

public IList<PitGrid> PitLayout
{
    get { return _pitLayout; }
    set { _pitLayout = value; }
}

CurrentView属性为当前拖拽物所在的pit控件。


private PitGrid _currentView;

public PitGrid CurrentView
{
    get { return _currentView; }
    set { _currentView = value; }
}

添加PositionX和PositionY两个可绑定属性,用来设置拖拽物的初始位置。当值改变时,将拖拽物的位置设置为新的值。


public static readonly BindableProperty PositionXProperty =
 BindableProperty.Create("PositionX", typeof(double), typeof(PanContainer), default(double), propertyChanged: (bindable, oldValue, newValue) =>
 {
     var obj = (PanContainer)bindable;
     //obj.Content.TranslationX = obj.PositionX;
     obj.Content.TranslateTo(obj.PositionX, obj.PositionY, 0);

 });

public static readonly BindableProperty PositionYProperty =
BindableProperty.Create("PositionY", typeof(double), typeof(PanContainer), default(double), propertyChanged: (bindable, oldValue, newValue) =>
{
    var obj = (PanContainer)bindable;
    obj.Content.TranslateTo(obj.PositionX, obj.PositionY, 0);
    //obj.Content.TranslationY = obj.PositionY;

});

订阅PanGestureRecognizer的PanUpdated事件:

 private async void PanGestureRecognizer_OnPanUpdated(object sender, PanUpdatedEventArgs e)
{
    var isInPit = false;
    var isAdsorbInPit = false;

    switch (e.StatusType)
    {
        case GestureStatus.Started: // 手势启动
            break;
        case GestureStatus.Running: // 手势正在运行
            break;
        case GestureStatus.Completed: // 手势完成
            break;   
    }
}              

接下来我们将对手势的各状态:启动、正在运行、已完成的状态做处理

手势开始

  • GestureStatus.Started:手势开始时触发, 触发动画效果,将拖拽物缩小,同时向消息订阅者发送PanType.Start消息。
case GestureStatus.Started:
    Content.Scale=0.5;
    WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.Start, this.CurrentView), TokenHelper.PanAction);

    break;

手势运行

GestureStatus.Running:手势正在运行时触发,这个状态下,
根据手指在屏幕上的移动距离来计算translationX和translationY,他们是拖拽物在X和Y方向上的移动距离。
在X轴方向不超过屏幕的左右边界,即x不得大于this.Width - Content.Width / 2,不得小于 0 - Content.Width / 2

同理
在Y轴方向不超过屏幕的上下边界,即y不得大于this.Height - Content.Height / 2,不得小于 0 - Content.Height / 2

代码如下:

 case GestureStatus.Running:
    var translationX =
        Math.Max(0 - Content.Width / 2, Math.Min(PositionX + e.TotalX, this.Width - Content.Width / 2));
    var translationY =
        Math.Max(0 - Content.Height / 2, Math.Min(PositionY + e.TotalY, this.Height - Content.Height / 2));

接下来判定拖拽物边界

pit的边界是通过Region类来描述的,Region类有四个属性:StartX、EndX、StartY、EndY,分别表示pit的左右边界和上下边界。

public class Region
{
    public string Name { get; set; }
    public double StartX { get; set; }
    public double EndX { get; set; }
    public double StartY { get; set; }
    public double EndY { get; set; }
}

对PitLayout中的pit进行遍历,判断拖拽物是否在pit内,如果在,则将isInPit设置为true。

判定条件是如果拖拽物的中心位置在pit的边缘内,则认为拖拽物在pit内。


```csharp
if (PitLayout != null)
{

    foreach (var item in PitLayout)
    {

        var pitRegion = new Region(item.X, item.X + item.Width, item.Y, item.Y + item.Height, item.PitName);
        var isXin = translationX >= pitRegion.StartX - Content.Width / 2 && translationX <= pitRegion.EndX - Content.Width / 2;
        var isYin = translationY >= pitRegion.StartY - Content.Height / 2 && translationY <= pitRegion.EndY - Content.Height / 2;
        if (isYin && isXin)
        {
            isInPit = true;
            if (this.CurrentView == item)
            {
                isSwitch = false;
            }
            else
            {
                if (this.CurrentView != null)
                {
                    isSwitch = true;
                }
                this.CurrentView = item;

            }

        }
    }

}

isSwitch是用于检测是否跨过pit,当CurrentView非Null改变时,说明拖拽物跨过了紧挨着的两个pit,需要手动触发PanType.Out和PanType.In消息。

IsInPitPre用于记录在上一次遍历中是否已经发送了PanType.In消息,如果已经发送,则不再重复发送。

if (isInPit)
{
    if (isSwitch)
    {
        WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.Out, this.CurrentView), TokenHelper.PanAction);
        WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.In, this.CurrentView), TokenHelper.PanAction);
        isSwitch = false;
    }
    if (!isInPitPre)
    {
        WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.In, this.CurrentView), TokenHelper.PanAction);
        isInPitPre = true;


    }
}
else
{
    if (isInPitPre)
    {
        WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.Out, this.CurrentView), TokenHelper.PanAction);
        isInPitPre = false;
    }
    this.CurrentView = null;

}

最后,将拖拽物控件移动到当前指尖的位置上:

Content.TranslationX = translationX;
Content.TranslationY = translationY;

break;

手势结束

  • GustureStatus.Completed:手势结束时触发,触发动画效果,将拖拽物放大,同时回弹至原来的位置,最后向消息订阅者发送PanType.Over消息。
case GestureStatus.Completed:

    Content.TranslationX= PositionX;
    Content.TranslationY= PositionY;
    Content.Scale= 1;
    WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.Over, this.CurrentView), TokenHelper.PanAction);

    break;

使用控件

拖拽物

拖拽物可以是任意控件。它将响应手势。在这里定义一个圆形的250*250的半通明黑色BoxView,这个抽象的唱盘就是拖拽物。将响应“平移手势”和“点击手势”

<BoxView HeightRequest="250"
        WidthRequest="250"
        Margin="7.5"
        Color="#60000000"
        VerticalOptions="CenterAndExpand"
        HorizontalOptions="CenterAndExpand"
        CornerRadius="250" ></BoxView>

创建pit集合

MainPage.xaml中定义一个
PitContentLayout
,这个AbsoluteLayout类型的容器控件,内包含一系列控件作为pit,这些pit集合将作为平移手势容器的判断依据。

<AbsoluteLayout x:Name="PitContentLayout">
    <--pit控件-->
    ...
</AbsoluteLayout>

在页面加载完成后,将PitContentLayout中的pit集合赋值给平移手势容器的PitLayout属性。

private async void MainPage_Appearing(object sender, EventArgs e)
{
    this.DefaultPanContainer.PitLayout=this.PitContentLayout.Children.Select(c => c as PitGrid).ToList();
}

至此我们完成了平移手势系统的搭建。

这个控件可以拓展到任何检测手指在屏幕上的移动,并可用于将移动应用于内容的用途,例如地图或者图片的平移拖拽等。

项目地址

Github:maui-samples

准备数据集:从CIFAR-10抽离鸟与飞机的图片

from torchvision import datasets
from torchvision import transforms
data_path = './data'

# 加载训练集
cifar10 = datasets.CIFAR10(root = data_path, train=True, download=False)
# 加载验证集
cifar10_val = datasets.CIFAR10(root=data_path, train=False, download=False)

# 使用To_Tensor 将 32*32*3 的图片格式转为 3*32*32 的张量格式
to_tensor = transforms.ToTensor()

# 进行标签转换,否则下面开始训练时会报错:IndexError: Target 2 is out of bounds
label_map={0:0, 2:1}

# 分别从训练集和验证集中抽取鸟与飞机图片
cifar2 = [(to_tensor(img), label_map[label]) for img, label in cifar10 if label in [0, 2]]
cifar2_val = [(to_tensor(img), label_map[label]) for img, label in cifar10_val if label in [0, 2]]

验证下,是否获取成功

import matplotlib.pyplot as plt
img, _ = cifar2[100]
plt.imshow(img.permute(1, 2, 0))
<matplotlib.image.AxesImage at 0x29bdaed6aa0>

使用
DataLoader
封装数据集

from torch.utils.data import DataLoader

# 训练集数据加载器
train_loader = DataLoader(cifar2, batch_size=64, pin_memory=True, shuffle=True, num_workers=4, drop_last=True) # type: ignore
# 验证集数据加载器
val_loader = DataLoader(cifar2_val, batch_size=64, pin_memory=True, num_workers=4, drop_last=True)

子类化nn.Module

我们打算放弃
nn.Sequential
带来的灵活性。使用更自由的子类化
nn.Module


为了子类化
nn.Module
,我们至少需要定义一个
forward()
函数,该函数用于接收模块的输入并返回输出,这便是模块计算的之处。


Pytorch
中,如果使用标准的
torch
操作,自动求导将自动处理反向传播,也就是不需要定义
backward()
函数。

重新定义我们的模型:

import torch
from torch import nn
import torch.nn.functional as F

class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, padding=1)    # 卷积层
        self.conv2 = nn.Conv2d(in_channels=16, out_channels=8, kernel_size=3, padding=1)
        self.fc1 = nn.Linear(8*8*8, 32) # 全连接层,8个8x8的特征图,每个特征图有8个通道
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)    # 图片初始大小为32x32,经过第一次池化,特征图大小为16x16
        out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)  # 经过池化,特征图大小为8x8
        out = out.view(-1, 8*8*8)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

假设卷积层输入特征图大小为
\(W_{in}\times H_{in}\)
,卷积核大小为
\(K\)
,padding大小为
\(P\)
,stride为
\(S\)
,卷积层输出特征图大小为
\(W_{out}\times H_{out}\)
,那么有如下公式:

\(W_{out} = \lfloor \frac{W_{in}+2P-K}{S} \rfloor +1\)

\(H_{out} = \lfloor \frac{H_{in}+2P-K}{S} \rfloor +1\)
其中,
\(\lfloor x \rfloor\)
表示将
\(x\)
向下取整的结果。

在这个代码中,第一个卷积层的输入特征图大小为32x32,卷积核大小为3,padding大小为1,stride为1,因此将上述公式代入计算,得到:

\(W_{out} = \lfloor \frac{32+2\times1-3}{1} \rfloor +1 = 32\)

\(H_{out} = \lfloor \frac{32+2\times1-3}{1} \rfloor +1 = 32\)

因此,第一个卷积层的输出特征图大小为32x32。

简单测试下模型是否运行

model = Net()
model(img.unsqueeze(0))
tensor([[-0.0153, -0.1532]], grad_fn=<AddmmBackward0>)

训练卷积神经网络

训练过程有两个迭代组成:

  • 第一层迭代:代表迭代周期(epoch)
  • 第二层迭代:对
    DataLoader
    传来的每批次数据集进行训练

在每一次循环中:

  • 向模型提供输入(正向传播)
  • 计算损失(正向传播)
  • 将老梯度归零
  • 调用
    loss.backward()
    来计算损失相对所有参数的梯度(反向传播)
  • 让优化器朝着更低的损失迈进

定义训练的函数,并尝试在GPU上进行训练:

device =torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Training on {device}.")
Training on cuda.
import datetime

def train_loop(n_epochs, optimizer, model, loss_fn, train_loader):
    for epoch in range(1, n_epochs+1):
        loss_train = 0.0
        for imgs, labels in train_loader:   # 在数据加载器中获取批处理循环数据集

            imgs = imgs.to(device=device)   # 这两行代码将imgs labels移动到device指定的设备
            labels = labels.to(device=device)

            outputs = model(imgs)           # 通过模型计算一个批次的结果
            loss = loss_fn(outputs, labels) # 计算最小化损失
            optimizer.zero_grad()           # 去掉最后一轮的梯度
            loss.backward()                 # 执行反向传播
            optimizer.step()                # 更新模型
            loss_train += loss.item()       # 对每层循环得到的损失求和,避免梯度变化

        if epoch ==1 or epoch%10 == 0:
            print("{} Epoch {}, Train loss {}".             # 总损失/训练数据加载器的长度,得到每批平均损失
                  format(datetime.datetime.now(), epoch, loss_train / len(train_loader)))

上面已经准备好了
model

train_loader
,还需准备
optimizere

loss_fn

import torch.optim as optim

# 模型也需要搬到GPU,否则会报错:
model = Net().to(device=device)    # RuntimeError: Input type (torch.cuda.FloatTensor) and weight type (torch.FloatTensor) should be the same

optimizer = optim.SGD(model.parameters(), lr=1e-2)  # 使用随机梯度下降优化器
loss_fn = nn.CrossEntropyLoss() # 交叉熵损失

# 调用训练循环
train_loop(n_epochs=100,
            optimizer=optimizer,
            model=model,
            loss_fn=loss_fn,
            train_loader=train_loader)
2023-04-08 16:49:02.897419 Epoch 1, Train loss 0.6789790311684976
2023-04-08 16:50:12.260929 Epoch 10, Train loss 0.45727716023341203
2023-04-08 16:51:29.474510 Epoch 20, Train loss 0.3460641039105562
2023-04-08 16:52:45.412158 Epoch 30, Train loss 0.3255017975775095
2023-04-08 16:53:59.949844 Epoch 40, Train loss 0.3127688937462293
2023-04-08 16:55:14.758279 Epoch 50, Train loss 0.3003842735137695
2023-04-08 16:56:29.352129 Epoch 60, Train loss 0.2895182979603608
2023-04-08 16:57:44.294486 Epoch 70, Train loss 0.2761662933879938
2023-04-08 16:58:58.890680 Epoch 80, Train loss 0.2641859925710238
2023-04-08 17:00:13.058129 Epoch 90, Train loss 0.25313296078298336
2023-04-08 17:01:27.434814 Epoch 100, Train loss 0.2413799591266956
# 再创建一个没有被打乱的训练数据加载器,用于验证
train_loader_ = DataLoader(cifar2, batch_size=64, shuffle=False, num_workers=4, drop_last=True)

def validate(model, train_loader, val_loader):
    for name, loader in [('trian', train_loader), ('val', val_loader)]:
        correct = 0
        total = 0
        with torch.no_grad():   # 在这里,我们希望不更新参数
            for imgs, labels in loader:

                imgs = imgs.to(device=device)
                labels = labels.to(device=device)

                outputs = model(imgs)
                _, predicted = torch.max(outputs, dim=1)    # 将最大值的索引作为输出

                total += labels.shape[0]
                correct += int((predicted == labels).sum())
        print("Accuracy: {}: {}".format(name, correct/total))

validate(model, train_loader_, val_loader)
Accuracy: trian: 0.9037459935897436
Accuracy: val: 0.8765120967741935

准确率确实还可以,但模型结构还是过于简单,继续顺着书本调整下!

改进神经网络

一般来说,模型训练结果的优劣主要有三方面决定:1、模型结构;2、训练过程;3、数据集。

在这里,暂不考虑第三种带来的变化,事实上,很多情况下,数据集的质量很能影响模型的泛化性,但是由于我们使用的是专门用于教学的数据集,因此只考虑前两种变化对模型预测精确度带来的变化。

增加内存容量:宽度

宽度,即神经网络的宽度:每层神经元数,或每个卷积的通道数。

我们只需要在第1个卷积层中指定更多的输出通道,并相应地增加后续层数,便可得到更长的向量。

此外,将模型训练过程中的中间通道数作为参数而不是硬编码数字传递给
__init__()

现在重写
Net
类:

class NetWidth(nn.Module):
    def __init__(self, n_channel=32):
        super().__init__()
        self.n_channel = n_channel
        self.conv1 = nn.Conv2d(in_channels=3, out_channels=n_channel, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(in_channels=n_channel, out_channels=n_channel//2,        # 增加了神经网络的宽度
                               kernel_size=3, padding=1)  
        self.fc1 = nn.Linear((n_channel//2)*8*8, 32)    
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
        out = F.max_pool2d(torch.tanh(out), 2)
        out = out.view(-1, (self.n_channel//2)*8*8)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

现在看看改变了宽度后,模型的参数数量:

n1 = sum(p.numel() for p in model.parameters())  # 增加宽度前的模型参数数量
model2 = NetWidth().to(device=device)            
n2 = sum(p.numel() for p in model2.parameters())    # 增加宽度后的模型参数数量
print(n1)
print(n2)
18090
38386

容量越大,模型所能管理的输入的可变性就越大。但是相应的,模型出现过拟合的可能性也会增加。

处理增加数据集来避免过拟合之外,还可以调整训练过程。

模型收敛和泛化:正则化

  1. 权重惩罚
    稳定泛化第一种方法添加正则化项。在这里我们添加
    L2
    正则化,它是所有权重的平方和(
    L1
    正则化是模型中所有权重的绝对值之和)。

    L2
    正则化也成为权重衰减,对参数的负梯度为:
    \(w_i=-2\times lambda\times w_i\)
    ,其中
    lambda
    为超参数,在Pytorch中称为权重衰减。

    因此,在损失函数中加入L2正则化,相当于在优化步骤中将每个权重按其当前值的比例递减。权重参数适用于网络的所有参数,例如偏置。
def training_loop_l2reg(n_epochs, optimizer, model, loss_fn, train_loader):
    for epoch in range(1, n_epochs+1):
        loss_train = 0.0
        for imgs, labels in train_loader:
            imgs = imgs.to(device=device)
            labels = labels.to(device=device)
            outputs = model(imgs)
            loss = loss_fn(outputs, labels)

            l2_lambda = 0.001       # 加入L2正则化
            l2_norm = sum(p.pow(2.0).sum() for p in model.parameters())

            loss = loss+l2_lambda*l2_norm
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            loss_train += loss.item()

        if epoch==1 or epoch%10 == 0:
            print("{} Epoch {}, Training loss {}".format(
                datetime.datetime.now(), epoch, loss_train/len(train_loader)
            ))
  1. Dropout

Dropout将网络每轮训练迭代中神经元随即清零。Dropout在每次迭代中有效地生成具有不同神经元拓扑结构的模型,使得模型中的神经元在过拟合过程中协调记忆的机会更少。另一中观点是,Dropout在整个网络中干扰了模型生成的特征,产生了一种接近于增强的效果。

class NetDropout(nn.Module):
    def __init__(self, n_channel=32):
        super().__init__()
        self.n_channel = n_channel
        self.conv1 = nn.Conv2d(in_channels=3, out_channels=n_channel, kernel_size=3, padding=1)
        self.conv1_dropout = nn.Dropout2d(p=0.4)                                        # 使用dropout,p为一个元素归零的概率
        self.conv2 = nn.Conv2d(in_channels=n_channel, out_channels=n_channel//2,        # 增加了神经网络的宽度
                               kernel_size=3, padding=1)  
        self.conv2_dropout = nn.Dropout2d(p=0.4)
        self.fc1 = nn.Linear((n_channel//2)*8*8, 32)    
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
        out = self.conv2_dropout(out)
        out = F.max_pool2d(torch.tanh(out), 2)
        out = self.conv2_dropout(out)
        out = out.view(-1, (self.n_channel//2)*8*8)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out
  1. 批量化归一

批量归一化背后的主要思想是将输入重新调整到网络的激活状态,从而使小批量具有一定的理想分布,这有助于避免激活函数的输入过多地进入函数的包和部分,从而消除梯度并减慢训练速度。

class NetBatchNorm(nn.Module):
    def __init__(self, n_channel=32):
        super().__init__()
        self.n_channel = n_channel
        self.conv1 = nn.Conv2d(in_channels=3, out_channels=n_channel, kernel_size=3, padding=1)
        self.conv1_batchnorm = nn.BatchNorm2d(num_features=n_channel)                   # 使用批量归一化
        self.conv2 = nn.Conv2d(in_channels=n_channel, out_channels=n_channel//2,        # 增加了神经网络的宽度
                               kernel_size=3, padding=1)  
        self.conv2_batchnorm = nn.BatchNorm2d(num_features=n_channel//2)
        self.fc1 = nn.Linear((n_channel//2)*8*8, 32)    
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = self.conv1_batchnorm(self.conv1(x))
        out = F.max_pool2d(torch.tanh(out), 2)
        out = self.conv2_batchnorm(self.conv2(out))
        out = F.max_pool2d(torch.tanh(out), 2)
        out = out.view(-1, (self.n_channel//2)*8*8)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

现在使用
NetBatchNorm

training_loop_l2reg
重新训练并评估我们的模型,希望较之前能有提升!

model = NetBatchNorm().to(device=device)
optimizer = optim.SGD(model.parameters(), lr=1e-2)  # 使用随机梯度下降优化器
loss_fn = nn.CrossEntropyLoss() # 交叉熵损失

training_loop_l2reg(
    n_epochs=100,
    optimizer=optimizer,
    model=model,
    loss_fn=loss_fn,
    train_loader=train_loader
)
2023-04-08 17:22:51.919275 Epoch 1, Training loss 0.5400954796335636
2023-04-08 17:24:01.077684 Epoch 10, Training loss 0.3433214044914796
2023-04-08 17:25:18.132063 Epoch 20, Training loss 0.2857391257316638
2023-04-08 17:26:34.441769 Epoch 30, Training loss 0.24476417631675035
2023-04-08 17:27:50.975030 Epoch 40, Training loss 0.21916839241599426
2023-04-08 17:29:09.751893 Epoch 50, Training loss 0.193350423557254
2023-04-08 17:30:26.556550 Epoch 60, Training loss 0.17405275838115278
2023-04-08 17:31:46.126329 Epoch 70, Training loss 0.15676446583790657
2023-04-08 17:33:06.333187 Epoch 80, Training loss 0.14270161565106648
2023-04-08 17:34:25.760439 Epoch 90, Training loss 0.13285309878679422
2023-04-08 17:35:45.502106 Epoch 100, Training loss 0.12409532667161563

再次测量模型精度:

model.eval()
validate(model=model, train_loader=train_loader_, val_loader=val_loader)
Accuracy: trian: 0.9859775641025641
Accuracy: val: 0.8805443548387096

可以看到在训练集上,准确率高达0.98,而验证集却只有0.88,还是存在着过拟合的风险。

最后将模型参数保存:

torch.save(model.state_dict(), "./models/birdsVsPlane.pt")  # 只保存了模型参数

由于我们使用的模型和数据都是在GPU上进行训练的,因此加载模型还需要确定设备位置:

load_model = NetBatchNorm().to(device=device)
load_model.load_state_dict(torch.load("./models/birdsVsPlane.pt", map_location=device))
<All keys matched successfully>

加载完毕,简单测试下:

img, label = cifar2[5]
img = img.to(device=device)
load_model(img.unsqueeze(0)), label
(tensor([[ 4.4285, -4.5254]], device='cuda:0', grad_fn=<AddmmBackward0>), 0)
img_ = img.to('cpu')    # 使用plt绘图,要先将图片转到cpu上
plt.imshow(img_.permute(1,2,0))
<matplotlib.image.AxesImage at 0x29d35a4b850>

参考文献

[1] Eli Stevens. Deep Learning with Pytorch[M]. 1. 人民邮电出版社, 2022.02 :144-163.

image

不论你是否关心,不可否认,AGI的时代即将到来了。

在这个突如其来的时代中,OpenAI的ChatGPT无疑处于浪潮之巅。而在ChatGPT背后,我们不能忽视的是LLM(Large Language Model)大型语言模型。

一夜之间所有的大厂商都在搞LLM,虽然很难有谁能和OpenAI相匹敌,但是随着AI领域的新摩尔定律的发功,很快啊,如今的智障都会在不久的将来成为智神,只能说留给人类嘲笑的时间不多了。

如果了解LLM的训练成本的话,注定了这必然不是人人都可从零创造ChatGPT的时代,也注定了这只会是绝对算力实力的竞争,也是抢显卡的竞争。

与其关心谁家的AI更智障,不如关心一下LLM时代之后的软件开发会变成什么样子。

这更像是一个全新的云计算的时代,软件开发者就像是无需关心机房如何运维一样,未来接入LLM的软件开发,也无需关心模型如何训练,仅需通过简单的API调用,就可以发挥AI的力量,一切语义理解都会更加便捷,一切人机交互都会重新塑造。

这不仅仅改变了我们构建软件的方法,也改变了我们理解软件的方式。

如果还感觉这个时代还很远,可以看看如今的一些AI应用,文本生成、文本润色、语言翻译、人机对话、语法纠错、知识问答、内容摘要、代码解释、缺陷修复等等这任意一个都可以是传统应用都需要煞费苦心的功能,如今却仅需要一个接口,通过简单的参数调整就可以实现。这就是LLM的力量。

而且模型会随着训练不断的改进,也就意味着应用程序不需要进行任何的逻辑改动,就可以享受到模型训练所带来的能力提升。这也就契合了 Schillace法则的第一条:
Don’t write code if the model can do it; the model will get better, but the code won’t.

如果对LLM的底层原理感兴趣,可以阅读
What Is ChatGPT Doing … and Why Does It Work?—Stephen Wolfram Writings
这篇文章,充分了解ChatGPT背后的来龙去脉。

而对于迎接新时代的软件开发者来说,更加关心的则是如何将LLM融入到我们的软件应用中。

而作为.NET的开发者,Semantic Kernel的出现,直接打开了这道大门。


参考资料:

  1. Concepts Overview for LLM AI | Microsoft Learn
  2. LLM AI Models | Microsoft Learn
  3. Schillace Laws of Semantic AI


  1. 题图:[Midjourney] In the early morning, a huge black sphere hung in the sky, as if about to descend. Human engineers looked up from the ground, with an orange hue and a screen printing style, in a wide format. --ar 2:1
  2. 本文 Notion AI的片汤话未能提供任何协助

系列文章目录和关于我

一丶基本概念&Nacos架构

1.为什么需要注册中心

  • 实现服务治理、服务动态扩容,以及调用时能有负载均衡的效果。

    如果我们将服务提供方的ip地址配置在服务消费方的配置文件中,当服务提供方实例上线下线,消费方都需要重启服务,导致二者耦合度过高。注册中心就是在二者之间加一层,实现解耦合。

    image-20230408103559163

  • 健康检查和服务摘除:主动的检查服务健康情况,对于宕机的服务将其摘除服务列表

2.Nacos 的架构

nacos_arch.jpg

  • Naming Service
    :注册中心,提供服务注册,注销,管理
  • Config Service
    :配置中心,Nacos 配置中心为服务配置提供了编辑、存储、分发、变更管理、历史版本管理等功能,并且支持在实例运行中,更改配置。
  • OpenAPI
    :nacos对外暴露的接口,Provider App(服务提供者)就是调用这里的接口,实现将自己注册到nacos,Consumer App(服务消费者)也是使用这里的接口拉去配置中心中的服务提供者的信息。

3.nacos数据模型

image-20230408120650010

二丶nacos注册中心简单使用

我们使用nacos作为注册中心,只需要下载nacos提供的jar包并运行启动nacos服务,然后在服务提供者,消费者中引入
spring-cloud-starter-alibaba-nacos-discovery
,并配置
spring.cloud.nacos.discovery.server-addr=nacos服务启动的地址
,即可在nacos可视化界面看到:

image-20230408155841722

那么服务是如何注册到nacos的昵?

三丶服务注册源码分析

当我们服务引入
spring-cloud-starter-alibaba-nacos-discovery
,便可以实现自动进行注册,这是因为在
spring.facotries
中自动装配了
NacosServiceRegistryAutoConfiguration

SpringBoot源码学习1——SpringBoot自动装配源码解析+Spring如何处理配置类的

image-20230408160900201

1.NacosServiceRegistryAutoConfiguration 引入了哪些类

点进
NacosServiceRegistryAutoConfiguration
源码中,发现它注入了一下三个类

1.1.NacosServiceRegistry

image-20230408161455326

image-20230408161622634

  • ServiceInstance
    表示的是服务发现中的一个实例

    这个接口定义了类似于
    getHost
    ,
    getIp
    获取注册实例host,ip等方法,是springcloud定义的规范接口

  • Registration
    一个标记接口,
    ServiceRegistry<R>
    这里面的R泛型就是
    Registration

    是springcloud定义的规范接口

  • ServiceRegistry
    服务注册,定义如何向注册中心进行注册,和取消注册

    这个接口定义了
    register服务注册
    ,
    deregister服务取消注册
    等方法,入参是
    Registration
    。它是springcloud定义的规范接口。

spring cloud 定义了诸多规范接口,无论是服务注册,还是负载均衡,让其他中间件实现
  • NacosServiceRegistry
    nacos服务注册接口,实现了
    ServiceRegistry
    ,定义了如何注册,如何取消注册,维护服务状态等。

1.2.NacosRegistration

image-20230408162843995

image-20230408162701079

NacosRegistration

Registration
的实现类,象征着一个Nacos注册中心的服务,也就是我们自己写的springboot服务

1.3.NacosAutoServiceRegistration

image-20230408163607142

image-20230408163114101

  • AutoServiceRegistration
    一个标记接口,表示当前类是一个自动服务注册类
  • AbstractAutoServiceRegistration
    实现了
    ApplicationListener
    ,监听
    WebServerInitializedEvent web服务初始化结束事件
    ,在
    ApplicationListener#onApplicationEvent
    中进行服务注册
  • NacosAutoServiceRegistration
    使用
    NacosServiceRegistry

    NacosRegistration
    的注册到nacos注册中心

一通分析之后,可以看到
NacosAutoServiceRegistration
是最核心的类,它负责监听事件,调用
NacosServiceRegistry
,将服务注册到注册中心。

2.
AbstractAutoServiceRegistration
监听事件进行注册

此类是SpringCloud提供的模板类,让市面上众多注册中心中间件实现它,快速接入SpringCloud生态。

image-20230408164650713

2.1 WebServerInitializedEvent 从何而来

AbstractAutoServiceRegistration
想响应
WebServerInitializedEvent
,那么
WebServerInitializedEvent
是哪儿发出的昵?


WebServerStartStopLifecycle#start
方法

image-20230408165846649

image-20230408165805172

WebServerStartStopLifecycle
实现了
Lifecycle
,在spring容器刷新结束的时候,会使用
LifecycleProcessor
调用所以
Lifecycle#start
,从而发送
ServletWebServerInitializedEvent(WebServerInitializedEvent子类)
推送事件

Reactive的springboot上下文则是由WebServerStartStopLifecycle推送ReactiveWebServerInitializedEvent事件,原理一样,如下图

image-20230408171748437

2.2 NacosAutoServiceRegistration如何进行服务注册

AbstractAutoServiceRegistration
在响应事件后,会调用bind方法,进而调用
register
进行服务注册,这里就会调用到
NacosAutoServiceRegistration#register

image-20230408171934715

那么到底如何进行服务注册?

image-20230408172157911

可以看到直接调用
NacosServiceRegistry#register(NacosRegistration)
进行服务注册

3.NacosServiceRegistry 服务注册

image-20230408172550692

可以看到这里使用
NamingService

Instance
进行注册

  • NamingService
    ,
    nacos
    框架中的类,负责服务注册和取消注册
  • Instance
    ,
    nacos
    框架中的类,定义一个服务,记录ip,端口等信息
可以看到nacos有自己一套东西,脱离springcloud,也可以使用,这就是松耦合

下面我们看下NamingService是如何进行服务注册的
image-20230408173117526

  • 如果是临时实例,会使用
    ScheduledThreadPoolExecutor
    ,每5秒发送一次心跳,发送心跳即请求nacos注册中心
    /instance/beat
    接口

  • 然后调用
    NamingProxy
    进行服务注册

    image-20230408173758235

    最终底层通过Http请求的方式,请求nacos服务的
    /nacos/v1/ns/instance

image-20230408174719447

4.nacos注册中心如何处理服务注册的请求

上面一通分析,我们直到了springboot服务是如何启动的时候,自动进行服务注册的,如何进行服务注册的,但是nacos服务端是如何响应注册请求的的昵

image-20230408174916201

  • 从请求中拿实例信息

    image-20230408175100283

    主要包含上述这些字段。

  • ServiceManager#registerInstance

    服务注册的逻辑主要在
    addInstance
    方法中

    image-20230408180419438

    首先根据待注册服务的
    namespaceId命名中间id

    serviceName服务名称

    ephemeral是否临时服务
    构建出一个key,由于我们是一个临时实例,key最终为
    com.alibaba.nacos.naming.iplist.ephemeral + namespaceId ## + serviceName

    然后调用
    ConsistencyService一致性协议服务#put
    进行注册,这里和Nacos支持AP,CP架构有关,后续我们分析到一致性协议再补充。

    这里会调用到
    DelegateConsistencyServiceImpl(一致性协议门面)
    他会根据key中的是临时实例,还是非临时实例,选择协议,最终选择到
    DistroConsistencyServiceImpl
    ,继续调用
    put
    方法

    image-20230408186666660820

    image-20230408181215306

    可以看到
    DistroConsistencyServiceImpl(Distro一致性协议服务)
    会同步到nacos集群中的其他实例,这部分我们后续分析,我们重点看下onPut,看看nacos服务到底如何注册。

    image-20230408181806440

    image-20230408182847588

    至此服务注册请求结束了,只是将注册请求信息包装成了任务加入到
    Notifier
    的任务队列中。

5.nacos 服务注册表结构

在看怎么处理阻塞队列中的任务前,我们看下nacos的注册表结构

image-20230408190341273

对应ServiceManager中的serviceMap属性

/**
 * key 是命名空间
 * value 是 分组名称和Service服务的map
 *
 */
private final Map<String, Map<String, Service>> serviceMap = new ConcurrentHashMap<>();

//Service结构如下
//集群和集群对象组成map
private Map<String, Cluster> clusterMap = new HashMap<>();

//Cluster 中的属性记录所有实例Instance的集合

6.nacos服务注册异步任务队列处理注册任务

上面分析到最终服务注册请求被包装放到
Notifier
的任务队列中。我们看下任务队列的任务在哪里被拿出来消费。

Notifier
实现了Runnable,在
DistroConsistencyServiceImpl
中使用
@PostConstruct
将它提交到了调度线程池中。

image-20230408183237936

也就是说会有一个单线程调用
Notifier#run

image-20230408183406413

image-20230408184903851

后续会调用到
Service#onChange
,其
updateIPs
方法会更新实例的ip地址

// 这里 instances 里面就包含了新实例对象
// ephemeral 为 ture,临时实例
public void updateIPs(Collection<Instance> instances, boolean ephemeral) {

    // clusterMap 对应集群的Map
    Map<String, List<Instance>> ipMap = new HashMap<>(clusterMap.size());
    // 把集群名字都放入到ipMap里面,value是一个空的ArrayList
    for (String clusterName : clusterMap.keySet()) {
        ipMap.put(clusterName, new ArrayList<>());
    }

    // 遍历全部的Instance,这个List<Instance> 包含了之前已经注册过的实例,和新注册的实例对象
    // 这里的主要作用就是把相同集群下的 instance 进行分类
    for (Instance instance : instances) {
        try {
          
            // 判断客户端传过来的是 Instance 中,是否有设置 ClusterName
            if (StringUtils.isEmpty(instance.getClusterName())) {
                // 如果没有,就给ClusterName赋值为 DEFAULT
                instance.setClusterName(UtilsAndCommons.DEFAULT_CLUSTER_NAME);
            }

            // 判断之前是否存在对应的 ClusterName,如果没有则需要创建新的 Cluster 对象
            if (!clusterMap.containsKey(instance.getClusterName())) {
                // 创建新的集群对象
                Cluster cluster = new Cluster(instance.getClusterName(), this);
                cluster.init();
                // 放入到集群 clusterMap 当中
                getClusterMap().put(instance.getClusterName(), cluster);
            }

            // 通过集群名字,从 ipMap 里面取
            List<Instance> clusterIPs = ipMap.get(instance.getClusterName());
            // 只有是新创建集群名字,这里才会为空,之前老的集群名字,在方法一开始里面都 value 赋值了 new ArrayList对象
            if (clusterIPs == null) {
                clusterIPs = new LinkedList<>();
                ipMap.put(instance.getClusterName(), clusterIPs);
            }

            // 把对应集群下的instance,添加进去
            clusterIPs.add(instance);
        } catch (Exception e) {
        }
    }

    // 分好类之后,针对每一个 ClusterName ,写入到注册表中
    for (Map.Entry<String, List<Instance>> entry : ipMap.entrySet()) {
        // entryIPs 已经是根据ClusterName分好组的实例列表
        List<Instance> entryIPs = entry.getValue();
        
        // 对每一个 Cluster 对象修改注册表  ->updateIps
        clusterMap.get(entry.getKey()).updateIps(entryIPs, ephemeral);
    }

}

针对每一个集群分别进行
Cluster#updateIps

public void updateIps(List<Instance> ips, boolean ephemeral) {

    // 先判断是否是临时实例
    // ephemeralInstances 临时实例
    // persistentInstances 持久化实例
    // 把对应数据先拿出来,放入到 新创建的 toUpdateInstances 集合中
    Set<Instance> toUpdateInstances = ephemeral ? ephemeralInstances : persistentInstances;

    // 先把老的实例列表复制一份 , 先复制一份新的
    //写时复制,先复制一份
    HashMap<String, Instance> oldIpMap = new HashMap<>(toUpdateInstances.size());
    for (Instance ip : toUpdateInstances) {
        oldIpMap.put(ip.getDatumKey(), ip);
    }

    //省略了同步到其他nacos服务的代码。。。
    
    // 最后把传入进来的实例列表,重新初始化一个 HaseSet,赋值给toUpdateInstances
    toUpdateInstances = new HashSet<>(ips);
    
    // 判断是否是临时实例
    if (ephemeral) {
        // 直接把之前的实例列表替换成新的
        ephemeralInstances = toUpdateInstances;
    } else {
        persistentInstances = toUpdateInstances;
    }
}

image-20230408182638959

LLaMA:开放和高效的基础语言模型

https://arxiv.org/pdf/2302.13971.pdf

https://github.com/facebookresearch/llama

Part1

前言

我们介绍了LLaMA,这是一个参数范围从7B到65B的基础语言模型集合。我们在数以万亿计的标记上训练我们的模型,并表明有可能完全使用公开可用的数据集来训练最先进的模型,而不必求助于专有的和不可获取的数据集。特别是,LLaMA-13B 在大多数基准上超过了GPT-3(175B), LLaMA-65B与最好的模型Chinchilla-70B和PaLM-540B相比具有竞争力。我们向研究界发布了我们所有的模型。

总结:

  • 仅仅在公开的数据集 上进行训练了多个尺度的模型,就可以达到最先进的效果。
  • 对模型和实现方式进行优化,加速训练。

Part2

方法

1
使用的数据

英语CommonCrawl[67%]
:我们用CCNet管道( Wenzek等人 , 2020年)对五个CommonCrawl转储进行预处理,范围从2017年到2020年。这个过程在行的层面上对数据进行了删除,用fastText线性分类器进行语言识别,以去除非英语页面,并用n-gram语言模型过滤低质量内容。此外,我们训练了一个线性模型来对维基百科中用作参考文献的页面与随机抽样的页面进行分类,并丢弃了未被分类为参考文献的页面。

C4 [15%]
:在探索性的实验中,我们观察到,使用多样化的预处理Com-monCrawl数据集可以提高性能。因此,我们将公开的C4数据集( Raffel等人,2020)纳入我们的数据。C4的预处理也包含重复数据删除和语言识别步骤:与CCNet的主要区别在于质量过滤,它主要依赖于标点符号的存在或网页中的单词和句子的数 量等判例。

Github[4.5%]
:我们使用谷歌BigQuery上的GitHub公共数据集。我们只保留在Apache、BSD和MIT许可下发布的项目。此外,我们用基于行长或字母数字字符比例的启发式方法过滤了低质量的文件,并用规范的表达式删除了模板,如标题。最后,我们在文件层面上对结果数据集进行重复计算,并进行精确匹配。

维基百科[4.5%]
:我们添加了2022年6月至8月期间的维基百科转储,涵盖了20使用拉丁字母或西里尔字母的语言:BG、CA、CS、DA、DE、EN、ES、FR、HR、HU、IT、NL、PL、PT、RO、RU、SL、SR、SV、UK。我们对数据进行处理,以删除超链接、评论和其他格式化的模板。

古腾堡和Books3[4.5%]
:我们的训练数据 包括两个书体:Guten- berg项目和TheP-ile( Gao等人,2020)的Books3部分,后者是一个用于训练大型语言模型的公开可用数据集。我们在书籍层面上进行重复数据删除,删除内容重叠度超过90%的书籍。

ArXiv[2.5%]
: 我们处理了arXiv的Latex文件,将科学数据添加到我们的数据集中。按照Lewkowycz等人(2022)的做法,我们删除了第一节之前的所有内容,以及书目。我们还删除了.tex文件中的注释,以及用户写的内联扩展的定义和宏,以提高不同论文的一致性。

Stack Exchange[2%]
:我们包括了Stack Exchange的转储,这是一个高质量的问题和答案的网站,涵盖了从计算机科学到化学等不同的领域。我们保留了28个最大网站的数据,重新将HTML标签从文本中移出,并将答案按分数(从高到低)排序。

2
标记器

标记器
: 我们用字节对编码(BPE)算法( Sennrich等人,2015)对数据进行标记,使用 Sentence-Piece(Kudo和Richardson,2018)中的实现。值得注意的是,我们将所有数字分割成单个数字,并回退到字节来分解未知的UTF-8字符。

总的来说,我们的整个训练数据集在标记化之后大约包含1.4T的标记。对于我们的大多数训练数据,每个标记在训练过程中只使用一次,但维基百科和图书领域除外,我们对其进行了大约两个epochs训练。

Part3

模型结构

基本还是transformer结构,主要是以下一些不同:

预归一化[GPT3]
:为了提高训练的稳定性,我们对每个transformer子层的输入进行规范化,而不是对输出进行规范化。我们使用Zhang和Sennrich(2019)介绍的RMSNorm归一化函数。

SwiGLU激活函数[PaLM]
:我们用SwiGLU激活函数替换ReLU的非线性,由Shazeer(2020)引入以提高性能。我们使用的维度是$\frac{2}{3}4d$,而不是PaLM中的4d。

旋转嵌入[GPTNeo]
:我们删除了绝对位置嵌入,取而代之的是在网络的每一层添加Su等人(2021)介绍的旋转位置嵌入(RoPE)。表2中给出了我们不同模型的超参数细节。

3
优化器

我们的模型使用
AdamW
optimizer( Loshchilov和Hutter,2017)进行训练,超参数如下:β1 = 0.9,β2 = 0.95。我们使用一个余弦学习率计划,使最终的学习率等于最大的10%。我们使用0.1的权重衰减和梯度剪裁为1.0。我们使用2,000个预热步骤,并随着模型的大小而改变学习率和批次大小(详见表2)。

4
高效的实现

我们进行了一些优化,以提高我们模型的训练速度。首先,我们使用causal multi-head attention,以减少内存使用和运行时间。这个实现可在xformers库中找到。这是通过不存储注意力权重和不计算由于语言建模任务的因果性质而被掩盖的键/查询分数来实现的。

为了进一步提高训练效率,我们重新缩减了在后向传递过程中建议使用的激活量。更确切地说,我们保存了计算成本较高的激活,如线性层的输出。这是通过手动实现transformer层的后向函数来实现的,而不是依靠PyTorch的autograd。为了充分受益于这种优化,我们需要如Korthikanti等人(2022)所述,通过使用模 型和序列并行,减少模型的内存使用。此外,我们还尽可能地过度重视激活的计算和GPU之间通过网络的通信(由于all_reduce操作)。当训练一个65B参数的模型时,我们的代码在2048个A100GPU和80GB的内存上处理大约380个令牌/秒/GPU。 这意味着在我们包含1.4T标记的数据集上进行训练大约需要21天。

Part4

结果