! [ -e /content ] && pip install -Uqq fastai # 在Colab上升级fastai
图像序列
#! 使用 pip 安装 rarfile 和 av
#! 使用 pip 安装 -Uq pyopenssl
如何使用fastai训练图像序列到图像序列的任务。
本教程使用fastai处理图像序列。我们将着眼于两个任务:
- 首先,我们将在UCF101数据集上进行视频分类。您将学习如何将视频转换为单独的帧。我们还将使用fastai的中级API构建数据处理管道。
- 其次,我们将构建一些简单的模型并评估我们的准确性。
- 最后,我们将训练一个基于SotA的变换器架构。
from fastai.vision.all import *
UCF101 动作识别
UCF101 是一个由现实动作视频组成的动作识别数据集,收集自 YouTube,涵盖 101 种动作类别。此数据集是 UCF50 数据集的扩展,后者有 50 种动作类别。
“UCF101 共有来自 101 个动作类别的 13320 个视频,在动作方面提供了最大的多样性,并且伴随着相机运动、物体外观和姿势、物体尺度、视角、杂乱背景、光照条件等方面的巨大变化,它是迄今为止最具挑战性的数据集。由于大多数现有的动作识别数据集并不真实,而是由演员摆拍而成,UCF101 旨在通过学习和探索新的现实动作类别来鼓励进一步的动作识别研究”
设置
我们需要从他们的网站下载UCF101数据集。这是一个较大的数据集(6.5GB),如果你的网络连接较慢,可能想要在晚上或在终端中进行此操作(以避免阻塞笔记本)。fastai
的untar_data
无法下载此数据集,因此我们将使用wget
,然后使用rarfile
解压文件。
fastai
的数据集位于~/.fastai/archive
目录下,我们将在那里下载UCF101。
::: {#cell-9 .cell 0=‘缓’ 1=‘慢’}
# !wget -P ~/.fastai/archive/ --no-check-certificate https://www.crcv.ucf.edu/data/UCF101/UCF101.rar
:::
您可以在终端运行此命令以避免阻塞笔记本。
让我们创建一个函数来unrar
下载的数据集。这个函数与untar_data
非常相似,但处理的是.rar
文件。
from rarfile import RarFile
def unrar(fname, dest):
"Extract `fname` to `dest` using `rarfile`"
= URLs.path(c_key='data')/fname.name.withsuffix('') if dest is None else dest
dest print(f'extracting to: {dest}')
if not dest.exists():
= str(fname)
fname if fname.endswith('rar'):
with RarFile(fname, 'r') as myrar:
myrar.extractall(dest.parent)else:
raise Exception(f'Unrecognized archive: {fname}')
rename_extracted(dest)return dest
为了保持一致,我们将在 ~/.fasta/data
中提取 UCF 数据集。这是 fastai 存储解压数据集的地方。
= Path.home()/'.fastai/archive/UCF101.rar'
ucf_fname = Path.home()/'.fastai/data/UCF101' dest
解压像这样的一个大文件非常慢。
::: {#cell-16 .cell 0=‘缓’ 1=‘慢’}
= unrar(ucf_fname, dest) path
extracting to: /home/tcapelle/.fastai/data/UCF101
:::
提取后的数据集文件结构是每个动作一个文件夹:
path.ls()
(#101) [Path('/home/tcapelle/.fastai/data/UCF101/Hammering'),Path('/home/tcapelle/.fastai/data/UCF101/HandstandPushups'),Path('/home/tcapelle/.fastai/data/UCF101/HorseRace'),Path('/home/tcapelle/.fastai/data/UCF101/FrontCrawl'),Path('/home/tcapelle/.fastai/data/UCF101/LongJump'),Path('/home/tcapelle/.fastai/data/UCF101/GolfSwing'),Path('/home/tcapelle/.fastai/data/UCF101/ApplyEyeMakeup'),Path('/home/tcapelle/.fastai/data/UCF101/UnevenBars'),Path('/home/tcapelle/.fastai/data/UCF101/HeadMassage'),Path('/home/tcapelle/.fastai/data/UCF101/Kayaking')...]
在里面,您会找到每个实例一个视频,这些视频是.avi
格式的。我们需要将每个视频转换为图像序列,以便能够使用我们的fastai视觉工具集。
torchvision有一个内置的视频读取器,可能能够简化此任务
UCF101-帧
├── 化妆
| |── v_ApplyEyeMakeup_g01_c01.avi
| ├── v_ApplyEyeMakeup_g01_c02.avi
| | ...
├── 锤击
| ├── v_Hammering_g01_c01.avi
| ├── v_Hammering_g01_c02.avi
| ├── v_Hammering_g01_c03.avi
| | ...
...
├── 玩悠悠球
├── v_YoYo_g01_c01.avi
...
├── v_YoYo_g25_c03.avi
我们可以通过使用 get_files
并传递 '.avi'
扩展名来一次性获取所有视频。
= get_files(path, extensions='.avi')
video_paths 0:4] video_paths[
(#4) [Path('/home/tcapelle/.fastai/data/UCF101/Hammering/v_Hammering_g22_c05.avi'),Path('/home/tcapelle/.fastai/data/UCF101/Hammering/v_Hammering_g21_c05.avi'),Path('/home/tcapelle/.fastai/data/UCF101/Hammering/v_Hammering_g03_c03.avi'),Path('/home/tcapelle/.fastai/data/UCF101/Hammering/v_Hammering_g18_c02.avi')]
我们可以使用av
将视频转换为帧:
import av
def extract_frames(video_path):
"convert video to PIL images "
= av.open(str(video_path))
video for frame in video.decode(0):
yield frame.to_image()
= list(extract_frames(video_paths[0]))
frames 0:4] frames[
[<PIL.Image.Image image mode=RGB size=320x240>,
<PIL.Image.Image image mode=RGB size=320x240>,
<PIL.Image.Image image mode=RGB size=320x240>,
<PIL.Image.Image image mode=RGB size=320x240>]
我们有 PIL.Image
对象,因此我们可以使用 fastai 的 show_images
方法直接显示它们。
0:5]) show_images(frames[
让我们获取一个视频路径
= video_paths[0]
video_path video_path
Path('/home/tcapelle/.fastai/data/UCF101/Hammering/v_Hammering_g22_c05.avi')
我们想要将所有视频导出为帧,让我们构建一个能够将一个视频导出为帧的函数,并将生成的帧存储在同名文件夹中。
让我们获取文件夹名称:
'') video_path.relative_to(video_path.parent.parent).with_suffix(
Path('Hammering/v_Hammering_g22_c05')
我们还将为我们的 frames
版本的 UCF 创建一个新目录。您需要至少 7GB 的空间来完成此操作,之后您可以删除包含视频的原始 UCF101 文件夹。
= path.parent/'UCF101-frames'
path_frames if not path_frames.exists(): path_frames.mkdir()
我们将创建一个函数,该函数接受视频路径,并将帧提取到我们的新 UCF-frames
数据集中,保持相同的文件夹结构。
def avi2frames(video_path, path_frames=path_frames, force=False):
"Extract frames from avi file to jpgs"
= path_frames/video_path.relative_to(video_path.parent.parent).with_suffix('')
dest_path if not dest_path.exists() or force:
=True, exist_ok=True)
dest_path.mkdir(parentsfor i, frame in enumerate(extract_frames(video_path)):
/f'{i}.jpg') frame.save(dest_path
avi2frames(video_path)/video_path.relative_to(video_path.parent.parent).with_suffix('')).ls() (path_frames
(#161) [Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g22_c05/63.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g22_c05/90.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g22_c05/19.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g22_c05/111.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g22_c05/132.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g22_c05/59.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g22_c05/46.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g22_c05/130.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g22_c05/142.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g22_c05/39.jpg')...]
现在我们可以使用 fastcore 的 parallel
批量处理整个数据集。在 CPU 核心数量较少的机器上,这可能会很慢。在一台 12 核心的机器上,处理时间为 4 分钟。
::: {#cell-39 .cell 0=‘缓’ 1=‘慢’}
#并行(avi2frames, 视频路径)
:::
在此之后,您将获得如下文件夹层级:
UCF101-frames
├── ApplyEyeMakeup
| |── v_ApplyEyeMakeup_g01_c01
| │ ├── 0.jpg
| │ ├── 100.jpg
| │ ├── 101.jpg
| | ...
| ├── v_ApplyEyeMakeup_g01_c02
| │ ├── 0.jpg
| │ ├── 100.jpg
| │ ├── 101.jpg
| | ...
├── Hammering
| ├── v_Hammering_g01_c01
| │ ├── 0.jpg
| │ ├── 1.jpg
| │ ├── 2.jpg
| | ...
| ├── v_Hammering_g01_c02
| │ ├── 0.jpg
| │ ├── 1.jpg
| │ ├── 2.jpg
| | ...
| ├── v_Hammering_g01_c03
| │ ├── 0.jpg
| │ ├── 1.jpg
| │ ├── 2.jpg
| | ...
...
├── YoYo
├── v_YoYo_g01_c01
│ ├── 0.jpg
│ ├── 1.jpg
│ ├── 2.jpg
| ...
├── v_YoYo_g25_c03
├── 0.jpg
├── 1.jpg
├── 2.jpg
...
├── 136.jpg
├── 137.jpg
数据管道
我们已经将所有视频转换为图像,现在我们准备开始构建我们的 fastai 数据管道。
= Path.home()/'.fastai/data/UCF101-frames'
data_path 0:3] data_path.ls()[
(#3) [Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering'),Path('/home/tcapelle/.fastai/data/UCF101-frames/HandstandPushups'),Path('/home/tcapelle/.fastai/data/UCF101-frames/HorseRace')]
我们为每个动作类别创建一个文件夹,文件夹内包含每个动作实例的子文件夹。
def get_instances(path):
" gets all instances folders paths"
= []
sequence_paths for actions in path.ls():
+= actions.ls()
sequence_paths return sequence_paths
通过这个函数,我们获取每个动作的单独实例,这些是我们需要分类的图像序列。 我们将构建一个管道,其输入为 实例路径。
= get_instances(data_path)
instances_path 0:3] instances_path[
(#3) [Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g07_c03'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g13_c07')]
我们需要按数字顺序对视频帧进行排序。我们将对pathlib的Path
类进行补丁,以返回按数字排序的文件列表。修改fastcore的ls
方法,增加一个可选参数sort_func
可能是个不错的主意。
@patch
def ls_sorted(self:Path):
"ls but sorts files by name numerically"
return self.ls().sorted(key=lambda f: int(f.with_suffix('').name))
0].ls_sorted() instances_path[
(#187) [Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02/0.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02/1.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02/2.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02/3.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02/4.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02/5.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02/6.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02/7.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02/8.jpg'),Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02/9.jpg')...]
让我们获取前5帧
= instances_path[0].ls_sorted()[0:5]
frames open(img) for img in frames]) show_images([Image.
我们将构建一个包含单个帧的元组,并且能够展示它们自己。我们将使用与siamese_tutorial
相同的思路。由于一个视频可能有很多帧,我们不想显示所有帧,因此show
方法将仅显示第一帧、中间帧和最后一帧。
class ImageTuple(fastuple):
"A tuple of PILImages"
def show(self, ctx=None, **kwargs):
= len(self)
n = self[0], self[n//2], self[n-1]
img0, img1, img2if not isinstance(img1, Tensor):
= tensor(img0), tensor(img1),tensor(img2)
t0, t1,t2 = t0.permute(2,0,1), t1.permute(2,0,1),t2.permute(2,0,1)
t0, t1,t2 else: t0, t1,t2 = img0, img1,img2
return show_image(torch.cat([t0,t1,t2], dim=2), ctx=ctx, **kwargs)
for fn in frames).show(); ImageTuple(PILImage.create(fn)
我们将使用中级 API 从转换后的列表创建我们的 Dataloader。
class ImageTupleTfm(Transform):
"A wrapper to hold the data on path format"
def __init__(self, seq_len=20):
store_attr()
def encodes(self, path: Path):
"Get a list of images files for folder path"
= path.ls_sorted()
frames = len(frames)
n_frames = slice(0, min(self.seq_len, n_frames))
s return ImageTuple(tuple(PILImage.create(f) for f in frames[s]))
= ImageTupleTfm(seq_len=5)
tfm = instances_path[0]
hammering_instance hammering_instance
Path('/home/tcapelle/.fastai/data/UCF101-frames/Hammering/v_Hammering_g14_c02')
tfm(hammering_instance).show()
使用此设置,我们可以将 parent_label
作为我们的标签函数。
parent_label(hammering_instance)
'Hammering'
= RandomSplitter()(instances_path) splits
我们将使用fastai的Datasets
类,我们需要传递一个变换的列表
。第一个列表[ImageTupleTfm(5)]
是我们如何获取x
,第二个列表[parent_label, Categorize]
是我们如何获取y
。因此,从每个实例路径中,我们抓取前5张图像来构建一个ImageTuple
,并通过parent_label
从父文件夹中获取动作的标签,然后对标签进行Categorize
处理。
= Datasets(instances_path, tfms=[[ImageTupleTfm(5)], [parent_label, Categorize]], splits=splits) ds
len(ds)
13320
= ds.dataloaders(bs=4, after_item=[Resize(128), ToTensor],
dls =[IntToFloatTensor, Normalize.from_stats(*imagenet_stats)]) after_batch
重构
def get_action_dataloaders(files, bs=8, image_size=64, seq_len=20, val_idxs=None, **kwargs):
"Create a dataloader with `val_idxs` splits"
= RandomSplitter()(files) if val_idxs is None else IndexSplitter(val_idxs)(files)
splits = ImageTupleTfm(seq_len=seq_len)
itfm = Datasets(files, tfms=[[itfm], [parent_label, Categorize]], splits=splits)
ds = ds.dataloaders(bs=bs, after_item=[Resize(image_size), ToTensor],
dls =[IntToFloatTensor, Normalize.from_stats(*imagenet_stats)], drop_last=True, **kwargs)
after_batchreturn dls
= get_action_dataloaders(instances_path, bs=32, image_size=64, seq_len=5)
dls dls.show_batch()
基线模型
我们将构建一个简单的基线模型。它将使用预训练的resnet单独编码每个帧。我们利用 TimeDistributed
层将resnet相同地应用于每个帧。这个简单的模型将仅仅将每个帧的概率平均。还提供了一个 simple_splitter
函数,以避免破坏编码器的预训练权重。
class SimpleModel(Module):
def __init__(self, arch=resnet34, n_out=101):
self.encoder = TimeDistributed(create_body(arch, pretrained=True))
self.head = TimeDistributed(create_head(512, 101))
def forward(self, x):
= torch.stack(x, dim=1)
x return self.head(self.encoder(x)).mean(dim=1)
def simple_splitter(model): return [params(model.encoder), params(model.head)]
我们不需要在最后添加 sigmoid
层,因为损失函数会将熵与 sigmoid 融合以获得更好的数值稳定性。我们的模型将为每个类别输出一个值。您可以使用 torch.sigmoid
和 argmax
来恢复预测的类别。
= SimpleModel().cuda() model
= dls.one_batch() x,y
查看模型内部发生了什么以及输出结果是什么,总是一个好主意。
print(f'{type(x) = },\n{len(x) = } ,\n{x[0].shape = }, \n{model(x).shape = }')
type(x) = <class '__main__.ImageTuple'>,
len(x) = 5 ,
x[0].shape = (32, 3, 64, 64),
model(x).shape = torch.Size([32, 101])
我们准备好创建一个学习器了。损失函数不是必须的,因为DataLoader
已经在构建Datasets
时使用了Categorify
转换在输出上包含了二元交叉熵。
dls.loss_func
FlattenedLoss of CrossEntropyLoss()
我们将利用 MixedPrecision
回调来加速我们的训练(通过在学习者对象上调用 to_fp16
)。
TimeDistributed
层对内存的需求较高(它将图像序列转换为批量维度),因此如果出现 OOM 错误,请尝试减少批量大小。
由于这是一个分类问题,我们将监控分类 准确率
。在创建学习者时,可以直接传递模型分割器。
= Learner(dls, model, metrics=[accuracy], splitter=simple_splitter).to_fp16() learn
learn.lr_find()
SuggestedLRs(lr_min=0.0006309573538601399, lr_steep=0.00363078061491251)
3, 1e-3, freeze_epochs=3) learn.fine_tune(
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 3.685684 | 3.246746 | 0.295045 | 00:19 |
1 | 2.467395 | 2.144252 | 0.477102 | 00:18 |
2 | 1.973236 | 1.784474 | 0.545420 | 00:19 |
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.467863 | 1.449896 | 0.626877 | 00:24 |
1 | 1.143187 | 1.200496 | 0.679805 | 00:24 |
2 | 0.941360 | 1.152383 | 0.696321 | 00:24 |
68% 对于我们只有 5 帧的简单基线来说,算不错。
learn.show_results()
我们可以通过将图像编码器的输出传递给nn.LSTM
来改善我们的模型,以获得一些帧间关系。为此,我们必须获取图像编码器的特征,因此我们需要修改我们的代码,使用create_body
函数,并在之后添加一个池化层。
= resnet34
arch = nn.Sequential(create_body(arch, pretrained=True), nn.AdaptiveAvgPool2d(1), Flatten()).cuda() encoder
如果我们检查编码器的输出,对于每个图像,我们会得到一个512的特征图。
0]).shape encoder(x[
(32, 512)
= TimeDistributed(encoder)
tencoder =1)).shape tencoder(torch.stack(x, dim
(32, 5, 512)
这是一个非常适合输入递归层的格式。让我们重构并在最后添加一个线性层。我们将输出隐藏状态到一个线性层,以计算概率。其背后的想法是,隐藏状态编码了序列的时间信息。
class RNNModel(Module):
def __init__(self, arch=resnet34, n_out=101, num_rnn_layers=1):
self.encoder = TimeDistributed(nn.Sequential(create_body(arch, pretrained=True), nn.AdaptiveAvgPool2d(1), Flatten()))
self.rnn = nn.LSTM(512, 512, num_layers=num_rnn_layers, batch_first=True)
self.head = LinBnDrop(num_rnn_layers*512, n_out)
def forward(self, x):
= torch.stack(x, dim=1)
x = self.encoder(x)
x = x.shape[0]
bs = self.rnn(x)
_, (h, _) return self.head(h.view(bs,-1))
让我们创建一个分割函数,以便分别训练编码器和其余部分。
def rnnmodel_splitter(model):
return [params(model.encoder), params(model.rnn)+params(model.head)]
= RNNModel().cuda() model2
= Learner(dls, model2, metrics=[accuracy], splitter=rnnmodel_splitter).to_fp16() learn
learn.lr_find()
SuggestedLRs(lr_min=0.0006309573538601399, lr_steep=0.0012022644514217973)
5, 5e-3) learn.fine_tune(
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 3.081921 | 2.968944 | 0.295796 | 00:19 |
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.965607 | 1.890396 | 0.516892 | 00:25 |
1 | 1.544786 | 1.648921 | 0.608108 | 00:24 |
2 | 1.007738 | 1.157811 | 0.702703 | 00:25 |
3 | 0.537038 | 0.885042 | 0.771772 | 00:24 |
4 | 0.351384 | 0.849636 | 0.781156 | 00:25 |
这个模型训练起来更困难。一个好的想法是添加一些Dropout。让我们尝试增加序列长度。另一种方法是使用更适合这种任务的层,比如ConvLSTM或者能够更复杂地建模时空关系的图像Transformer。 一些想法:
- 尝试以不同方式采样帧(随机间隔、更多帧等…)
基于变换器的模型
新的基于变换器的架构快速介绍
最近出现了一些基于变换器的图像模型,都是在引入视觉变换器(ViT)之后出现的。目前我们有许多该架构的变体,并且在pytorch中有很好的实现,这些实现集成在timm中,而@lucidrains维护着一个包含所有变体和优雅pytorch实现的代码库。
最近,图像模型已经扩展到视频/图像序列,它们使用变换器来共同编码空间和时间。在这里,我们将训练TimeSformer架构以进行动作识别任务,因为从头开始训练它似乎更容易。我们将使用@lucidrains的实现。
目前我们没有访问预训练模型的权限,但在某些块上加载ViT
权重可能是可行的,但在这里并没有这样做。
安装
首先,我们需要安装该模型:
!pip install -Uq timesformer-pytorch
from timesformer_pytorch import TimeSformer
训练
TimeSformer
的实现期望输入图像序列的格式为:(batch_size, seq_len, c, w, h)
。我们需要包装模型以在传递给前向方法之前将图像序列堆叠。
class MyTimeSformer(TimeSformer):
def forward(self, x):
= torch.stack(x, dim=1)
x return super().forward(x)
= MyTimeSformer(
timesformer = 128,
dim = 128,
image_size = 16,
patch_size = 5,
num_frames = 101,
num_classes = 12,
depth = 8,
heads = 64,
dim_head = 0.1,
attn_dropout = 0.1
ff_dropout ).cuda()
= Learner(dls, timesformer, metrics=[accuracy]).to_fp16() learn_tf
learn_tf.lr_find()
SuggestedLRs(lr_min=0.025118863582611083, lr_steep=0.2089296132326126)
12, 5e-4) learn_tf.fit_one_cycle(
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 4.227850 | 4.114154 | 0.091216 | 00:41 |
1 | 3.735752 | 3.694664 | 0.141517 | 00:42 |
2 | 3.160729 | 3.085824 | 0.256381 | 00:41 |
3 | 2.540461 | 2.478563 | 0.380255 | 00:42 |
4 | 1.878038 | 1.880847 | 0.536411 | 00:42 |
5 | 1.213030 | 1.442322 | 0.642643 | 00:42 |
6 | 0.744001 | 1.153427 | 0.720345 | 00:42 |
7 | 0.421604 | 1.041846 | 0.746997 | 00:42 |
8 | 0.203065 | 0.959380 | 0.779655 | 00:42 |
9 | 0.112700 | 0.902984 | 0.792042 | 00:42 |
10 | 0.058495 | 0.871788 | 0.801802 | 00:42 |
11 | 0.043413 | 0.868007 | 0.805931 | 00:42 |
learn_tf.show_results()