エクサウィザーズ Engineer Blog

株式会社エクサウィザーズのエンジニアチームブログ

Creating CI / CD pipeline using GitHub Actions self-hosted runners on AWS ECS

f:id:tadashi-nemoto0713:20201019135904p:plain

This is English version of this article.

techblog.exawizards.com


Hello, I'm Tadashi Nemoto from the DevOps team.

I joined ExaWizards this year in July in order to improve CI / CD promote the usage of automated testing in product development.

In this article, I will demonstrate how to create GitHub Actions with self-hosted runners on AWS ECS.

GitHub Actions and self-hosted runners

You may already know of or use GitHub Actions if you are an active GitHub user.

Combined with the Actions available on GitHub Marketplace, you can easily build a variety of powerful CI/CD pipelines.

In addition, instead of using runners provided by GitHub, you can also prepare your own runner (self-hosted runners).

About self-hosted runners - GitHub Docs

If you use GitHub Actions on a GitHub provided runner you will be charged on a free-to-use plus pay-as-you-go basis.

However, there is no additional charge for using GitHub Actions with a self-hosted runner on your own infrastructure.

You can set up new self-hosted runners from the Repository or Organization settings.

f:id:tadashi-nemoto0713:20201016201036p:plain

Once the setup is complete, you will see that it has been added as a runner.

Runners configured in a repository can be executed in that repository, and runners configured in an organization can be executed in all the repositories of that organization.

f:id:tadashi-nemoto0713:20201016201348p:plain

Finally, in your GitHub Actions configuration file, you should indicate that you want it to run using your self-hosted runners.

f:id:tadashi-nemoto0713:20210325114741p:plain

It is true that you will need to set up and maintain self-hosted runners by yourself.

However, I believe it's attractive that we don't have to prepare and maintain the management part of a CI / CD workflow (like a Jenkins master instance). GitHub offers this functionality for free.

Running self-hosted runners on Docker

The source code of the self-hosted runner agent is available as open source, but GitHub currently doesn't provide a Docker image for it.

There are open source projects to get self-hosted runners running on Docker and Kubernetes.


In this article, we will work together to create a CI / CD pipeline using GitHub Actions with self-hosted runners on AWS ECS.

  1. Running self-hosted runners on Docker

  2. Running self-hosted runners on AWS ECS

  3. Creating a CI / CD pipeline to deploy an application to AWS ECS using those self-hosted runners


First, we'll try to run self-hosted runners on our local machine using the below Docker image.

github.com

In order to launch a runner for an organization, run the following docker command:

f:id:tadashi-nemoto0713:20210325114821p:plain

Because we want to run workflows such as docker build, we need to be able to use Docker commands inside this container.

You can solve this by sharing the Docker daemon on the host machine (-v /var/run/docker.sock:/var/run/docker.sock part, Docker outside of Docker)

Using Docker-in-Docker for your CI or testing environment? Think twice.

Running self-hosted runners on AWS ECS

Next, we'll use the Docker image from earlier and run it in AWS ECS.

In this article, I will focus on three points using AWS CDK (Typescript).


The first is how to achieve Docker outside of Docker (DooD) in AWS ECS (the -v /var/run/docker.sock:/var/run/docker.sock part).

In AWS ECS, you can solve this problem by adding a Volume to the Task side and mounting that Volume on the Container side.

f:id:tadashi-nemoto0713:20210325114902p:plain

f:id:tadashi-nemoto0713:20210325114933p:plain

AWS ECS offers two startup types, Fargate and EC2, but as Fargate is not currently supported to do the above, I chose the EC2 startup type this time.


The second is about the role you give to the ECS Task.

Of course, you can do this by storing AWS access keys in GitHub and passing them to GitHub Actions.

However, you can eliminate the need to store AWS access keys on the GitHub side by giving the self-hosted runner’s container itself the role it needs for the above.

f:id:tadashi-nemoto0713:20210325115036p:plain


The third is about spot instances.

Self-hosted runners are used for CI/CD, so you can leverage spot instances to keep costs low.

With AWS CDK, you can use a spot instance by setting the spotInstanceDraining property to true.

f:id:tadashi-nemoto0713:20210325115131p:plain

Create CI / CD pipeline to deploy an application to AWS ECS

This time, we will create CI / CD pipeline to deploy an application to AWS ECS using that GitHub Actions self-hosted runners.

It's complicated because it's the same AWS ECS, but it's assumed that the cluster running self-hosted runners and the cluster running the application are separate.

f:id:tadashi-nemoto0713:20201020153820p:plain

Docker build and push to ECR
↓
Edit Task Definition file to newer docker image
↓
Register Task Definition and wait Service to update

When combined with the steps of GitHub Actions provided by AWS, it looks like this.

f:id:tadashi-nemoto0713:20210325115227p:plain

As mentioned earlier, you'll need to store and pass your AWS access key to GitHub in aws-actions/configure-aws-credentials step.

f:id:tadashi-nemoto0713:20210325115303p:plain

However, this is not necessary this time because we have given self-hosted runners the privileges they need to do so.

Summary

In this article, I have demonstrated how to use GitHub Actions self-hosted runners on AWS ECS. I believe it would be beneficial for the following use cases:

  • You're using GitHub Actions to deploy an application to AWS, but you don't want to pass AWS access keys to GitHub.

  • You want to create a cost-efficient CI/CD environment by utilizing spot instances (especially if you expect to significantly exceed your free quota).

  • You want to run your CI/CD environment on a machine with higher specs than GitHub's runner.


Our team plans to create better CI/CD pipelines based on GitHub Actions in the future.


hrmos.co

Real-time pose estimation in Android

This article is focused on Pose Estimation using TensorFlow Lite. I will guide you through every step from picking an ML model to displaying an output on the screen, with detailed explanations and materials for further reading. We will not dive deep into Machine Learning, however, as our primary goal is to learn how to use the tools provided by TensorFlow to accomplish the task of pose estimation. No prior Machine Learning experience is required, but it is assumed that you have some Java/Kotlin and Android proficiency. Without further ado, let’s get started!

Part 1: TensorFlow Lite

TensorFlow is an open source library for numerical computation and machine learning. It uses Python to provide an API for training and running ML models and deep neural networks, while executing operations in C++.

Data flow graphs are structures that describe how data moves through a series of processing nodes. Each node is a mathematical operation, and each node's input/output is a multidimensional data array, or a tensor.

Simply put, to receive an array of key points representing a human pose we need to format the initial image to match processing node's expected input and run it through a series of transformations described in a model - a process called inference.

TensorFlow Lite is a lightweight version of TensorFlow built specifically for mobile and embedded devices. It supports a set of core operations which have been tuned for performance while staying relatively lean in size. TFLite also provides an interpreter with hardware acceleration in Android (NNAPI). To learn more about TFLite and its constraints, please refer to this guide.

Quick start

To kick start your Android project, please check out the official documentation and this demo app:

Android guide

Pose Estimation demo

In short, to add tflite module to your project, modify your app's build.gradle as follows:

// Check the latest tensorflow-lite version at JCenter: 
// [https://bintray.com/google/tensorflow/tensorflow-lite](https://bintray.com/google/tensorflow/tensorflow-lite)
ext.tfliteVersion = '0.0.0-nightly'

android {
    defaultConfig {
        ndk {
            // include only relevant architectures to reduce apk size
            abiFilters 'armeabi-v7a', 'arm64-v8a'
        }
    }
}

dependencies {
    implementation 'org.tensorflow:tensorflow-lite:$tfLiteVersion'
}

The dependency contains core TFLite classes. Let's go over some of them one by one:

Interpreter - A class that helps with building and accessing a native interpreter, which is an interface between Java code and the core C++ tensor flow logic. In its constructor you can provide a file path to your pre-trained model and Interpreter.Options (more on that later).

Delegate - An interface for providing a native handle to a delegate - an executor that handles partial (or full) computation of a data flow graph.

Tensor - A representation of a multidimensional byte array containing input or output data.

Delegates

By default, all computation will be handled by the CPU. You can parallelize inference on CPU by setting the number of threads the task will run on:

val numThreads = 4 // depends on the number of cores the CPU has
val options = Interpreter.Options().apply { setNumThreads(numThreads) }

TensorFlow Lite provides 3 built-in delegates to run inference on:

GPU - provides a great increase in performance and power efficiency. I would recommend picking the GPU delegate as the default option, with the caveat that your device has to support OpenCL or OpenGL ES 3.1 and that not all operations are supported. You can read more about it in the official docs.

To add the GPU delegate to your project, add the following dependency:

// Check the latest gpu delegate version at JCenter
// https://bintray.com/google/tensorflow/tensorflow-lite-gpu
dependencies {
    implementation 'org.tensorflow:tensorflow-lite-gpu:$tfLiteVersion'
}

NNAPI - a delegate that utilizes Neural Networks API providing hardware acceleration on newer Android devices (API 27+). It is included in the tensorflow-lite package, so you don't need to add an extra dependency.

Hexagon - a substitution for the NNAPI delegate on older Android devices that do not fully support Neural Networks API.

Add the following dependency if you want to support older devices:

// Check the latest Hexagon version at JCenter
// https://bintray.com/google/tensorflow/tensorflow-lite-hexagon
ext.tfLiteHexagon = '0.0.0-nightly'
dependencies {
    implementation 'org.tensorflow:tensorflow-lite-hexagon:$tfLiteHexagon'
}

Below is the complete interpreter setup snippet:

/* (c) ExaWizards */

sealed class DelegateOptions {
    data class CPU(val numThreads: Int): DelegateOptions()
    object GPU: DelegateOptions()
    object NNAPI: DelegateOptions()
    object Hexagon: DelegateOptions()
}

fun createInterpreter(
        model: MappedByteBuffer,
        delegateOptions: DelegateOptions
): Interpreter {
    val options = Interpreter.Options().apply {
        when (delegateOptions) {
          DelegateOptions.CPU -> setNumThreads(numThreads)
        DelegateOptions.NNAPI -> setUseNNAPI(true)
        DelegateOptions.GPU -> addDelegate(GpuDelegate())
        DelegateOptions.Hexagon -> addDelegate(HexagonDelegate())
      }    
    }
    return Interpreter(model, options)
}

Support library (experimental)

The TensorFlow team provides an optional package with various utility classes to simplify image operations and tensor buffer processing. If you don't want to deal with bitmap manipulations and bit shifting, then give this library a shot!

