并行化实现基于BP神经网络的手写体数字识别

手写体数字识别可以堪称是神经网络学习的“Hello World” ,我今天要说的是如何实现BP神经网络算法的并行化,我们仍然是以手写体数字识别为例,会给出实现原理与不同参数的实例分析。
并行的实现是基于MPICHOpenMP两种,运行环境是Linux

环境搭建

MPI

MPI的环境搭建说简单也简单,说难也难,CSDN上有各种教程,大部分都是对的,我只提一下我在搭建环境的时候遇到的问题以及要注意的地方。
1.如果只在一台机器上跑代码的话,就相对简单一些,只需要下载压缩包,解压,编译安装就好了,这里要注意的是安装的时候,MPI依赖于gcc、g++、Fortran等编译工具,我们大多数人的机器上应该只有C/C++的编译工具,如果我们不打算在Fortran上使用MPI的话,可以选择在安装的时候禁用掉Fortran,好像是在安装命令的末尾加上–disable Fortran就可以了。
2.如果是想搭集群,首先要把MPI安装在相同的目录例如:/usr/local/mpich,然后还要注意要使用相同的用户名进行ssh免密登录的配置。这些东西都有很完整的教程,我在这里就不进行详细说了。

OpenMP

如果你的编译链工具版本够新的话,编译OpenMP只需要加上一句-fopenmp即可

BP神经网络算法基础

1. 算法框架


BP神经网络的过程主要分为两个阶段,第一阶段是信号的前向传播,从输入层经过隐含层,最后到达输出层;第二阶段是误差的反向传播,从输出层到隐含层,最后到输入层,依次调节隐含层到输出层的权重和偏置,输入层到隐含层的权重和偏置。

2.样本训练

正向传播

对每一层遍历每一个神经细胞,做如下操作:

  1. 获取第n个神经细胞的输入权重数组
  2. 遍历输入权重数组每一个输入权重,累加该权重和相应输入的乘积
  3. 将累加后的值通过激活函数,得到当前神经细胞的最终输出
  4. 该输出作为下一层的输入,对下一层重复上述操作,直到输出层输出为止
    激活函数: sigmoid(S型)函数

反向训练

1.首先输入期望输出,同输出层的输出进行计算得到输出误差数组
2.然后对包括输出层的每一层: 遍历当前层的神经细胞,得到该神经细胞的输出,同时利用反向传播激活函数计算反向传播回来的误差, 进行调整权重矩阵。
激活函数: sigmoid(S型)函数的导函数

算法参考博客:
https://blog.csdn.net/xuanwolanxue/article/details/71565934
https://blog.csdn.net/qq_41645895/article/details/85265148
https://blog.csdn.net/u014303046/article/details/7820001

3. 数据处理

1. 训练数据集

简介

样本来源于美国提供的MNIST数据集一共包含7万个样本

数据集包含四个二进制文件:
train-images-idx3-ubyte: training set images #训练集图片
train-labels-idx1-ubyte: training set labels #训练集标签
t10k-images-idx3-ubyte: test set images #测试集图片
t10k-labels-idx1-ubyte: test set labels #测试集标签
训练集有60000个训练样本,测试集有10000个样本

文件的格式如下
TRAINING SET IMAGE FILE (train-images-idx3-ubyte):
[offset] [type] [value] [description]
0000 32 bit integer 0x00000803(2051) magic number #文件头魔数
0004 32 bit integer 60000 number of images #图像个数
0008 32 bit integer 28 number of rows #图像宽度
0012 32 bit integer 28 number of columns #图像高度
0016 unsigned byte ?? pixel #图像像素值
0017 unsigned byte ?? pixel
………
xxxx unsigned byte ?? pixel
数据来源:http://yann.lecun.com/exdb/mnist/

读取数据

数据集的读取单独设置一个类,每次读数据传进一个index参数,index表示要读取文件中第几张图片,这就需要用到C++文件流的seekg()函数来索引到正确的位置,seekg()函数有两种形式的重载,我们采用的单参数重载,参数是相对于文件初始的偏移量。
偏移量 = 文件头大小 + (index - 1) * 图片大小

