风格迁移
(1) 初始化合成图像,例如将其初始化为内容图像(content image);
(2) 利用预训练网络(如VGG-19)的某些层抽取内容图像与合成图像的内容特征,再用某些层抽取风格图像与合成图像的风格特征;
(3) 根据抽取出来的content feature map和style feature map计算出内容损失(content loss,使合成图像与内容图像在内容特征上接近)和风格损失(style loss,使合成图像与风格图像在风格特征上接近);
(4) 根据当前的合成图像自身计算出全变分损失(total variation loss,有助于减少合成图像中的噪点);
(5) 将这三个损失按一定比例加权(主观更倾向于合成什么样的图像),计算出最终的总损失;
(6) 根据损失反向传播误差,逐步更新合成图像的参数,降低损失,最终结束训练,图像风格迁移成功。
当模型训练结束,输出风格迁移的模型参数
import torch
import torchvision
import torch.nn as nn
from PIL import Image
import matplotlib.pyplot as plt
# content_img[1364, 2047, 2] # imread读取的图像前两个维度是高和宽,第三个维度表示选择RGB三个通道中的哪个通道
# ImageNet先验归一化
# 该均值和标准差来源于ImageNet数据集统计得到,如果建立的数据集分布和ImageNet数据集数据分布类似(来自生活真实场景,例如人像、风景、交通工具等),或者使用PyTorch提供的预训练模型,推荐使用该参数归一化。如果建立的数据集并非是生活真实场景(如生物医学图像),则不推荐使用该参数
rgb_mean = torch.tensor([0.485, 0.456, 0.406])
rgb_std = torch.tensor([0.229, 0.224, 0.225])
# 在使用深度学习框架构建训练数据时,通常需要数据归一化,
#对图像的3个rgb通道分别标准化,并转换格式,以利于网络的训练
# 输入图像必须是PIL/np.ndaray
def preprocess(img, image_shape):
transforms = torchvision.transforms.Compose([
torchvision.transforms.Resize(image_shape),
# ToTensor()将其图像由(h,w,c)转置为(c,h,w),再把像素值从[0,255]变换到[0,1]
torchvision.transforms.ToTensor(),
# 标准化
torchvision.transforms.Normalize(mean=rgb_mean, std=rgb_std)])
return transforms(img).unsqueeze(0) # unsqueeze将图像升至4维,增加批次数=1,便于后续图像处理可以更好地进行批操作
# 在训练过程可视化中,通常需要反归一化,以显示能用人眼看得懂的正常的图
def postprocess(img):
# 将图像从训练的GPU环境移至CPU并转为3维(c,h,w)(消去第四维batch)
img = img[0].to(rgb_std.device)
# 先将(c,h,w)转化为(h,w,c)实施反归一化,并将输出范围限制到[0,1]
#clamp函数,将值限定在一定范围内
img = torch.clamp(img.permute(1, 2, 0) * rgb_std + rgb_mean, 0, 1)
# 再将(h,w,c)转化为(c,h,w),ToPILImage()将Tensor的每个元素乘以255;将数据由Tensor转化成Uint8
# ToPILImage()要求输入图像若是tensor,则shape必须是(c,h,w)形式
return torchvision.transforms.ToPILImage()(img.permute(2, 0, 1))
pretrained_net = torchvision.models.vgg19(pretrained=True)
# 输出vgg-19的网络结构:5个卷积块,前两个块中有2个卷积层,后三个块中有4个卷积层
为了抽取图像的内容特征和风格特征,我们可以选择VGG网络中某些层的输出。 一般来说,越靠近输入层,越容易抽取图像的细节信息;反之,则越容易抽取图像的全局信息。 为了避免合成图像过多保留内容图像的细节(我们只需要保留一个大概的主题及轮廓即可,细节方面由风格特征把握),我们选择VGG较靠近输出的层来输出图像的内容特征。 我们还从VGG中选择不同层的输出来匹配局部和全局的风格,这些图层也称为风格层。 VGG网络使用了5个卷积块,实验中,我们选择第四卷积块的最后一个卷积层作为内容层,选择每个卷积块的第一个卷积层作为风格层。 这些层的索引可以通过打印pretrained_net实例获取。允许一定的变形
style_idx = [0, 5, 10, 19, 28] # 各卷积块中的第一个卷积层的索引分别是Sequential中的0, 5, 10, 19, 28
content_idx = [25] # 第4个卷积块中最后一个卷积层的索引是Sequential中的25
# 构建一个新的网络net,它只保留需要用到的VGG的所有层。
# 因为用到最深的层是第28层,因此我们要保留vgg网络中第28层及前面的所有层,而28层以后的均不要保留
# pretrained_net.features输出vgg网络的feature属性(即平均池化之前的所有层)
layers = []
# max(content_layers + style_layers)求用到的最深层的索引
for i in range(max(style_idx + content_idx) + 1):
# 逐层加入到所需的layers列表中
layers.append(pretrained_net.features[i])
# 将layers逐元素加入到Sequential中
net = nn.Sequential(*layers)
net
# 风格特征提取层如下:
# (0): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
# (5): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
# (10): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
# (19): Conv2d(256, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
# (28): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
# 内容特征提取层如下:
# (25): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
net结构展示如下
给定输入X,如果我们简单地调用前向传播net(X),只能获得最后一层的输出。 由于我们还需要中间层的输出,因此这里我们逐层计算,并保留内容层和风格层的输出。
#提取图像的特征
def extract_features(x, content_idx, style_idx):
content_features = []
style_features = []
for i in range(len(net)):
# 计算当前层输出
temp_layer = net[i]
x = temp_layer(x)
# 若当前层索引在风格索引列表中
if i in style_idx:
style_features.append(x)
# 若当前层索引在内容索引列表中
if i in content_idx:
content_features.append(x)
return content_features, style_features
下面定义两个函数:get_contents函数对内容图像抽取内容特征; get_styles函数对风格图像抽取风格特征。 因为在训练时无须改变预训练的VGG的模型参数,所以我们可以在训练开始之前就提取出内容特征和风格特征。 由于合成图像是风格迁移所需迭代的模型参数,我们只能在训练过程中通过调用extract_features函数来抽取合成图像的内容特征和风格特征。
# 提取内容图像的内容特征
def get_content_features(content_img, image_shape):
# 对content_img先进行预处理并移至gpu,便于直接输入网络
content_x = preprocess(content_img, image_shape).cuda()
# 提取内容图像的内容特征
content_features_x, _ = extract_features(content_x, content_idx, style_idx)
return content_x, content_features_x
# 提取风格图像的风格特征
def get_style_features(style_img, image_shape):
# 对style_img先进行预处理并移至gpu,便于直接输入网络
style_x = preprocess(style_img, image_shape).cuda()
# 提取风格图像的风格特征
_, style_features_x = extract_features(style_x, content_idx, style_idx)
return style_x, style_features_x
三个损失函数(内容损失、风格损失、全变分损失)
def calc_contentloss(Y_hat, Y):
# 从动态计算梯度的树中分离目标
# 计算所有通道对应矩阵的差的平方和,再除以所有元素个数
# 这里把Y detach一下是因为原始内容图像的特征图无需参与反向传播(视为已知常量),所以将它从计算图中分离,否则的话反向更新会影响该值
return torch.square(Y_hat - Y.detach()).mean()
# 输入是vgg某层输出的特征图,尺寸为(c,h,w)
#因为矩阵容易出现太大值,下面是为了防止风格损失的误差太大
def calc_gram(x):
c = x.shape[1] # c是输出的风格特征图的通道数
hw = x.shape[2] * x.shape[3] # hw是一张特征图矩阵中所有元素的个数
x = x.reshape((c, hw)) # 将(c,h,w)变换为(c,h*w)
return torch.matmul(x, x.T) / (c * hw) # matmul是矩阵乘法
# 计算风格损失,这里假设风格图像的格拉姆矩阵已经提前计算好了
def calc_styleloss(Y_hat, gram_Y):
# 这里把gram_Y detach一下是因为原始风格图像的格拉姆矩阵无需参与反向传播(视为已知常量),所以将它从计算图中分离,否则的话反向更新会影响该值
return torch.square(calc_gram(Y_hat) - gram_Y.detach()).mean()
def calc_tvloss(Y_hat):
# [:, :, 1:, :]表示取各通道图像矩阵的第1行至最后一行(起始行为0行)
# [:, :, :-1, :]表示取各通道图像矩阵的第0行至倒数第二行
# 矩阵相减取绝对值再在各通道上取平均(所有元素加起来除以总元素数)
return 0.5 * (torch.abs(Y_hat[:, :, 1:, :] - Y_hat[:, :, :-1, :]).mean() +
torch.abs(Y_hat[:, :, :, 1:] - Y_hat[:, :, :, :-1]).mean())
content_weight, style_weight, tv_weight = 1, 1000, 10
# 计算总的损失函数值
def compute_loss(X, content_Y, content_Y_hat, style_Y_gram, style_Y_hat):
# 分别计算内容损失、风格损失和全变分损失
# 对1对y,y_hat求内容损失,乘以权重后添加到列表中
content_l = [calc_contentloss(Y_hat, Y) * content_weight for Y_hat, Y in zip(content_Y_hat, content_Y)]
# 对5对y,y_hat分别求风格损失,乘以权重后添加到列表中
style_l = [calc_styleloss(Y_hat, Y) * style_weight for Y_hat, Y in zip(style_Y_hat, style_Y_gram)]
# 求总变差损失,乘以权重
tv_l = calc_tvloss(X) * tv_weight
# 对所有损失求和(5个风格损失,1个内容损失,1个总变差损失)
l = sum(10 * style_l + content_l + [tv_l]) #乘10应该在参数给值的时候就有,而不是现在
return content_l, style_l, tv_l, l
在风格迁移中,合成的图像是训练期间唯一需要更新的变量。因此,我们可以定义一个简单的模型SynthesizedImage,并将合成的图像视为模型参数。模型的前向传播只需返回模型参数即可。
class SynthesizedImage(nn.Module):
def __init__(self, img_shape):
super(SynthesizedImage, self).__init__()
self.weight = nn.Parameter(torch.rand(*img_shape))
def forward(self):
return self.weight
def get_inits(X, lr, style_Y):
# X是内容图像的预处理结果
gen_img = SynthesizedImage(X.shape).cuda()
# 将初始化的weight参数改为已有的图像X的参数(即像素)
gen_img.weight.data.copy_(X.data)
# 定义优化器
trainer = torch.optim.Adam(gen_img.parameters(), lr=lr)
# 对各风格特征图计算其格拉姆矩阵,并依次存于列表中
style_Y_gram = [calc_gram(Y) for Y in style_Y]
# !!!gen_img()!!!括号
return gen_img(), style_Y_gram, trainer
在训练模型进行风格迁移时,我们不断抽取合成图像的内容特征和风格特征,然后计算损失函数。下面定义了训练循环。
def train(X, content_Y, style_Y, lr, num_epochs, lr_decay_epoch):
# X是初始化的合成图像,style_Y_gram是原始风格图像的格拉姆矩阵列表
X, style_Y_gram, trainer = get_inits(X, lr, style_Y)
# 定义学习率下降调节器
scheduler = torch.optim.lr_scheduler.StepLR(trainer, lr_decay_epoch, 0.8)
for epoch in range(num_epochs):
trainer.zero_grad()
# Y_hat是用合成图像计算出的特征图
content_Y_hat, style_Y_hat = extract_features(X, content_idx, style_idx)
content_l, style_l, tv_l, l = compute_loss(X, content_Y, content_Y_hat, style_Y_gram, style_Y_hat)
# 反向传播误差(计算l对合成图像像素矩阵的导数,因为l的唯一自变量是合成图像像素矩阵)
l.backward()
# 更新一次合成图像的像素参数
trainer.step()
# 更新学习率超参数
scheduler.step()
# 每5个epoch记录一次loss信息
if (epoch + 1) % 5 == 0:
# 由于风格损失列表有5项,因此算个总损失输出
print('迭代次数:{} 内容损失:{:.9f} 风格损失:{:.9f} 总变差损失:{:.9f}' .format(epoch+1, sum(content_l).item(), sum(style_l).item(), tv_l.item()))
# 训练结束后返回合成图像
return X
开始训练
content_img = Image.open(r'C:\Users\13930\Pictures\Saved Pictures\猫狗.jpg')
style_img = Image.open(r'C:\Users\13930\Pictures\Saved Pictures\摩天轮.jpg')
image_shape = (300, 450)
net = net.cuda()
# 计算内容图像的预处理结果(因为我们将内容图像作为合成图像的初始化图像作为网络的初始输入)和抽取到的内容特征
X, content_features_Y = get_content_features(content_img, image_shape)
# 计算风格图像抽取到的风格特征
_, style_features_Y = get_style_features(style_img, image_shape)
output = train(X, content_features_Y, style_features_Y, lr = 0.3, num_epochs = 500, lr_decay_epoch = 50)
# 调用后处理函数处理最终的合成图像,将其转换为正常格式的可视化图像
output = postprocess(output)
# 显示图像
plt.imshow(output)
plt.show()
大概就是提取内容图像和风格图像的特征,形成一张合成图像
提取合成图像的内容特征和风格特征,分别与内容图像和风格图像做对比
不断调整,模型参数
是的。这是一个最原始的风格迁移,所以确实每一张图片都要重新训练。之后有其他算法改变他
序列模型
序列数据和其他数据的区别:不独立
对于t时刻之前的数据进行建模,得到自回归模型模型f, 通过模型f预测。
怎么建模?建模后,怎么通过模型预测?
文本预处理
文本的常见预处理步骤:
- 将文本作为字符串加载到内存中。
- 将字符串拆分为词元(如单词和字符)。
- 建立一个词表,将拆分的词元映射到数字索引。
- 将文本转换为数字索引序列,方便模型操作,进入模型训练。
import torch
import d2l.torch
import re
import collections
d2l.torch.DATA_HUB['time_machine'] = (d2l.torch.DATA_URL + 'timemachine.txt',
'090b5e7e70c295757f55df93cb0a180b9691891a')
"""将时间机器数据集加载到文本行的列表中"""
def read_time_machine():
with open(d2l.torch.download('time_machine'),'r') as f:
lines = f.readlines()#lines为list列表,每个元素代表读取的每一行文本
print(lines[0])
print(lines[10])
#将每一行文本中不是大小写字母的字符都替换为空格字符
#同时去掉每一行首尾的空格,将字符变为小写字符
return [re.sub('[^A-Za-z]+',' ',line).strip().lower() for line in lines] #返回结果为list列表,每个元素代表读取的每一行文本
lines = read_time_machine()
"""将文本行拆分为单词或字符词元"""
def tokenize(lines,type='word'):
if type == 'word':
#将每一行文本文字以空格分开,列表中的元素是一个词
#返回类型是一个list of list,也即是:
'''
[['the', 'time', 'machine', 'by', 'h', 'g', 'wells'],
['the', 'time', 'traveller', 'for', 'so', 'ithhhh']]
'''
return [line.split() for line in lines]
elif type == 'char':
#将每行文本转换成一个含字符的列表
#返回类型是一个list of list,列表中每一个元素是一个字符,也即是:
'''
[['t', 'h', 'e', ' ', 't', 'i', 'm', 'e', ' ', 'm'],
['t', 'h', 'e', ' ', 't', 'i', 'm', 'e', ' ', 't']]
'''
return [list(line) for line in lines]
else:
print('未知类型:',type)
tokens = tokenize(lines,'word') #tokens为list of list类型
class Vocab:
def __init__(self,tokens=None,min_freq=0,reserve_tokens=None):
if tokens == None:
tokens = []
if reserve_tokens == None:
reserve_tokens = []
# 按出现频率排序
counter_corpus = count_corpus(tokens)#计算每个不同字符出现的次数
self._token_freqs = sorted(counter_corpus.items(),key=lambda x:x[1],reverse=True)
#根据字符出现的次数进行排序,从而映射成对应的id,
#类型为一个列表,列表元素是一个二元组,列表元素已经排好序
# 未知词元的索引为0
self.idx_to_token = ['<unk>']+reserve_tokens #idx_to_token为一个列表类型
self.token_to_idx = {token:idx
for idx,token in enumerate(self.idx_to_token)} #token_to_idx为一个字典类型
for token,freq in self._token_freqs:
if freq<min_freq:
break
#因为_token_freqs根据freqs已经是排好序了
#如果出现freq小于min_freq,那么后面所有元素的freq都会小于min_freq
#因此使用break终止循环
if token not in self.token_to_idx:
self.idx_to_token.append(token)
self.token_to_idx[token] = len(self.idx_to_token)-1
# #将10行到17行代码重新改写一下
# for token,freq in self._token_freqs:
# if freq<min_freq:
# break
# self.idx_to_token.append(token)
# self.token_to_idx = {token:idx
# for idx,token in enumerate(self.idx_to_token)}
def __len__(self):
return len(self.idx_to_token)
def __getitem__(self, tokens):
if not isinstance(tokens,(list,tuple)):
return self.token_to_idx.get(tokens,self.unk)
return [self.__getitem__(token) for token in tokens]
#使用递归根据token得到idx,因为tokens有可能为list of list类型
def to_token(self,indices):
if not isinstance(indices,(list,tuple)):
return self.idx_to_token[indices]
return [self.idx_to_token[index] for index in indices]
#个人应该写成return [self.to_token(index) for index in indices]
#因为indices有可能为list of list类型
@property
def unk(self):
return 0 # 未知词元的索引为0
@property
def token_freqs(self):
return self._token_freqs
"""统计词元的频率"""
def count_corpus(tokens):
# 这里的tokens可能是1D列表或2D列表
if len(tokens)==0 or isinstance(tokens[0],list):
# 将list of list类型的词元列表展平成一个列表
tokens = [token for line in tokens for token in line]
return collections.Counter(tokens)
#返回一个列表,列表元素是一个(token词,freq在文本中出现的频率)二元组
vocab = Vocab(tokens)
"""返回时光机器数据集的词元索引列表和词表"""
def load_corpus_time_machine(max_tokens=-1):
lines = read_time_machine() #lines为一个list类型,里面每个元素为一行文本句子
#使用字符(而不是单词)实现文本词元化
tokens = tokenize(lines,type='char')#tokens为list of list
vocab = Vocab(tokens)
# 因为时光机器数据集中的每个文本行不一定是一个句子或一个段落
#所以将所有文本行展平到一个列表中
corpus = [vocab[token] for line in tokens for token in line]
#print(corpus)
if max_tokens>0:
corpus = corpus[:max_tokens]
return corpus,vocab
corpus,vocab = load_corpus_time_machine()
#corpus也即是把给定的文本转换成对应的数字id
#从而用于训练,vocab类包含了如何将一个文本字符映射成对应的数字id
#(根据字符出现的次数排序来映射对应的id),以及如何将一个id映射回一个字符等操作
len(corpus),len(vocab) #len(vocab)调用的是vocab中__len()__函数
语言模型
3元语法,x4与x3 和x2相关
n越大,模型精度越高,但是存储空间要求越高(x4点要存x3和x2、x5点要存x4和x3等等)
在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不⼀定在原始序列上相邻。对于语言建模,目标是基于到目前为止我们看到的词元来预测下⼀个词元,因此标签是移位了⼀个词元的原始序列。每次可以从数据中随机生成⼀个小批量,参数batch_size指定了每个小批量中子序列样本的数目,参数num_steps是每个子序列中预定义的时间步数。
def seq_data_iter_random(corpus, batch_size, num_steps): #@save
"""使⽤随机抽样⽣成⼀个⼩批量⼦序列"""
# 从随机偏移量开始对序列进⾏分区,随机范围包括num_steps-1
corpus = corpus[random.randint(0, num_steps - 1):]
# 减去1,是因为我们需要考虑标签
num_subseqs = (len(corpus) - 1) // num_steps
# ⻓度为num_steps的⼦序列的起始索引
initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
# 在随机抽样的迭代过程中,
# 来⾃两个相邻的、随机的、⼩批量中的⼦序列不⼀定在原始序列上相邻
random.shuffle(initial_indices)
def data(pos):
# 返回从pos位置开始的⻓度为num_steps的序列
return corpus[pos: pos + num_steps]
num_batches = num_subseqs // batch_size
for i in range(0, batch_size * num_batches, batch_size):
# 在这⾥,initial_indices包含⼦序列的随机起始索引
initial_indices_per_batch = initial_indices[i: i + batch_size]
X = [data(j) for j in initial_indices_per_batch]
Y = [data(j + 1) for j in initial_indices_per_batch]
yield torch.tensor(X), torch.tensor(Y)
保证两个相邻的小批量中的子序列在原始序列上也是相邻的。这种策略在基于小批量的迭代过程中保留了拆分的子序列的顺序,因此称为顺序分区。
def seq_data_iter_sequential(corpus, batch_size, num_steps): #@save
"""使⽤顺序分区⽣成⼀个⼩批量⼦序列"""
# 从随机偏移量开始划分序列
offset = random.randint(0, num_steps)
num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
Xs = torch.tensor(corpus[offset: offset + num_tokens])
Ys = torch.tensor(corpus[offset + 1: offset + 1 + num_tokens])
Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
num_batches = Xs.shape[1] // num_steps
for i in range(0, num_steps * num_batches, num_steps):
X = Xs[:, i: i + num_steps]
Y = Ys[:, i: i + num_steps]
yield X, Y
把上述两种解法包装到一个类中
class SeqDataLoader: #@save
"""加载序列数据的迭代器"""
def __init__(self, batch_size, num_steps, use_random_iter, max_tokens):
if use_random_iter:
self.data_iter_fn = d2l.seq_data_iter_random
else:
self.data_iter_fn = d2l.seq_data_iter_sequential
self.corpus, self.vocab = d2l.load_corpus_time_machine(max_tokens)
self.batch_size, self.num_steps = batch_size, num_steps
def __iter__(self):
return self.data_iter_fn(self.corpus, self.batch_size, self.num_steps)
定义load_data_time_machine函数
以后采取文本类型的样本都可以用这个函数
def load_data_time_machine(batch_size, num_steps, use_random_iter=False, max_tokens=10000):#@save
"""返回时光机器数据集的迭代器和词表"""
data_iter = SeqDataLoader(batch_size, num_steps, use_random_iter, max_tokens)
return data_iter, data_iter.vocab