Currently it's in beta, so please be careful when adding it to your main application. I'd recommend playing around with it in a side project to catch any potential shortcomings for your use case.

To add the dependency, modify your build.gradle

// Check the latest support library version at JCenter
// https://bintray.com/google/tensorflow/tensorflow-lite-support
ext.tfLiteSupportVersion = '0.1.0-rc1'
dependencies {
    implementation 'org.tensorflow:tensorflow-lite-support:$tfLiteSupportVersion'
}

Let's take a look at some of the classes and interfaces available:

ImageProcessor - a class that accumulates various transformations and applies them to a target TensorImage

ImageOperator - a base interface for TensorImage transformations, including:

  • Rot90Op - rotate an image by 90 degrees counter-clockwise N times.
  • ResizeOp - resize an image to match the target size. It performs scaling, so be careful to preserve your original aspect ratio.
  • ResizeWithCropOrPadOp - crop or pad an image to match your model's expected input size. It does not scale the original image, make sure to scale it down before applying this operator.

TensorOperator - a base interface for TensorBuffer transformations:

  • NormalizeOp - perform normalization - adjust buffer values to a common scale, usually in a range of [-1; 1].
  • QuantizeOp - perform quantization - map float values to a smaller set of integer numbers. It is used in quantized models to increase performance at the cost of precision.
  • DequantizeOp - reverse quantization.

Below is an example of building an ImageProcessor and transforming an image bitmap:

/* (c) ExaWizards */

val imageProcessor = ImageProcessor.Builder()
            .add(ResizeOp(scaledHeight, scaledWidth, ResizeOp.ResizeMethod.BILINEAR))
            .add(Rot90Op(numRotations))
            .add(ResizeWithCropOrPadOp(modelHeight, modelWidth))
        // f(x) = (x - 127.5) / 127.5; f(x) ∈ [-1; 1]; x ∈ [0; 255]
            .add(NormalizeOp(127.5f, 127.5f)) 
            .build()
val tensorImage = TensorImage.fromBitmap(bitmap)
val processedImage = imageProcessor.process(tensorImage)

TFLite wrapper (experimental)

If your model contains metadata, it enables you to use the TensorFlow Lite wrapper code generator. The Model wrapper eliminates the need to set up your delegates, manually performing image transformations and dealing with raw TensorBuffer output. The extent to which generated code will be helpful to you entirely depends on the completeness of the metadata. Also, keep in mind that this feature is in an experimental phase, so you'll probably have to wait until it becomes stable before replacing all your ML-related logic with generated code.

To learn more about the wrapper code generator, please refer to the official docs.

Part 2: Model

To accomplish our task - human pose estimation - it is crucial that we have a basic understanding of our ML model and learn about our expected inputs/outputs. The TFLite "Getting started" page and linked source code provide enough information to kick start a new Proof of Concept project, but if we are going to make any changes to the core logic or simply want to compare existing options - it's better to know what we're dealing with.

Picking the right model

Let's start by examining a repository of open-sourced ML models - TensorFlow hub. This is a great place to search for domain-specific, format-specific solutions.

Our search query would be an "image pose detection" domain with "model format" filter set to TFLite (as of June 2020, there's only one model satisfying this criteria - MobileNet_075). Now, we have two options: pure model or model + metadata. From tensorflow.org:

TensorFlow Lite metadata provides a standard for model descriptions. The metadata is an important source of knowledge about what the model does and its input / output information. The metadata consists of both - human readable parts which convey the best practice when using the model, and - machine readable parts that can be leveraged by code generators, such as the TensorFlow Lite Android code generator.

Let's further examine model with metadata. I found this useful tool to visualize the model's structure: Netron. After uploading the .tflite file we can see convolutional layers the model has and check what the expected inputs and outputs are:

f:id:ivanpo:20201006145032p:plain
Graph

f:id:ivanpo:20201006145103p:plain
Metadata

A closer look

First, let's understand the input requirements:

  • Image: FloatArray [1][353][257][3]

To prepare the image for classification, we'll need to scale it down to 353x257 pixels, extract each pixel's RGB value and normalize it, meaning the values should be within [-1;1].

Second, let's pay attention to the outputs:

  • Image (Grayscale): [1][23][17][17]

    An input image that has been reduced to 23x17 points, and each keypoint (out of 17 in total) has received a "confidence score"

  • Offsets: [1][23][17][34]

    Since the output matrix has a much smaller size, we want to get a better idea of where the original keypoint might have been. Offset vectors are here to help — once we pick the right (x, y) for keypoints, apply the following formula to get the final coordinates:

    y = keyPoint.y * originalHeight + offsets[0][keyPoint.y][keyPoint.x][keyPoint.index]

    x = keyPoint.x * originalWidth + offsets[0][keyPoint.y][keyPoint.x][keyPoint.index + 17]

  • Forward displacement: [1][23][17][64]

    Backward displacement: [1][23][17][1]

    In multi-pose estimation, when there are multiple poses to detect, it is not enough to pick a keypoint with the highest score — we need to pick multiple keypoints and group them into a graph representing a distinct human pose. Displacement arrays are used in a fast greedy decoding algorithm explained in this paper: PersonLab: Person Pose Estimation and Instance Segmentation with a Bottom-Up, Part-Based, Geometric Embedding Model. I will discuss the implementation later in the series.

The output structure seems to correspond with TensorFlow Pose Estimation starter guide:

  • Heatmaps: [1][height][width][N]
  • Offsets: [1][height][width][N * 2]
  • Forward displacements: [1][height][width][E * 2]
  • Backward displacements: [1][height][width][E * 2]

You might have noticed that something doesn't add up. The backward displacements matrix should be the same shape as the forward displacements: [1][23][17][64], but instead we are getting [1][23][17][1]. I believe it's a known problem (it is mentioned on StackOverflow), however it only affects multi-pose estimation. For single-pose estimation we will be using a much simpler "brute-force" solution that doesn't involve part-based graph traversal.

Part 3: Inference

Now that I‘ve given an overview of TFLite, models and available support tools, it's time to dive into the process of inference. The goal is to feed a prepared TensorImage to an interpreter and extract 17 key points with their (x, y) location and probability (confidence).

Preparation

If you manually downloaded the right model for your task, I recommend placing it in the /assets folder. If you don't want to check the file into VCS, simply add it to .gitignore and use this handy Gradle script, which will download the file automatically at build time:

/* (c) ExaWizards */

// download.gradle
def targetFile = "src/main/assets/posenet_model_meta.tflite"
def modelFloatDownloadUrl = "https://tfhub.dev/tensorflow/lite-model/posenet/mobilenet/float/075/1/metadata/1?lite-format=tflite"

task downloadModelFloat(type: DownloadUrlTask) {
    doFirst {
        println "Downloading ${modelFloatDownloadUrl}"
    }
    sourceUrl = "${modelFloatDownloadUrl}"
    target = file("${targetFile}")
}

class DownloadUrlTask extends DefaultTask {
    @Input
    String sourceUrl

    @OutputFile
    File target

    @TaskAction
    void download() {
        ant.get(src: sourceUrl, dest: target)
    }
}

preBuild.dependsOn downloadLibs

// Add this line to your build.gradle
// apply from:'download.gradle'

Now, we need to set up an interpreter. Depending on your target devices and benchmarks, you may choose one of the few available delegates, and load the model from the assets folder:

/* (c) ExaWizards */

private fun createInterpreter(device: Model.Device): Interpreter {
        val options = Interpreter.Options().apply {
            when (device) {
                Model.Device.CPU -> setNumThreads(numThreads)
                Model.Device.GPU -> addDelegate(GpuDelegate())
                Model.Device.NNAPI -> setUseNNAPI(true)
            }
        }
        return Interpreter(loadModelFile("posenet_model_meta.tflite", context), options)
}

private fun loadModelFile(path: String, context: Context): MappedByteBuffer {
        val fileDescriptor = context.assets.openFd(path)
        val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
        return inputStream.channel.map(
            FileChannel.MapMode.READ_ONLY, fileDescriptor.startOffset, fileDescriptor.declaredLength
        )
}

To avoid getting errors during model loading, add this to your app's build.gradle to disable .tflite file compression:

android {
    ...
    aaptOptions {
        noCompress "tflite"
  }
}

Assuming you've already prepared TensorImage (check Part 4 for more info), let's proceed with inference.

Single pose estimation

The interpreter takes an input array of ByteBuffer with a Tensor shape defined by the model; in our case it's [1, 353, 257, 3]. The output array will contain four 4-dimensional float arrays: Heatmaps, Offsets, Forward displacements, Backward displacements. You can get their default shapes by calling

getInterpreter().getOutputTensor(i).shape(),

where i ∈ [0, 3], as we have 4 output tensors.

/* (c) ExaWizards */

val outputMap = mutableMapOf<Int, Any>()

fun estimatePose(tensorImage: TensorImage): Person {
        val inputArray = arrayOf(tensorImage.buffer)
        (0 until interpreter.outputTensorCount).forEach {
            outputMap[it] = reshapeTo4dArray(interpreter.getOutputTensor(it).shape())
        }
        interpreter.runForMultipleInputsOutputs(inputArray, outputMap)
                // parse outputMap
                return extractKeyPoints(outputMap, tensorImage.width, tensorImage.height)
}

private fun reshapeTo4dArray(shape: IntArray): Array<Array<Array<FloatArray>>> =
        Array(shape[0]) { Array(shape[1]) { Array(shape[2]) { FloatArray(shape[3]) } } }

Next step is to extract key points and create a Person object that contains all of the information we need to draw a person's shape on-screen. Since we are focusing on single pose estimation for now, we will only need two arrays: Heatmaps and Offsets. The idea is to find the locations of the key points with the highest confidence scores, calculate their (x, y) coordinates using offset adjustment and normalize the confidence score to the range [0;1].

/* (c) ExaWizards */

// order is important!
enum class BodyPart {
    NOSE, LEFT_EYE, RIGHT_EYE, LEFT_EAR, RIGHT_EAR, LEFT_SHOULDER, RIGHT_SHOULDER,
    LEFT_ELBOW, RIGHT_ELBOW, LEFT_WRIST, RIGHT_WRIST, LEFT_HIP, RIGHT_HIP,
    LEFT_KNEE, RIGHT_KNEE, LEFT_ANKLE, RIGHT_ANKLE
}

data class Position(val x: Int, val y: Int)
data class KeyPoint(val bodyPart: BodyPart, val position: Position, val score: Float)
data class Person(val keyPoints: List<KeyPoint>, val score: Float)

@Suppress("UNCHECKED_CAST")
private fun extractKeyPoints(
    outputMap: Map<Int, Any>,
    imageWidth: Int,
    imageHeight: Int
): Person {
    val heatMaps = outputMap[0] as Array<Array<Array<FloatArray>>>
    val offsets = outputMap[1] as Array<Array<Array<FloatArray>>>

    val height = heatMaps[0].size
    val width = heatMaps[0][0].size
    val numKeyPoints = heatMaps[0][0][0].size

    val keyPoints = mutableListOf<KeyPoint>()
    val bodyParts = enumValues<BodyPart>()
    var totalConfidence = 0f
    for (keyPoint in 0 until numKeyPoints) {
        var maxVal = heatMaps[0][0][0][keyPoint]
        var maxRow = 0
        var maxCol = 0
        // Find the (row, col) locations of where the keyPoints are most likely to be.
        for (row in 0 until height) {
            for (col in 0 until width) {
                if (heatMaps[0][row][col][keyPoint] > maxVal) {
                    maxVal = heatMaps[0][row][col][keyPoint]
                    maxRow = row
                    maxCol = col
                }
            }
        }
        val yDisplacement = offsets[0][maxRow][maxCol][keyPoint]
        val xDisplacement = offsets[0][maxRow][maxCol][keyPoint + numKeyPoints]
        val yCoord = maxRow / (height - 1).toFloat() * imageHeight + yDisplacement
        val xCoord = maxCol / (width - 1).toFloat() * imageWidth + xDisplacement
        val confidence = sigmoid(maxVal)
        val bodyPart = bodyParts[keyPoint]
        totalConfidence += confidence
        keyPoints.add(KeyPoint(bodyPart, Position(xCoord.toInt(), yCoord.toInt()), confidence))
    }

    return Person(keyPoints, totalConfidence / numKeyPoints)
}

/** Returns a value within [0,1].   */
private fun sigmoid(x: Float): Float {
    return (1.0f / (1.0f + exp(-x)))
}

And there we have it - a Person object containing key point locations and their confidence scores! The next step would be to filter key points by a confidence threshold and translate the coordinates back to the starting image dimensions - remember, we applied a number of transformations (rotation, scale, crop) to the original input. I will discuss this logic later in the series, using a CameraX feed as an example.

Multi-pose estimation

If we want to get more than one person's key points, the brute-force key point search solution above will not work. As I mentioned before, we have to use forward and backward displacement arrays to handle this task.

The idea of a modified algorithm is described in this PersonLab paper:

f:id:ivanpo:20201006145205p:plain
Multipose algorithm

As you can see, the algorithm is non-trivial and requires a bit of time to get right. You can try implementing it yourself, or use one of these open source projects as an example:

PoseNet Typescript by TensorFlow

PoseNet Java by shaqian

Important note: Before you decide to enable multi-pose estimation, make sure your model supports it! The current model listed on TensorFow Hub returns incorrect displacement arrays, so try using a modified version from this StackOverflow answer instead.

f:id:ivanpo:20201006145314p:plain
Multipose output

Part 4: Camera 1̶ 2̶ X

Android CameraX is a great library used to seamlessly integrate camera logic into the project's codebase by combining existing use cases that interface with the device's camera API: Preview, Image Analysis, Image Capture. If you're not familiar with the CameraX architecture, please refer to the official documentation page.

f:id:ivanpo:20201006145348p:plain
from CameraX documentation

In this part we will focus on combining Preview with Image Analysis to display an inferred human pose on screen in real time.

Preparation

To get started with CameraX and get a better idea of its architecture and capabilities, I recommend following Google’s codelab page. I you want a quick start by looking at a complete implementation, you can refer to my PoseNet sample (coming soon).

Image Analysis

Once you're familiar with the CameraX API, let's start by setting up an ImageAnalysis use case. First, we might want to request a specific resolution by calling

val builder = ImageAnalysis.Builder().setTargetResolution(Size(width, height))

Keep in mind that in order to infer a human pose in real time we will need to heavily downscale our original image to match the Model's input size. However, we can't just request any arbitrary resolution; instead, it will depend on the Camera implementation and will fall back to the nearest available resolution in case the requested size doesn't exist.

Next, let's set an appropriate backpressure strategy. Inferrence takes time, so we won't be able to process every frame from the camera feed before the next one comes in. To avoid buffer overflow, we will skip subsequent frames until we're done processing the current one:

builder.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)