bool dataLoader::readIndex(int* label, int pos) {if (mLabelFile.is_open() && !mLabelFile.eof()) {mLabelFile.seekg(mLableStartPos + pos*mLabelLen);mLabelFile.read((char*)label, mLabelLen);return mLabelFile.gcount() == mLabelLen;}return false;
}bool dataLoader::readImage(char imageBuf[], int pos) {if (mImageFile.is_open() && !mImageFile.eof()) {mImageFile.seekg(mImageStartPos + pos*mImageLen);mImageFile.read(imageBuf, mImageLen);return mImageFile.gcount() == mImageLen;}return false;
}
//label 用于保存标签 imagebuf保存图片像素 pos表示需要读取数据集中第几张图片
bool dataLoader::read(int* label, char imageBuf[], int pos) {if (readIndex(label, pos)) {return readImage(imageBuf, pos);}return false;
}

2. 算法相关参数声明

样本参数:
每个图片样本从二进制文件中读取的格式是unsigned char [28 * 28] (图片大小为28 * 28)
然后对样本的像素矩阵进行归一化处理,转成double数组:像素值大于128置1否则置0

inline void preProcessInputData(const unsigned char src[], double out[], int size) {for (int i = 0; i < size; i++) {out[i] = (src[i] >= 128) ? 1.0 : 0.0;}
}

权重参数:
神经网络的每一层都有一个二维的权重数组,在隐藏层每一个神经细胞都会有28*28个输入,每个细胞对应一个输出,所以输出层的每个神经细胞对应有隐藏层细胞总数个的输入。权重的初始化是由随机数生成。

// 随机整数数[x, y]
inline int RandInt(int x, int y)
{ return rand() % (y - x + 1) + x;
}// 随机浮点数(0, 1)
inline double RandFloat()
{ return (rand()) / (RAND_MAX + 1.0);
}// 随机布尔值
inline bool RandBool()
{return RandInt(0, 1) ? true : false;
}// 随机浮点数(-1, 1)
inline double RandomClamped()
{ return rand() % 1000 * 0.001 - 0.5;
}// 高斯分布
inline double RandGauss()
{static int   iset = 0;static double gset = 0;double fac = 0, rsq = 0, v1 = 0, v2 = 0;if (iset == 0){do{v1 = 2.0*RandFloat() - 1.0;v2 = 2.0*RandFloat() - 1.0;rsq = v1*v1 + v2*v2;} while (rsq >= 1.0 || rsq == 0.0);fac = sqrt(-2.0*log(rsq) / rsq);gset = v1*fac;iset = 1;return v2*fac;}else{iset = 0;return gset;}
}

4. 并行机制

MPI–数据并行

数据并行的方法适用于MPI,假设默认隐藏层的神经细胞数量为100,那么权重矩阵的大小为28 * 28 *100,这个大小并不是很适用于MPI进行矩阵运算并行,在这种小计算量的地方采用并行,有很大几率会由于过大的通信开销导致程序运行变慢,所以我们采用训练样本并行的方法。
采用训练样本并行需要慎重的考虑执行的进程数和粒度大小的设置,因为权重数组的更新是依赖于之前训练过的样本的,所以采用样本并行可能会导致识别率的降低。
1.基于模型的配置随机初始化网络模型参数
2.将当前这组参数分发到各个工作节点
3.在每个工作节点,用数据集的一部分数据进行训练
4.将各个工作节点的参数的均值作为全局参数值
5.若还有训练数据没有参与训练,则继续从第二步开始

因此MPI的数据并行就是一个不断分传样本->分进程计算权值->回传权值->主进程计算新权值->所有进程统一权值的过程。

