筋肉空間の提案とボディービルの掛け声生成への応用

# 本文書について

本文書では、筋肉トレーニングメニューを含むテキストを入力としてボディービル大会等で用いられる掛け声を生成・出力する手法について述べる。
提案する手法は、入力された筋トレメニューを主要な筋肉部位を基底として定義した筋肉空間上のベクトルに変換し、当該筋肉ベクトルを用いて適切なボディービルの掛け声を推定・出力する。
本文書ではさらに、当該手法を用いて実装した筋トレメニューをつぶやくとボディービルの掛け声をreplyするslack botについて、その出力結果と使用感について共有する。

【実装例】
<img width="400" alt="muscle1.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293276/8066d1f8-1455-d835-6ad7-522382f5ea2f.gif">

# ボディービルの掛け声生成

## 概要

システムの入出力は下図の通りとなる。
<img width="500" alt="flow1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293276/c92e2d51-cbaa-51ce-08da-925f263b3d6f.png">


より具体的に書くと筋トレメニュー空間から掛け声空間への写像である。
<img width="500" alt="flow2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293276/66c43213-f816-dfea-48dd-4dbb4c80d12d.png">


例えば `腕立て伏せ` を `大胸筋が歩いている` に紐付ける、などの組み合わせによるルールを用いることで当該写像を実現することは出来るが、筋トレメニューや掛け声は両者とも日々増えていく性質を持つため、すべての組み合わせを網羅したルールを記述することは現実的ではない。

一つの方法として、固定次元を持つ何らかの中間的な空間へ一時的に写像しベクトル間の類似度を参照して変換する方法が考えられる。
筋トレメニューからの中間空間への変換と中間空間からの掛け声への変換を独立に考えることが出来るため、両者間のすべての組み合わせを検討することなく筋トレメニューからボディービルの掛け声への変換が実現できる。

本文書では、中間的な空間として主要な筋肉部位を基底として定義する **筋肉空間** の利用を提案する。
<img width="500" alt="flow3.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293276/dea31f58-5138-1101-21f1-75599aee777b.png">


## 筋肉空間

ここでは、人体における主要な筋肉をいくつか選択し各筋肉に対応する基底ベクトルによって筋肉空間を定義する。
主要筋肉は各筋トレメニューが鍛える対象となる筋肉や掛け声に紐付けられる筋肉との関連性が求めやすいため、筋トレメニュー空間及び掛け声空間からの筋肉空間への写像が比較的容易と考えられるためである。

本文書では、下記の11筋肉を主要筋肉として定義し各主要筋肉に対応する基底を持つ11次元の空間を筋肉空間として定義した。
各筋トレメニュー及び掛け声は後述する処理により筋肉空間上の11次元のベクトルとして表現することが出来る。

* 三角筋
* 僧帽筋
* 上腕二頭筋
* 上腕三頭筋
* 広背筋
* 大胸筋
* 腹直筋
* 大臀筋
* 大腿四頭筋
* 大腿二頭筋
* 腓腹筋

## 筋トレメニュー/掛け声からの筋肉ベクトルへの変換方法について

筋トレメニュー空間の各元はメニュー名に紐づく筋肉であるターゲット筋肉の情報を持つ。

|メニュー名  |ターゲット筋肉  |
|---|---|
|腕立て伏せ   | 大胸筋,上腕三頭筋  |
| プランク |大腰筋,腹横筋,脊柱起立筋  |
|ツイストドラゴンフラッグ  |外腹斜筋,内腹斜筋 |

また同様に掛け声空間の各元も掛け声に紐づくターゲット筋肉情報を持つ。

|掛け声名  |ターゲット筋肉  |
|---|---|
|大胸筋が歩いている   | 大胸筋  |
| お尻にバタフライ |大臀筋,中臀筋  |