Finally, let's create an Analyzer. Image.Analyzer will receive an ImageProxy object which we will use to get an Image and transform it using the ImageProcessor class provided by the TensorFlow Lite support library.

Here's sample code for ImageAnalysis setup

/* (c) ExaWizards */

val useCase: ImageAnalysis = ImageAnalysis.Builder()
    .setTargetResolution(Size(targetWidth, targetHeight))
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
    .build()
    .apply(::setAnalyzer)

private fun setAnalyzer(imageAnalysis: ImageAnalysis) {
    imageAnalysis.setAnalyzer(
        Executors.newSingleThreadExecutor(),
        ImageAnalysis.Analyzer { image ->
            val transformedImage = image.use {
                processImage(
                    image.image ?: throw Exception("Unexpected ImageProxy"),
                    image.imageInfo.rotationDegrees,
                    modelConfig.modelWidth,
                    modelConfig.modelHeight
                )
            }
            val person = estimatePose(transformedImage.tensorImage)
            onPoseData(PoseData(
                person,
                transformedImage.originalSize,
                transformedImage.scaledSize,
                transformedImage.paddedSize,
                transformedImage.orientation)
            )
        }
    )
}

data class PoseData(
    val person: Person,
    val originalSize: Size,
    val scaledSize: Size,
    val paddedSize: Size,
    val orientation: Orientation,
    val transformedBitmap: Bitmap?
)

Important note: if you're using the GPU delegate for inference, remember that only the original thread that instantiated a GPU delegate can call it. Here, I'm using Executors.newSingleThreadExecutor() as an image processing executor and lazily creating a GPU instance. That means I cannot reuse the same delegate once I discard the ImageAnalysis object and have to instantiate a new delegate again.

Image Transformation

To prepare an image for inference we need to perform the following series of transformations:

Downscaling → Rotation → Cropping → Normalization

In order to translate the resulting pose coordinates back to the original dimensions, I recommend keeping each step's variables in a data class — that way it will be easier to apply each transformation in reverse order.

Important note: CameraX provides an Image in YUV_420_888 format, which we will convert to RGB values in order to extract a byte buffer for further image processing with PoseNet. I am using RenderScript for YUV → RGB conversion; you can take a look at the "sample approach" here.

The TensorFlow Lite support library provides helper operations discussed earlier, each resulting in creating a new TensorImage that holds a modified Bitmap. A complete image processing function looks something like this:

/* (c) ExaWizards */

private val yuvToRgbConverter = YuvToRgbConverter(context.applicationContext)

data class TransformedImage(
    val tensorImage: TensorImage,
    val originalSize: Size,
    val scaledSize: Size,
    val paddedSize: Size,
    val orientation: Orientation
)

fun processImage(
    image: Image,
    rotationDegrees: Int,
    targetWidth: Int, // input tensor size
    targetHeight: Int // input tensor size
): TransformedImage {
    val imageBitmap = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888)
    yuvToRgbConverter.yuvToRgb(image, bitmap)
    val numRotations = rotationDegrees / 90
    val scale = min(image.height.toDouble() / targetWidth, image.width.toDouble() / targetHeight)
    val scaledSize = Size((image.width / scale).toInt(), (image.height / scale).toInt())
    val orientation = if (numRotations % 2 == 0) {
        Orientation.HORIZONTAL
    } else {
        Orientation.VERTICAL
    }
    val imageProcessor = ImageProcessor.Builder()
        .add(ResizeOp(scaledSize.height, scaledSize.width, ResizeOp.ResizeMethod.BILINEAR))
        .add(Rot90Op(-numRotations))
        .add(ResizeWithCropOrPadOp(targetHeight, targetWidth))
        .add(NormalizeOp(127.5f, 127.5f))
        .build()
    val tensorImage = TensorImage.fromBitmap(imageBitmap)
    return TransformedImage(
        imageProcessor.process(tensorImage),
        Size(image.width, image.height),
        scaledSize,
        Size(targetWidth, targetHeight),
        orientation
    )
}

Coordinate translation

The final step is to extract the inferred pose‘s key points and apply the coordinate translation algorithm to match the camera's preview layout. The tricky part is to add (x, y) padding in case your pose overlay view aspect ratio doesn't match the original image. The CameraX preview window will do the same, and the effect is similar to ImageView's centerCrop scale type. Let's add this extension function:

/* (c) ExaWizards */

private val minConfidence = 0.7f

fun PoseData.extractKeyPoints(val width: Int, val height: Int): Map<BodyPart, PointF> {
    val scaledWidth: Int
    val scaledHeight: Int
    val originalWidth: Int
    val originalHeight: Int
    when (orientation) {
        Orientation.HORIZONTAL -> {
            scaledWidth = scaledSize.width
            scaledHeight = scaledSize.height
            originalWidth = originalSize.width
            originalHeight = originalSize.height
        }
        Orientation.VERTICAL -> {
            scaledWidth = scaledSize.height
            scaledHeight = scaledSize.width
            originalWidth = originalSize.height
            originalHeight = originalSize.width
        }
    }
    val xOffset = (scaledWidth - paddedSize.width) / 2.0
    val yOffset = (scaledHeight - paddedSize.height) / 2.0

    // crop or pad to fit current view
    val originalRatio = originalHeight / originalWidth.toDouble()
    val widthFactor: Double
    val heightFactor: Double
    val xPad: Double
    val yPad: Double
    if (width * originalRatio >= height) {
        // width is the basis
        xPad = .0
        yPad = (height - width * originalRatio) / 2
        widthFactor =
            (width / originalWidth.toDouble()) * originalWidth / scaledWidth.toDouble()
        heightFactor =
            (width * originalRatio / originalHeight.toDouble()) * originalHeight / scaledHeight.toDouble()
    } else {
        xPad = (width - height / originalRatio) / 2
        yPad = .0
        widthFactor =
            ((height / originalRatio) / originalWidth.toDouble()) * originalWidth / scaledWidth.toDouble()
        heightFactor =
            (height / originalHeight.toDouble()) * originalHeight / scaledHeight.toDouble()
    }

    return person.keyPoints
            .asSequence()
            .filter { it.score > minConfidence }
            .map {
                it.bodyPart to it.position.toAdjustedPoints(
                    widthFactor,
                    heightFactor,
                    xOffset,
                    yOffset,
                    xPad,
                    yPad
                )
            }
            .toMap()
}