double trainEpoch(dataLoader& src, NetWork& bpnn, int imageSize, int numImages) {//for mpiint task_count = 0;int rank = 0;int tag = 0;MPI_Status status;//for traindouble net_target[NUM_NET_OUT];char* temp = new char[imageSize];double* net_train = new double[imageSize];//get mpi messageMPI_Comm_size(MPI_COMM_WORLD, &task_count);  //get num of ranksMPI_Comm_rank(MPI_COMM_WORLD, &rank);        //get current rank number--task_count;                                //the num of ranks used for trainingdouble comun_time = 0.0;for (int i = 0; i < numImages;) {int row1 = bpnn.mNeuronLayers[0]->mNumNeurons;int row2 = bpnn.mNeuronLayers[1]->mNumNeurons;int col1 = bpnn.mNeuronLayers[0]->mNumInputsPerNeuron + 1;int col2 = bpnn.mNeuronLayers[1]->mNumInputsPerNeuron + 1;double weights1[row1][col1];double weights2[row2][col2];double new_weights1[row1][col1];double new_weights2[row2][col2];if(rank != 0){int sample_num = 0;if(i + task_count * SIZE > numImages){sample_num = (numImages - i) / task_count;if(rank <= ((numImages - i) % task_count))sample_num++;}else{sample_num = SIZE;}for(int loop = 0; loop < sample_num; loop++){int label = 0;memset(net_target, 0, NUM_NET_OUT * sizeof(double));if (src.read(&label, temp, i + ((rank-1) * sample_num) + loop)) {net_target[label] = 1.0;preProcessInputData((unsigned char*)temp, net_train, imageSize);bpnn.training(net_train, net_target);}else {cout << "读取训练数据失败" << endl;break;}}}if(rank != 0){for(int loop = 0; loop < row1; loop++){for(int loop1 = 0; loop1 < col1; loop1++)weights1[loop][loop1] = bpnn.mNeuronLayers[0]->mWeights[loop][loop1];}for(int loop = 0; loop < row2; loop++){for(int loop1 = 0; loop1 < col2; loop1++)weights2[loop][loop1] = bpnn.mNeuronLayers[1]->mWeights[loop][loop1];}for(int loop = 0; loop < row1; loop++){MPI_Send(weights1[loop], col1, MPI_DOUBLE, 0, tag, MPI_COMM_WORLD);    }MPI_Send(weights2, row2*col2, MPI_DOUBLE, 0, tag, MPI_COMM_WORLD);}MPI_Barrier(MPI_COMM_WORLD);if(rank == 0){//father rankdouble cur_time = MPI_Wtime();for(int loop = 0; loop < row1; loop++){for(int loop1 = 0; loop1 < col1; loop1++)new_weights1[loop][loop1] = 0;}for(int loop = 0; loop < row2; loop++){for(int loop1 = 0; loop1 < col2; loop1++)new_weights2[loop][loop1] = 0;}for(int j = 1; j <= task_count; j++){//recv and calculate the new weightsfor(int loop = 0; loop < row1; loop++)MPI_Recv(weights1[loop], row1*col1, MPI_DOUBLE, j, tag, MPI_COMM_WORLD, &status);MPI_Recv(weights2, row2*col2, MPI_DOUBLE, j, tag, MPI_COMM_WORLD, &status);for(int loop = 0; loop < row1; loop++){for(int loop1 = 0; loop1 < col1; loop1++)new_weights1[loop][loop1] += weights1[loop][loop1];}for(int loop = 0; loop < row2; loop++){for(int loop1 = 0; loop1 < col2; loop1++)new_weights2[loop][loop1] += weights2[loop][loop1];}}for(int loop = 0; loop < row1; loop++){for(int loop1 = 0; loop1 < col1; loop1++)new_weights1[loop][loop1] /= task_count;}for(int loop = 0; loop < row2; loop++){for(int loop1 = 0; loop1 < col2; loop1++)new_weights2[loop][loop1] /= task_count;}for(int j = 1; j <= task_count; j++){for(int loop = 0; loop < row1; loop++)MPI_Send(new_weights1[loop], col1, MPI_DOUBLE, j, tag, MPI_COMM_WORLD);MPI_Send(new_weights2, row2*col2, MPI_DOUBLE, j, tag, MPI_COMM_WORLD);}for(int loop = 0; loop < row1; loop++){for(int loop1 = 0; loop1 < col1; loop1++)bpnn.mNeuronLayers[0]->mWeights[loop][loop1] = new_weights1[loop][loop1];}for(int loop = 0; loop < row2; loop++){for(int loop1 = 0; loop1 < col2; loop1++)bpnn.mNeuronLayers[1]->mWeights[loop][loop1] = new_weights2[loop][loop1];}cout << "已学习:" << i << "\r";cur_time = MPI_Wtime() - cur_time;comun_time += cur_time;}if(rank !=0){//get new weightsfor(int loop = 0; loop < row1; loop++)MPI_Recv(weights1, col1, MPI_DOUBLE, 0, tag, MPI_COMM_WORLD, &status);MPI_Recv(weights2, row2*col2, MPI_DOUBLE, 0, tag, MPI_COMM_WORLD, &status);for(int loop = 0; loop < row1; loop++){for(int loop1 = 0; loop1 < col1; loop1++)bpnn.mNeuronLayers[0]->mWeights[loop][loop1] = weights1[loop][loop1];}for(int loop = 0; loop < row2; loop++){for(int loop1 = 0; loop1 < col2; loop1++)bpnn.mNeuronLayers[1]->mWeights[loop][loop1] = weights2[loop][loop1];}}MPI_Barrier(MPI_COMM_WORLD);i += task_count*SIZE;}if(rank == 0)cout << " comun_time=" << comun_time << endl;delete []net_train;delete []temp;return bpnn.getError();}
//

这里需要注意的是MPI_Recv和MPI_Send缓冲区大小有限制,当时我发送一个大小为7w+个double大小的数组就发生了一直阻塞的情况,后来借鉴了别人的经验,将数组分开多次发送就解决了,但是可能加大了通信的总开销

OpenMP–计算并行

当时我在考虑应该在哪里使用OpenMP来改进算法时,我的思路一直局限在样本并行上,我忽略了MPI与OpenMP的区别,后来我突然想起来,当时OpenMP的最经典应用就是用在矩阵运算上,而我们在本算法中,大量的计算开销都是产生于矩阵的运算。
因此使用OpenMP更改算法就变得简单了起来,只要找到矩阵运算的部分,加上合适的原语即可。

5. 可扩展性设计

在算法中,会有一些可以自由调整的参数,为了在进行不同维度的算法效果分析时的方便,我们把可变的参数放到一个文件中,在程序执行的开始,使用文件中的数据来初始化本次执行的一些可变参数。
文件包含:
训练样本量 #input_size 学习率 #learning_rate
隐含层神经元数量 #number 并行粒度 #para_size

6.结果分析

测试机器硬件型号:cups:8核 Intel® Core™ i7-4720HQ @ 2.60GHz
固定参数设置:
测试样本:1w 学习率:0.5 训练周期:1 隐藏层细胞:100 粒度:10 训练样本6w
串行程序: 时间开销27.9s 正确率94.01%
8线程–OpenMP: 时间开销7.9s 正确率93.86%
单节点8进程–MPI: 时间开销18.1s 正确率91.2%

并行参数调整分析

1. 不同输入样本量


测试机器硬件型号:
cups:8核 Intel® Core™ i7-4720HQ @ 2.60GHz
固定参数设置:测试样本:1w 学习率:0.5 训练周期:1 进程数:8 隐藏层细胞:100 粒度:10
在OpenMP中,当我们输入维度随倍数递增,我们的时间开销基本上也随倍数递增,但是错误率随有提升,但是提升的效果不是十分显著,趋势已经趋于平缓。

在MPI中,当输入的维度随倍数递增,不论是总开销还是进程之间的通信开销依旧基本上随倍数递增,但是通信开销的占比几乎不变,与OpenMP相似,错误率的下降呈逐渐缓慢的趋势。

2.不同粒度


测试机器硬件型号:
cups:8核 Intel® Core™ i7-4720HQ @ 2.60GHz
固定参数设置:
测试样本:1w 训练样本:6w学习率:0.5 训练周期:1 进程数:8 隐藏层细胞:100

当我们改变MPI并行粒度的大小的时候,如图6,我们可以看到正确率是一个先下降后上升的过程。当粒度处于10-50之间的时候,由于权重数组的更新对于样本之间存在很强的依赖性,随着粒度的增大,我们回传参数的次数变小,导致了正确率的降低。当粒度再次增大,我们每一个进程分到的样本量变大,这时粒度的增大弥补了进程之间权重数组更新不同步的缺陷,正确率回升。
在时间开销上,计算量的变化基本不大,但是粒度增大,进程之间的通信量变少,导致了通信开销减小,也就造成了总开销变低。

3.不同进程/线程数


测试机器硬件型号:
cups:8核 Intel® Core™ i7-4720HQ@2.60GHz
固定参数设置:
测试样本:1w 训练样本:6w学习率:0.5 训练周期:1 粒度:10 隐藏层细胞:100
在OpenMP中,当我们开启的线程数不断增大时,时间的开销是一个先减小后增大的过程,由于程序在一个8核的机器上执行,所以当开启的线程数达到8的时候,时间开销达到最小,再增大之后,就需要8个核共同协调完成多出来的8个线程,也就导致了时间开销又再次加大。
在OpenMP中由于使用的是计算并行,所以调整线程的大小对正确率没有影响,图中的正确率的浮动在1%左右属于正常现象,可能是由于初始化时随机数的不同所导致。

测试机器硬件型号:
节点1: cups:8核 Intel® Core™ i7-4720HQ@2.60GHz
节点2: cpus:8核 Intel® Core™ i7-6700HQ@2.60GHz

固定参数设置:
测试样本:1w 训练样本:6w学习率:0.5 训练周期:1 粒度:10 隐藏层细胞:100

当我们在单台机器节点1上进行开启不同进程数的测试时,如图8我们可以发现开启4个进程和开启8个进程的总开销几乎一致,但是8个进程的通信开销占比变大了,这也就意味着,开启多个进程虽然能在计算上加速,但是通信开销也会变大,当我们开启更多的12个进程时,通信的开销占据了总开销的一半还多。
当我们同样开启12个进程在两个节点上执行的时候,时间的总开销更是高达2200多秒,通信占比更是高达98.83%。

由于权重参数的样本依赖性,我们开启的进程数越多,对正确率的影响也越大,错误率随着进程数的增大不断增加。

算法参数调整分析

1.不同训练周期数

测试机器硬件型号:
cups:8核 Intel® Core™ i7-4720HQ@2.60GHz
固定参数设置:
测试样本:1w 训练样本:6w学习率:0.5 线程/进程数:8 粒度:10 隐藏层细胞:100

在当我们不断加大训练周期的时候,不论采用哪种并行算法都是时间开销呈线性递增,识别的成功率也会有所增长。但是当训练周期达到一定的值的时候,正确率的提升微乎其微,似乎达到了一个极限,这个时候就不是单凭加大训练量就能继续提高正确率的,需要我们去改善训练所用的算法,例如改变其他参数、更换激活函数、使用卷积神经网络等。

2.不同学习率

测试机器硬件型号:
cups:8核 Intel® Core™ i7-4720HQ@2.60GHz
固定参数设置:
测试样本:1w训练样本:6w训练周期:1 线程/进程数:8 粒度:10 隐藏层细胞:100

随着学习率的增大,正确率的变化由平缓到线性急速降低,我们可以推测,最优的学习率大致在0-1之间,时间的变化在0.1秒之内,属于正常变化,几乎没有太大影响。

3.不同隐藏层细胞数


隐藏层细胞数的增加提高了正确率,但是到后面会有所收敛,由于计算量的倍增,时间开销同样也会倍增。

小结

当计算量不足够大,而且网络通信开销比较大的时候,使用OpenMP进行并行优化的效果要比MPI优化的效果更为明显。
同样是在单节点上执行,由于OpenMP是多线程并行,大多数的数据是共享的,每一个线程的计算是独立的、互不干扰的,因此在数据传递上就比MPI的多线程,数据不共享体现出了优势,有效的减小了通信的开销占比。当然,我们在多节点执行的时候,其中一个节点使用了虚拟机,有可能也是程序执行时间被拖慢的原因之一。
这并不是说MPI与OpenMP相比就失去了优势,这只能说明OpenMP更适合于我们这次所选的课题。MPI的优势在于,可以将多台机器组建成一个集群,而不是局限于单台机器。
这是本黑菜第一次写博客,如果文章里哪里出现了学术上的问题,还请各位大佬及时指正,谢谢。

参考资料

https://blog.csdn.net/a493823882/article/details/78683445
https://blog.csdn.net/xbinworld/article/details/74781605

CSDN下载

https://download.csdn.net/download/qq_41645895/11068914

并行化实现基于BP神经网络的手写体数字识别相关推荐

  1. 基于BP神经网络的手写体数字识别matlab仿真实现

    目录 一.理论基础 二.核心程序 三.测试结果 一.理论基础 文字.数字识别是一个典型的模式识别问题,也是模式识别中一个非常重要的应用领域.在文字.数字识别系统中,手写体的文字与识别是一个较难的领域, ...

  2. matlab+BP神经网络实现手写体数字识别

    个人博客文章链接:http://www.huqj.top/article?id=168 接着上一篇所说的 BP神经网络,现在用它来实现一个手写体数字的识别程序,训练素材来自吴恩达机器学习课程,我把打包 ...

  3. 基于BP神经网络手写数字和字母识别

    一:系统介绍 这个程序是在MATLAB中编写,基于BP神经网络的文字符号识别系统的具体实现,该系统既可以实现单一手写字符,也可以实现一连串的字符,而且具有较高的准确率.本系统主要有几个模块,图片输入, ...

  4. 基于BP神经网络算法的性别识别

    目录 基于 BP 神经网络算法的性别识别 1 目录 1 1.背景介绍 2 2. OpenCV 的介绍 3 3.安装 OpenCV 4 4. BP 神经网络算法介绍和实践 4 4.1 BP 神经网络结构 ...

  5. ​【交通标志识别】基于BP神经网络实现交通标志识别matlab代码

    1 简介 近年来,交通标志识别在车辆视觉导航系统中是一个热门研究课题.为了安全驾驶和高效运输,交通部门在公路道路上设置了各类重要的交通标志,以提醒司机和行人有关道路交通信息,如指示标志.警告标志.禁止 ...

  6. 基于KNN算法的手写体数字识别

    基于KNN算法的手写体数字识别 KNN分类算法是一种经典的分类算法,属于懒惰学习算法的一种. 1.算法原理 工作原理:存在一个样本数据集合,也称作训练样本集,并且样本集中每个数据都存在标签,即我们知道 ...

  7. 【图像识别】基于BP神经网络实现手写体大写字母识别附matlab代码

    1 简介 手写体字符识别是人机交互领域的一个重要内容,本文基于 BP 神经网络实现了任意数量字符模版的多字符手写体字符识别.分为以下几步,第一,首先对目标图像进行识别前预处理.包括灰度图像二值化,图像 ...

  8. 基于AlexNet卷积神经网络的手写体数字识别系统研究-附Matlab代码

    ⭕⭕ 目 录 ⭕⭕ ✳️ 一.引言 ✳️ 二.手写体数字识别系统 ✳️ 2.1 MNIST 数据集 ✳️ 2.2 CNN ✳️ 2.3 网络训练 ✳️ 三.手写体数字识别结果 ✳️ 四.参考文献 ✳️ ...

  9. 基于SVM+HOG的手写体数字识别

    本文是对下面这篇文章的一些略微详细的解释... OpenCV Hog+SVM 学习 最近在学习数字识别,搜索资料的时候,发现了这篇文章.文章很久了,是2013年发的,那时候我才刚上大学.....用的是 ...

最新文章

  1. ORB_SLAM2 PnPSolver
  2. 可视化卷积神经网络的过滤器_万字长文:深度卷积神经网络特征可视化技术(CAM)最新综述...
  3. 基础排序算法(冒泡排序,选择排序,插入排序)
  4. 零基础Java学习之多态
  5. 简单的C语言五子棋(两种模式:移动光标输入坐标和移动光标按键)
  6. java 中使用mongodb_mongodb在java中的使用
  7. js符号转码_JS 字符串编码函数(解决URL特殊字符传递问题):escape()、encodeURI()、encodeURIComponent()区别详解...
  8. delphi 点击wsdl出不了描述文件_iOS 13 公测版来了,安装公测版官方描述文件
  9. python设计模式之猴子补丁模式
  10. java连接oftp_[Share] EDI 系统之 OFTP 端口
  11. ajax返回的java list_ssm+ajax异步请求返回list遍历
  12. 数据库删除表中多列语法总结
  13. 用C++写一个简单的表白小程序
  14. Materials studio中的简单聚合物的建立及盒子的弛豫
  15. Adobe Premiere(Pr视频剪辑)下载安装
  16. 各种字体下载地址和移动端支持字体简析
  17. 华为鸿蒙如何添加桌面小组件,万能小组件添加至桌面怎么弄?桌面添加应用方法图文详解...
  18. win10安装linux虚拟机
  19. python创建person类用printinfo方法_python学习(三)面向对象
  20. mysql查询同名同姓重名人数,全国同名同姓人数在线查询,重名率查询

热门文章

  1. sqr和Oracle的区别,Oracle学习笔记:a inner join b与from a,b where a.x=b.x的差异
  2. 并查集leetcode经典逆序思维
  3. 【大数据与云计算】大数据多维分析引擎在魅族公司的实践
  4. 计算机二级在线报名进不去,全国计算机等级考试网上报名注意事项
  5. 代码怎样review?,安卓驱动面试
  6. #NASA立扫把挑战#真相,一点也不香
  7. oracle查询语句出现问号,plsql查询数据库-中文显示问号问题
  8. opencv中的resize 函数 的理解以及引申
  9. 《HTML+CSS+JavaScript》之第8章 超链接
  10. Deep Silver 将《地铁 离去》从Steam改至Epic商城