筋トレメニュー空間及び掛け声空間を筋肉空間に写像する上で主要筋肉とターゲット筋肉間の類似度を利用する。
本文書では、筋トレメニューまたは掛け声に紐づく各ターゲット筋肉を主要筋肉の類似度に基づく筋肉ベクトル$\vec{t}$に変換し、その和を筋トレメニューまたは掛け声の筋肉ベクトル$\vec{m}$とした。
ここで、ターゲット筋肉と主要筋肉の類似度は各筋肉の定義文をそれぞれ用意し、文間の類似度を[COTOHA](https://api.ce-cotoha.com/contents/)のsimilarity APIを用いて算出した。
例えば、`腓腹筋`と`ヒラメ筋`であれば、予め用意したそれぞれの定義文`脚のふくらはぎを構成する筋肉`、`腓腹筋とともに下腿三頭筋を構成する筋肉`間の類似度を計算する。

筋肉ベクトル$\vec{m}$は下記の式で表現することが出来る。

```math
\vec{m} = \sum \vec{t}
```

ただし、

```math
t_i = \left\{
\begin{array}{ll}
1 & (t^{def} = m_i^{def}) \\
0.8 & (\arg \max_i  similarity(t^{def}, m_i^{def})) \\
0 & (other)
\end{array}
\right. \\
```

ここでt_iは筋肉ベクトル$\vec{t}$のi次元目の値を、$t^{def}$はターゲット筋肉の定義文を、$m_i^{def}$はi番目の主要筋肉の定義文を、$similarity(X,Y)$はX,Y間の類似度を示す。

<details><summary>参考スクリプト(muscle_space.py)</summary><div>

``` python
# -*- coding: utf-8 -*-

import sys
import os
import pickle
import json
import requests
import time


CLIENT_ID = 'XXX'
CLIENT_SECRET = 'XXX'
API_BASE_URL = 'https://api.ce-cotoha.com/api/dev/nlp/'
ACCESS_TOKEN_PUBLISH_URL = 'https://api.ce-cotoha.com/v1/oauth/accesstokens'


def get_access_token():
    headers = {'Content-Type': 'application/json',
               'charset': 'UTF-8',}
    data = {'grantType':'client_credentials',
            'clientId':CLIENT_ID,
            'clientSecret':CLIENT_SECRET}
    data = json.dumps(data)
    response = requests.post(ACCESS_TOKEN_PUBLISH_URL, headers=headers, data=data)
    response = json.loads(response.text)
    return response['access_token']

if not os.path.isfile('./ACCESS_TOKEN.pickle'):
    ACCESS_TOKEN = get_access_token()
    with open('ACCESS_TOKEN.pickle', mode='wb') as f:
        pickle.dump(ACCESS_TOKEN, f)
with open('ACCESS_TOKEN.pickle', mode='rb') as f:
    ACCESS_TOKEN = pickle.load(f)

def similarity(s1,s2):
    global ACCESS_TOKEN
    headers = {'Content-Type': 'application/json',
               'charset': 'UTF-8',
               'Authorization': 'Bearer '+ACCESS_TOKEN}
    data = {'s1':s1,
            's2':s2}
    data= json.dumps(data)
    response = requests.post(API_BASE_URL+'v1/similarity', headers=headers, data=data)
    response = json.loads(response.text)
    if response['status'] == 99998:
        ACCESS_TOKEN = get_access_token()
        with open('ACCESS_TOKEN.pickle', mode='wb') as f:
            pickle.dump(ACCESS_TOKEN, f)
        return similarity(s1,s2)
    try:
        score = response['result']['score']
    except:
        print(response['message'])
        time.sleep(1)
        return similarity(s1,s2)
    return score


class MuscleSpace:

    def __init__(self, basis_path, other_path, vec_path):
        self.basis_muscles = dict()
        self.other_muscles = dict()
        self.muscle2vector = dict()

        with open(basis_path) as fin:
            for line in fin:
                line = line.strip()
                m,d = line.split(',')
                self.basis_muscles[m] = d
        with open(other_path) as fin:
            for line in fin:
                line = line.strip()
                m,d = line.split(',')
                self.other_muscles[m] = d
        if os.path.isfile(vec_path):
            with open(vec_path, mode='rb') as f:
                self.muscle2vector = pickle.load(f)
        else:
            self._compute_muscle_vector()

    def _compute_muscle_vector(self):
        idx = list(self.basis_muscles.keys())

        for m in self.basis_muscles.keys():
            vec = [0.] * len(idx)
            vec[idx.index(m)] = 1.
            self.muscle2vector[m] = vec

        for m,d in self.other_muscles.items():
            vec = [0.] * len(idx)
            for basis_m in idx:
                sim = similarity(d, self.basis_muscles[basis_m])
                vec[idx.index(basis_m)] = sim
            self.muscle2vector[m] = vec

        with open(vec_path, mode='wb') as f:
            pickle.dump(self.muscle2vector, f)
            
    def muscles2vec(self, muscles):
        vec =[0.]*len(self.basis_muscles)
        for m in muscles:
            m_vec = self.muscle2vector[m]
            if max(m_vec)==1.:
                vec[m_vec.index(1.)] += 1.
            else:
                vec[m_vec.index(max(m_vec))] += 0.8
        return vec
            

if __name__ == "__main__":
    ''' 
    【basis_definition.txt 例】

    三角筋,肩を覆う大きな筋肉
    僧帽筋,背中の中央から上部の表層に広がる大きな筋
    ...

    【other_definition.txt 例】

    肩甲下筋,腕を内向きにひねる肩関節内旋の主働筋
    小胸筋,胸部の筋肉のうち、胸郭外側面にある胸腕筋のうちの一つ
    ...

    '''
    ms = MuscleSpace('../data/basis_definition.txt',
                     '../data/other_definition.txt',
                     '../data/m_vec.pickle')
    vec = ms.muscles2vec(['大胸筋', 'ヒラメ筋'])
    print(vec)

```
</div></details>

## 掛け声生成

上述した筋肉ベクトルへの変換手法を用いて筋トレメニューからの掛け声生成を行う。

まず、入力された文章に含まれる筋トレメニューをすべて抽出する。
抽出された筋トレメニューに対して筋肉ベクトル変換を行うことで入力文に対する筋肉ベクトルを得ることが出来る。

得られた筋肉ベクトルと予め計算した掛け声に対する筋肉ベクトルについて筋肉空間上のcos類似度を計算することで類似している掛け声を推定する。
ここでは多様な掛け声が生成されることを期待して類似度が高い掛け声10個までをランダムに選択し、また予め用意した文字列をランダムに追加する実装とした。

<details><summary>参考スクリプト(muscle_shout.py)</summary><div>

``` python
# -*- coding: utf-8 -*-

import sys
import random
import numpy as np
from collections import defaultdict
from muscle_space import MuscleSpace

class MuscleShout:
    def __init__(self, muscle_space, shouts_path, menu_path):
        self.muscle_space = muscle_space

        self.shout2vector = dict()
        with open(shouts_path,'r') as fin:
            for line in fin:
                line = line.strip()
                items = line.split(',')
                shout = items[0]
                if len(items)>1:
                    muscles = items[1:]
                    self.shout2vector[shout] = self.muscle_space.muscles2vec(muscles)
                else:
                    self.shout2vector[shout] = list()

        self.menu2muscles = defaultdict(set)
        with open(menu_path,'r') as fin:
            for line in fin:
                line = line.strip()
                items = line.split(',')
                menu = items[0]
                muscles = items[1:]
                self.menu2muscles[menu] |= set(muscles)

    def _sentence2muscle_vec(self, sentence):
        muscles = list()
        for menu in self.menu2muscles.keys():
            if menu in sentence:
                for m in self.menu2muscles[menu]:
                    muscles.append(m)
        return self.muscle_space.muscles2vec(muscles)

    def _cos_sim(self, v1, v2):
        v1 = np.array(v1)
        v2 = np.array(v2)
        return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))

    def _select_shout(self, muscle_vector):
        candidate = list()
        others = list()

        for shout,svec in self.shout2vector.items():
            if svec:
                cos_sim = self._cos_sim(svec, muscle_vector)
                if cos_sim > 0:
                    candidate.append(shout)
            else:
                others.append(shout)

        while( len(candidate)< 10):
            candidate.append(random.choice(others))

        return random.choice(candidate)

    def _shout2muscle(self,shout):
        mvec = self.shout2vector[shout]
        if not mvec:
            return '筋肉'
        muscles = list(self.muscle_space.basis_muscles.keys())
        return muscles[mvec.index(max(mvec))]

    def reply(self, sentence):
        m_vec = self._sentence2muscle_vec(sentence)
        chosen_shout = self._select_shout(m_vec)
        chosen_muscle = self._shout2muscle(chosen_shout)

        reply_text = ''
        reply_text += random.choice([chosen_muscle+'が！ ',
                                     chosen_muscle+'！！ ',
                                     chosen_muscle+'を見てよ！ ',
                                     '今日も',
                                     '出た！',
                                     'いいね！',
                                     'ハロー！マッソー！'
                                     ])
        reply_text += chosen_shout
        reply_text += random.choice(['！',
                                     '！！',
                                     'ー！！',
                                     '！マッソー！',
                                     '！'+chosen_shout+'！！',
                                     '！'+chosen_muscle+'が！！',
                                     ])
        return reply_text

if __name__ == "__main__":
    ''' 
    【shout.txt 例】

    僧帽筋が並じゃないよ,僧帽筋
    二頭がチョモランマ,上腕二頭筋
    ...

    【menu.txt 例】

    腕立て伏せ,大胸筋,上腕三頭筋
    クランチ,腹直筋
    ...

    '''
    muscle_space = MuscleSpace('../data/basis_definition.txt',
                               '../data/other_definition.txt',
                               '../data/m_vec.pickle')
    muscle_shout = MuscleShout(muscle_space,
                               '../data/shout.txt',
                               '../data/menu.txt')
    reply_text = muscle_shout.reply('バイシクルクランチ20回×3セット')
    print(reply_text)

```
</div></details>

# slackbotとしての実装

下記記事を参考にslackbotとして実装を行い、所属している開発チームslackの筋トレ用チャンネル(`muscle`チャンネル)で公開した。

* [PythonのslackbotライブラリでSlackボットを作る](https://qiita.com/sukesuke/items/1ac92251def87357fdf6)

muscleチャンネルでは、開発チームの有志が日々自身が実施した筋肉トレーニング内容を書き込んだり、筋トレにおける知識を共有する場となっている。
本チャンネルにおいて、筋トレ内容に対してボディービルの掛け声をreplyするbotが存在すれば、日々の筋トレに対するモチベーション向上に寄与することは想像に難くない。

実装において、用意した筋トレメニュー、掛け声、筋肉定義文の種類数は下記のとおりである。

|  |種類数  |
|---|---|
|筋トレメニュー   | 118  |
| 掛け声 |72  |
|筋肉定義文   | 32  |

## 入出力例

<img width="550" alt="muscle2.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293276/0d5bb3b0-ad29-fdb4-1ac3-62cc881a347e.gif">

# まとめ

本文書では筋トレメニューを入力としてボディービル大会等で用いられる掛け声を生成する手法を提案した。
また当該手法を用いてslack botを実装することで同僚の筋トレへのモチベーション維持に貢献し、ひいては開発環境の向上に繋げることが出来た。

今回の実装では筋トレメニュー名のみを対象としていたが、実際のテキストではその回数・セット数も同時に述べられることが多い(例：バイシクルクランチ20回×3セット)。
今後はテキスト中の回数やセット数に応じて出力される掛け声が変化するような改良を行うことで、さらなる開発環境の向上に寄与していきたい。
また、ぜひ読者の皆様も、お持ちであろう筋トレ用のコミュニケーションツールにおいて実装を試みて欲しい。

# 参考文献
* 公益社団法人 日本ボディビル・フィットネス連盟 (監修)「ボディビルのかけ声辞典」、スモール出版
* 荒川 裕志「筋肉の使い方・鍛え方パーフェクト事典」、ナツメ社