private fun Position.toAdjustedPoints(
    widthFactor: Double,
    heightFactor: Double,
    xOffset: Double,
    yOffset: Double,
    xPad: Double,
    yPad: Double
) = PointF(
    ((x + xOffset) * widthFactor + xPad).toFloat(),
    ((y + yOffset) * heightFactor + yPad).toFloat()
)

That's it! Now all you need to do is to invalidate() the view on every update from ImageAnalyzer and draw a circle where each of the extracted key points are:

/* (c) ExaWizards */

// inside PoseOverlayView.kt

private var pointMap: Map<BodyPart, PointF> = emptyMap()
    set(value) {
        field = value
        invalidate()
    }

private val circleRadius = 8.0f
private val circlePaint: Paint = Paint().apply {
    color = Color.WHITE
    strokeWidth = 8.0f
}

fun updatePoseData(poseData: PoseData) {
    pointMap = poseData.extractKeyPoints()
}

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas ?: return
    canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
    pointMap.forEach { entry ->
        entry.values.forEach { canvas.drawCircle(it.x, it.y, circleRadius, circlePaint) }
    }
}

Part 5: Definition of Done

Previously we learned how to set up an interpreter, pick the right model, how to attach the CameraX analyzer and draw the output on a canvas. There’s one more thing left to cover: how to improve user experience depending on your use case. You may need more precise key point estimation, or, maybe, fast inference time is critical for a smooth UX. In this part we will discuss some tips and tricks that may be worth considering.

Optimizing for accuracy

Posenet is a fully convolutional model, meaning it was trained with a specific image size but can process larger images, sacrificing performance in favor of accuracy. The only rule is that the size should be a multiple of 16, plus 1 (see this answer). Previously, we talked about the expected input/output tensor’s shape: [1, 353, 257, 3] for the input and [1, 23, 17, X] for the various output tensors. As you may remember, input shape represents the amount of input image pixels times 3 (one Float per each RGB-channel). the output shape scales linearly with an outputStride: outWidth = ((inputWidth - 1) / outputStride) + 1, where the outputStride can be 8, 16 or 32. The lower the outputStride, the higher the accuracy, but the slower the speed.

A pre-trained .tflite model does not support a variable output stride, but we can change the input tensor shape and adjust our expectations for the output tensor. Here’s how to do it:

/* (c) ExaWizards */

//create an interpreter first
val interpreter: Interpreter = Interpreter(model, options)

// let's double the size of the default tensor
fun resizeInput() {
    interpreter.resizeInput(0, intArrayOf(1, 705, 513, 3))
}

// remember to scale a processed image size to 705x513 instead of 353x257
fun <T> estimatePose(byteBuffer: ByteBuffer, decoder: Decoder<T>): T {
    val inputArray = arrayOf(byteBuffer)
        // output shapes will become [1, 45, 33, X]
    model.run(inputArray, outputs.buffer)
    return decoder.decode(create4DArray(outputs))
}

Important note: remember that inference time does not scale linearly. On my Pixel 1 test device, using the GPU delegate, I was able to get ~70ms average inference, while doubling the input size brought the time up to ~270ms!

This method is useful if you don’t care about real-time performance and instead are analyzing a static image while running some scene transition animation or showing a brief loading screen after taking a picture.

Optimizing for performance

If we can afford to sacrifice accuracy to gain true real-time pose estimation even on lower-end devices, it might be a good idea to scale the image down to even smaller size. Remember to adjust your input/output tensor shape accordingly.

One other bit of advice I can give you is to optimize the image processing part. During my tests on Pixel 1 I was using the TFLite support library, and the image processing took up to ~60ms on average, almost the same time as inference itself! Here's what it looked like:

/* (c) ExaWizards */

val imageProcessor = ImageProcessor.Builder()
    .add(ResizeOp(scaledSize.height, scaledSize.width, ResizeOp.ResizeMethod.BILINEAR))
    .add(Rot90Op(-numRotations))
    .add(ResizeWithCropOrPadOp(targetHeight, targetWidth))
    .add(NormalizeOp(127.5f, 127.5f))
    .build()
val tensorImage = TensorImage.fromBitmap(imageBitmap)
val tensorBuffer = imageProcessor.process(tensorImage).tensorBuffer

Under the hood each ImageOperator produces a new Bitmap by applying a transformation to the original image, and the last operation in the chain transforms a Bitmap into a ByteBuffer and performs normalization on it. Let's take a look at how we can optimize this:

  • Combine ResizeOp with Rot90Op
  • Leave ResizeWithCropOrPadOp as is
  • Combine Bitmap → ByteBuffer with NormalizeOp

You can create your own operators by implementing the ImageOperator and TensorOperator interfaces, which are a part of the TFLite support library, but I will show you a sample image transformation without ImageProcessor to better understand how it works:

/* (c) ExaWizards */

val rotateMatrix = Matrix()
val scale = min(
    image.height.toDouble() / targetWidth,
    image.width.toDouble() / targetHeight
)
val scaledSize = Size((image.width / scale).toInt(), (image.height / scale).toInt())
val sx: Float = scaledSize.width / image.width.toFloat()
val sy: Float = scaledSize.height / image.height.toFloat()
// combine ResizeOp with Rot90Op
rotateMatrix.preScale(sx, sy)
rotateMatrix.postRotate(rotationDegrees.toFloat())
val rotatedBitmap = Bitmap.createBitmap(
        imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height,
        rotateMatrix, true
    )

// see ResizeWithCropOrPadOp.java for implementation
val croppedBitmap = cropBitmap(rotatedBitmap, targetHeight, targetWidth)

// extract RGB values and normalize them
val mean = 128f
val std = 128f
val bytesPerChannel = 4
val inputChannels = 3
val batchSize = 1
val inputBuffer = ByteBuffer.allocateDirect(
    batchSize * bytesPerChannel * croppedBitmap.height * croppedBitmap.width * inputChannels
)
inputBuffer.order(ByteOrder.nativeOrder())
inputBuffer.rewind()
val intValues = IntArray(croppedBitmap.width * croppedBitmap.height)
croppedBitmap.getPixels(intValues, 0, croppedBitmap.width, 0, 0, croppedBitmap.width, croppedBitmap.height)
for (pixelValue in intValues) {
    inputBuffer.putFloat(((pixelValue shr 16 and 0xFF) - mean) / std)
    inputBuffer.putFloat(((pixelValue shr 8 and 0xFF) - mean) / std)
    inputBuffer.putFloat(((pixelValue and 0xFF) - mean) / std)
}
return inputBuffer

By applying this simple improvement I was able to save ~25ms on average, bringing the image processing time down to ~35ms.

Frame interpolation

My final tip for you is about providing users with a smooth UX even if your computational budget is relatively low.

Like I mentioned before, the Pixel 1 is not the most performant device to run inference on, with an average time of ~100ms (including image processing) using default tensor shapes. That means every pose update will take at least 100ms to appear on screen, resulting in an average of 10 frames per second. What should we do if we simply can't go faster, but still want smooth 60fps updates?

In that case I suggest using a trick involving interpolation. The idea is that, whenever a new pose update comes, instead of drawing a new frame immediately, we start gradually moving existing points to their new destination over time, creating the illusion of smooth updates. If an update happens before the points reach their previous destination, simply start a new intrepolator from their current position to the new one. It's important to remember that this trick will introduce an artificial delay and will de-sync the camera feed and pose overlay view, making the experience arguably worse on more performant devices (i.e., capable of at least 30fps updates). Still, you can make the interpolation time dynamic and adjust it at runtime based on how much time the last inference took to complete.

/* (c) ExaWizards */

// in FluidPoseView.kt
...
private var pointMap: MutableMap<BodyPart, PointF> = mutableMapOf()
private val interpolator = LinearInterpolator()
private val flow = MutableStateFlow<MutableMap<BodyPart, PointF>?>(null)
private val coroutineScope: CoroutineScope? = (context as? AppCompatActivity)?.lifecycleScope
private var animJob: Job? = null
private val durationNanos = 1e8f

private val evaluator = object : TypeEvaluator<MutableMap<BodyPart, PointF>> {
    private val pointFEvaluator: PointFEvaluator = PointFEvaluator()

    override fun evaluate(
        fraction: Float,
        startValue: MutableMap<BodyPart, PointF>?,
        endValue: MutableMap<BodyPart, PointF>?
    ): MutableMap<BodyPart, PointF> {
        val updated = startValue?.mapValues { entry ->
            val startPointF = entry.value
            val endPointF = endValue?.get(entry.key)
            when {
                startPointF == zeroPoint -> endPointF ?: zeroPoint
                endPointF == null -> zeroPoint
                else -> pointFEvaluator.evaluate(fraction, startPointF, endPointF)
            }
        }?.toMutableMap() ?: mutableMapOf()
        endValue?.forEach {
            updated.addIfAbsent(it.key, it.value)
        }
        return updated
    }
}

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    animJob = coroutineScope?.launch {
        flow.collectLatest { endValue ->
            endValue ?: return@collectLatest
            val startValue = pointMap
            val startTime = System.nanoTime()
            while (true) {
                val time = awaitFrame()
                val fraction = (time - startTime) / durationNanos
                if (fraction >= 1.0f) {
                    break
                }
                val interpolatedFraction = interpolator.getInterpolation(fraction)
                pointMap = evaluator.evaluate(interpolatedFraction, startValue, endValue)
                invalidate()
            }
        }
    }
}

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    animJob?.cancel()
}

override fun onDraw(canvas: Canvas?) {
    // pointMap.values
    //        .filter { it != zeroPoint }
    //        .forEach { canvas.drawCircle(it.x, it.y, circleRadius, circlePaint) }
}

f:id:ivanpo:20201006150119g:plain
Low performance, discrete frames

f:id:ivanpo:20201006150229g:plain
Low performance, interpolated frames

Conclusion

