0%

The Cross-Correlation Operation

假设对于一个只有一个颜色空间的3x3的图片,另外有一个2x2的卷积核,Cross-Correlation Operation的定义如下图:

cross-correlation-example

代码实现:

1
2
3
4
5
6
7
8
9
import tensorflow as tf
def corr2d(X, K):
"""Compute 2D cross-correlation."""
h, w = K.shape
Y = tf.Variable(tf.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1)))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
Y[i, j].assign(tf.reduce_sum(X[i:i + h, j:j + w] * K))
return Y

Custom Convolution Layer

1
2
3
4
5
6
7
8
9
10
11
12
13
class Conv2DDense(tf.keras.layers.Layer):
def __init__(self):
super(Conv2DDense, self).__init__()
self.weight = None
self.bias = None

def build(self, input_shape):
initializer = tf.random_normal_initializer()
self.weight = self.add_weight(name="weight", shape=input_shape, initializer=initializer)
self.bias = self.add_weight(name="bias", shape=(1,), initializer=initializer)

def call(self, inputs, **kwargs):
return corr2d(inputs, self.weight) + self.bias
注意卷机层的输出shape是由输入和自己的卷积核一起决定的,而之前自定义Layer的输出shape是初始化时由用户决定的。

Learning a Kernel

在对图片进行卷积操作时,我们很多时候是不知道应该将卷积核的每个元素的数值设置为多少的。假设我们知道输入的图片以及经过卷积操作之后应该得到的输出图片,要想知道卷积核每个元素的取值,这个问题其实本质上就是一个线性规划的问题,可以用梯度下降法来求的近似数值解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def learning_a_kernel(input: Union[tf.Tensor, tf.Variable], wanted_output: Union[tf.Tensor, tf.Variable], kernel_shape):
# Construct a two-dimensional convolutional layer with 1 output channel and a
# kernel of kernal_shape. For the sake of simplicity, we ignore the bias here
conv2d = tf.keras.layers.Conv2D(1, shape=kernel_shape, use_bias=False)

reshaped_input = tf.reshape(input, (1, input.shape[0], input.shape[1], 1))
reshaped_wanted_output = tf.reshape(wanted_output, (1, wanted_output.shape[0], wanted_output.shape[1], 1))
_ = conv2d(reshaped_input) # this is use to gen weights in layer
for i in range(10):
with tf.GradientTape(watch_accessed_variables=False) as g:
g.watch(conv2d.weights[0])
calculated_output = conv2d(reshaped_input)
loss = (abs(calculated_output - reshaped_wanted_output)) ** 2

update = tf.multiply(3.21e-2, g.gradient(loss, conv2d.weights[0]))

weights = conv2d.get_weights()
weights[0] = conv2d.weights[0] - update
conv2d.set_weights(weights)

Custom Layers and Blocks

背景

对于神经网络,不论是单个神经元,还是一层神经元,还是多层神经元组成的神经网络,它们都有同样的抽象结构:

  • 接受一组输入
  • 产生对应的输出
  • 有可调节的网络参数

在深度学习中,通常会封装一个多层网络,这个多层比单层神经网络要多,但是比整个深度神经网络要少的网络。这个多层网络同样具有以上的抽象结构。封装这个多层网络的是为了方便复用网络结构,例如在ResNet-152架构中(用于计算机视觉的深度网络),整个模型有几百层神经网络,但是这个神经网络是由重复的几组多层网络结构构成的。这种设计在许多深度神经网络中都很常见。

通常,我们把这种封装好的多层网络结构叫做block。而且,在深度学习编程框架中,一个Block通常封装在一个类中。这个类需要实现以下功能:

  • 一个forward function用于将输入转换为输出
  • 保存网络的参数
  • 一个backward function用于计算梯度,但是感谢深度学习框架中的自动微分功能,我们通常不用实现这个backward function。

自定义block示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import tensorflow as tf


