这里以手写数字识别为例,全流程代码实现,使用C语言实现卷积、池化、Relu、全连接、Log_Sigmod、softmax层,篇章主要分三大部分:pytorch训练、C语言实现深度学习操作、嵌入式端构建。(三个部分会有一定程度上的交叉,中间有疑问可以先记着,往后看就通了)
觉得还行的话点个赞呗
Pytorch训练数据集:
网络模型构建:
通常来说手写数字识别的神经网络只需要两层全连接层就够了,但是为了实现大多数网络模型的常用操作,我们这里加入了卷积、池化等操作:
此网络结构以两层卷积、以及全连接层构成,中间穿插池化以及ReLu激活函数。
class Net_2(nn.Module):
def __init__(self):
super(Net_2, self).__init__()
self.conv1 = nn.Conv2d(1, 10, kernel_size=5)
self.conv2 = nn.Conv2d(10, 20, kernel_size=5)
self.fc1 = nn.Linear(320, 50)
self.fc2 = nn.Linear(50, 10)
self.flatten = nn.Flatten()
self.softmax = nn.LogSoftmax(dim=1)
def forward(self, x):
x = self.conv1(x)
x = F.relu(F.max_pool2d(x, 2))
x = self.conv2(x)
x = F.relu(F.max_pool2d(x, 2))
x = self.flatten(x)
x = F.relu(self.fc1(x))
x = self.fc2(x)
return self.softmax(x)
数据集获取及重建:
数据集我们使用pytorch深度学习中自带的MNIST数据集,此数据集中包含大量书写数字数据,包括训练集和测试集。
在官方文档中,此处获取数据是需要进行标准化的,但是我后面需要进行数据类型转换,为了不影响参数,就先不对数据进行转换,知识单纯读取出数据并转换为张量类型
# 为了后面修改数据类型,这里不进行标准化
transform = torchvision.transforms.Compose([
torchvision.transforms.ToTensor(),
# torchvision.transforms.Normalize((0.1307,),(0.3081,))
])
# 训练集数据构建
train_loader = torch.utils.data.DataLoader(
torchvision.datasets.MNIST('./data/',train=True,download=False,transform=transform),
batch_size=64,
shuffle=False
)
# 测试集数据构建
test_loader = torch.utils.data.DataLoader(
torchvision.datasets.MNIST('./data/',train=False,download=False,transform=transform),
batch_size=1000,
shuffle=False
)
数据转换并构建自己的数据集:
MNIST数据集的原始图像为0~1的单通道浮点型,为了契合我们后期在嵌入式上方便部署,在这里将数据二值化处理,0.3是二值化阈值,可以根据自己的需求更改。
原始图像及图像:
转换的图像及数据:
# 重构数据获取方式
class My_DataSet(Dataset):
def __init__(self, datas, labels):
self.datas = datas
self.labels = labels
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
data = self.datas[idx]
label = self.labels[idx]
return data, label
# 构建待存储数据张量
train_data = torch.tensor([])
train_label = torch.tensor([],dtype=torch.int64)
valid_data = torch.tensor([])
valid_label = torch.tensor([],dtype=torch.int64)
# 类型转换,原始图像是0-1的单通道浮点型数据,0.3是阈值
for data, label in train_loader:
new_data = torch.where(data >= 0.3, torch.tensor(1.), torch.tensor(0.))
train_data = torch.cat([train_data,new_data],dim=0)
train_label = torch.cat([train_label,label],dim=0)
for data, label in test_loader:
new_data = torch.where(data >= 0.3, torch.tensor(1.), torch.tensor(0.))
valid_data = torch.cat([valid_data,new_data],dim=0)
valid_label = torch.cat([valid_label,label],dim=0)
# 数据获取类实例化
Train_Data = My_DataSet(train_data,train_label)
Test_Data = My_DataSet(valid_data,valid_label)
# 数据获取
train_iter = DataLoader(Train_Data,batch_size=128,shuffle=True)
test_iter = DataLoader(Test_Data,batch_size=1000,shuffle=False)
开始训练并保存模型:
这里都是深度学习训练的基本操作了,不做过多赘述,会保存下来测试集准确率最高的以此模型。
def train(epoch):
model.train()
running_loss = 0.0
running_corrects = 0
for batch_idx, (data, label) in enumerate(train_iter):
data = data.to(device)
label = label.to(device)
optimizer_ft.zero_grad()
with torch.set_grad_enabled(True):
outputs = model(data)
loss = criterion(outputs,label)
_, preds = torch.max(outputs,1)
loss.backward()
optimizer_ft.step()
running_loss += loss.item() * data.size(0)
running_corrects += torch.sum((preds == label.data))
epoch_loss = running_loss / len(train_iter.dataset)
epoch_acc = running_corrects.double() / len(train_iter.dataset) * 100
scheduler.step(np.mean(epoch_loss))
print('Train:{} Loss: {:.4f} Acc: {:.4f} Lr: {}'.format(epoch, epoch_loss, epoch_acc, optimizer_ft.param_groups[0]['lr']))
train_acc_history.append(epoch_acc.to("cpu"))
train_losses.append(epoch_loss)
def valid():
global best_acc
model.eval()
running_loss = 0.0
running_corrects = 0
with torch.no_grad():
for batch_idx, (data, label) in enumerate(test_iter):
data = data.to(device)
label = label.to(device)
outputs = model(data)
loss = criterion(outputs,label)
_, preds = torch.max(outputs, 1)
running_loss += loss.item() * data.size(0)
running_corrects += torch.sum((preds == label.data))
epoch_loss = running_loss / len(test_iter.dataset)
epoch_acc = running_corrects.double() / len(test_iter.dataset) * 100
print('\nTest set: Avg. loss: {:.4f}, Accuracy: {}/{} ({:.0f}%)\n'.format(
epoch_loss, running_corrects, len(test_iter.dataset),
epoch_acc))
if epoch_acc > best_acc:
best_acc = epoch_acc
torch.save(model.state_dict(),'number_2.pth')
print('Renew Model!!!')
val_acc_history.append(epoch_acc.to("cpu"))
valid_losses.append(epoch_loss)
train_on_gpu = torch.cuda.is_available()
if not train_on_gpu:
print("CUDA is training on CPU...")
else:
print("CUDA is training on GPU...")
device = torch.device("cuda:0" if train_on_gpu else "cpu")
val_acc_history = []
train_acc_history = []
train_losses = []
valid_losses = []
best_acc = 0
model = Net_2().to(device)
optimizer_ft = optim.Adam(model.parameters(),lr=0.001) #优化器设置
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer_ft, mode='min', factor=0.8, patience=5, verbose=True, threshold=0.00001, threshold_mode='rel', cooldown=3, min_lr=0, eps=1e-08)
criterion = nn.NLLLoss()
for epoch in range(150):
train(epoch)
valid()
效果还行,基本在98%以上
将模型数据转换为C语言可读的格式:
首先先来对比一下pytorch模型数据和C语言需要的数据:
# pytorch数据:
name1: tensor([ 数据1, 数据2, ..., 数据n],\n [ 数据1, 数据2, ..., 数据n],\n ....)
# C语言需要的数据:
float name1[] = {数据1, 数据2, 数据3, ......, 数据n};
对比发现,我们需要干的是如下几件事:
- 提出数据名作为C的变量名
- 将数据拍扁,作为一个一维数组
- 将tensor内容转换为字符串,并只保留数据部分
- 删掉'\n',并且为了美观,将多个连续的空格转换为单空格
然后就可以开始操作了,以第二层的卷积偏置为对象来演示:
开始前需要设置torch的显示属性,分别是科学技术法和数据显示大小,科学计数法没有影响但是看着不自然,数据内容显示量要设置为无穷大,否则会有‘...’:
float fc1_weight[] = {-0.0435, 0.0083, 0.0253, ..., -0.1570, 0.0342, 0.0154};
float conv1_weight[] = {-1.1184e-01, 6.3655e-02};
torch.set_printoptions(sci_mode=False, threshold=torch.inf)
model = torch.load('./number.pth') #读取模型
#C语言数据起始段
inp = 'float ' + param_tensor.replace('.','_') + '[]' + ' = {'
#float layer_category[] = {
#将多维数据摊平为一维
data = model['conv2.bias'].view(-1)
# tensor([ 0.1306, -0.0828, -0.0754, -0.0553, 0.0371, 0.1295, 0.0217, 0.1280,
# 0.0367, -0.1206, 0.0187, -0.2399, -0.0774, -0.2128, -0.0714, 0.0386,
# 0.0130, -0.1668, -0.0499, -0.1803], device='cuda:0')
# 转换为字符串形式
param_s = str(data.cpu())
# 只保留字符串中[之后和]之前的数据
param_s = param_s.split('[')[1].split(']')[0]
# '0.1306, -0.0828, -0.0754, -0.0553, 0.0371, 0.1295, 0.0217, 0.1280, 0.0367, -0.1206, 0.0187, -0.2399, -0.0774, -0.2128, -0.0714, 0.0386, 0.0130, -0.1668, -0.0499, -0.1803
#'
#删掉多余的\n
param_s = param_s.replace("\n", "")
#使用正则表达式将连续的多个空格转换为单空格
param_s = re.sub(r'\s+', ' ', param_s)
inp += param_s
#加尾缀并换行
inp += '};\r\n'
完整过程:
model = torch.load('./number.pth')
inp = ''
for param_tensor in model:
inp = 'float ' + param_tensor.replace('.','_') + '[]' + ' = {'
load = model[param_tensor].view(-1)
param_s = str(load.cpu())
param_s = param_s.split('[')[1].split(']')[0]
param_s = param_s.replace("\n", "")
param_s = re.sub(r'\s+', ' ', param_s)
inp += param_s
inp += '};\r\n'
with open('./para2.h', 'a+', encoding='utf-8') as f:
f.write(inp)
至此,Python部分全部完成。
C语言实现深度学习操作:
卷积:
首先,卷积就是点乘相加的过程:
单通道卷积:
多通道卷积:
多通道卷积则是每个卷积核与对应的通道点乘,在将每个通道的卷积结果相加再加偏置。
在清楚了这个之后我们再看pytorch的卷积数据构建形式。
这里我们定义的一个三通道3*3大小的输入数据,卷积核大小为2,输出通道为2,为了便于演示和计算,卷积核参数以及偏置参数我们自己设置。
Input = [
[[0,0,0],
[0,1,1],
[0,1,1]],
[[0,0,0],
[0,0,1],
[0,2,2]],
[[0,0,0],
[0,2,2],
[0,0,0]]
]
Weight = [
[[[1,1],
[-1,-1]],
[[-1,-1],
[-1,1]],
[[1,0],
[0,0]]],
[[[0,0],
[-1,1]],
[[0,0],
[1,0]],
[[-1,1],
[0,-1]]]
]
Bias = [1,2]
Weight = torch.tensor(Weight,dtype=torch.float)
Bias = torch.tensor(Bias,dtype=torch.float)
Input = torch.tensor(Input,dtype=torch.float)
conv1 = nn.Conv2d(3,2,kernel_size=2)
conv1.weight = nn.Parameter(Weight)
conv1.bias = nn.Parameter(Bias)
print(Input.shape)
# torch.Size([3, 3, 3])
print(conv1.weight.shape)
# torch.Size([2, 3, 2, 2])
print(conv1.bias.shape)
# torch.Size([2])
out = conv1(Input)
print(out.shape)
# torch.Size([2, 2, 2])
print(out)
tensor([[[0., 0.],
[3., 2.]],
[[1., 0.],
[5., 4.]]], grad_fn=<SqueezeBackward1>)
卷积的权重参数结构为[2, 3, 2, 2],可以看出,pytorch的参数结构相比于tensorflow更人性化,此计算过程可以这样理解:
现在我想要计算输入特征第三个通道的第二个输出特征的卷积结果,那么在权重参数维度上的选择就是,在第一个维度上选择1(数组索引,后续一样),在第二个维度上选择2,那么就会取得一个2*2的卷积核[[ , ], [ , ]],就可以用此与输入特征对应位置进行点乘。
在我们上述内容中有对维度摊平操作,也就是在摊平后的维度是[24]。在计算机内部本身是不存在数组维度一说,一个数组中的所有数据都是连续的,只是为了便于操作才加入的维度及对应索引。
在清楚数据计算方式后我们就可以使用C语言开始实现多通道卷积操作,在函数中将ReLu作为同时计算,就不用再单独设立一个函数了:
unsigned int count = 0;
/****************************************************************************************/
//img: 输入特征
//Img_Size: 输入特征单通道的尺寸,如3*28*28,那么就是3通道尺寸大小为28的特征
//w:卷积权重参数
//In_Ch:输入通道数
//Out_Ch:输出通道数
//Kernel_Size:卷积核大小
//out:输出特征
//Out_Size:输出特征单通道尺寸大小
//use_relu:是否使用ReLu激活函数。0不使用,1使用
/****************************************************************************************/
void convs_f(float* img, int Img_Size,
float* w, int In_Ch, int Out_Ch, int Kernel_Size,
float* b,
float* out, int Out_Size,
char use_relu)
{
float tmp = 0.0, tmp1 = 0.0;
for (int m = 0; m < Out_Ch; m++)
{
for (int k = 0; k <= Img_Size - Kernel_Size; k++) //特征平面的行 列平移 行卷积
{
for (int r = 0; r <= Img_Size - Kernel_Size; r++) //特征平面的列 行平移 列卷积
{
//多通道累加
tmp1 = 0.0;
for (int n = 0; n < In_Ch; n++)
{
tmp = 0.0;
//单次卷积 点对点相乘 然后相加
for (int i = 0; i < Kernel_Size; i++) //卷积的行
{
for (int j = 0; j < Kernel_Size; j++) //卷积的列
{
tmp += *(img + n * (Img_Size * Img_Size) + (i + k) * Img_Size + j + r) * *(w + m * (Kernel_Size * Kernel_Size * In_Ch) + n * (Kernel_Size * Kernel_Size) + i * Kernel_Size + j);
/*tmp += img[n][i + k][j + r] * w[m][n][i][j];*/
}
}
//累加多个特征平面的卷积结果
tmp1 += tmp;
}
if (use_relu == 0) *(out + m * Out_Size * Out_Size + k * Out_Size + r) = tmp1 + b[m];
else if(use_relu == 1) *(out + m * Out_Size * Out_Size + k * Out_Size + r) = tmp1 + b[m] >= 0 ? tmp1 + b[m] : 0;
count++;
}
}
}
}
既然使用了C语言,那么C语言最大的魅力莫过于指针,因此,在清楚数据结构计算方式后,所有的参数均可以用一级指针(也就是一维数组)来操作。这样就可以提高函数的可用性并且还避免了多级指针中需要多次malloc再free的繁琐操作。
池化(max_pooling):
池化操作和卷积相近,只是不需要有卷积核参数计算。
/****************************************************************************************/
//img: 输入特征
//Img_Size: 输入特征单通道的尺寸,如3*28*28,那么就是3通道大小为尺寸大小为28的特征
//Img_Ch:输入通道数
//Out_Ch:输出通道数
//out:输出特征
//Kernel_Size:卷积核大小
//use_relu:是否使用ReLu激活函数。0不使用,1使用
/****************************************************************************************/
void max_pooling_f(float *img, int Img_Size, int Img_Ch, float *out, int Out_Size,int Kernel_Size, char use_relu)
{
int i, j, k, r, z;
float tmp1, tmp2, tmp3;
for (z = 0; z < Img_Ch; z++) {
for (i = 0, k = 0; i < Img_Size; i = i + Kernel_Size, k++)
{
for (j = 0, r = 0; j < Img_Size; j = j + Kernel_Size, r++)
{
tmp1 = 0.0f;
tmp2 = 0.0f;
tmp3 = 0.0f;
tmp1 = *(img+ z * (Img_Size * Img_Size) + i * Img_Size + j) > *(img + z * (Img_Size * Img_Size) + i * Img_Size + j + 1) ? *(img + z * (Img_Size * Img_Size) + i * Img_Size + j) : *(img + z * (Img_Size * Img_Size) + i * Img_Size + j + 1);
tmp2 = *(img + z * (Img_Size * Img_Size) + (i + 1) * Img_Size + j) > *(img + z * (Img_Size * Img_Size) + (i + 1) * Img_Size + j + 1) ? *(img + z * (Img_Size * Img_Size) + (i + 1) * Img_Size + j) : *(img + z * (Img_Size * Img_Size) + (i + 1) * Img_Size + j + 1);
tmp3 = tmp1 > tmp2 ? tmp1 : tmp2;
if(use_relu == 0) *(out + z * (Out_Size * Out_Size) + k * Out_Size + r) = tmp3;
else if(use_relu == 1) *(out + z * (Out_Size * Out_Size) + k * Out_Size + r) = tmp3 >= 0 ? tmp3 : 0;
count++;
}
}
}
return 0;
}
全连接层:
/****************************************************************************************/
//img: 输入特征
//In_Feature: 输入特征参数
//w:权重参数
//b:偏置参数
//Out:输出特征
//Out_Feature:输出特征参数
//use_relu:是否使用ReLu激活函数。0不使用,1使用
/****************************************************************************************/
void Linear(float* Img, int In_Feature, float* w, float* b, float *Out,int Out_Feature, char use_relu)
{
float tmp = 0;
int i = 0, j = 0;
for (i = 0; i < Out_Feature; i++) {
tmp = 0;
for (j = 0; j < In_Feature; j++) {
tmp += *(Img + j) * *(w + i * In_Feature + j);
}
tmp += *(b + i);
if (use_relu == 0) *(Out + i) = tmp;
else if (use_relu == 1) *(Out + i) = tmp >= 0 ? tmp : 0;
count++;
}
}
Log_Softmax层:
void log_softmax(float* input, int size) {
// 计算原始的 softmax
float max_val = input[0];
for (int i = 1; i < size; ++i) {
if (input[i] > max_val) {
max_val = input[i];
}
}
float sum_exp = 0.0;
for (int i = 0; i < size; ++i) {
input[i] = expf(input[i] - max_val);
sum_exp += input[i];
}
// 计算对数 Softmax
for (int i = 0; i < size; ++i) {
input[i] = logf(input[i] / sum_exp);
}
}
获取最大置信度索引:
char find_max(float* input, int size) {
int i = 0, index_target = 0, max_val = *input;
for (int i = 1; i < size; i++) {
if (*(input+i) > max_val) {
max_val = *(input + i);
index_target = i;
}
}
return index_target;
}
嵌入式端构建:
由于不同设备的不同,这边我只提供一个思路,作者所用的IMX6ULL,屏幕大小是7寸屏,如果有需要的话可以和我要源码。
首先是简单创建一个界面,分别包括手绘区,开始转换区(右上),界面清除区(右下)
MNIST数据集中的一张图片大小是28*28,实际我们为了便于展示肯定不会这么小,作者此处的绘制区域大小为590*590,画笔大小为30*30,也就是说只需要缩小20倍就可以达到预定要求。
随后用交替行缩小算法将图片缩小至28*28:
这里同时加入数据转换,将32位像素数据点转换为0,1二值化数据,实现方式:
//屏幕尺寸
#define SCREEN_WIDTH 1024
#define SCREEN_HEIGHT 640
//手绘区
#define START_X 0
#define START_Y 0
#define SIZE 590
//缩小尺寸展示区
#define M_START_X 600
#define M_START_Y 200
#define M_SIZE 28
#define SCALE 20
/*********************************************/
//raw_img:原始图像
//change_img:缩小后的图像
//Input:送神经网络的输入数据
//scale:缩小比例
/*********************************************/
void Reduce_Image(unsigned int *raw_img, unsigned int *change_img, float Input[28][28], int scale)
{
int i, j, row = 0, column = 0;
for(i = 0; i < SIZE / scale - 1; i++) {//列
for(j = 0; j < SIZE / scale - 1; j++) {//行
change_img[i*M_SIZE+j] = raw_img[(START_Y+row)*SCREEN_WIDTH+column];
Input[i][j] = change_img[i*M_SIZE+j] == 0 ? 0. : 1.;
column += scale;
}
row += scale;
column = 0;
}
}
float Input[28][28];
float Out[10][24][24];
float Out_Pool[10][12][12];
float Out_Conv2[20][8][8];
float Out_Pool2[20][4][4];
float Out_Fc1[50];
float Out_Fc2[10];
if(sample.x >= 800 && sample.x <= 900 && sample.y >= START_Y && sample.y <= START_Y + 100){
printf("开始转换\r\n");
Reduce_Image(screen.screen_base,min_img,Input,SCALE);
convs_f(Input, 28,conv1_weight, 1, 10, 5,conv1_bias, Out, 24,0);
printf("comp: %d\r\n", count);
count = 0;
max_pooling_f(Out, 24, 10, Out_Pool, 12, 2,1);
printf("comp: %d\r\n", count);
count = 0;
convs_f(Out_Pool, 12, conv2_weight, 10, 20, 5, conv2_bias, Out_Conv2, 8, 0);
printf("comp: %d\r\n", count);
count = 0;
max_pooling_f(Out_Conv2, 8, 20, Out_Pool2, 4, 2, 1);
printf("comp: %d\r\n", count);
count = 0;
Linear(Out_Pool2, 320, fc1_weight, fc1_bias, Out_Fc1, 50, 1);
printf("comp: %d\r\n", count);
count = 0;
Linear(Out_Fc1, 50, fc2_weight, fc2_bias, Out_Fc2, 10, 0);
printf("comp: %d\r\n", count);
count = 0;
log_softmax(Out_Fc2, 10);
printf("res: %d \r\n",find_max(Out_Fc2, 10));
当触摸到指定区域后开始转换 ,程序中的count是作为debug用的,实际运行中可以删除,测试一下。
测试没问题。
后续有时间再更新深度学习其他算法的C实现。。。