We learned how to integrate TensorFlow Lite into your project, explored the TensorFlow support library package, analyzed the Posenet model, discussed what the inference is and leveraged the CameraX API to efficiently analyze the camera feed in real time. You can apply many of the concepts discussed here to other use cases, too, and to give you a quick start I will prepare an open source sample project showcasing the on-device machine learning kit.

Thanks for your time!

GitHub Actions の self-hosted runners を AWS ECS で動かして、CI / CD パイプラインを作る

f:id:tadashi-nemoto0713:20201019135904p:plain

DevOps エンジニアの 根本 征 です。

7月からエクサウィザーズ にジョインし、CI / CD パイプラインの改善や自動テストの布教などを行っています。

今回は GitHub Actions の self-hosted runners を AWS ECS 上に構築し運用してみたので、その試行錯誤について紹介したいと思います。

GitHub Actions と self-hosted runners

GitHub Actions は GitHub ユーザーであれば現在多くの方がご存知・ご活用されているかと思います。

GitHub Marketplace で公開されている Actions と組み合わせることによって、簡単に様々な CI / CD パイプラインを構築することができます。

そんな GitHub Actions ですが、GitHubが提供する Runner を使う代わりに自前で用意することもできます(self-hosted runners)。

セルフホストランナーについて - GitHub Docs

GitHub が提供する Runner で GitHub Actionsを利用する場合、無料利用枠 + 従量課金の課金体制になります。

しかし、self-hosted runners で GitHub Actions を利用する場合には別途料金がかかることはありません。

Repository または Organization の設定からself-hosted runners をセットアップすることができます(Linux / MacOS / Windows 毎に手順が示されます)。

f:id:tadashi-nemoto0713:20201016201036p:plain

セットアップが完了すると、Runner として追加されていることが確認できます。

Repository で設定した Runner はその Repository で、そして Organization で設定した Runner はその Organization 内の全ての Repository で実行することができます。

f:id:tadashi-nemoto0713:20201016201348p:plain

最後に GitHub Actions の設定ファイルにおいて、self-hosted runners で実行することを記述します。

f:id:tadashi-nemoto0713:20210325114741p:plain

self-hosted runners はマシン自体は自分たちで調達・メンテナンスをしないといけないですが、ワークフローを管理する部分(Jenkins でいう master)を自前で用意せず無料で利用できる点は魅力的だと感じます。

self-hosted runners を Docker で動かす

self-hosted runners 自体はオープンソースとして公開されていますが、現在 Docker イメージは提供されていません。

調べたところ、Docker・Kubernetes で self-hosted runners 動かすためにオープンソースプロジェクトで様々な試行錯誤が行われているみたいです。


本記事では AWS ECS をメインに下記の手順で解説します。

  1. self-hosted runners を Docker で動かす

  2. self-hosted runners を AWS ECS で動かす

  3. その self-hosted runners を用い、アプリケーションを AWS ECS にデプロイするパイプラインを作る


まず下記の Docker イメージを使い、手元で self-hosted runners を立ち上げてみます。

github.com

Organization に対して Runner を立ち上げるためには下記 docker コマンドを実行します。

f:id:tadashi-nemoto0713:20210325114821p:plain

GitHub Actions では Docker の操作(ビルドなど)を行うため、このコンテナ内でも Docker コマンドが使えることが必要になります。

そのため、ホストマシン上の Docker daemon を共有することで解決しています(-v /var/run/docker.sock:/var/run/docker.sock の部分、Docker outside of Docker)

Using Docker-in-Docker for your CI or testing environment? Think twice.

self-hosted runners を AWS ECS で動かす

次に先ほどの Docker イメージを使って、AWS ECSで動かしてみます。

今回は AWS CDK(Typescript) をベースに3つのポイントに絞って解説します。


1つ目に AWS ECS で Docker outside of Docker(DooD) をどう実現するかについてです(-v /var/run/docker.sock:/var/run/docker.sockの部分)。

AWS ECS だと Task 側に Volume を追加し、Container 側にその Volume をマウントすることで解決することができます。

f:id:tadashi-nemoto0713:20210325114902p:plain

f:id:tadashi-nemoto0713:20210325114933p:plain

AWS ECS では起動タイプとして Fargate と EC2 がありますが、現状 Fargate で上記を行うことがサポートされていなかったため、今回はEC2起動タイプを選択しました。


2つ目に ECS Task に与える Role についてです。

今回の GitHub Actions 上でのパイプラインでは、Docker ビルド ・ECRへのイメージのアップロード・ECSへのデプロイまで行おうとしています。

もちろん GitHub 側に AWS アクセスキーを保存し、それを GitHub Action に渡すことで上記を実現することができます。

しかし、self-hosted runners のコンテナ自体に上記に必要な Role を与えることによって、GitHub側に AWS アクセスキーを保存する必要自体をなくすことができます。

f:id:tadashi-nemoto0713:20210325115036p:plain


3つ目に、スポットインスタンスについてです。

self-hosted runners は CI / CD として使うため、スポットインスタンスを活用してコストを抑えることができます。

AWS CDK の場合、spotInstanceDraining プロパティを true にすることでスポットインスタンスを利用することができます。

f:id:tadashi-nemoto0713:20210325115131p:plain

アプリケーションを AWS ECS へデプロイする Workflow を作る

今回はこの GitHub Actions と self-hosted runners を活用して、アプリケーションを AWS ECS へデプロイする Workflow を作りたいと思います。

同じ AWS ECS なのでややこしくなってしまいますが、self-hosted runners を動かすクラスタとアプリケーションを動かすクラスタは別という想定です。

f:id:tadashi-nemoto0713:20201020153820p:plain

具体的には下記のような手順になります。

Docker build して ECR にPushする
↓
Task Definition ファイルを編集する(Dockerイメージの部分を新しくする)
↓
Task Definition を新たに登録し、Service が更新されるまで待つ

AWSで提供されている GitHub Actions の Step と組み合わせると下記のようになります。

f:id:tadashi-nemoto0713:20210325115227p:plain

先ほども述べましたが、本来は aws-actions/configure-aws-credentials の Step で下記のように AWS アクセスキーを GitHub に保存し渡してあげる必要があります。

f:id:tadashi-nemoto0713:20210325115303p:plain

しかし、今回は self-hosted runners に必要な権限を渡してあげているのでこの必要はありません。

おわりに

今回はGitHub Actions の self-hosted runners を AWS ECS 上に構築してみましたが、下記のような状況でメリットがあると考えています。

  • GitHub Action を使ってアプリケーションをAWSへデプロイする際、GitHub 側に不必要に AWS アクセスキーを渡したくない

  • スポットインスタンスや活用することによって、コストパフォーマンスよく CI / CD 環境を運用したい(特に無料枠を大幅に超えることが予想される場合)

  • GitHub が提供する Runner よりスペックの高いマシンで CI / CD 環境を運用したい


今後、この GitHub Actions をベースにより良い CI / CD パイプラインが作れたら良いと考えています。


hrmos.co

【連載】時系列データにおける異常検知(1)

はじめに

こんにちは。MLエンジニアの福成毅です。

私は、ある自社プロダクトの要素技術として時系列異常検知モデルの開発に携わってきました(2019/10 〜 2020/03)。 異常検知には今まで取り組んだことがなかったですが、時間をかけて様々なアプローチがあることを学びました。 異常検知は、機械の故障やシステム障害などにおいて発生する異常データを見つけ出す手法であり、様々な産業での応用が期待されています。 一方で教師データ(特に異常データ)の不足や時系列特有の制約など、どうしても難易度が高くなりがちなタスクでもあります。

今回の投稿では、異常検知の基本的な考え方を述べ、時系列異常検知における代表的なタスクの紹介を行います。 何回かに分けて投稿しますので、少し長くなりますが、おつきあい頂ければ幸いです。

基本的な考え方

ここでは異常検知の基本的な考え方について述べていきます。

教師なし学習

異常検知は教師あり学習・教師なし学習どちらでも解くことができますが、 どちらかというと教師なし学習の方がスタンダードなやり方になります。

イメージとしては、まず「正常モデル」のみを作り上げ、 この正常モデルでは「理解」できなかったデータは異常であると考えるということです。

ちなみに正常と異常が選り分けられなくていなくとも、異常データが正常データに比べてごくわずかであれば、異常データがノイズとなるだけで正常モデルを作ることができます(とはいえ正常データのみで正常モデルを作ることがベストではありますが・・・)。

f:id:t-fukunari:20200424170800p:plain

教師あり学習の難しさ

なぜ教師なし学習が異常検知においてスタンダードなのか。 もちろんラベルさえあれば教師あり学習でも行うことは可能ですが、いくつかハードルを乗り越える必要があると思います。

具体的には、以下のようなケースがあると考えられます。

そもそもラベルがない

よくある話です。まだ異常に遭遇していなかったり、異常のパターンが網羅できてなかったりすることが理由として考えられます。 また後に述べますが、正常の定義が変わっていくことでラベルをつけることができないケースも考えられます。

異常データが少なすぎる

そもそも異常は滅多に起こらず(だからこそ「異常」なのですが)、 正常データは十分あるが異常データがほとんど得られないということが考えられます。 このような不均衡データでモデルを作るとどうしても予測が正常に偏りがちになります。

未知の異常に遭遇する可能性が高い

いままで運良く故障しなかった箇所の故障、新手の詐欺・ハッキングetc... 大方我々を待ち受けているのは未知の異常です。これまでの既知の異常でモデルを作ったとしても、未知の異常が得られるたびに、再学習や時には問題設定の変更を強いられることになります。

f:id:t-fukunari:20200424170852p:plain

確率分布による正常モデル

ではその「正常モデル」をどうやって作っていくか。色々方法はあるかと思いますが、よくあるのは、確率分布を考えるアプローチかと思います。 ざっくり説明すると、正常データでヒストグラムを作り、それを滑らかにするイメージです。 ここでの正常データは、異常が含まれていないか、含まれていたとしてもごくわずかであることを前提とします。 ごくわずかであれば含まれていてもよいというのは、わずかであればモデル化の際に無視されるためです。

f:id:t-fukunari:20200508134927p:plain

そして異常かどうか調べたいデータが上記の分布において確率が低いところで観測された場合、 正常とされる中でめったに起こらないことが起こった = 別の分布から発生したのではないか?と疑うことができます。つまり正常ではないということです。どれぐらいの低確率だったら異常とするか = 閾値をどれくらいにするかは調整次第ですが、様々な手法は概ねこの考え方から派生します。