class MyBlock(tf.keras.Model):
"""
a custom block with a hidden layer(256 unit) and a output layer(10 unit)
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.hidden = tf.keras.layers.Dense(units=256, activation=tf.nn.relu)
self.out = tf.keras.layers.Dense(units=10)

def call(self, inputs, training=None, mask=None):
return self.out(self.hidden(inputs))

自定义layer

参数管理

延迟初始化

参数存储和加载

使用GPU

不使用任何构建工具

对于C/C++代码来说,不使用任何构建工具来辅助编译C/C++代码的话,就需要自己手动指定使用的编译器(clang、gcc/g++或者其他编译器),一般来说需要在编译命令下指定这些:

  • 指定需要编译的源文件
  • 指定头文件的搜索目录
  • 指定库文件的搜索目录
  • 需要链接的动态库和静态库
  • 指定需要额外定义的宏或者取消定义的宏来控制编译
  • 指定编译优化选项
  • 指定语言标准
  • 等等

以gcc/g++为例,使用-D指定额外定义的宏,-U取消宏定义:

1
2
3
-DUseXX //#define UseXX
-DMaxSize=500 //#define MaxSize 500
-UUseXX //#undef UseXX

-I(i的大写) 添加头文件搜索目录

1
-I/usr/local/include/xxx -I/usr/local/Cellor/yyy

-L 添加库文件搜索目录

1
-L/usr/local/lib -L/usr/local/Cellor/lib

-l(L的小写)添加链接库

1
-la -lb -lz

-g生成调试信息

1
2
3
4
-g #以操作系统的本地格式产生调试信息
-g1 #输出的调试信息
-g2 #输出默认量的调试信息
-g3 #输出更多的调试信息

警告选项

1
2
3
-w #关闭显示所有警告信息
-Wall #开启大部分警告提示,默认
-Werror #将警告视为错误,出现警告即放弃编译

-O优化选项

1
2
3
4
-O0 #不优化
-O1 #
-O2
-O3

语言选项

1
2
3
-ansi #支持符合ANSI标准的C程序
-std=c99 #C99标准
-std=c++11 # c++11标准

直接使用gcc/g++命令在使用代码文件不多,库直接的依赖关系不复杂的情况下是可以接受的。但是在一些大型项目中,源文件上千上万,各个文件和库直接的依赖关系复杂,如果还手动输入编译命令的话,复杂度就会变得很大。

make工具

make工具是一个从源码自动构建所需“目标”的构建工具,他依赖通过读取一个叫做Makefile的脚本文件来实现自动化构建。

Makefile本身其实一种管理代码源文件之间依赖关系的脚本,在这个脚本中描述了项目工程中的各个“目标”或者源文件之间的依赖关系以及构建各个目标所需要的命令。

斯图亚特·费尔德曼在1977年在贝尔实验室里制作了最初的make程序,最初的make程序被多次重/改写,形成了多种版本的make工具,比较出名的有GNU make, BSD make,microsoft nmake等等。这些make工具都有不同的规范和标准,使用的Makefile的语法标准也不同。

下面以GNU Make使用的Makefile的格式对Makefile进行简介。

GNU Makefile格式

编写Makefile的规则一般如下:

1
2
3
4
5
6
7
8
9
10
target: prerequisites
[tab]COMMAND
[tab]COMMAND
...
...
...

target2: prerequisites
...
...

Makefile中target就是make命令中所需指定的构建目标。make会把在Makefile中定义的第一target作为默认target,即如果不给make命令传递target参数,则默认为makefile中的第一个定义的target。

prerequisites说明了这个target依赖哪些其他的target或者文件,这个字段可以为空,说明这个target没有依赖。如果target有依赖的情况下,make会记录target是否有更新,如果依赖没有更新,make就不会重复构建target。

command就是这个生成这个target所需要的命令,这个命令可以是任意的命令

注意,可能有一个误区就是prerequisites字段只是用来让make进行依赖解析和判断target是否需要重建的,依赖和command本身没有任何关系。如果以为某个target依赖了某些源文件,那么make在解析的时候自动会将这些源文件传递给命令,这是不对的。例如target main依赖了main.cpp文件,那么这么写是没有用的:

1
2
main: main.cpp
g++ -o main

应该这么写:

1
2
main: main.cpp
g++ -o main main.cpp

下面举一个例子来使用makefile构建一个可执行文件,在a.h定义了类A,在a.cpp中实现了类A的方法,在main.cpp中引入了a.h使用了类A的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//a.h
class A {
public:
void say();
};

//c.cpp
#include "a.h"
#include <iostream>
void A::say() {
std::cout << "hello world" << std::endl;
}

//main.cpp
#include "a.h"
int main() {
A a;
a.say();
return 0;
}

对应的Makefile的一种形式

1
2
3
4
5
6
7
8
9
10
11
CXX = g++
main: a.cpp.o main.cpp.o
$(CXX) -o test main.o a.o

main.o: a.h main.cpp
echo "main"
$(CXX) -c main.cpp -o main.o

a.o: a.h a.cpp
echo "a"
$(CXX) -c a.cpp -o a.o

还有很多其他写法:

1
2
3
CXX = g++
main: a.h a.cpp main.cpp
$(CXX) -o test main.cpp a.cpp

但是这种写法不好的地方在于,如果a.h,a.cpp,main.cpp中有任意一个文件有变化,这几个文件都要重新再编译一次。

执行make命令就会构建出目标main,生成test可执行文件。

1
make #等价使用make main

也可以不构建目标main,选择构建其他目标,例如构建main.cpp.o目标:

1
make main.cpp.o #执行g++ -c main.cpp,生成main.o文件

make对target依赖的解析是一个类似递归的过程,例如上例中目标main依赖了目录a.cpp.o和main.cpp.o那么make会先去执行a.cpp.o下的命令和main.cpp.o下的命令,然后再来执行目标main定义的命令。

在make file中有几个特殊的target,一个是clean,另外一个是install,不是说这些命令必须执行某种命令,而是大家默认了使用cmake执行这些target应该做的事,clean就应该是去清除之前构建目标生成出来的文件,而install应该将构建目标生成出来的文件安装到指定目录下。你也可以在Makefile中将clean定义为执行安装操作的命令,然后把install定义为执行clean操作的命令,但是这不合理。

之前提到Makefile本质上就是一个脚本文件,既然是脚本文件,那就可以在Makefile中定义变量,使用变量,使用条件控制语句,定义函数,使用函数,include其他Makefile文件等。这些使用可以去网上自己去搜索。

Makefile本身只关心各个文件或目标之间的依赖关系,省去了开发者在修改某些代码文件后判断需要重新编译生成目标文件的时间。但是在Makefile中最后还是需要自己去调用编译器命令,在命令中指定include的目录,链接库的路径,需要链接的库。另外在不同的操作系统下还需要指定不同的编译器,指定不同的include头文件目录等等。

最后说明一下,由于Makefile本身不关心编译过程的,因为最后的编译还是通过在Makefile中编写调用编译命令,所以使用make+Makefile理论上可以用来编译任何代码。

GNU Autotools

之前说到在不同的代码运行平台下,可能需要在Makefile中指定不同的编译器的名称,指定不同的头文件路径,链接不同的链接库等等,如果用make工具,那么就需要对不同的运行平台,就需要编写不同的Makefile,这不仅会增加工作量,而且要求程序员对各个平台也要有一定的熟悉程度。GNU Autotools的目的就是为了在不同类Unix平台下,针对GNU make工具快速生成对应的Makefile文件。

GUN Autotools是一个GNU工具集,它包括autoscan、autoheader,autoconf,automake,aclocal和libtool等等。最主要使用的两个工具就是autoconf和automake。使用Autotools最终也会生成Makefile文件,然后调用make命令来构建出项目。使用Autotools构建项目的三部曲一般是./configure,make,make install。

运行configure脚本用于系统检测,一般主要检测当前平台的编译器、库文件、头文件等等,这些检查的结果将用于将config.h.in和Makefile.in文件转化为最终的config.h和Makefile文件。configure脚本一般有几千行,非常复杂,如果要手写的话很困难,可以使用autoconf工具来自动生成这个configure脚本。autoconf工具通过读取一个configure.ac的模版文件,来生成configure脚本。configure.ac文件是一个由m4宏编写的文件,autoconf在处理configure.ac文件时就会将这些宏展开为shell脚本代码。

在开始的时候,可以使用autoscan工具来生成一个configure.ac的模版,autoscan生成的文件的名字为configure.scan,直接修改configure.scan名字为configure.ac即可。

再以写Makefile时的示例项目为例,通过对autoscan生成的模版再进行修改的情况下,编写的configure.ac文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#                                               -*- Autoconf -*-
# Process this file with autoconf to produce a configure script.

AC_PREREQ([2.69]) #autoconf版本最低要求2.69
AC_INIT([test], [1.0], [www.xxx-bug-report.com]) #设置项目名称,版本号,bug上报网址
AM_INIT_AUTOMAKE # 项目需要使用automake,初始化automake
AC_CONFIG_FILES([Makefile]) #告诉configure 根据系统信息和Makefile.in生成Makefile文件

# Checks for programs.
AC_PROG_CXX #告诉configure 检查c++编译器,由于没有指定这个宏的参数,默认参数为g++
AC_PROG_CC #告诉configure 检查c编译器,由于没有指定这个宏的参数,默认参数为gcc

# Checks for libraries.

# Checks for header files.

# Checks for typedefs, structures, and compiler characteristics.

# Checks for library functions.

AC_OUTPUT

configure脚本会根据Makefile.in来生成Makefile,但是Makefile.in的脚本也是又长又复杂的,但是和configure的生成一样,Makefile.in可以由一个模版文件通过automake来生成,这个模版文件就是Makefile.am。

在这个例子下,对应的Makefile.am:

1
2
3
4
5
6
7
8
AUTOMAKE_OPTIONS = foreign #因为这里的例子不是标准的 GNU 项目的结构,所以结构声明为 foreign
bin_PROGRAMS = test #告诉automake 需要Makefile构建的项目是一个可执行文件,其名称为test
test_SOURCES = main.cpp a.cpp #告诉automake 项目使用的源文件

#test_CPPFLAGS = #设置c++编译的编译选项,这里不需要额外的编译选项
#test_LDFLAGS = #设置链接选项,这了不需要额外的链接选项,
#test_LDADD = #设置要链接的库文件,这里也没有
#INCLUDES = #设置头文件搜索目录,这里也没有额外的头文件

接下来,依次运行下面几个命令即可完成对项目的编译

1
2
3
4
5
aclocal #使用aclocal初始化m4环境
autoconf #将configure.ac生成configure脚本
automake --add-missing #将Makefile.am生成Makefile.in
./configure #检查系统环境,将Makefile.in和config.h.in(这里没有使用到)生成Makefile和config.h
make

在发布软件的时候,不需要将configure.ac和Makefile.am文件发布出去,只需要将Makefile.in,config.in.h和configure脚本发布出去即可。

GNU Autotools工具对类Unix系统的用户来说比较友好,对于使用windows系统的用户来说就很不友好了,需要进行很多的配置才行。

CMake

之前提到不同的平台下,不同的IDE间使用的make工具的规范和标准是不同的,Makefile的标准也就不同,CMake的出现解决了不同make工具之间规范和标准不同的问题。

CMake让构建人员通过一个与平台无关的CMakeList.txt文件来说明这个项目的构建流程,然后再根据目标平台生成对应的Makefile文件。

CMakeList.txt是使用CMake自己的一套语法来编写的。还是以之前的例子来说明一个CMaList.txt有哪些内容:

1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.13) #cmake最低版本3.13
project(Test) #设置CMake项目名称

set(CMAKE_CXX_STANDARD 11) #设置C++标准为C++11

set(TEST_SRC main.cpp a.cpp)

add_executable(test ${TEST_SRC}) #添加说明要构建一个可执行文件,文件名为test,源码文件为main.cpp,a.cpp

之后执行命令:

1
2
cmake .
make

就可以完成对项目的编译构建。

CMake会自动检查当前平台使用的编译器信息,当然也可以在CMake中要使用的编译器

总结

代码构建工具的目的

1、代码构建工具的首要目的就是帮助程序员能够从代码构建出最后的产物

2、帮助管理代码中的依赖问题

3、加快编译构建速度,能够并行编译,并且只对需要重新编译的部分进行编译。

为什么c/c++的项目这么难管理

以下原因只是我的主观看法:

1、c/c++发展的太早,早到发展初期根本没有想到依赖管理的问题,早到当时根本没有考虑各个平台下统一标准,统一规范的问题。

2、

3、与系统强相关,在不同系统中,使用的c/c++的头文件,库文件都是不同的,另外,在一个平台下编译出来的二进制文件有可能无法在另外一个平台下使用,需要从源码重新编译。

TODO: 将PPT中的一些内容补充到这篇博客中

代理模式简介

代理模式和装饰模式在代码结构上没什么差别,只是代码模式和装饰模式的目的和对内部对象的管理行为不同。代理模式能为对象提供行为基本相同的接口, 装饰模式则能为对象提供加强的接口。代理模式通常自行管理其服务对象的生命周期, 而装饰的生成则总是由客户端进行控制。

使用代理模式的场景

  • 延迟初始化
  • 访问控制
  • 打包请求,收到请求后不立即去请求,而是将将多个请求一起发出

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class VideoPlayer {
public:
virtual void listVideos() = 0;
virtual void getVideoInfo(int id) = 0;
virtual void downloadVideo(int id) = 0;
};

class BasicVideoPlayer : public VideoPlayer {
public:
void listVideos() override {

}

void getVideoInfo(int id) override {

}

void downloadVideo(int id) override {

}
};

class CachedVideoPlayer : public VideoPlayer {
private:
std::unique_ptr<VideoPlayer> videoPlayer;
public:
CachedVideoPlayer(std::unique_ptr<VideoPlayer> && videoPlayer): videoPlayer(std::move(videoPlayer)) {

}

void listVideos() override {

}

void getVideoInfo(int id) override {

}

void downloadVideo(int id) override {

}

};

示例中VideoPlayer类是一个接口类。BasicVideoPlayer是一个基本的播放类,但是该类的效率非常低,如果客户端多次请求同一个视频,该类会反复下载该视频。CachedVideoPlayer内部组合了一个VideoPlayer,同时也实现了VideoPlayer的接口。CachedVideoPlayer会讲实际下载的工作委派给源下载器,同时CachedVideoPlayer会将已经下载的视频进行缓存,不会多次反复下载同一个视频。

背景

在RGP射击游戏中,通常有大量的子弹、导弹、碎片等对象被生成,如果每个对象都有一份对立的属性值,那么一旦游戏运行起来,内存很快就会被消耗完。但是其实这些对象很多的属性大多情况下都是相同的,这里就可以将这些共同的属性只保留一份。这时候就可以使用享元模式。

享元模式简介

享元模式放弃了在每个对象中保存所有数据的方式, 通过共享多个对象所共有的相同状态, 让你能在有限的内存容量中载入更多对象。享元模式也叫缓存模式

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <string>
#include <map>

class Color;
class Texture;
class Canvas;

class TreeType {
private:
std::string name;
Color color;
Texture texture;
public:
TreeType(const std::string & name, const Color & color, const Texture & texture):
name(name), color(color), texture(texture) {}
void draw(Canvas & convas, float x, float y) {}
};

class Tree {
private:
float x, y;
const TreeType & treeType;
public:
Tree(float x, float y, TreeType & treeType): x(x), y(y), treeType(treeType) {}

virtual void draw(Canvas & canvas) {
this.treeType->draw(canvas, x, y);
}
};


class TreeTypeFactory {
private:
std::map<std::string, TreeType> treeTypes;
TreeTypeFactory() = default;
static TreeTypeFactory _instance;
public:
static TreeTypeFactory & shared();

TreeType & getTreeType(const std::string & name, const Color & color, const Texture & texture) {
//if name not in this.treeTypes, create a tree type else return treeTypes[name]
}
};

class Forest {
private:
std::vector<Tree *> trees;
public:
planeTree(float x, float y, const std::string & treeTypeName, const Color & color, const Texture & texture) {
TreeType & treeType = TreeTypeFactory.shared().getTreeType(treeTypeName, color, texture);
Tree * tree = new Tree(x, y, treeType);
this->trees.push_back(tree);
}

virtual void draw(Canvas & canvas) {
for (auto tree : trees) {
tree->draw();
}
}
};

在上面的示例中,我们需要在画布中渲染一个森林,森林是由Forest类表示的,一个森林由数百万个树对象构成,树类用Tree表示,对于同一类Tree的不同对象,除了坐标x、y不同之外,使用的颜色,贴纸都一样,因此将颜色,贴纸数据都放到一个享元对象中,享元类用TreeType表示。之后每个Tree对象都持有一个享元对象的引用。一般一类树都引用同一个享元对象。

TreeTypeFactory用于享元对象的获取,如果之前享元对象不存在,则返回一个新的享元对象,如果享元对象存在,则直接返回。

外观模式简介

假设你的代码必须和某个复杂的库或框架进行交互,正常情况下你不会用到第三方库的全部功能,这时候你可以为包含许多活动部件的复杂系统提供一个简单的接口,这就是外观模式。使用外观模式,与直接调用复杂系统相比,提供的功能可能比较有限,但是它却包含了客户端真正关心的功能。

外观模式简单的理解就是一层接口再封装。

装饰器模式简介

装饰器模式可以为已有类的方法增加新的行为,例如为一个文件数据读写类的写数据方法增加数据加密的行为。而实现的策略不是基于继承自这个已有的类。

装饰器模式通常代码结构

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
//interface
class DataManipulable {
public:
virtual size_t writeData(const char * data) = 0;
virtual size_t readData(char * buffer, size_t len) = 0;
};

class BaseFileDataManipulator: public DataManipulable {
private:
const std::string _filename;
public:
BaseFileDataManipulator(const std::string & filename): _filename(filename) {}

size_t writeData(const char * data) override {
//write data to file
}

size_t readData(char * buffer, size_t len) {
//read data from file
}
};

//decorator interface
class DataManipulableDecorator: public DataManipulable {
private:
DataManipulable & _dataManipulator;
public:
DataManipulableDecorator(DataManipulable & dataManipulator): _dataManipulator(dataManipulator) {}
DataManipulable & getManipulator() {
return _dataManipulator;
}
};

//concrete decorators
class EncryptDataManipulator: public DataManipulableDecorator {
public:
EncryptDataManipulator(DataManipulable & dataManipulator): DataManipulable(dataManipulator) {}
size_t writeData(const char * data) override {
//encrypt data first

//then delegate to _dataManipulator
return this->getManipulator().writeData(data);
}

size_t readData(char * buffer, size_t len) override {
//delegate to _dataManipulator
size_t readLen = this->getManipulator().readData(buffer, len);

//do decrypt data operation

return readLen;
}
};

class CompressDataManipulator: public DataManipulableDecorator {
public:
CompressDataManipulator(DataManipulable & dataManipulator): DataManipulableDecorator(dataManipulator) {}

size_t writeData(const char * data) override {
//compress data first

//then delegate to _dataManipulator
return this->getManipulator().writeData(data);
}

size_t readData(char * data, size_t len) {
//delegate to _dataManipulator
size_t readLen = this->getManipulator().readData(buffer, len);

//do decompress operation

return readLen
}
};

在上例中,BaseFileDataManipulator是一个已有基本功能的数据读写类,而EncryptDataManipulator和CompressDataManipulator分别用于给已有的数据读写类增加加密数据和压缩数据的能力。EncryptDataManipulator和CompressDataManipulator并没有继承自BaseFileDataManipulator,而是继承自DataManipulableDecorator这个接口类。但是BaseFileDataManipulator和DataManipulableDecorator都继承自DataManipulable这个接口类。客户端只关心和DataManipulable中定义的接口即可。

和适配器模式的对比

  • 适配器模式一般用于对已有对象的接口进行修改或者增加新的接口,而适配器模式一般不会改变已有对象的接口,而是对已有对象的接口的实现上添加新的行为。

说明

🧚std黑魔法记录,遇到一个记一个

std::strlen

  • 🧙🏻用法:同c中的strlen函数

std::lock_gurad

  • 🧙🏻用法:有点类似python中的with lock

  • 🧪原理:在构造函数中需要传入一个锁,在构造时,自动加锁,在析构时自动解锁

  • 🌰示例:

    1
    2
    3
    4
    5
    void sync_func() {
    std::mutex lock;
    std::lock_guard<std::mutex> guard(lock);
    //do something
    }

std::memcmp

  • 🧙🏻用法:比较两块内存中的数据是否是一致的

std::allocate_shared

  • 🧙🏻用法:在std::make_shared的功能基础上,使用自定义的内存Allocator,Allocator需要实现allocate和deallocate这两个方法。

std::hash

  • 🧙🏻用法:如果某种数据类型需要作为std无序容器(例如unordered_map、unordered_set)的key的话,则要实现std::hash这个模版对应类型的具体化

std::numeric_limits

  • 🧙🏻用法:获取某种类型的最大最小值

  • 🌰示例:

    1
    std::numeric_limits<int>::max();

std::this_thread::yield

  • 🧙🏻用法:把当前线程占据的时间片让渡出去,并且不参加让出时间片的竞争

  • 🌰示例:

    1
    std::this_thred::yield();

背景

在深度学习训练好模型后,模型可能在测试集表现的非常好,但是实际部署到生产环境中后,由于数据分布的突然变化,导致模型实际效果很差。而且,有些时候恰恰就是因为模型的部署才导致了数据分布的变化。例如一个判断是否要给贷款人贷款的场景中,一般来说穿皮鞋的贷款人要比穿运动鞋来贷款的人有更低的违约风险,于是训练了一个根据人的鞋来判断是否要给人贷款的模型。当这个模型部署后,人们就可能发现这个模型的判断模式,以此在来贷款的时候故意都穿上皮鞋。

Distribute Shift类型

Covariate Shift

Covariate Shift是由于样本输入特征概率分布变化导致的,但是$P(y | x)$条件概率不变。例如在一个判断图片是猫还是狗的二分类学习任务中,在训练时,输入的样本都是真实世界的图片,但是训练集中的图片却是卡通图片。

Label Shift

Label Shift和Covariate Shift相反,Label Shift是由于输入样本的标签概率分布导致的,同时$P(x|y)$条件概率不变。Label Shift在输出标签会影响输入特征的情况下是很常见的。例如在根据病人症状来判断病人患病的场景下,患的病是会影响病人症状的。

Concept Shift

Concept Shift是由于样本标签的定义变化导致的。例如有些标签的定义会随着国家或者地域的变化而不同。

Distribution Shift的解决方法

经验误差与真实误差

回顾一下我们训练模型时的步骤,我们在训练集${(x_1, \space y_1)\, \dots, (x_n, \space y_n)}$上不断迭代,调整模型$f$的参数,为了简化,我们不考虑正则化项,我们的目标是最小化在训练集上的误差:

上面的式子可以看作是模型的经验误差。而真实的误差是模型在真实分布为$p(x, \space y)$的所有样本中的损失函数的期望值。

但在实际情况下我们无法获取所有样本,所以通常我们用经验误差去拟合实际误差。

Covariate Shift 解决方法

假设我们的训练样本的特征的概率分布是$q(x)$,但是我们实际部署模型,预测目标的特征的概率分布是$p(x)$。由于Covariate Shift的特点是$p(y \space | \space x) = q(y \space | \space x)$,因此,由$(2)$式可得:

其中,$\frac{p(x)}{q(x)}$可以看作一个样本来自于真实分布的概率和样本来自于错误分布的概率的比值,通过这个比值我们可以重新对每个样本数据进行权重划分,在$(1)$式中,所有样本的权重值都是1,即所有原本都看作同等重要的,经过重新划分之后样本的权重设为$\frac{p(x)}{q(x)}$,即如果样本来自于真实分布的概率越大,那么样本的权重值越高。那么可以使用经过修正后的loss function:

但是,我们如何知道$\frac{p(x)}{q(x)}$的比值是多少呢?为了获取这个比值,我们需要从错误分布的样本和从正确分布中获取的样本。另外,我们可以先利用这些数据先学习一个二分类器,用于区分样本是来自于错误分布还是来自于正确分布。假设我们用$z=1$表示样本来自正确分布$p(x)$,用$z=0$表示样本来自错误分布$q(x)$,那么有:

其中$P(z = 1 \space | \space x)$表示训练的分类器在输入样本为$x$的情况下,判断样本来自正确分布的概率。如果二分类器使用logistic regression,那么$P(z = 1 \space | \space x) = \frac{1}{1+e^(-h(x))}$($h$为线性回归函数),那么也有$\frac{p(x)}{q(x)} = e^(h(x))$。

因此,由上面的推导,我们可以得出解决Covariate Shift的方法:

假设我们有用于原来机器学习任务的训练集:${(x_1, \space y_1), \space \dots , \space (x_n, \space y_n)}$以及用于测试用的测试${(u_1, \space v_1), \space \dots , \space (u_m, \space v_m)}$

1、生成用于二分类学习用的训练集:${(x_1, \space 0), \space \dots , \space (x_n, \space 0), \space (u_1, \space 1), \space \dots , \space (u_m, \space 1)}$

2、用第一步生成的训练集学习一个logistic regression的二分类器来得到其中的线形回归函数$h$

3、将$e^(h(x_i))$或者更好的$min(e^(h(x_1)), \space c)$($c$是一个常量)作为权重,用于原始训练集${(x_1, \space y_1), \space \dots , \space (x_n, \space y_n)}$上

注意,这个算法依赖于一个前提,那就是$q(x_i)$不能为0。

Label Shift 纠正方法

//TODO: lable shift 纠正的思路和Covariate Shift一样,只是解决方法有点不好理解。后续再补上

Consept Shift纠正方法

对于concept shift的纠正方法,我们一般直接在之前训练出来的神经网络的参数上,使用新的训练集进行重新训练即可。

组合模式简介

组合模式将对象组合成树状结构

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#define MAX_CHILD_COMPONENT_COUNT 10
class Component {
public:
virtual void execute() = 0;
};

class SimpleComponent: public Component {
public:
void execute() override {
//execute
}
};

class CompositeComponent: public Component {
private:
Component* children[MAX_CHILD_COMPONENT_COUNT];
public:
void add(Component* child) {

}

void remove(Component *child) {

}

void execute() override {
for (auto child : children) {
child->execute();
}
}
};

背景

假设你有一个叫形状(shape)的基类,它扩展出来两个子类:圆形(Circle)和方形(Square),同时你希望对这两个形状添加颜色(红色和蓝色),一个方法是基于继承,基于圆形和方形分别创建两个子类,如RedCircleRedSquare。这样的问题是,每增加新的形状和颜色,都需要创建更多的子类。另外一种方法就是将颜色抽象成一个类,然后让形状类有一个颜色的成员变量,现在形状类可以将所有与颜色相关的工作委派给自己的颜色成员变量。

桥接模式简介

桥接模式模式可以看作一种最简单的组合,外部只和组合类交互,而不会接触到内部的被组合的类。

代码示例

遥控器和可遥控设备直接的桥接示例,用户只直接使用遥控器,而不会直接和设备进行交互。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Device {
protected:
int _volume;
int _channel;
public:
virtual bool isEnabled() = 0;
virtual void enable() = 0;
virtual void disable() = 0;
virtual int getVolume() = 0;
virtual void setVolume(int volume) = 0;
virtual int getChannel() = 0;
virtual void setChannel(int channel) = 0;
};

class Remote {
protected:
Device *_device;
public:
Remote(Device *device): _device(device) {}

void togglePower() {
if (_device->isEnabled()) {
_device->disable();
} else {
_device->enabel();
}
}

void volumeDown() {
_device->setVolume(_device->getVolume() - 1);
}

void volumeUp() {
_device->setVolume(_device->getVolume() + 1);
}

void channelDown() {
_device->setChannel(_device->getChannel() - 1);
}

void channelUp() {
_device->setChannel(_device->getChannel() + 1)
}
};

class Radio: public Device {
public:
//function implementations
};

class TV: public Device {
public:
//function implementations
};

总结

桥接模式通常会用于开发前期进行设计,

背景

适配器模式目的是为了让接口不兼容的对象能够相互合作,例如一个流数据处理的第三方库,它目前只能处理XML格式输入的数据,但是你自己从生产环境获取到数据流是JSON格式的,因此你无法直接使用这个第三方库,因为它所需的输入数据与你的程序不兼容。

你可以修改这个第三方库来支持JSON,但是这可能需要修改其他依赖这个第三库的现有代码,甚至你可能根本就没有这个第三方库的源代码。

这时,你可以创建一个适配器,它用于将JSON格式数据转换为XML格式的数据,并调用第三方库进行数据处理,你的代码之后就与这个适配器进行交互。

适配器模式简介

  • 适配器实现与其中一个现有对象兼容的接口
  • 其他对象可以使用该接口安全得调用适配器方法
  • 适配器方法被调用后再调用其内部对象的接口
有时你甚至可以创建一个双向适配器来实现双向转换调用

代码示例

经典的方钉圆孔问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class RoundReg {
private:
int _radius;

public:
RoundReg(int radius): _radius(radius) {}
virtual int getRadius() {
return _radius
}
};

class RoundHole {
private:
int _radius;

public:
RoundHole(int radius): _radius(radius) {}
virtual int getRadius() {
return _radius
}
bool fits(RoundReg &roundReg) {
return _radius == roundReg.getRadius()
}
};

class SquareReg {
private:
int _edge;

private:
SquareReg(int edge): _edge(edge) {}
int getEdge() {
return _edge;
}
}

class SquarePegAdapter: RoundReg {
private:
SquareReg _squareReg;

public:
SquarePegAdapter(const SquareReg& peg): _squareReg(peg) {}
int getRadius() override {
return _squareReg.getEdge() * sqrt(2) / 2
}
};

SquarePegAdapter内部封装了SquareReg,RoundHole不能直接和SquareReg交互,而和SquarePegAdapter进行交互。

虽然SquarePegAdapter继承自RoundReg,但是这里继承的目的只是为了获取和RoundReg相同的接口而已,SquareRegAdapter相当于为SquarePeg增加了新的接口