<br />

|---|---|---|---|
| [![](https://www.tensorflow.org/images/tf_logo_32px.png)View on TensorFlow.org](https://www.tensorflow.org/tutorials/video/video_classification) | [![](https://www.tensorflow.org/images/colab_logo_32px.png)Run in Google Colab](https://colab.research.google.com/github/tensorflow/docs/blob/master/site/en/tutorials/video/video_classification.ipynb) | [![](https://www.tensorflow.org/images/GitHub-Mark-32px.png)View source on GitHub](https://github.com/tensorflow/docs/blob/master/site/en/tutorials/video/video_classification.ipynb) | [![](https://www.tensorflow.org/images/download_logo_32px.png)Download notebook](https://storage.googleapis.com/tensorflow_docs/docs/site/en/tutorials/video/video_classification.ipynb) |

This tutorial demonstrates training a 3D convolutional neural network (CNN) for video classification using the [UCF101](https://www.crcv.ucf.edu/data/UCF101.php) action recognition dataset. A 3D CNN uses a three-dimensional filter to perform convolutions. The kernel is able to slide in three directions, whereas in a 2D CNN it can slide in two dimensions. The model is based on the work published in [A Closer Look at Spatiotemporal Convolutions for Action Recognition](https://arxiv.org/abs/1711.11248v3) by D. Tran et al. (2017). In this tutorial, you will:

- Build an input pipeline
- Build a 3D convolutional neural network model with residual connections using Keras functional API
- Train the model
- Evaluate and test the model

This video classification tutorial is the second part in a series of TensorFlow video tutorials. Here are the other three tutorials:

- [Load video data](https://www.tensorflow.org/tutorials/load_data/video): This tutorial explains much of the code used in this document.
- [MoViNet for streaming action recognition](https://www.tensorflow.org/hub/tutorials/movinet): Get familiar with the MoViNet models that are available on TF Hub.
- [Transfer learning for video classification with MoViNet](https://www.tensorflow.org/tutorials/video/transfer_learning_with_movinet): This tutorial explains how to use a pre-trained video classification model trained on a different dataset with the UCF-101 dataset.

## Setup

Begin by installing and importing some necessary libraries, including:
[remotezip](https://github.com/gtsystem/python-remotezip) to inspect the contents of a ZIP file, [tqdm](https://github.com/tqdm/tqdm) to use a progress bar, [OpenCV](https://opencv.org/) to process video files, [einops](https://github.com/arogozhnikov/einops/tree/master/docs) for performing more complex tensor operations, and [`tensorflow_docs`](https://github.com/tensorflow/docs/tree/master/tools/tensorflow_docs) for embedding data in a Jupyter notebook.

    pip install remotezip tqdm opencv-python einops
    pip install -U tensorflow keras

    import tqdm
    import random
    import pathlib
    import itertools
    import collections

    import cv2
    import einops
    import numpy as np
    import remotezip as rz
    import seaborn as sns
    import matplotlib.pyplot as plt

    import tensorflow as tf
    import keras
    from keras import layers

## Load and preprocess video data

The hidden cell below defines helper functions to download a slice of data from the UCF-101 dataset, and load it into a [`tf.data.Dataset`](https://www.tensorflow.org/api_docs/python/tf/data/Dataset). You can learn more about the specific preprocessing steps in the [Loading video data tutorial](https://www.tensorflow.org/tutorials/load_data/video), which walks you through this code in more detail.

The `FrameGenerator` class at the end of the hidden block is the most important utility here. It creates an iterable object that can feed data into the TensorFlow data pipeline. Specifically, this class contains a Python generator that loads the video frames along with its encoded label. The generator (`__call__`) function yields the frame array produced by `frames_from_video_file` and a one-hot encoded vector of the label associated with the set of frames.

<br />

<button type="button" class="button-red button expand-control">Toggle code</button>

<br />

    def list_files_per_class(zip_url):
      """
        List the files in each class of the dataset given the zip URL.

        Args:
          zip_url: URL from which the files can be unzipped. 

        Return:
          files: List of files in each of the classes.
      """
      files = []
      with rz.RemoteZip(URL) as zip:
        for zip_info in zip.infolist():
          files.append(zip_info.filename)
      return files

    def get_class(fname):
      """
        Retrieve the name of the class given a filename.

        Args:
          fname: Name of the file in the UCF101 dataset.

        Return:
          Class that the file belongs to.
      """
      return fname.split('_')[-3]

    def get_files_per_class(files):
      """
        Retrieve the files that belong to each class. 

        Args:
          files: List of files in the dataset.

        Return:
          Dictionary of class names (key) and files (values).
      """
      files_for_class = collections.defaultdict(list)
      for fname in files:
        class_name = get_class(fname)
        files_for_class[class_name].append(fname)
      return files_for_class

    def download_from_zip(zip_url, to_dir, file_names):
      """
        Download the contents of the zip file from the zip URL.

        Args:
          zip_url: Zip URL containing data.
          to_dir: Directory to download data to.
          file_names: Names of files to download.
      """
      with rz.RemoteZip(zip_url) as zip:
        for fn in tqdm.tqdm(file_names):
          class_name = get_class(fn)
          zip.extract(fn, str(to_dir / class_name))
          unzipped_file = to_dir / class_name / fn

          fn = pathlib.Path(fn).parts[-1]
          output_file = to_dir / class_name / fn
          unzipped_file.rename(output_file,)

    def split_class_lists(files_for_class, count):
      """
        Returns the list of files belonging to a subset of data as well as the remainder of
        files that need to be downloaded.

        Args:
          files_for_class: Files belonging to a particular class of data.
          count: Number of files to download.

        Return:
          split_files: Files belonging to the subset of data.
          remainder: Dictionary of the remainder of files that need to be downloaded.
      """
      split_files = []
      remainder = {}
      for cls in files_for_class:
        split_files.extend(files_for_class[cls][:count])
        remainder[cls] = files_for_class[cls][count:]
      return split_files, remainder

    def download_ufc_101_subset(zip_url, num_classes, splits, download_dir):
      """
        Download a subset of the UFC101 dataset and split them into various parts, such as
        training, validation, and test. 

        Args:
          zip_url: Zip URL containing data.
          num_classes: Number of labels.
          splits: Dictionary specifying the training, validation, test, etc. (key) division of data 
                  (value is number of files per split).
          download_dir: Directory to download data to.

        Return:
          dir: Posix path of the resulting directories containing the splits of data.
      """
      files = list_files_per_class(zip_url)
      for f in files:
        tokens = f.split('/')
        if len(tokens) <= 2:
          files.remove(f) # Remove that item from the list if it does not have a filename

      files_for_class = get_files_per_class(files)

      classes = list(files_for_class.keys())[:num_classes]

      for cls in classes:
        new_files_for_class = files_for_class[cls]
        random.shuffle(new_files_for_class)
        files_for_class[cls] = new_files_for_class

      # Only use the number of classes you want in the dictionary
      files_for_class = {x: files_for_class[x] for x in list(files_for_class)[:num_classes]}

      dirs = {}
      for split_name, split_count in splits.items():
        print(split_name, ":")
        split_dir = download_dir / split_name
        split_files, files_for_class = split_class_lists(files_for_class, split_count)
        download_from_zip(zip_url, split_dir, split_files)
        dirs[split_name] = split_dir

      return dirs

    def format_frames(frame, output_size):
      """
        Pad and resize an image from a video.

        Args:
          frame: Image that needs to resized and padded. 
          output_size: Pixel size of the output frame image.

        Return:
          Formatted frame with padding of specified output size.
      """
      frame = tf.image.convert_image_dtype(frame, tf.float32)
      frame = tf.image.resize_with_pad(frame, *output_size)
      return frame

    def frames_from_video_file(video_path, n_frames, output_size = (224,224), frame_step = 15):
      """
        Creates frames from each video file present for each category.

        Args:
          video_path: File path to the video.
          n_frames: Number of frames to be created per video file.
          output_size: Pixel size of the output frame image.

        Return:
          An NumPy array of frames in the shape of (n_frames, height, width, channels).
      """
      # Read each video frame by frame
      result = []
      src = cv2.VideoCapture(str(video_path))  

      video_length = src.get(cv2.CAP_PROP_FRAME_COUNT)

      need_length = 1 + (n_frames - 1) * frame_step

      if need_length > video_length:
        start = 0
      else:
        max_start = video_length - need_length
        start = random.randint(0, max_start + 1)

      src.set(cv2.CAP_PROP_POS_FRAMES, start)
      # ret is a boolean indicating whether read was successful, frame is the image itself
      ret, frame = src.read()
      result.append(format_frames(frame, output_size))

      for _ in range(n_frames - 1):
        for _ in range(frame_step):
          ret, frame = src.read()
        if ret:
          frame = format_frames(frame, output_size)
          result.append(frame)
        else:
          result.append(np.zeros_like(result[0]))
      src.release()
      result = np.array(result)[..., [2, 1, 0]]

      return result

    class FrameGenerator:
      def __init__(self, path, n_frames, training = False):
        """ Returns a set of frames with their associated label. 

          Args:
            path: Video file paths.
            n_frames: Number of frames. 
            training: Boolean to determine if training dataset is being created.
        """
        self.path = path
        self.n_frames = n_frames
        self.training = training
        self.class_names = sorted(set(p.name for p in self.path.iterdir() if p.is_dir()))
        self.class_ids_for_name = dict((name, idx) for idx, name in enumerate(self.class_names))

      def get_files_and_class_names(self):
        video_paths = list(self.path.glob('*/*.avi'))
        classes = [p.parent.name for p in video_paths] 
        return video_paths, classes

      def __call__(self):
        video_paths, classes = self.get_files_and_class_names()

        pairs = list(zip(video_paths, classes))

        if self.training:
          random.shuffle(pairs)

        for path, name in pairs:
          video_frames = frames_from_video_file(path, self.n_frames) 
          label = self.class_ids_for_name[name] # Encode labels
          yield video_frames, label

<br />

<br />

    URL = 'https://storage.googleapis.com/thumos14_files/UCF101_videos.zip'
    download_dir = pathlib.Path('./UCF101_subset/')
    subset_paths = download_ufc_101_subset(URL, 
                            num_classes = 10, 
                            splits = {"train": 30, "val": 10, "test": 10},
                            download_dir = download_dir)

```
train :
0%|          | 0/300 [00:00<?, ?it/s]
1%|          | 2/300 [00:00<00:19, 15.53it/s]
1%|▏         | 4/300 [00:00<00:17, 16.65it/s]
2%|▏         | 6/300 [00:00<00:17, 16.94it/s]
3%|▎         | 9/300 [00:00<00:16, 18.17it/s]
4%|▎         | 11/300 [00:00<00:16, 17.40it/s]
4%|▍         | 13/300 [00:00<00:16, 17.14it/s]
5%|▌         | 15/300 [00:00<00:17, 16.73it/s]
6%|▌         | 17/300 [00:00<00:16, 16.89it/s]
6%|▋         | 19/300 [00:01<00:16, 17.01it/s]
7%|▋         | 21/300 [00:01<00:17, 16.37it/s]
8%|▊         | 23/300 [00:01<00:16, 16.90it/s]
8%|▊         | 25/300 [00:01<00:16, 16.86it/s]
9%|▉         | 27/300 [00:01<00:16, 16.29it/s]
10%|▉         | 29/300 [00:01<00:16, 16.84it/s]
10%|█         | 31/300 [00:01<00:15, 17.35it/s]
11%|█         | 33/300 [00:01<00:15, 17.06it/s]
12%|█▏        | 35/300 [00:02<00:15, 17.05it/s]
12%|█▏        | 37/300 [00:02<00:15, 16.57it/s]
13%|█▎        | 39/300 [00:02<00:15, 16.61it/s]
14%|█▎        | 41/300 [00:02<00:15, 16.33it/s]
14%|█▍        | 43/300 [00:02<00:16, 15.86it/s]
15%|█▌        | 45/300 [00:02<00:15, 16.32it/s]
16%|█▌        | 47/300 [00:02<00:15, 16.58it/s]
16%|█▋        | 49/300 [00:02<00:15, 16.51it/s]
17%|█▋        | 51/300 [00:03<00:15, 16.34it/s]
18%|█▊        | 53/300 [00:03<00:14, 16.82it/s]
18%|█▊        | 55/300 [00:03<00:14, 16.43it/s]
19%|█▉        | 58/300 [00:03<00:13, 17.65it/s]
20%|██        | 60/300 [00:03<00:13, 18.04it/s]
21%|██        | 62/300 [00:03<00:13, 17.97it/s]
21%|██▏       | 64/300 [00:03<00:13, 17.76it/s]
22%|██▏       | 66/300 [00:03<00:13, 16.99it/s]
23%|██▎       | 68/300 [00:04<00:14, 16.36it/s]
23%|██▎       | 70/300 [00:04<00:13, 16.70it/s]
24%|██▍       | 72/300 [00:04<00:13, 16.83it/s]
25%|██▍       | 74/300 [00:04<00:13, 16.51it/s]
25%|██▌       | 76/300 [00:04<00:13, 16.42it/s]
26%|██▌       | 78/300 [00:04<00:13, 16.84it/s]
27%|██▋       | 80/300 [00:04<00:13, 16.67it/s]
27%|██▋       | 82/300 [00:04<00:13, 15.98it/s]
28%|██▊       | 85/300 [00:05<00:12, 17.42it/s]
29%|██▉       | 87/300 [00:05<00:12, 17.43it/s]
30%|██▉       | 89/300 [00:05<00:11, 17.73it/s]
30%|███       | 91/300 [00:05<00:12, 17.23it/s]
31%|███       | 93/300 [00:05<00:12, 17.15it/s]
32%|███▏      | 95/300 [00:05<00:12, 16.71it/s]
32%|███▏      | 97/300 [00:05<00:12, 15.91it/s]
33%|███▎      | 99/300 [00:05<00:12, 16.21it/s]
34%|███▎      | 101/300 [00:06<00:12, 15.89it/s]
34%|███▍      | 103/300 [00:06<00:12, 16.17it/s]
35%|███▌      | 105/300 [00:06<00:11, 16.80it/s]
36%|███▌      | 107/300 [00:06<00:11, 17.00it/s]
36%|███▋      | 109/300 [00:06<00:11, 17.09it/s]
37%|███▋      | 111/300 [00:06<00:11, 15.92it/s]
38%|███▊      | 113/300 [00:06<00:11, 16.25it/s]
38%|███▊      | 115/300 [00:06<00:10, 17.21it/s]
39%|███▉      | 117/300 [00:06<00:10, 17.07it/s]
40%|███▉      | 119/300 [00:07<00:10, 17.09it/s]
40%|████      | 121/300 [00:07<00:10, 17.74it/s]
41%|████      | 123/300 [00:07<00:10, 17.51it/s]
42%|████▏     | 126/300 [00:07<00:09, 18.17it/s]
43%|████▎     | 128/300 [00:07<00:09, 17.69it/s]
43%|████▎     | 130/300 [00:07<00:09, 17.61it/s]
44%|████▍     | 132/300 [00:07<00:09, 17.58it/s]
45%|████▍     | 134/300 [00:07<00:09, 17.81it/s]
45%|████▌     | 136/300 [00:08<00:09, 17.78it/s]
46%|████▌     | 138/300 [00:08<00:09, 17.95it/s]
47%|████▋     | 140/300 [00:08<00:08, 18.14it/s]
47%|████▋     | 142/300 [00:08<00:08, 18.10it/s]
48%|████▊     | 144/300 [00:08<00:08, 17.68it/s]
49%|████▊     | 146/300 [00:08<00:08, 18.15it/s]
49%|████▉     | 148/300 [00:08<00:08, 17.06it/s]
50%|█████     | 150/300 [00:08<00:08, 17.42it/s]
51%|█████     | 152/300 [00:08<00:08, 17.13it/s]
51%|█████▏    | 154/300 [00:09<00:08, 16.51it/s]
52%|█████▏    | 156/300 [00:09<00:09, 15.66it/s]
53%|█████▎    | 158/300 [00:09<00:08, 16.34it/s]
53%|█████▎    | 160/300 [00:09<00:10, 12.78it/s]
54%|█████▍    | 162/300 [00:09<00:09, 13.89it/s]
55%|█████▍    | 164/300 [00:09<00:10, 13.20it/s]
55%|█████▌    | 166/300 [00:09<00:09, 13.84it/s]
56%|█████▌    | 168/300 [00:10<00:09, 14.03it/s]
57%|█████▋    | 170/300 [00:10<00:08, 14.72it/s]
57%|█████▋    | 172/300 [00:10<00:08, 15.07it/s]
58%|█████▊    | 174/300 [00:10<00:09, 12.90it/s]
59%|█████▊    | 176/300 [00:10<00:09, 13.64it/s]
59%|█████▉    | 178/300 [00:10<00:08, 14.71it/s]
60%|██████    | 180/300 [00:10<00:08, 14.01it/s]
61%|██████    | 182/300 [00:11<00:07, 14.94it/s]
61%|██████▏   | 184/300 [00:11<00:07, 15.23it/s]
62%|██████▏   | 186/300 [00:11<00:07, 16.01it/s]
63%|██████▎   | 188/300 [00:11<00:06, 16.81it/s]
63%|██████▎   | 190/300 [00:11<00:06, 16.59it/s]
64%|██████▍   | 192/300 [00:11<00:06, 16.90it/s]
65%|██████▍   | 194/300 [00:11<00:06, 17.45it/s]
65%|██████▌   | 196/300 [00:11<00:06, 17.26it/s]
66%|██████▌   | 198/300 [00:11<00:05, 17.62it/s]
67%|██████▋   | 201/300 [00:12<00:05, 17.61it/s]
68%|██████▊   | 203/300 [00:12<00:05, 18.17it/s]
68%|██████▊   | 205/300 [00:12<00:05, 18.51it/s]
69%|██████▉   | 207/300 [00:12<00:04, 18.80it/s]
70%|██████▉   | 209/300 [00:12<00:04, 18.64it/s]
70%|███████   | 211/300 [00:12<00:04, 18.32it/s]
71%|███████   | 213/300 [00:12<00:04, 17.58it/s]
72%|███████▏  | 215/300 [00:12<00:04, 17.99it/s]
72%|███████▏  | 217/300 [00:13<00:04, 18.33it/s]
73%|███████▎  | 219/300 [00:13<00:04, 18.38it/s]
74%|███████▎  | 221/300 [00:13<00:04, 18.62it/s]
75%|███████▍  | 224/300 [00:13<00:04, 18.65it/s]
75%|███████▌  | 226/300 [00:13<00:03, 18.83it/s]
76%|███████▌  | 228/300 [00:13<00:04, 17.87it/s]
77%|███████▋  | 230/300 [00:13<00:03, 18.03it/s]
77%|███████▋  | 232/300 [00:13<00:03, 18.46it/s]
78%|███████▊  | 234/300 [00:13<00:03, 18.81it/s]
79%|███████▊  | 236/300 [00:14<00:03, 18.71it/s]
79%|███████▉  | 238/300 [00:14<00:03, 18.95it/s]
80%|████████  | 240/300 [00:14<00:03, 18.55it/s]
81%|████████  | 242/300 [00:14<00:03, 17.40it/s]
81%|████████▏ | 244/300 [00:14<00:03, 17.29it/s]
82%|████████▏ | 246/300 [00:14<00:03, 17.24it/s]
83%|████████▎ | 248/300 [00:14<00:03, 16.28it/s]
83%|████████▎ | 250/300 [00:14<00:03, 15.99it/s]
84%|████████▍ | 252/300 [00:15<00:02, 16.40it/s]
85%|████████▍ | 254/300 [00:15<00:02, 16.84it/s]
85%|████████▌ | 256/300 [00:15<00:02, 16.80it/s]
86%|████████▌ | 258/300 [00:15<00:02, 17.44it/s]
87%|████████▋ | 261/300 [00:15<00:02, 18.80it/s]
88%|████████▊ | 263/300 [00:15<00:01, 18.81it/s]
88%|████████▊ | 265/300 [00:15<00:01, 18.58it/s]
89%|████████▉ | 267/300 [00:15<00:01, 18.77it/s]
90%|████████▉ | 269/300 [00:15<00:01, 18.37it/s]
90%|█████████ | 271/300 [00:16<00:01, 18.16it/s]
91%|█████████ | 273/300 [00:16<00:01, 17.53it/s]
92%|█████████▏| 275/300 [00:16<00:01, 17.52it/s]
92%|█████████▏| 277/300 [00:16<00:01, 17.48it/s]
93%|█████████▎| 279/300 [00:16<00:01, 17.09it/s]
94%|█████████▎| 281/300 [00:16<00:01, 17.26it/s]
94%|█████████▍| 283/300 [00:16<00:00, 17.27it/s]
95%|█████████▌| 286/300 [00:16<00:00, 18.41it/s]
96%|█████████▌| 288/300 [00:16<00:00, 18.45it/s]
97%|█████████▋| 290/300 [00:17<00:00, 17.41it/s]
97%|█████████▋| 292/300 [00:17<00:00, 17.11it/s]
98%|█████████▊| 294/300 [00:17<00:00, 17.62it/s]
99%|█████████▊| 296/300 [00:17<00:00, 17.35it/s]
99%|█████████▉| 298/300 [00:17<00:00, 17.90it/s]
100%|██████████| 300/300 [00:17<00:00, 17.50it/s]
100%|██████████| 300/300 [00:17<00:00, 16.96it/s]

val :
0%|          | 0/100 [00:00<?, ?it/s]
2%|▏         | 2/100 [00:00<00:05, 19.45it/s]
4%|▍         | 4/100 [00:00<00:04, 19.51it/s]
6%|▌         | 6/100 [00:00<00:05, 17.63it/s]
8%|▊         | 8/100 [00:00<00:05, 17.70it/s]
10%|█         | 10/100 [00:00<00:05, 17.32it/s]
12%|█▏        | 12/100 [00:00<00:05, 17.49it/s]
14%|█▍        | 14/100 [00:00<00:04, 17.59it/s]
17%|█▋        | 17/100 [00:00<00:04, 18.71it/s]
19%|█▉        | 19/100 [00:01<00:04, 18.59it/s]
21%|██        | 21/100 [00:01<00:04, 17.94it/s]
23%|██▎       | 23/100 [00:01<00:04, 18.15it/s]
25%|██▌       | 25/100 [00:01<00:04, 17.42it/s]
27%|██▋       | 27/100 [00:01<00:04, 17.11it/s]
30%|███       | 30/100 [00:01<00:03, 17.98it/s]
32%|███▏      | 32/100 [00:01<00:03, 17.74it/s]
35%|███▌      | 35/100 [00:01<00:03, 18.33it/s]
38%|███▊      | 38/100 [00:02<00:03, 19.03it/s]
40%|████      | 40/100 [00:02<00:03, 18.91it/s]
42%|████▏     | 42/100 [00:02<00:03, 19.05it/s]
44%|████▍     | 44/100 [00:02<00:03, 18.66it/s]
47%|████▋     | 47/100 [00:02<00:02, 19.63it/s]
50%|█████     | 50/100 [00:02<00:02, 19.68it/s]
52%|█████▏    | 52/100 [00:02<00:02, 18.42it/s]
54%|█████▍    | 54/100 [00:02<00:02, 17.81it/s]
56%|█████▌    | 56/100 [00:03<00:02, 17.96it/s]
58%|█████▊    | 58/100 [00:03<00:02, 16.63it/s]
60%|██████    | 60/100 [00:03<00:02, 16.27it/s]
63%|██████▎   | 63/100 [00:03<00:02, 17.52it/s]
66%|██████▌   | 66/100 [00:03<00:01, 18.47it/s]
68%|██████▊   | 68/100 [00:03<00:01, 18.41it/s]
70%|███████   | 70/100 [00:03<00:01, 18.73it/s]
72%|███████▏  | 72/100 [00:03<00:01, 18.83it/s]
75%|███████▌  | 75/100 [00:04<00:01, 19.47it/s]
77%|███████▋  | 77/100 [00:04<00:01, 19.49it/s]
79%|███████▉  | 79/100 [00:04<00:01, 18.57it/s]
81%|████████  | 81/100 [00:04<00:01, 18.15it/s]
83%|████████▎ | 83/100 [00:04<00:00, 17.97it/s]
85%|████████▌ | 85/100 [00:04<00:00, 17.65it/s]
88%|████████▊ | 88/100 [00:04<00:00, 18.52it/s]
90%|█████████ | 90/100 [00:04<00:00, 18.27it/s]
92%|█████████▏| 92/100 [00:05<00:00, 18.61it/s]
94%|█████████▍| 94/100 [00:05<00:00, 18.50it/s]
96%|█████████▌| 96/100 [00:05<00:00, 18.90it/s]
98%|█████████▊| 98/100 [00:05<00:00, 18.28it/s]
100%|██████████| 100/100 [00:05<00:00, 18.13it/s]
100%|██████████| 100/100 [00:05<00:00, 18.27it/s]

test :
0%|          | 0/100 [00:00<?, ?it/s]
2%|▏         | 2/100 [00:00<00:05, 18.12it/s]
5%|▌         | 5/100 [00:00<00:04, 19.79it/s]
7%|▋         | 7/100 [00:00<00:04, 19.44it/s]
9%|▉         | 9/100 [00:00<00:04, 19.32it/s]
12%|█▏        | 12/100 [00:00<00:04, 20.10it/s]
15%|█▌        | 15/100 [00:00<00:04, 19.88it/s]
18%|█▊        | 18/100 [00:00<00:04, 20.40it/s]
21%|██        | 21/100 [00:01<00:03, 20.10it/s]
24%|██▍       | 24/100 [00:01<00:03, 19.97it/s]
26%|██▌       | 26/100 [00:01<00:03, 19.61it/s]
28%|██▊       | 28/100 [00:01<00:03, 19.48it/s]
30%|███       | 30/100 [00:01<00:03, 19.49it/s]
32%|███▏      | 32/100 [00:01<00:03, 18.53it/s]
34%|███▍      | 34/100 [00:01<00:03, 18.09it/s]
36%|███▌      | 36/100 [00:01<00:03, 17.52it/s]
38%|███▊      | 38/100 [00:02<00:03, 16.92it/s]
40%|████      | 40/100 [00:02<00:03, 16.41it/s]
42%|████▏     | 42/100 [00:02<00:03, 16.91it/s]
45%|████▌     | 45/100 [00:02<00:03, 18.04it/s]
47%|████▋     | 47/100 [00:02<00:02, 17.79it/s]
50%|█████     | 50/100 [00:02<00:02, 18.77it/s]
52%|█████▏    | 52/100 [00:02<00:02, 17.87it/s]
54%|█████▍    | 54/100 [00:02<00:02, 17.31it/s]
56%|█████▌    | 56/100 [00:03<00:02, 16.70it/s]
58%|█████▊    | 58/100 [00:03<00:02, 16.34it/s]
60%|██████    | 60/100 [00:03<00:02, 16.71it/s]
62%|██████▏   | 62/100 [00:03<00:02, 17.42it/s]
64%|██████▍   | 64/100 [00:03<00:02, 17.65it/s]
66%|██████▌   | 66/100 [00:03<00:01, 17.06it/s]
69%|██████▉   | 69/100 [00:03<00:01, 18.13it/s]
71%|███████   | 71/100 [00:03<00:01, 18.43it/s]
73%|███████▎  | 73/100 [00:03<00:01, 18.70it/s]
75%|███████▌  | 75/100 [00:04<00:01, 18.22it/s]
77%|███████▋  | 77/100 [00:04<00:01, 18.32it/s]
80%|████████  | 80/100 [00:04<00:01, 19.29it/s]
83%|████████▎ | 83/100 [00:04<00:00, 19.06it/s]
85%|████████▌ | 85/100 [00:04<00:00, 18.95it/s]
87%|████████▋ | 87/100 [00:04<00:00, 18.89it/s]
89%|████████▉ | 89/100 [00:04<00:00, 19.12it/s]
91%|█████████ | 91/100 [00:04<00:00, 18.87it/s]
93%|█████████▎| 93/100 [00:05<00:00, 18.96it/s]
96%|█████████▌| 96/100 [00:05<00:00, 19.23it/s]
98%|█████████▊| 98/100 [00:05<00:00, 19.32it/s]
100%|██████████| 100/100 [00:05<00:00, 18.77it/s]
100%|██████████| 100/100 [00:05<00:00, 18.49it/s]

```

Create the training, validation, and test sets (`train_ds`, `val_ds`, and `test_ds`).

<br />

<button type="button" class="button-red button expand-control">Toggle code</button>

<br />

    n_frames = 10
    batch_size = 8

    output_signature = (tf.TensorSpec(shape = (None, None, None, 3), dtype = tf.float32),
                        tf.TensorSpec(shape = (), dtype = tf.int16))

    train_ds = tf.data.Dataset.from_generator(FrameGenerator(subset_paths['train'], n_frames, training=True),
                                              output_signature = output_signature)


    # Batch the data
    train_ds = train_ds.batch(batch_size)

    val_ds = tf.data.Dataset.from_generator(FrameGenerator(subset_paths['val'], n_frames),
                                            output_signature = output_signature)
    val_ds = val_ds.batch(batch_size)

    test_ds = tf.data.Dataset.from_generator(FrameGenerator(subset_paths['test'], n_frames),
                                             output_signature = output_signature)

    test_ds = test_ds.batch(batch_size)

<br />

<br />

```
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1764734577.687497  122959 gpu_device.cc:2020] Created device /job:localhost/replica:0/task:0/device:GPU:0 with 13638 MB memory:  -> device: 0, name: Tesla T4, pci bus id: 0000:00:05.0, compute capability: 7.5
I0000 00:00:1764734577.689726  122959 gpu_device.cc:2020] Created device /job:localhost/replica:0/task:0/device:GPU:1 with 13756 MB memory:  -> device: 1, name: Tesla T4, pci bus id: 0000:00:06.0, compute capability: 7.5
I0000 00:00:1764734577.691959  122959 gpu_device.cc:2020] Created device /job:localhost/replica:0/task:0/device:GPU:2 with 13756 MB memory:  -> device: 2, name: Tesla T4, pci bus id: 0000:00:07.0, compute capability: 7.5
I0000 00:00:1764734577.694100  122959 gpu_device.cc:2020] Created device /job:localhost/replica:0/task:0/device:GPU:3 with 13756 MB memory:  -> device: 3, name: Tesla T4, pci bus id: 0000:00:08.0, compute capability: 7.5
```

## Create the model

The following 3D convolutional neural network model is based off the paper [A Closer Look at Spatiotemporal Convolutions for Action Recognition](https://arxiv.org/abs/1711.11248v3) by D. Tran et al. (2017). The paper compares several versions of 3D ResNets. Instead of operating on a single image with dimensions `(height, width)`, like standard ResNets, these operate on video volume `(time, height, width)`. The most obvious approach to this problem would be replace each 2D convolution ([`layers.Conv2D`](https://www.tensorflow.org/api_docs/python/tf/keras/layers/Conv2D)) with a 3D convolution ([`layers.Conv3D`](https://www.tensorflow.org/api_docs/python/tf/keras/layers/Conv3D)).

This tutorial uses a (2 + 1)D convolution with [residual connections](https://arxiv.org/abs/1512.03385). The (2 + 1)D convolution allows for the decomposition of the spatial and temporal dimensions, therefore creating two separate steps. An advantage of this approach is that factorizing the convolutions into spatial and temporal dimensions saves parameters.

For each output location a 3D convolution combines all the vectors from a 3D patch of the volume to create one vector in the output volume.

![3D convolutions](https://www.tensorflow.org/images/tutorials/video/3DCNN.png)

This operation is takes `time * height * width * channels` inputs and produces `channels` outputs (assuming the number of input and output channels are the same. So a 3D convolution layer with a kernel size of `(3 x 3 x 3)` would need a weight-matrix with `27 * channels ** 2` entries. The reference paper found that a more effective \& efficient approach was to factorize the convolution. Instead of a single 3D convolution to process the time and space dimensions, they proposed a "(2+1)D" convolution which processes the space and time dimensions separately. The figure below shows the factored spatial and temporal convolutions of a (2 + 1)D convolution.

![(2+1)D convolutions](https://www.tensorflow.org/images/tutorials/video/2plus1CNN.png)

The main advantage of this approach is that it reduces the number of parameters. In the (2 + 1)D convolution the spatial convolution takes in data of the shape `(1, width, height)`, while the temporal convolution takes in data of the shape `(time, 1, 1)`. For example, a (2 + 1)D convolution with kernel size `(3 x 3 x 3)` would need weight matrices of size `(9 * channels**2) + (3 * channels**2)`, less than half as many as the full 3D convolution. This tutorial implements (2 + 1)D ResNet18, where each convolution in the resnet is replaced by a (2+1)D convolution.

    # Define the dimensions of one frame in the set of frames created
    HEIGHT = 224
    WIDTH = 224

    class Conv2Plus1D(keras.layers.Layer):
      def __init__(self, filters, kernel_size, padding):
        """
          A sequence of convolutional layers that first apply the convolution operation over the
          spatial dimensions, and then the temporal dimension. 
        """
        super().__init__()
        self.seq = keras.Sequential([  
            # Spatial decomposition
            layers.Conv3D(filters=filters,
                          kernel_size=(1, kernel_size[1], kernel_size[2]),
                          padding=padding),
            # Temporal decomposition
            layers.Conv3D(filters=filters, 
                          kernel_size=(kernel_size[0], 1, 1),
                          padding=padding)
            ])

      def call(self, x):
        return self.seq(x)

A ResNet model is made from a sequence of residual blocks.
A residual block has two branches. The main branch performs the calculation, but is difficult for gradients to flow through.
The residual branch bypasses the main calculation and mostly just adds the input to the output of the main branch.
Gradients flow easily through this branch.
Therefore, an easy path from the loss function to any of the residual block's main branch will be present.
This avoids the vanishing gradient problem.

Create the main branch of the residual block with the following class. In contrast to the standard ResNet structure this uses the custom `Conv2Plus1D` layer instead of [`layers.Conv2D`](https://www.tensorflow.org/api_docs/python/tf/keras/layers/Conv2D).

    class ResidualMain(keras.layers.Layer):
      """
        Residual block of the model with convolution, layer normalization, and the
        activation function, ReLU.
      """
      def __init__(self, filters, kernel_size):
        super().__init__()
        self.seq = keras.Sequential([
            Conv2Plus1D(filters=filters,
                        kernel_size=kernel_size,
                        padding='same'),
            layers.LayerNormalization(),
            layers.ReLU(),
            Conv2Plus1D(filters=filters, 
                        kernel_size=kernel_size,
                        padding='same'),
            layers.LayerNormalization()
        ])

      def call(self, x):
        return self.seq(x)

To add the residual branch to the main branch it needs to have the same size. The `Project` layer below deals with cases where the number of channels is changed on the branch. In particular, a sequence of densely-connected layer followed by normalization is added.

    class Project(keras.layers.Layer):
      """
        Project certain dimensions of the tensor as the data is passed through different 
        sized filters and downsampled. 
      """
      def __init__(self, units):
        super().__init__()
        self.seq = keras.Sequential([
            layers.Dense(units),
            layers.LayerNormalization()
        ])

      def call(self, x):
        return self.seq(x)

Use `add_residual_block` to introduce a skip connection between the layers of the model.

    def add_residual_block(input, filters, kernel_size):
      """
        Add residual blocks to the model. If the last dimensions of the input data
        and filter size does not match, project it such that last dimension matches.
      """
      out = ResidualMain(filters, 
                         kernel_size)(input)

      res = input
      # Using the Keras functional APIs, project the last dimension of the tensor to
      # match the new filter size
      if out.shape[-1] != input.shape[-1]:
        res = Project(out.shape[-1])(res)

      return layers.add([res, out])

Resizing the video is necessary to perform downsampling of the data. In particular, downsampling the video frames allow for the model to examine specific parts of frames to detect patterns that may be specific to a certain action. Through downsampling, non-essential information can be discarded. Moreoever, resizing the video will allow for dimensionality reduction and therefore faster processing through the model.

    class ResizeVideo(keras.layers.Layer):
      def __init__(self, height, width):
        super().__init__()
        self.height = height
        self.width = width
        self.resizing_layer = layers.Resizing(self.height, self.width)

      def call(self, video):
        """
          Use the einops library to resize the tensor.  

          Args:
            video: Tensor representation of the video, in the form of a set of frames.

          Return:
            A downsampled size of the video according to the new height and width it should be resized to.
        """
        # b stands for batch size, t stands for time, h stands for height, 
        # w stands for width, and c stands for the number of channels.
        old_shape = einops.parse_shape(video, 'b t h w c')
        images = einops.rearrange(video, 'b t h w c -> (b t) h w c')
        images = self.resizing_layer(images)
        videos = einops.rearrange(
            images, '(b t) h w c -> b t h w c',
            t = old_shape['t'])
        return videos

Use the [Keras functional API](https://www.tensorflow.org/guide/keras/functional) to build the residual network.

    input_shape = (None, 10, HEIGHT, WIDTH, 3)
    input = layers.Input(shape=(input_shape[1:]))
    x = input

    x = Conv2Plus1D(filters=16, kernel_size=(3, 7, 7), padding='same')(x)
    x = layers.BatchNormalization()(x)
    x = layers.ReLU()(x)
    x = ResizeVideo(HEIGHT // 2, WIDTH // 2)(x)

    # Block 1
    x = add_residual_block(x, 16, (3, 3, 3))
    x = ResizeVideo(HEIGHT // 4, WIDTH // 4)(x)

    # Block 2
    x = add_residual_block(x, 32, (3, 3, 3))
    x = ResizeVideo(HEIGHT // 8, WIDTH // 8)(x)

    # Block 3
    x = add_residual_block(x, 64, (3, 3, 3))
    x = ResizeVideo(HEIGHT // 16, WIDTH // 16)(x)

    # Block 4
    x = add_residual_block(x, 128, (3, 3, 3))

    x = layers.GlobalAveragePooling3D()(x)
    x = layers.Flatten()(x)
    x = layers.Dense(10)(x)

    model = keras.Model(input, x)

    frames, label = next(iter(train_ds))
    model.build(frames)

    # Visualize the model
    keras.utils.plot_model(model, expand_nested=True, dpi=60, show_shapes=True)

![png](https://www.tensorflow.org/static/tutorials/video/video_classification_files/output_GAsKrM8r-bKM_0.png)

## Train the model

For this tutorial, choose the [`tf.keras.optimizers.Adam`](https://www.tensorflow.org/api_docs/python/tf/keras/optimizers/Adam) optimizer and the [`tf.keras.losses.SparseCategoricalCrossentropy`](https://www.tensorflow.org/api_docs/python/tf/keras/losses/SparseCategoricalCrossentropy) loss function. Use the `metrics` argument to the view the accuracy of the model performance at every step.

    model.compile(loss = keras.losses.SparseCategoricalCrossentropy(from_logits=True), 
                  optimizer = keras.optimizers.Adam(learning_rate = 0.0001), 
                  metrics = ['accuracy'])

Train the model for 50 epoches with the Keras [`Model.fit`](https://www.tensorflow.org/api_docs/python/tf/keras/Model#fit) method.

> [!NOTE]
> **Note:** This example model is trained on fewer data points (300 training and 100 validation examples) to keep training time reasonable for this tutorial. Moreover, this example model may take over one hour to train.

    history = model.fit(x = train_ds,
                        epochs = 50, 
                        validation_data = val_ds)

```
Epoch 1/50
2025-12-03 04:03:13.734165: E external/local_xla/xla/stream_executor/cuda/cuda_timer.cc:86] Delay kernel timed out: measured time has sub-optimal accuracy. There may be a missing warmup execution, please investigate in Nsight Systems.
2025-12-03 04:03:13.923484: E external/local_xla/xla/stream_executor/cuda/cuda_timer.cc:86] Delay kernel timed out: measured time has sub-optimal accuracy. There may be a missing warmup execution, please investigate in Nsight Systems.
I0000 00:00:1764734611.337259  123135 device_compiler.h:196] Compiled cluster using XLA!  This line is logged at most once for the lifetime of the process.
37/Unknown 65s 986ms/step - accuracy: 0.0558 - loss: 2.9484
2025-12-03 04:04:09.707653: E external/local_xla/xla/stream_executor/cuda/cuda_timer.cc:86] Delay kernel timed out: measured time has sub-optimal accuracy. There may be a missing warmup execution, please investigate in Nsight Systems.
2025-12-03 04:04:09.880114: E external/local_xla/xla/stream_executor/cuda/cuda_timer.cc:86] Delay kernel timed out: measured time has sub-optimal accuracy. There may be a missing warmup execution, please investigate in Nsight Systems.
38/Unknown 84s 1s/step - accuracy: 0.0569 - loss: 2.9408
/tmpfs/src/tf_docs_env/lib/python3.9/site-packages/keras/src/trainers/epoch_iterator.py:160: UserWarning: Your input ran out of data; interrupting training. Make sure that your dataset or generator can generate at least `steps_per_epoch * epochs` batches. You may need to use the `.repeat()` function when building your dataset.
  self._interrupted_warning()
38/38 ━━━━━━━━━━━━━━━━━━━━ 100s 2s/step - accuracy: 0.0579 - loss: 2.9337 - val_accuracy: 0.1400 - val_loss: 2.4466
Epoch 2/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.1659 - loss: 2.2854 - val_accuracy: 0.0900 - val_loss: 2.3877
Epoch 3/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.1994 - loss: 2.1969 - val_accuracy: 0.1000 - val_loss: 2.5249
Epoch 4/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.2337 - loss: 2.0948 - val_accuracy: 0.1600 - val_loss: 2.2295
Epoch 5/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.2848 - loss: 2.0719 - val_accuracy: 0.2100 - val_loss: 2.1018
Epoch 6/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.2398 - loss: 1.9769 - val_accuracy: 0.1800 - val_loss: 2.2157
Epoch 7/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.2840 - loss: 1.9094 - val_accuracy: 0.2300 - val_loss: 2.1328
Epoch 8/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.3906 - loss: 1.7653 - val_accuracy: 0.2300 - val_loss: 2.3123
Epoch 9/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.4361 - loss: 1.6612 - val_accuracy: 0.2600 - val_loss: 2.0105
Epoch 10/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.4489 - loss: 1.5778 - val_accuracy: 0.3200 - val_loss: 2.1507
Epoch 11/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.4028 - loss: 1.6894 - val_accuracy: 0.2600 - val_loss: 2.1785
Epoch 12/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.4384 - loss: 1.6256 - val_accuracy: 0.4200 - val_loss: 1.6315
Epoch 13/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.4404 - loss: 1.5793 - val_accuracy: 0.4800 - val_loss: 1.4656
Epoch 14/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.4785 - loss: 1.4761 - val_accuracy: 0.3400 - val_loss: 1.9399
Epoch 15/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.4891 - loss: 1.3806 - val_accuracy: 0.4800 - val_loss: 1.5575
Epoch 16/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.5271 - loss: 1.3735 - val_accuracy: 0.4100 - val_loss: 1.7549
Epoch 17/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.4768 - loss: 1.3325 - val_accuracy: 0.4700 - val_loss: 1.3381
Epoch 18/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.5218 - loss: 1.3702 - val_accuracy: 0.4500 - val_loss: 1.5796
Epoch 19/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.4879 - loss: 1.2840 - val_accuracy: 0.4900 - val_loss: 1.3702
Epoch 20/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.5690 - loss: 1.1757 - val_accuracy: 0.5300 - val_loss: 1.4123
Epoch 21/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.6109 - loss: 1.1328 - val_accuracy: 0.5400 - val_loss: 1.3428
Epoch 22/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.5866 - loss: 1.1799 - val_accuracy: 0.5000 - val_loss: 1.3896
Epoch 23/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.6373 - loss: 1.1426 - val_accuracy: 0.5200 - val_loss: 1.3191
Epoch 24/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.6743 - loss: 0.9911 - val_accuracy: 0.5400 - val_loss: 1.1936
Epoch 25/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.6437 - loss: 1.0546 - val_accuracy: 0.4700 - val_loss: 1.4340
Epoch 26/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.6107 - loss: 1.1105 - val_accuracy: 0.4400 - val_loss: 1.4287
Epoch 27/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.6055 - loss: 1.1145 - val_accuracy: 0.4900 - val_loss: 1.4394
Epoch 28/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.6722 - loss: 1.0295 - val_accuracy: 0.5100 - val_loss: 1.3171
Epoch 29/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.6802 - loss: 0.9161 - val_accuracy: 0.5200 - val_loss: 1.2887
Epoch 30/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.6852 - loss: 0.9815 - val_accuracy: 0.5400 - val_loss: 1.3624
Epoch 31/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.6297 - loss: 1.0340 - val_accuracy: 0.5900 - val_loss: 1.0952
Epoch 32/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.6979 - loss: 0.8804 - val_accuracy: 0.5400 - val_loss: 1.2751
Epoch 33/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.6951 - loss: 0.9147 - val_accuracy: 0.5200 - val_loss: 1.1866
Epoch 34/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.6887 - loss: 0.8790 - val_accuracy: 0.5900 - val_loss: 1.0532
Epoch 35/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.6821 - loss: 0.8644 - val_accuracy: 0.5100 - val_loss: 1.5193
Epoch 36/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.7666 - loss: 0.8592 - val_accuracy: 0.5200 - val_loss: 1.2070
Epoch 37/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.7139 - loss: 0.8783 - val_accuracy: 0.5900 - val_loss: 1.1410
Epoch 38/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.7464 - loss: 0.7950 - val_accuracy: 0.6100 - val_loss: 1.0142
Epoch 39/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.7474 - loss: 0.7619 - val_accuracy: 0.5700 - val_loss: 1.1528
Epoch 40/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.7545 - loss: 0.7667 - val_accuracy: 0.6000 - val_loss: 1.0545
Epoch 41/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.7363 - loss: 0.7842 - val_accuracy: 0.6300 - val_loss: 0.9936
Epoch 42/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.7531 - loss: 0.7531 - val_accuracy: 0.5500 - val_loss: 1.1272
Epoch 43/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.7535 - loss: 0.8237 - val_accuracy: 0.5900 - val_loss: 1.1487
Epoch 44/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.8080 - loss: 0.7417 - val_accuracy: 0.5700 - val_loss: 1.0757
Epoch 45/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.7722 - loss: 0.7111 - val_accuracy: 0.6400 - val_loss: 1.0591
Epoch 46/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.7749 - loss: 0.6945 - val_accuracy: 0.6000 - val_loss: 1.1752
Epoch 47/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 50s 1s/step - accuracy: 0.7164 - loss: 0.7451 - val_accuracy: 0.6000 - val_loss: 1.0663
Epoch 48/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.7508 - loss: 0.6859 - val_accuracy: 0.5800 - val_loss: 1.2248
Epoch 49/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.8203 - loss: 0.6379 - val_accuracy: 0.6000 - val_loss: 1.0149
Epoch 50/50
38/38 ━━━━━━━━━━━━━━━━━━━━ 49s 1s/step - accuracy: 0.8169 - loss: 0.6051 - val_accuracy: 0.6200 - val_loss: 0.9454
```

### Visualize the results

Create plots of the loss and accuracy on the training and validation sets:

    def plot_history(history):
      """
        Plotting training and validation learning curves.

        Args:
          history: model history with all the metric measures
      """
      fig, (ax1, ax2) = plt.subplots(2)

      fig.set_size_inches(18.5, 10.5)

      # Plot loss
      ax1.set_title('Loss')
      ax1.plot(history.history['loss'], label = 'train')
      ax1.plot(history.history['val_loss'], label = 'test')
      ax1.set_ylabel('Loss')

      # Determine upper bound of y-axis
      max_loss = max(history.history['loss'] + history.history['val_loss'])

      ax1.set_ylim([0, np.ceil(max_loss)])
      ax1.set_xlabel('Epoch')
      ax1.legend(['Train', 'Validation']) 

      # Plot accuracy
      ax2.set_title('Accuracy')
      ax2.plot(history.history['accuracy'],  label = 'train')
      ax2.plot(history.history['val_accuracy'], label = 'test')
      ax2.set_ylabel('Accuracy')
      ax2.set_ylim([0, 1])
      ax2.set_xlabel('Epoch')
      ax2.legend(['Train', 'Validation'])

      plt.show()

    plot_history(history)

![png](https://www.tensorflow.org/static/tutorials/video/video_classification_files/output_Cd5tpNrtOrs7_0.png)

## Evaluate the model

Use Keras [`Model.evaluate`](https://www.tensorflow.org/api_docs/python/tf/keras/Model#evaluate) to get the loss and accuracy on the test dataset.

> [!NOTE]
> **Note:** The example model in this tutorial uses a subset of the UCF101 dataset to keep training time reasonable. The accuracy and loss can be improved with further hyperparameter tuning or more training data.

    model.evaluate(test_ds, return_dict=True)

```
13/13 ━━━━━━━━━━━━━━━━━━━━ 11s 824ms/step - accuracy: 0.6799 - loss: 1.1054
{'accuracy': 0.6800000071525574, 'loss': 1.0269427299499512}
```

To visualize model performance further, use a [confusion matrix](https://www.tensorflow.org/api_docs/python/tf/math/confusion_matrix). The confusion matrix allows you to assess the performance of the classification model beyond accuracy. In order to build the confusion matrix for this multi-class classification problem, get the actual values in the test set and the predicted values.

    def get_actual_predicted_labels(dataset): 
      """
        Create a list of actual ground truth values and the predictions from the model.

        Args:
          dataset: An iterable data structure, such as a TensorFlow Dataset, with features and labels.

        Return:
          Ground truth and predicted values for a particular dataset.
      """
      actual = [labels for _, labels in dataset.unbatch()]
      predicted = model.predict(dataset)

      actual = tf.stack(actual, axis=0)
      predicted = tf.concat(predicted, axis=0)
      predicted = tf.argmax(predicted, axis=1)

      return actual, predicted

    def plot_confusion_matrix(actual, predicted, labels, ds_type):
      cm = tf.math.confusion_matrix(actual, predicted)
      ax = sns.heatmap(cm, annot=True, fmt='g')
      sns.set(rc={'figure.figsize':(12, 12)})
      sns.set(font_scale=1.4)
      ax.set_title('Confusion matrix of action recognition for ' + ds_type)
      ax.set_xlabel('Predicted Action')
      ax.set_ylabel('Actual Action')
      plt.xticks(rotation=90)
      plt.yticks(rotation=0)
      ax.xaxis.set_ticklabels(labels)
      ax.yaxis.set_ticklabels(labels)

    fg = FrameGenerator(subset_paths['train'], n_frames, training=True)
    labels = list(fg.class_ids_for_name.keys())

    actual, predicted = get_actual_predicted_labels(train_ds)
    plot_confusion_matrix(actual, predicted, labels, 'training')

```
38/38 ━━━━━━━━━━━━━━━━━━━━ 36s 893ms/step
```

![png](https://www.tensorflow.org/static/tutorials/video/video_classification_files/output_1ucGpbiA-5qi_1.png)

    actual, predicted = get_actual_predicted_labels(test_ds)
    plot_confusion_matrix(actual, predicted, labels, 'test')

```
13/13 ━━━━━━━━━━━━━━━━━━━━ 11s 827ms/step
/tmpfs/src/tf_docs_env/lib/python3.9/site-packages/keras/src/trainers/epoch_iterator.py:160: UserWarning: Your input ran out of data; interrupting training. Make sure that your dataset or generator can generate at least `steps_per_epoch * epochs` batches. You may need to use the `.repeat()` function when building your dataset.
  self._interrupted_warning()
```

![png](https://www.tensorflow.org/static/tutorials/video/video_classification_files/output_Mfr7AT5T-7ZD_2.png)

The precision and recall values for each class can also be calculated using a confusion matrix.

    def calculate_classification_metrics(y_actual, y_pred, labels):
      """
        Calculate the precision and recall of a classification model using the ground truth and
        predicted values. 

        Args:
          y_actual: Ground truth labels.
          y_pred: Predicted labels.
          labels: List of classification labels.

        Return:
          Precision and recall measures.
      """
      cm = tf.math.confusion_matrix(y_actual, y_pred)
      tp = np.diag(cm) # Diagonal represents true positives
      precision = dict()
      recall = dict()
      for i in range(len(labels)):
        col = cm[:, i]
        fp = np.sum(col) - tp[i] # Sum of column minus true positive is false negative

        row = cm[i, :]
        fn = np.sum(row) - tp[i] # Sum of row minus true positive, is false negative

        precision[labels[i]] = tp[i] / (tp[i] + fp) # Precision 

        recall[labels[i]] = tp[i] / (tp[i] + fn) # Recall

      return precision, recall

    precision, recall = calculate_classification_metrics(actual, predicted, labels) # Test dataset

    precision

```
{'ApplyEyeMakeup': np.float64(0.42857142857142855),
 'ApplyLipstick': np.float64(0.6),
 'Archery': np.float64(0.6),
 'BabyCrawling': np.float64(0.5714285714285714),
 'BalanceBeam': np.float64(0.7777777777777778),
 'BandMarching': np.float64(0.9),
 'BaseballPitch': np.float64(0.625),
 'Basketball': np.float64(0.5833333333333334),
 'BasketballDunk': np.float64(1.0),
 'BenchPress': np.float64(1.0)}
```

    recall

```
{'ApplyEyeMakeup': np.float64(0.6),
 'ApplyLipstick': np.float64(0.3),
 'Archery': np.float64(0.3),
 'BabyCrawling': np.float64(0.8),
 'BalanceBeam': np.float64(0.7),
 'BandMarching': np.float64(0.9),
 'BaseballPitch': np.float64(1.0),
 'Basketball': np.float64(0.7),
 'BasketballDunk': np.float64(0.7),
 'BenchPress': np.float64(0.8)}
```

## Next steps

To learn more about working with video data in TensorFlow, check out the following tutorials:

- [Load video data](https://www.tensorflow.org/tutorials/load_data/video)
- [MoViNet for streaming action recognition](https://www.tensorflow.org/hub/tutorials/movinet)
- [Transfer learning for video classification with MoViNet](https://www.tensorflow.org/tutorials/video/transfer_learning_with_movinet)