また分布そのもので考えず、統計量で考えることも可能です。例えば、正常データの平均値からの距離が標準偏差×定数倍を超えたら異常とする、といったものです。データが少なすぎてどうしても分布を推定できそうにない時に使える手法です。

時系列データにおける考え方

以降では、時系列データにおける異常検知の考え方について述べていきます。

時系列データは常に一定の状態を取るとは限らず、着目する区間によって正常の意味が変わってきます。 そのため時系列データにおいての異常検知は、"どの時点に対しての"異常であるか?を意識する必要があります。 言い換えると、「ある区間Aのデータを正常な区間と考え、別の区間Bが区間Aに対して異常であるかどうかを調べること」ということになります。 あとは上で挙げた異常検知の考え方と同様に、 区間Aでモデルを作り、区間Bでのデータがそのモデルで「理解できない」とした時に「区間Aに対して区間Bが異常である」と言うことができます。 以降、便宜上この2つの区間を以下のように定義します。

  • 正常と仮定した区間A → 参照区間
  • 異常かどうかを調べたい区間B → 評価区間

大抵のタスクでは、直近の時系列に対して異常かどうかを判定したい場合が多いので、参照区間と評価区間を隣り合わせにすることがポイントです。 そして下図のようにスライドさせることで、すべての区間で異常かどうかを調べていく、という流れになります。

f:id:t-fukunari:20210408172512p:plain

また私自身経験はしていませんが、もし正常な区間が絶対的に定まるようなタスクの場合は、参照区間を正常な区間に固定し、 評価区間のみをスライドさせることも考えられます。この方法は機械の故障検知などで有効だと思われます。

f:id:t-fukunari:20210408172345p:plain

時系列異常検知のタスク

時系列における異常検知のタスクとしてよく出てくるのは、「外れ値検知」と「変化点検知」この2つかと思います。 字面を見るだけでもなんとなく違いをイメージできるかと思いますが、 先ほど紹介した参照区間と評価区間の枠組みを用いてこれらを説明します。

評価区間を1点にする → 外れ値検知

評価区間を1点とすることで、その1点が異常かどうかを調べることになります。 これが「外れ値検知」とよばれるタスクになります。

f:id:t-fukunari:20210408173002p:plain

そして、外れ値検知はさらに2種類に分けられます。

1つは時系列依存しない外れ値です。つまり、時系列をシャッフルさせても外れ値としてわかるものです。 この場合、値そのものが異常と判断できるので、先ほどの確率分布による正常モデルで考えることができます。 また、閾値を持たせることでルールベースでも検出できます。

f:id:t-fukunari:20210408172700p:plain

もう1つは時系列依存する外れ値です。つまり、時系列をシャッフルさせるとその値が異常であるとわからなくなるようなものです。 このような外れ値の場合はChangeFinderのような時系列予測系のモデルを用いた方がうまく解けます。

f:id:t-fukunari:20210408172724p:plain

評価区間を2点以上とる → 変化点検知

これに対し、評価区間をある程度の長さに取ると、「変化点検知」とよばれるタスクになります。 この場合、評価区間という「カタマリ」単位で異常かどうかが判断されます。

参照区間と評価区間を隣り合わせにしていることが前提で、 評価区間が異常と判断された場合、参照区間と評価区間の間で何かしらの「変化」が生じたということが言え、 これらの区間の境目が「変化点」ということになります。

f:id:t-fukunari:20210408174104p:plain

そしてこちらの変化点検知に対しても、外れ値検知と同様に、時系列依存する場合としない場合に分けることができます。

時系列依存しない場合のアプローチに関して、一番シンプルな方法としては、 参照区間と評価区間の統計量を計算しそれぞれ比較する、といった方法が考えられます。 また、これまでは正常区間のみでモデルを作ることをお話ししてきましたが、 評価区間にも十分データが揃うのであれば、評価区間でもモデルを作ることが可能です。 分布のイメージだと、参照区間と評価区間でそれぞれ分布ができるようなものです。 あとは分布そのもので比較したり、また「密度比推定」と呼ばれる分布の比をダイレクトに求める方法で異常かどうか調べていくことができます。

f:id:t-fukunari:20210408174952p:plain

時系列依存する場合は、外れ値検知と同様に予測モデルを作るアプローチが考えられます。 例えばAutoEncoderの再構成誤差を用いる方法が考えられます。

今回のまとめと次回予定

長くなりましたので今回はここまでです。ポイントを以下にまとめます。

  • 異常検知では、明示的な正解ラベルを学習に用いない教師なし学習が主流である。
  • 時系列中に2つの区間を設け、その中でモデル化を行いつつ区間をスライドさせるのが基本的な考え方である。
  • 区間の長さにより、大きくは外れ値検知・変化点検知に分けられる。
    • 両者ともに、時系列依存の有無の観点で分けることも可能である。

次回は、外れ値検知・変化点検知のより具体的なアプローチについて述べていきたいと思います。

参考文献

おわりに

エクサウィザーズは優秀なエンジニア、社会課題を一緒に解決してくれる魔法使い”ウィザーズ”を募集していますので、ご興味を持たれた方はぜひご応募ください。
採用情報|株式会社エクサウィザーズ

ExaWizards Engineer Blogでは、AIなどの技術情報を発信していきます。ぜひフォローをよろしくお願いします!
Linkedinもどしどしフォローお待ちしています!

ロボット制御における特異点

エクサウィザーズMLエンジニアの柳元です。 あけましておめでとうございます(遅い)。
エクサウィザーズのRobotTechチームはこれまでにマニピュレータロボットを使って 粉体秤量 、液体秤量、 パレタイジング 、ピッキング、コンプライアンス制御などの動作を機械学習させることに成功してきました。 そして、これらの学習済みモデルを COREVERY によってデリバーしています。
学習と制御のフローを考えたりデバッグをする上で、念頭に置かなければならないことの1つとして、特異点の問題があります。今回はロボット制御におけるこの「特異点問題」についてお話しします。

ロボットの特異点(Singularity)とは?

特異点と聞いて何を想像するでしょうか?多くの人が 技術的特異点 を想像するかもしれませんが、数学・物理学・制御学においては
特異点(とくいてん、英: singular point、シンギュラー・ポイント)は、一般解の点ではなく特異解の点こと。ある基準 (regulation)を適用できない、あるいは一般的な手順では求まらない(singular) 点である。特異点は、基準・手順に対して「—に於ける特異点」「—に関する特異点」という呼び方をする。
とあります(Wikipediaより)。
ロボット制御における特異点は、構造的に制御できない姿勢を指します。軌道に特異点が含まれている場合、ロボットは特異点付近において高速に移動(暴走)し、そして特異点で停止してしまいます。 なので、制御する際にはこれを避ける必要があります。

ロボットの姿勢の表現

ロボットがどんな体勢になっているかの表現は、ふつう以下のいずれかを使用します。
  1. 関節変位 (Joint space) $q$: 関節の角度の値
  2. 姿勢 (Pose, Task space) $r$: TCP(Tool Center Point, ロボットの手先の位置)を表す3次元空間の値
例えば、URのような6DoFのマニピュレータは、6個の関節(joint)を回転させて姿勢(pose)を変化させることができます。
画像は公式サイトを元に弊社で注釈をつけたものです

この場合は関節変位$q$と姿勢$r$はそれぞれ \begin{equation} q = [\theta_1, \theta_2, \theta_3, \theta_4, \theta_5, \theta_6] \\ r = [x, y, z, r_x, r_y, r_z] \end{equation} というベクトルで表現できます。
例えばこのロボットのエンドエフェクターにグリッパーを取り付けて、物体のピッキングをさせる場合について考えます。 物体は深度カメラで撮影されていて、その位置$P=[x_P, y_P, z_P]$は既に計算されています。 あとはロボットのTCPを位置$P$付近にくるように姿勢を制御してグリッパーを閉じれば、物体を掴むことができます。
ただ、ロボットそのものにできることは基本的には関節を動かすことだけです。 目的地点の位置や姿勢を指示されても、関節をどう動かせば目的の姿勢になるのかがわかりません。 そこで運動学を考える必要が出てきます。

運動学

運動学(kinematics)とは関節変位$q$と姿勢$r$の関係を表す数式であり、
  1. 順運動学(forward kinematics): 関節変位$q$から姿勢$r$を求める, $r = f(q)$
  2. 逆運動学(inverse kinematics): 姿勢$r$から関節変位$q$を求める, $q = f^{-1}(r)$
の2種類があります。 上述のピッキングの例だと、逆運動学を使って$r$を$q$に変換する必要があります。

同次変換行列

順運動学$f$について知るために、まず同次変換行列(Homogeneous transformation matrix)について説明します。
3次元空間に原点座標系$O$と座標系$A$があり、点$Q$が座標系$A$上の $r_{Q}=[x_{Q}, y_{Q}, z_{Q}]$に存在していたとします。 ここで、座標系$A$を原点座標系$O$に対して$p=[x_p, y_p, z_p]$移動させ、さらに$R_{OA}$回転させます2。 移動と回転後の点$Q'$の位置$r_Q'=[x_Q', y_Q', z_Q']$は \begin{equation} r_Q' = p + R_{OA}r_{Q} \end{equation} となり、これは下のように書けます: \begin{equation} \begin{bmatrix} r_Q' \\ 1 \\ \end{bmatrix}=\begin{bmatrix} & & & \\ &R_{OA}& &p \\ & & & \\ 0&0 &0&1 \end{bmatrix} \begin{bmatrix} r_{Q} \\ 1 \\ \end{bmatrix} = H_{OA} \begin{bmatrix} r_{Q} \\ 1 \end{bmatrix} \end{equation} この$H_{OA}$を同次変換行列といいます。

リンクとジョイント

ロボットはリンクジョイント(関節)で構成されています。 人間の腕で考えると、脇や肘、手首が回転の中心があるジョイントで、ジョイント同士の間にある上腕や前腕がリンクです。
再びURについて考えると、リンクとジョイントは下の図のようになっています。 「ジョイントn」の関節角度は$\theta_n$で、TCPはリンク6の上にある点です。
画像は公式サイトを元に弊社で注釈をつけたものです

このように、ロボットは複数の座標系が連なりあって構成されていて、各ジョイントの関節変位と姿勢によってTCPの姿勢、つまり順運動学が計算できると予想できます。

Denavit–Hartenberg parameters

Denavit–Hartenberg parameters(DHパラメータ)は、上のような同次変換によってロボットの順運動学を計算する際に必要な4つのパラメータ$\alpha, a, d, \theta$を指します。
Wikipediaより引用

上の図のようにDHパラメータと座標系が決まっている時、座標系$n-1$から座標系$n$への同次変換は次のようになります: \begin{equation} T_{n}^{n-1}={\rm Trans}_{z_{n-1}}(d_n) \cdot {\rm Rot}_{z_{n-1}}(\theta_n) \cdot {\rm Trans}_{x_{n}}(a_{n}) \cdot {\rm Rot}_{x_n}(\alpha_n) \end{equation} ただし、 \begin{equation} {\rm Trans}_{z_{n-1}}(d_n)= \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & d_n \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \hspace{30pt} {\rm Trans}_{x_n}(a_n)= \begin{bmatrix} 1 & 0 & 0 & a_n \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \\ {\rm Rot}_{z_{n-1}}(\theta_n)= \begin{bmatrix} \cos\theta_n & -\sin\theta_n & 0 & 0 \\ \sin\theta_n & \cos\theta_n & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \hspace{15pt} {\rm Rot}_{x_n}(\alpha_n)= \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & \cos\alpha_n & -\sin\alpha_n & 0 \\ 0 & \sin\alpha_n & \cos\alpha_n & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \end{equation} であり、それぞれが各パラメータによる並進または回転の同次変換となっていることがわかります。 また、Modified Denavit–Hartenberg parametersというものもあり、上記の古典的なDHパラメータと比較して各座標系の原点と変換の順序が異なっています。 詳しくは Wikipediaの記事 などを参照してください。

順運動学

URのようなマニピュレータの順運動学を導出する場合、リンク0とジョイント0が乗っている座標系0からリンク6とジョイント6が乗っている座標系6への同次変換$T_6^0$を導出する必要があり、 $$T_6^0=T_1^0\cdot T_2^1\cdot T_3^2\cdot T_4^3\cdot T_5^4\cdot T_6^5$$ によって求められます。 $T_n^{n-1}$は$\alpha_{n-1}, a_{n-1}, \theta_n, d_n$を変数とする関数とみなせます。 そしてURの場合、$\theta_n$が独立の変数なので、実質的には$T_6^0$は$q=[ \theta_1, \cdots, \theta_6]$を変数とする関数となります。 $T_6^0=T(q)$とすると、順運動学は \begin{equation} \begin{bmatrix} r \\ 1 \end{bmatrix}=T(q)\begin{bmatrix} r_0 \\ 1 \end{bmatrix} \end{equation} から、最初に書いた$r=f(q)$を導けます。

逆運動学

順運動学から逆運動学$f^{-1}$を求めるためには、上の式から逆算で求めれば良さそうですが、$T_N^0$の$N$が大きくなるほど逆算は困難になります。 $N=6$にもなると、ほとんど無理です。 そのため、順運動学から数値解析的に求めるのがふつうです。 以下は姿勢$r_{tgt}$の時の関節変位$q_{tgt}$を求める手順です。
  1. $q=q_0$(初期値の設定)
  2. $r=f(q)$(順運動学で$r$を求める)
  3. $| r_{tgt} - r |$の大きさを調べる
    1. $| r_{tgt} - r |$が十分小さければ、$q_{tgt}=q$として終了する
    2. そうでなければ、順運動学のヤコビ行列の逆行列によって$q$を修正する。手順2に戻る。
ヤコビ行列とは、
一変数スカラー値関数における接線の傾きおよび一変数ベクトル値函数の勾配の、多変数ベクトル値関数に対する拡張、高次元化
です(Wikipediaより)。 つまり、勾配法の要領でヤコビ行列から$q$の修正すべき向きと量が決まって、$q_{tgt}$に近づけていけます。
$r=[r_1, \cdots, r_M], q=[q_1, \cdots, q_N]$の場合、順運動学は \begin{equation} x_i=f_i(q_1, \cdots, q_N) \end{equation} であり、ヤコビ行列の要素は \begin{equation} J_i(q_j)=\frac{\partial f_i(q_1, \cdots q_N)}{\partial q_j} \end{equation} と表せます。
姿勢の空間は$r=[x, y, z, r_x, r_y, r_z]$のため$M=6$となります。 また、URのような6DoFマニピュレータの場合だと$q=[\theta_1, ..., \theta_6]$のため$N=6$となります。 よってヤコビ行列は$6\times 6$の正方行列となります。

特異点

問題は、順運動学のヤコビ行列の逆行列が存在していない場合です。 逆行列を持たない正方行列の特徴として行列式が0(${\rm det} J=0$)となることが挙げられます。 ヤコビ行列の逆行列は \begin{equation} J^{-1}=\frac{\Delta_{ij}}{{\rm det} J} \end{equation} となるので、${\rm det} J$が0に近づくほど$J^{-1}$の要素の絶対値は大きくなります。 姿勢$r$と$J^{-1}$から関節変位$q$を求めるので、このときの関節変位の速度も急上昇し、結果的にロボットが暴走しているように見えます。 そして、${\rm det} J$が0となると、逆行列を算出できなくなり停止してしまいます。 これがロボット制御における特異点の問題です。

擬似逆行列

今日、様々な解決策が提案されています。 もっともよく知られているのは、逆行列の代わりに擬似逆行列を使う方法です。 擬似逆行列の利点は、上のような例における特異点を避けること、そして7DoFのような冗長ロボットのように$N\neq6$でないためにヤコビ行列が正方でない場合についても求められることです。 欠点は計算量が多いことで、スピードを重視して転置行列で近似してしまう方法もあります。

おわりに

エクサウィザーズは優秀なエンジニア、社会課題を一緒に解決してくれる魔法使い”ウィザーズ”を募集していますので、ご興味を持たれた方はぜひご応募ください。
採用情報|株式会社エクサウィザーズ

注釈

1: ロボット(マニピュレータ)には直列タイプと並列タイプがあり、ここでは直列タイプについて扱う。直列タイプに含まれるのは、例えば UR, Panda, LBR iiwaなど、関節が直列な構造を持つものである。

2: $R_{OA}$は$3\times 3$の回転行列である。

エクサウィザーズ の技術力向上の取り組み

この記事について

皆さんご承知の通り、機械学習界隈は進化が早いです。一説によれば、機械学習に関する論文が1日100本以上出ているとも言われています。 そんな超速進化をしている機械学習ですが、それを扱うエクサウィザーズでは常に最新技術に追いつき、技術力を高めていく取り組みがあります。 この記事では、その取り組みの一部を紹介します。

社内における取り組み

社内では、主に2つの勉強会をしています。 案件共有会と論文読み会です

案件共有会

毎週1回1時間実施しており、主なトピックは取り組んだ案件の技術的/ビジネス的な取り組み内容の共有です。 この勉強会を通すことで、異分野との交流や知見の取り入れを諮り、相互作用による技術革新を狙っています。

この写真は最近行なった、ある自動車メーカーの異常検知タスク案件の取り組み共有の様子です。質疑応答も活発に行われました。 f:id:akira0926yosouguy:20191120151232p:plain

「ある分野では当たり前のアプローチが他の分野だとやられていない」というようなことが多々あります。特に弊社のようにケア事業、ロボット事業など取り組んでいる分野が多岐にわたる企業では、個々に取り組んでいると視野が狭くなったり、力が分散してしまいがちです。 このような知見の共有会を開くことで、各分野の技術交流を諮り、技術の転移・知見の共有で個々の技術を伸ばしていこうと考えています。

論文読み会

こちらも毎週1回1時間実施しており、論文1本当たり30分x2本という内容です。 毎週2人の担当者が質疑応答含め30分で、論文の内容をまとめて発表します。

1年間は約52週なので、この勉強会だけでも年間約100本分の論文の知見を取り入れることができます。 弊社では色々な分野の専門家がいるため、それぞれ異なったことに興味を持ち、選ぶ論文の研究分野もひとそれぞれです。普段自分が読まない分野の論文に触れることにより、技術の幅を広げることができます。

この資料は実際に発表で使われた資料です。弊社は海外籍の方も多いので、前半はこのように英語による発表をしています。

www.slideshare.net

そして、下記表は直近の発表内容ですが、ある分野に偏らずに色々なものが存在しているのがわかると思います。

論文のタイトル等 大まかな内容
RNNs Evolving on an Equilibrium Manifold:A Panacea for Vanishing and Exploding Gradients? RNNの改良
Neural Processes Gaussian Processのニューラルネットへの拡張
Gate Decorator: Global Filter Pruning Method for Accelerating Deep Convolutional Neural Networks ニューラルネットの枝刈り(圧縮)
Polygon RNN, Polygon RNN++ アノテーションを半自動化
Data Cleansing for Models Trained with SGD データクレンジングをNNを介して行う研究
Hamiltonian Graph Networks with ODE Integrator 物理シミュレーションへのDNN適用

社外を含めた取り組み

エクサウィザーズ では社内で閉じた取り組みだけでなく、社内の枠組みを超えた一般公開の勉強会も行なっています。 大きく分けると、研究者やエンジニア向けの国際会議や研究分野に絞った勉強会、キャリアやビジネス側の人も対象者含めた技術系以外の勉強会、の2つです。

近畿圏で多くの勉強会を行なっていることがエクサウィザーズ の特色です。 このような勉強会は首都圏では珍しくないのですが、近畿圏ではそこまで数は多くありません。 弊社は京都に拠点を持っているため、近畿圏で勉強会を開催することにより、近畿圏の機械学習コミュニティの醸成にも微力ながら貢献していきたいと考えています。

以前開催したCVPR2019読み会では、京都大学の鹿島先生もお招きして平日の19:30~21:00に3人の発表者x2日程で行いました。この資料は鹿島先生に登壇いただいた資料です。

www.slideshare.net

開催イベントはconnpassのExaWizardsグループのページから確認できますので、ご興味ある方は是非ご参加ください。 exawizards.connpass.com

国際会議や研究分野を絞った勉強会(研究者、エンジニア向け)

KDD, CVPR, AAAIのように注目度の高い国際会議の勉強会を開催しています。 たとえば去年行なったKDD 2018(データマイニング領域のトップカンファレンス)の論文読み会では、理化学研究所・京都大学と共同で行い、実際に参加された方を招待講演としてお呼びして開催しました。詳細や登壇資料は下記ブログをご覧ください。

techblog.exawizards.com

2019年の実績としては、CVPRの読み会, AAAIの読み会を実施しており、ICCVの読み会も実施予定ですのでご興味ある方は是非ご一緒に勉強しましょう!

exawizards.connpass.com

exawizards.connpass.com

また、GANやVAEなどの生成モデルにテーマを絞った勉強会も実施しています。

exawizards.connpass.com

ビジネス側の人も含めた勉強会やキャリアに関する勉強会など(技術系以外の勉強会)

世の中の課題を機械学習で解決することは、機械学習エンジニアが優れたモデルを作るだけでは達成できません。 その課題を機械学習の問題まで落とし込める「問題設定を考えるビジネス側の人」も巻き込む必要があります。 https://techblog.exawizards.com/entry/2018/08/15/184404エクサウィザーズでは、ビジネス側の方々も対象にした勉強会を実施しています。

exawizards.connpass.com

また、12月初旬にはマスクドアナライズさんを招いて、「これからの機械学習エンジニアに求められるスキル」をテーマに勉強会を行います。

exawizards.connpass.com

最後に

エクサウィザーズは機械学習の超速発展に対応するために様々な勉強会を開いて技術向上に取り組んでいます。 機会がありましたら、是非みなさんも参加してみてください

研究と両立しながら挑んだ3ヶ月間

1,はじめに

初めまして,3ヶ月間エクサウィザーズでお世話になりましたインターン生の井上です. 今回は研究と並行しながら臨んだインターンシップについて振り返り,エクサに興味を持っている方に取り組んだことや雰囲気を伝えることができれば幸いです.

自己紹介

早稲田大学の修士2年で機械学習に関するアルゴリズムや分析を行う研究室に所属し,現在は統計的因果推論の研究に携わっています. 学部では文系学部で統計学を学び,趣味の競馬に活用するためのAIの開発やデータ解析のコンペに参加していました. 現在は,機械学習のパッケージの使用経験や分析経験はあるものの,諸々の機械学習アルゴリズムについての知識は完全にわかっているというわけではない状態で不安と楽しみな感情を抱えつつインターンに臨みました.

インターンシップに参加するにあたって設定した課題

・インターンシップを通じて,どんなことに取り組みそこからどんな学びを得るのかというのを明確にするためにあらかじめ最終的な目標を設定しました. また,そこに到るために具体的にどのような進み方をするのかを検討しました.

【実際に設定した課題の例】

・機械学習に関する知識を広く身につける.そのために参考図書を読破する

・施策の立案に役に立つ手法の実装を行う

・実装した手法を用いて分析を行い,納得感の得られる施策を考える

などこのインターンシップ全体の大目標と共に短期的な中目標も設定した上で課題に取り組んでいきました.

インターンシップの初日に面接でお世話になった遠藤さんにお時間をいただき,ディスカッションを行いながら具体的な目標設定を行いました. 遠藤さんは私がインターンシップを通じた成果を感じられるよう,精力的にアドバイスやご指摘をしてくださり,目標設定にも非常に熱が入りました.

また,その後も目標がどの程度達成することができているのかを確認するために1 on 1で面談する機会を設けてくださり,お仕事がお忙しいにも関わらずよく目をかけてもらい,非常に感謝しています.本当にありがとうございました.

2,取り組んだこと

(1)機械学習周辺に関する勉強

機械学習周辺の知識を身につけるために課題の本を設定してもらい,約1ヶ月半で読破することを目標に取り組みました.

「[第2版]Python 機械学習プログラミング 達人データサイエンティストによる理論と実践」(表紙が緑の分厚い本に取り組みました)

最初から少しずつ読み進めていきましたが,読んではコードを写して実行しまたそれを繰り返す,という単純な作業を繰り返すことに飽きてしまったため,私はuci machine learning repositoryでサンプルデータを拾ってきて実際にデータを分析しながら,わからないところは本を参照しながら学ぶという形を取り勉強しました.また,理解したアルゴリズムはアウトプットして更に理解したいと思い,図を用いて端的にまとめるといった工夫をしながら進めることで楽しく勉強を進めることができました.

おかげで自分がこれまであまり触れたことの内容な手法の理解だけでなく,これまであやふやだった手法についても理解を促進することができたと感じました.

この期間は本の勉強に充てるだけではなく,実際に分析する際の手法の検討(主に論文の探索)も行い,見つけた手法が実際に適用することが可能かどうかを考える時間も設けていました.

(2)社内データを用いての機械学習モデルの構築

私は現在,統計的因果推論の研究をしているため可能であれば研究領域に近いことができると嬉しいなと思いながら機械学習手法の検討をしていました.

結果的に私はCare techのデータに対して因果探索手法を用いることができました.今後インターンシップを考えている学生の方へ”特に”伝えたいことは,

エクサウィザーズでは「対象とする手法が扱うデータに適合し,分析結果を通じて会社に利益をもたらすことが可能な機械学習モデルである」ということが伝えることができれば,インターンシップの立場であってもプロジェクトをデザインも主導することができるということです.

私自身は,自分がやりたいことをさせてくれた環境があるというのは非常に嬉しく感じました.

実際に取り組んだ手法は,

「Multiple-cause discovery combined with structure learning for high-dimensional discrete data and application to stock prediction」

https://link.springer.com/content/pdf/10.1007%2Fs00500-015-1764-8.pdf

という因果探索手法に着目しました.

f:id:K_Inoue_1030:20191024172829p:plain
論文中にある変数間の関係を模した図

この手法を用いることによって図のような因果関係を元にした変数間の関係を理解し,考察することを通じて納得間のある施策の立案につなげることができないかと考えこの手法に取り組みました.

【簡単なアルゴリズムの紹介】

Multiple-cause Discovery combined with Structure Learning(以下McDSL)では,大きく3つのステップに分かれたアルゴリズムになっています.


Step1: 変数間に何らかの因果関係があるかどうか探索する

Step2:不要な変数間の関係性を除去する

Step3:変数間の因果の向きを設定する


以上のような流れで変数間の因果関係を探索していきます.

Step1・2では条件付き独立性の検定を行い,変数間の関係性があるかどうかを確かめていきました.また,Step3では,統計的因果探索で用いられる回帰分析と独立性を考慮した方法によって因果の向きを定めていきました.(参照:清水昌平. 統計的因果探索. 講談社)

特に苦労したのは,条件付き独立性の検定を高次元データに対して適用する点でした.条件部に当たる変数が増大すると,非常に計算コストが増大してしまうため通常のカイ2乗検定による検定ではなく,以下の論文中にあるFCITという手法を採用しました.(https://arxiv.org/pdf/1804.02747)

実装を行った感想

実装自体に取り組み始めたのは8月の半ばからでしたが研究室の合宿や予稿の執筆もあり,かなりきつい時間でしたが濃密な時間だったように感じます.特に,うまく結果が出力されるまでに繰り返しアルゴリズムの理解を要し,それをプログラムとして具現化することや正しく結果を出せているかを確かめるためにシミュレーションを行うなどやることが多かったので日々必死に取り組みました.そのおかげで,何とか私がこれまでやってきたことを社内勉強会で共有し,インターンシップの最後を飾ることができたと思います.

実装自体は始めてみると楽しく,図が出力されうまく変数間の関係を表現した図が出力できたときには大きな達成感を感じることができ,良い経験になりました.また,社内の方々と図を元に結果の考察をした際に,図自体に興味を持っていただくことができたのでこうした点でも取り組んだ手法に意味があったのかなと感じています.

参考文献

・Chen, Weiqi, et al. "Multiple-cause discovery combined with structure learning for high-dimensional discrete data and application to stock prediction." Soft Computing 20.11 (2016): 4575-4588.

・Chalupka, Krzysztof, Pietro Perona, and Frederick Eberhardt. "Fast conditional independence test for vector variables with large sample sizes." arXiv preprint arXiv:1804.02747 (2018).

・清水昌平. 統計的因果探索. 講談社, 2017.

インターンシップの参考になれば・・・

・どのくらいの頻度で出社していたか?

→週2〜3日出社し,出社した際には10:00~19:00までフルタイムで勤務することが多かったのですが,都合に応じて自由にシフトを組むことができたので研究との両立をしながらインターンに参加し続けることができました.

・どんな人とお仕事をしたのか?

→私は,最終的にはCare techに関わるデータの分析を行うことになったのでCare techに関わってきたエンジニアの方と具体的な手法についてのディスカッションや実データに存在する変数に関しての議論を交わすことがありました.また,ビジネスサイドの方々とも関わることがあり,分析結果の考察や実際に結果を見せるにはどのようなデータを使うべきかといったことを熱心に話し合うことができました.協力してくださった皆さんのドメイン知識やこれまでの経験を通じたお話をしてくださったので勉強になることが非常に多かったです.私もなるべく自分の考えや分析結果を通じて思ったことを話したことに対して全力で向き合ってくれました.

・勉強会の内容や雰囲気は?

→勉強会ではこれまで取り扱った案件や参加した学会の雰囲気・学びが共有されることが多かったです.私はこの勉強会を通じてエクサの関わっているお仕事について多く知ることができたと思います.個々の勉強会の内容も非常に興味深いものが多く,聞いたことのある手法が実際に現場ではどのように使われるのかといったことを知ることができると思います.もし,エンジニアインターンを希望する学生の皆さんは積極的に参加することをお勧めします!!

3,最後に

3ヶ月間(特に最後の1ヶ月)はアルゴリズムの実装と予稿の執筆に追われ,アイデアが思いついた拍子に夜中に起きる日が続くなど大変なこともありました.しかし,機械学習の基礎の勉強から実装さらには実データの分析を行うことを通じて大幅に短期間でレベルアップができました.自分で取り組む課題を決めて,周囲の人を巻き込みながら仕事を進めていくというフローを実際に体験できたことは今後の大きな糧になると感じています.このような場を用意してくださったエクサウィザーズの皆様には本当に感謝しています.

3ヶ月間本当にありがとうございました.

尚、エクサウィザーズは優秀なエンジニア、社会課題を一緒に解決してくれる魔法使い”ウィザーズ”を募集しています。ご興味を持たれた方はぜひご応募ください。 採用情報 - 株式会社エクサウィザーズ

ExaWizards Engineer Blogでは、定期的にAIなどの技術情報を発信していきます。Twitter (https://twitter.com/BlogExawizards) で更新情報を配信していきますので、ぜひフォローをよろしくお願いします!