ナード戦隊データマン

機械学習と自然言語処理についてのブログ

相互翻訳されたhtml内の文をalignmentする

Sentence Alignment: 翻訳文は常に1対1で対応しているわけではありませんが、Sentence Alignmentでは、そのようなコーパスから1対1に対応したパラレルテキストを生成するタスクです。

github.com

やりたいこと

何らかの方法で「構造が極めて類似している」と判定された、相互翻訳されたWebページのペアがあるとします。そのhtmlのペアから、さらに1対1対応した文のペアのリストを抽出するのが目標です。

かんたんな方法

HTML構造が極めて類似しているのであれば、対応したxpathの文は一致している可能性が高いはずです。例えば、こちらにあるen.htmlとja.htmlは構造的に似た翻訳ページと言えます。

そこで、以下のようなコードを実行します。

import sys
import math
from lxml import etree
from difflib import SequenceMatcher
import MeCab
from functools import reduce
import operator
import re

tagger = MeCab.Tagger("-Owakati")


def tokenizer(text):
    return tagger.parse(text).split()


def p_nodetexts(text1, text2, tokenizer1, tokenizer2):
    s1 = len(tokenizer1(text1))
    s2 = len(tokenizer2(text2))
    diff_word = abs(s1/(s1+s2+1) - s2/(s1+s2+1))
    return 1/(diff_word+1)


def parse_html(html):
    root = etree.fromstring(html.encode("utf-8"), parser=etree.HTMLParser())
    return etree.ElementTree(root)


def getFromDict(dataDict, mapList):
    return reduce(operator.getitem, mapList, dataDict)


def setInDict(dataDict, mapList, value):
    getFromDict(dataDict, mapList[:-1])[mapList[-1]] = value


def format_text(text):
    text = text.replace("\n", " ").replace("\r", "")
    text = re.sub(r"[ ]+", " ", text)
    return text


def get_same_xpath(tree1, tree2):
    xpaths = {}
    texts = []
    stack = [tree1.getroot()]
    while stack:
        elm1 = stack.pop()
        tags = ["html", "title", "head", "body", "meta", "link", "script", "!--"]
        stack += elm1.getchildren()
        if elm1.tag in tags:
            continue
        path1 = tree1.getpath(elm1)
        try:
            elm2 = tree2.xpath(path1)[0]
        except IndexError:
            continue
        if elm2 is not None:
            path1 = path1.split("/")[1:]
            for i, tag in enumerate(path1):
                if tag not in getFromDict(xpaths, path1[:i]):
                    setInDict(xpaths, path1[:i + 1], {})
                else:
                    if elm1.text and elm2.text:
                        elm1.text = format_text(elm1.text)
                        elm2.text = format_text(elm2.text)
                        getFromDict(xpaths, path1[:i])[tag]["_src"] = elm1.text
                        getFromDict(xpaths, path1[:i])[tag]["_tgt"] = elm2.text
                        texts.append(elm1.text + "\t" + elm2.text)
    return xpaths, list(set(texts))


def align_texts(texts, threshold=0.65, tokenize=tokenizer):
    out = []
    for text in texts:
        try:
            text1, text2 = text.split("\t")
        except:
            continue
        p = p_nodetexts(text1, text2, tokenize, tokenize)
        print(p)
        if p < threshold:
            continue
        else:
            out.append(text)
    return out


if __name__ == "__main__":
    import json
    import pprint
    with open(sys.argv[1]) as f:
        tree1 = parse_html(f.read())

    with open(sys.argv[2]) as f:
        tree2 = parse_html(f.read())

    xpaths, texts = get_same_xpath(tree1, tree2)
    pprint.pprint(texts)

すると、対応するxpathの文が抽出されます。抽出されたものの例は以下で公開しています。

html_alignment_tools/test_txt.txt at master · sugiyamath/html_alignment_tools · GitHub

length-based method

抽出された文の中には、翻訳として適さないペアが存在しています。そこで、文の単語数を使って、ある値を出力し、その値が閾値以下であれば除外することを検討してみます。

def p_nodetexts(text1, text2, tokenizer1, tokenizer2):
    s1 = len(tokenizer1(text1))
    s2 = len(tokenizer2(text2))
    diff_word = abs(s1/(s1+s2+1) - s2/(s1+s2+1))
    return 1/(diff_word+1)

def align_texts(texts, threshold=0.65, tokenize=tokenizer):
    out = []
    for text in texts:
        try:
            text1, text2 = text.split("\t")
        except:
            continue
        p = p_nodetexts(text1, text2, tokenize, tokenize)
        if p < threshold:
            continue
        else:
            out.append(text)
    return out

このスコアは、「異なる言語であっても、単語数はだいたい近くなる」というような仮定を持っています。

    xpaths, texts = get_same_xpath(tree1, tree2)
    texts1 = align_texts(texts)
    texts2 = align_texts(texts, threshold=0.0)
    pprint.pprint(set(texts2).difference(set(texts1)))

以下は、除外されたペアです。

 'Event\t在学生からのメッセージ',

 'Event\t研究分野のご案内',

 'Low Temperature Quantum Physics Group performs the experimental researches '
 'in condensed matter physics at very low temperatures, in high magnetic '
 'fields and under high pressures. Besides the developments of new techniques '
 'and detection systems under such extreme conditions, synthesis of novel '
 'materials and growth of high quality single crystals are other major '
 'activities of the group.\t物質は一辺が1 cmの箱当たりにして10',

 'Material science is one of the key word to consider the modern society, and '
 'the structural and crystal physics gives a fundamental base for this '
 'field. \t'
 '結晶は原子が規則的に並んだものですが、相転移(例えば黒鉛がダイヤモンドになる等)が起こると原子や電子の分布は何らかの事情で変化します。それがたとえ僅か0.1Å以下の変化であっても結晶の性質(誘電性、伝導性、磁性など)が大きく変わることがしばしば起こります。このような原子や電子の変位をX '
 '線や中性子線などを用いた回折実験で「観る」ことにより、結晶の世界の法則を明らかにしていきます。',

 'Our research group is aiming at the ultrafast optical/THz manipulations of '
 'corrective electron and spin motions in solids by using advance light source '
 'such as mono-few optical cycle IR pulss and THz pulse.\t'
 '現代の光科学は、物質の性質を調べるという従来の枠組みを超えて、光で新しい物質相を '
 '創り、制御する、よりダイナミックな領域へと広がりつつあります。これは超高速、大容量の光通信、光コンピューティングを目指す社会的要請(Society '
 '5.0)に沿ったものです。 '
 '本研究グループでは、従来の半導体などを対象とした光機能の研究の枠を超え、高温超伝導体、量子スピン液体/マヨラナフェルミオン物質や、強相関ディラック半金属など、量子多体効果が生み出す「強相関電子」の世界を対象とします。極限的な赤外短パルス光(パルス幅~ '
 '5 フェムト秒、フェムト秒=千兆分の一 (10',

 'Our research targets are understanding of fundamental crystal growth '
 'mechanisms and development of crystal growth technology for obtaining '
 'high-quality materials. \t'
 '結晶成長物理グループでは、液相または気相から固相が形成される過程で生じる様々な現象を研究対象としています。半導体、金属合金、酸化物などの実用バルク材料の多くは液相からの結晶成長により作製されています。結晶成長過程において固液界面でどのようなメカニズムで結晶が成長し、結晶材料の組織がどのようなメカニズムで形成されていくのか、といった融液成長の本質はほとんど理解されていません。当グループでは結晶成長メカニズムを基礎的に解明し、これをベースに新規な結晶成長技術を開発し、高品質結晶材料を実現することを目指しています。',

 '\u3000We are currently studying nano size materials comprised of IV\t'
 '近年の物性物理の進展は、物質の極微細構造に踏み込んで物性と構造との関係を理解し、それを基礎として新しい素材を開拓する時代に突入している。この様な物性分野の進展は、これまで微細加工技術を基礎として進められてきた。現在では、電子線加工技術などにより数百Å程度のサイズの加工が可能である。さらに、トンネルスペクトロスコピーを利用すれば原子を1個ずつ制御する事が可能な時代でもある。'

例えば、'Event\t在学生からのメッセージ' というペアは翻訳として適していません。Eventを日本語で訳しても、「在学生からのメッセージ」にはならないからです。

このように、length-based手法を適用すると、適していないペアが除外されたことがわかります。

課題

このタスクの課題は、「より多く(recall)の翻訳文を、より正確に(precision)」抽出することです。length-based手法は、データも訓練も使わずに実行ができました。しかし、精度をあげようと思ったら、辞書や特徴量設計+訓練が必要になるかもしれません。

ただ、辞書や訓練する手間がかかるような手法は、多数の言語に対応させる際に面倒になります。各々の言語ごとにデータを用意しなければならないためです。bitextor1のようなツールがヨーロッパ言語にしか対応していないのは、少なくとも、言語ごとのトーカナイザと辞書の問題があります。

精度の高いコーパスを作成するためには、例えば抽出された文を人の目で確かめる作業が必要になるかもしれません。しかし、数億のペアを作成するためには、何年もかかる作業になってしまうでしょう。

また、xpathを指定する方法の場合、「より多くの翻訳文を抽出する」という部分が難点になります。翻訳されたページにおいて、xpathが正確に一致している要素は、dom構造が単に類似しているだけの要素よりもずっと少ないです。

参考

htmlの構造的な類似性を評価する

htmlの構造的類似性とは、2つのHTMLに対して、コンテンツを除いたHTMLツリーがどの程度似ているかを表す指標です。

単純なアルゴリズム

sdiff等のツールを使えば、2つの文書から相違する部分を抜き出すことができます。Automatic Acquisition of Chinese–English Parallel Corpus from the Web1 の中では、以下の計算方法で構造的スコアを計算しています。

S = sdiffの相違行数 / 合計行数

このスコアの場合、スコアが低いほど類似性が高いということになります。

何に使えるのか

構造スコアを使うことにより、相互翻訳されたWebページを発見することができます。例えば、

の2つのページの構造が似ていた場合、相互翻訳されている可能性が高いと判断することができます。これにより、この2つのページからパラレルコーパスを作成することができます。一般に、クローリングの中で構造スコアのような指標をもちいて相互翻訳ページを見つければ、Webから巨大なパラレルコーパスを作成できます。

コード

以下のpythonコードではsdiffは使っていませんが、difflibというライブラリによって構造スコアを計算しています。

import re
from difflib import SequenceMatcher


def extract(data):
    regex = re.compile(r"<.+?>")
    data_fix = re.findall(regex, data)
    data_fix1 = [
        re.sub(r'<\s*([a-z][a-z0-9]*)\s.*?(/?>)', r"<\1\2", x)
        for x in data_fix
    ]
    return '\n'.join(data_fix), '\n'.join(data_fix1)


def html_similarity(data1, data2, alpha=0.3):
    x1, x2 = extract(data1)
    y1, y2 = extract(data2)
    seq1 = SequenceMatcher(None, x1, y1)
    seq2 = SequenceMatcher(None, x2, y2)
    result = seq1.ratio() * alpha + seq2.ratio() * (1 - alpha)
    return result


if __name__ == "__main__":
    import sys
    with open(sys.argv[1]) as f:
        data1 = f.read()

    with open(sys.argv[2]) as f:
        data2 = f.read()

    print(html_similarity(data1, data2))

実行例

[コマンド]

python3 hsim.py doc1.html doc2.html

[出力]

0.849068608469161

補足

パラレルコーパスをWebのクローリングによって作成する場合、構造スコアの他に「ファイル長」を使うことができます。2つのファイルのファイル長の比率がある範囲に収まるかどうかを判定することで、相互翻訳ページの判定に使えます。この場合、ファイル長特徴量と構造スコア特徴量の2つをKNNに学習させれば、より良い候補選択方法をモデリングすることが可能です。ただし、その場合は教師データが必要になります。

また、相互翻訳されたページのペアを見つけても、そこからさらに文のペアを抽出しなければなりません。これは、bilingual sentence alignmentと呼ばれるタスク(?)です。この場合、HTMLの構造が利用できるような方法2を使うと良いかもしれません。

参考

tensor2tensorで和英翻訳

tensor2tensor1とは、ディープラーニングをより使いやすく、ML研究を加速させるために設計されたモデルとデータセットを含んだライブラリです。

実行フロー

  1. パラレルコーパスをダウンロード。
  2. コーパスを分離。
  3. 独自のデータジェネレータを登録。
  4. データを生成。
  5. 訓練。
  6. 対話的に翻訳を実行。

コード

パラレルコーパスのダウンロード (wget)

Tatoeba Project2 から抽出されたデータ3があるので、これをダウンロードします。

wget http://www.manythings.org/anki/jpn-eng.zip
unzip jpn-eng.zip

コーパスの分離 (split.py)

コーパスを英語と日本語に分離しておきます。これにはあまり意味はありません。

if __name__ == "__main__":
    with open("./jpn.txt") as f, \
         open("tatoeba_jp.txt", "w") as f_jp, \
         open("tatoeba_en.txt", "w") as f_en:
        for line in f:
            line = line.strip()
            line = line.split("\t")
            f_en.write(line[0]+"\n")
            f_jp.write(line[1]+"\n")

独自のデータジェネレータを登録 (myproblem.py)

こちら4 を参考に、データジェネレータを登録します。

from tensor2tensor.data_generators import problem
from tensor2tensor.data_generators import text_problems
from tensor2tensor.utils import registry
 
 
@registry.register_problem
class Translate_JPEN(text_problems.Text2TextProblem):
    @property
    def approx_vocab_size(self):
        return 2**13
    
    @property
    def is_generate_per_split(self):
        return False
 
    @property
    def dataset_splits(self):
        return [{
            "split": problem.DatasetSplit.TRAIN,
            "shards": 9,
        }, {
            "split": problem.DatasetSplit.EVAL,
            "shards": 1,
        }]
 
    def generate_samples(self, data_dir, tmp_dir, dataset_split):
        filename_jp = './data/tatoeba_jp.txt'
        filename_en = './data/tatoeba_en.txt'
 
        with open(filename_jp) as f_jp, open(filename_en) as f_en:
            for src, tgt in zip(f_jp, f_en):
                src = src.strip()
                tgt = tgt.strip()
                if not src or not tgt:
                    continue
                yield {'inputs': src, 'targets': tgt}

同じディレクトリに以下の__init__.pyが必要だそうです。

from . import myproblem

データを生成 (t2t-datagen)

t2t-datagen --data_dir=data/data1 --tmp_dir=tmp --problem=translate_jpen --t2t_usr_dir .

訓練 (t2t-trainer)

t2t-trainer --data_dir=data/data1 --problem=translate_jpen --model=transformer --hparams_set=transformer_base_single_gpu --output_dir=training_result1 --t2t_usr_dir=.

対話的に翻訳を実行(t2t-decoder)

t2t-decoder --data_dir=data/data1 --problem=translate_jpen --model=transformer --hparams_set=transformer_base_single_gpu --output_dir=training_result1 --decode_hparams="beam_size=4,alpha=0.6" --decode_interactive=true --t2t_usr_dir=.

いくつかの結果

>今日はいい天気。
It's a nice day today.

>今日は晴れ。
It's fine today.

>おいしい卵。
It's delicious.

>足が痛い。
My leg hurts.

>私は病気が怖い。
I am afraid of being ill.

>朝のコーヒーは格別にうまい。
In the morning, a good cup of coffee.

>やあ元気?
How are you?

>俺は警察だ馬鹿野郎!
Fantastic!

考察

翻訳できている文とあまりできていない文があります。翻訳できていない文は、そもそも訓練データ内に当該の語彙がありません。

実用レベルで行うには、もっと巨大なコーパスが必要になるのかもしれません。今回試した方法だけでは未知語への対処は難しいと思います。

参考

SHLデータセット: スマホセンサー情報に対する多目的アノテーション付きデータセット

SHLデータセット1は、多数のセンサーを使って収集されたユーザーの情報に対して、移動手段を含むいくつかのアノテーションがされたデータセットです。

github.com

※ コードは上記プロジェクトで公開しました。

概要

ラベルとセンサーの種類には以下のようなものがあります。

SHLデータセットの移動手段ラベル:

  • Still: standing or sitting; inside or outside a building
  • Walking: inside or outside
  • Run
  • Bike
  • Car: as driver, or as passenger
  • Bus: standing or sitting; lower deck or upper deck
  • Train: standing or sitting
  • Subway: standing or sitting

センサーの種類:

  • Accelerometer: x, y, z in m/s2
  • Gyroscope: x, y, z in rad/s
  • Magnetometer: x, y, z in μT
  • Orientation: quaternions in the form of w, x, y, z vector
  • Gravity: x, y, z in m/s2
  • Linear acceleration: x, y, z in m/s2
  • Ambient pressure in hPa
  • Google’s activity recognition API output: 0-100% of confidence for each class (“in vehicle”, “on bicycle”, “on foot”, “running”, “still”, “tilting”, “unknown”, “walking”)
  • Ambient light in lx
  • Battery level (0-100%) and temperature (in °C)
  • Satellite reception: ID, SNR, azimuth and elevation of each visible satellite
  • Wifi reception including SSID, RSSI, frequency and capabilities (i.e. encryption type)
  • Mobile phone cell reception including network type (e.g. GSM, LTE), CID, location area code (LAC), mobile country code (MCC), mobile network code (MNS), signal strength
  • Location obtained from satellites (latitude, longitude, altitude, accuracy)
  • Audio

移動手段予測のコード例

データのダウンロード (download.sh)

まずデータをダウンロードします。

wget http://www.shl-dataset.org/wp-content/uploads/SHLDataset_User1Hips_v1/SHLDataset_User1Hips_v1.zip.001 &
wget http://www.shl-dataset.org/wp-content/uploads/SHLDataset_User1Hips_v1/SHLDataset_User1Hips_v1.zip.002 &
wget http://www.shl-dataset.org/wp-content/uploads/SHLDataset_User1Hips_v1/SHLDataset_User1Hips_v1.zip.003 &
wget http://www.shl-dataset.org/wp-content/uploads/SHLDataset_User1Hips_v1/SHLDataset_User1Hips_v1.zip.004 &
wget http://www.shl-dataset.org/wp-content/uploads/SHLDataset_User1Hips_v1/SHLDataset_User1Hips_v1.zip.005 &

wait

echo "Done"

p7zip-fullをインストールします。

apt install p7zip-full

データを展開します。

7z x SHLDataset_User1Hips_v1.zip.001

Note: 7zipで分割ファイルを展開する場合、最初のID(001)を指定して展開するだけでOKです。

データをcsvに変換。(data_fixer.py)

加速度センサーとジャイロスコープだけ切り出します。

import os
import pandas as pd
from tqdm import tqdm


root_path = "./release/User1/"
csv_root = "./release/User1/"

"""
./release/User1/010317/Hips_Motion.txt
./release/User1/010317/Label.txt
"""

names1 = [
    "time", "accx", "accy", "accz", "gyrox", "gyroy", "gyroz", "magx", "magy",
    "magz", "oriw", "orix", "oriy", "oriz", "gravx", "gravy", "gravz", "laccx",
    "laccy", "laccz", "press", "alt", "temp"
]

names2 = [
    "time", "label", "finelabel", "roadlabel", "trafficlabel", "tunnelslabel",
    "sociallabel", "foodlabel"
]


def blocks(f, size=65536):
    while True:
        b = f.read(size)
        if not b:
            break
        yield b


def line_count(path):
    with open(path) as f:
        total = sum(b1.count("\n") for b1 in blocks(f))
    return total


def load_one(path):

    file1 = "Hips_Motion.txt"
    file2 = "Label.txt"
    
    feature_path = os.path.join(path, file1)
    label_path = os.path.join(path, file2)

    if line_count(feature_path) != line_count(label_path):
        return None, False

    df = pd.read_csv(feature_path, sep=" ", header=None, names=names1)
    df_label = pd.read_csv(label_path, sep=" ", header=None, names=names2)

    df["time"] = df["time"].astype("int")
    df_label["time"] = df["time"].astype("int")

    features = ["time", "accx", "accy", "accz", "gyrox", "gyroy", "gyroz"]

    labels = ["time", "label"]

    df = df[features]
    df_label = df_label[labels]

    df = df.merge(df_label, on=["time"])
    del (df_label)
    
    return df, True


def load_all():
    for idx in tqdm(os.listdir(root_path)):
        path = os.path.join(root_path, idx)
        try:
            df, flag = load_one(path)
            if flag:
                df.to_csv(os.path.join(root_path, idx+".csv"), index=False)
        except Exception as e:
            with open("fixing.log", "a") as f:
                f.write("path:{}, error:{}".format(path, repr(e)))


def concat_data():
    dfs = []
    for filename in tqdm(os.listdir(csv_root)):
        if filename.endswith(".csv"):
            dfs.append(
                pd.read_csv(os.path.join(csv_root, filename)))
    df = pd.concat(dfs)
    df["time"].astype("int")
    df = df.sort_values(by=["time"])
    df.to_csv("data.csv", index=False)


if __name__ == "__main__":
    load_all()
    concat_data()

ウィンドウ分割(window_segmentation.py)

生データの各点では予測できないので、点を線に変換して予測するためにWindow分割します。

import pandas as pd
from tqdm import tqdm
import numpy as np

cols = ["time", "accx", "accy", "accz", "gyrox", "gyroy", "gyroz", "label"]


def most_frequent(arr):
    return max(set(arr), key=arr.count)


def window_segmentation(df):
    df["time"] = df["time"].astype("int")
    df = df[df["label"] != 0]
    df = df.sort_values(by=["time"])

    out = []
    out_label = []
    prev_time = None
    current_group = []
    current_group_label = []

    for time, accx, accy, accz, gyrox, gyroy, gyroz, label in tqdm(
            zip(*[df[col] for col in cols])):
        if len(current_group) == 512:
            out.append(current_group)
            out_label.append(most_frequent(current_group_label))
            current_group = []
            current_group_label = []
        if prev_time is None:
            current_group.append([accx, accy, accz, gyrox, gyroy, gyroz])
            current_group_label.append(label)
        elif time - prev_time < 100:
            current_group.append([accx, accy, accz, gyrox, gyroy, gyroz])
            current_group_label.append(label)
        elif len(current_group) < 512:
            current_group = []
            current_group_label = []
        prev_time = time
    out, out_label = np.array(out), np.array(out_label)
    print(out.shape)
    print(out_label.shape)

    np.save("data_features.npy", out)
    np.save("data_labels.npy", out_label)
    return out, out_label


if __name__ == "__main__":
    df = pd.read_csv("./data.csv")
    window_segmentation(df)

均衡化 (fix_window.py)

データの偏りをなくします。

import numpy as np


def balancing(feature_data="./data_features.npy",
              label_data="./data_labels.npy"):

    labels = [1, 2, 3, 4, 5, 6, 7, 8]
    X = np.load(feature_data)
    y = np.load(label_data)

    min_length = y.shape[0]
    for label in labels:
        tmp = np.sum(y == label)
        if tmp < min_length:
            min_length = tmp

    data = []
    data_label = []
    for label in labels:
        indices = np.where(y == label)[0][:min_length]
        data.append(X[indices])
        data_label.append(y[indices])

    X = np.concatenate(data)
    y = np.concatenate(data_label)

    print(X.shape)
    print(y.shape)
    np.save("data_features_balanced.npy", X)
    np.save("data_labels_balanced.npy", y)
    return X, y


if __name__ == "__main__":
    balancing()

訓練・テスト(train.py)

CNNによるモデルを訓練します。

import numpy as np
from keras.models import Sequential, load_model
from keras.layers import SeparableConv1D, MaxPooling1D, Flatten
from keras.layers import Dense
from keras.callbacks import ModelCheckpoint
from sklearn.metrics import classification_report
from sklearn.model_selection import train_test_split


def build_model(nlabels=9):
    print("prepareing model")
    model = Sequential([
        SeparableConv1D(48, 4, 1, input_shape=(512, 6)),
        SeparableConv1D(48, 4, 1),
        MaxPooling1D(2),
        SeparableConv1D(64, 4, 1),
        SeparableConv1D(64, 4, 1),
        MaxPooling1D(4),
        SeparableConv1D(80, 4, 1),
        SeparableConv1D(80, 4, 1),
        MaxPooling1D(4),
        Flatten(),
        Dense(nlabels, activation="softmax")
    ])

    model.compile(loss="sparse_categorical_crossentropy",
                  optimizer="nadam",
                  metrics=["accuracy"])

    return model


if __name__ == "__main__":
    model = build_model()
    callbacks = [
        ModelCheckpoint("./model_best.h5",
                        monitor="val_loss",
                        save_best_only=True,
                        mode="min")
    ]

    X = np.load("./data/final/data_features_balanced.npy")
    y = np.load("./data/final/data_labels_balanced.npy")
    X = np.nan_to_num(X)
    y = np.nan_to_num(y)
    X_train, X_test, y_train, y_test = train_test_split(X, y, train_size=0.8)
    X_valid, X_test, y_valid, y_test = train_test_split(X_test,
                                                        y_test,
                                                        train_size=0.5)
    model.fit(X_train,
              y_train,
              validation_data=(X_valid, y_valid),
              epochs=300,
              batch_size=1000,
              callbacks=callbacks)
    model = load_model("./model_best.h5")
    y_pred = model.predict_classes(X_test)
    print(classification_report(y_test, y_pred))

精度

1=Still, 2=Walking, 3=Run, 4=Bike, 5=Car, 6=Bus, 7=Train, 8=Subway

              precision    recall  f1-score   support

           1       0.64      0.83      0.73       605
           2       0.92      0.87      0.89       634
           3       0.98      0.99      0.99       626
           4       0.95      0.92      0.93       606
           5       0.90      0.93      0.92       579
           6       0.91      0.78      0.84       645
           7       0.61      0.68      0.64       643
           8       0.53      0.41      0.46       606

    accuracy                           0.80      4944
   macro avg       0.81      0.80      0.80      4944
weighted avg       0.81      0.80      0.80      4944

参考

加速度センサーだけで移動手段を予測する

加速度センサーによる移動手段予測とは、スマホの加速度センサーから収集したデータを利用し、車・電車・歩行などの移動手段を予測する方法です。

tmd_experiments/e2e_model at master · sugiyamath/tmd_experiments · GitHub

※ コードは上記プロジェクトから見れます。

概要

ある記事1で紹介されていた手法を参考にします。TMDデータセット2を今回も使用します。

加速度センサーは、x, y, zの情報を持っています。これらの情報(生データ)をサイズ512程度のWindowに分割し、このWindowをそのままCNNへ入力します。

コード

前処理モジュール (preprocessing.py)

import numpy as np


ACC = "android.sensor.accelerometer"
GYRO = "android.sensor.gyroscope"
SOUND = "sound"


def data_loader(infile, sensor_type):
    with open(infile) as f:
        data = []
        for line in f:
            line = line.strip()
            line = line.split(",")
            if line[1] == sensor_type:
                try:
                    line[0] = int(line[0])
                    if line[0] < 0:
                        line[0] *= -1
                    line[2:] = list(map(float, line[2:]))
                    data.append(line)
                except ValueError:
                    print(infile)
    return data


def window_segmentation(data, window_length=512):
    out = []
    row = []
    for d in data:
        if len(row) < window_length:
            x = list(map(float, d[2:]))
            row.append(x)
        elif len(row) == window_length:
            out.append(row)
            row = []
            x = list(map(float, d[2:]))
            row.append(x)
        else:
            raise Exception("somehting wrong")
    if len(row) == window_length:
        out.append(row)
    out = np.array(out)
    print(out.shape)
    return out

データ生成器 (data_generator.py)

root_pathをTMDデータセットのパスにしてください。

import os
import numpy as np
from tqdm import tqdm
from preprocessing import data_loader, window_segmentation
from preprocessing import ACC

root_path = "/root/work/tmd/raw_data/"

DATA = [
    "U1", "U2", "U3", "U4", "U5", "U6", "U7", "U8", "U9", "U10", "U11", "U13",
    "U14", "U16", "U12", "U15"
]

label2id = {"Bus": 0, "Car": 1, "Still": 2, "Train": 3, "Walking": 4}


def data_extraction(path):
    acc = data_loader(path, sensor_type=ACC)
    acc = window_segmentation(acc)
    return acc, acc.shape[0]


def logging(path, e):
    with open("training.log", "a") as f:
        f.write("file:{}, error: {}".format(path, repr(e)))
        f.write("\n")


def define_labels(filename, window_length, label2id):
    labelid = label2id[filename.split("_")[2]]
    labels = [labelid for _ in range(window_length)]
    return labels


def data_processing(X, y, paths):
    for path, filename in tqdm(paths):
        try:
            acc, window_length = data_extraction(path)
        except Exception as e:
            logging(path, e)
            continue
        try:
            labels = define_labels(filename, window_length, label2id)
            acc = acc.tolist()
            assert len(acc) == len(labels)
        except Exception as e:
            logging(path, e)
            continue
        X += acc
        y += labels
    return X, y


def data_create(users, test=False):
    ds = [
        os.path.join(root_path, d) for d in os.listdir(root_path) if d in users
    ]
    X = []
    y = []
    for d in tqdm(ds):
        paths = [(os.path.join(d, path), path) for path in os.listdir(d)
                 if ".csv" in path]
        X, y = data_processing(X, y, paths)
    X = np.array(X)
    y = np.array(y)
    print(X.shape)
    print(y.shape)
    return X, y

訓練・テスト(train.py)

CNNの構造はこのコードを見てください。

import pandas as pd
import pickle
import numpy as np
from keras.models import Sequential, load_model
from keras.layers import SeparableConv1D, MaxPooling1D, Flatten
from keras.layers import Dropout, Dense
from keras.callbacks import ModelCheckpoint
from sklearn.metrics import classification_report
from sklearn.model_selection import train_test_split
from data_generator import data_create, DATA


def build_model(nlabels=5):
    print("prepareing model")
    model = Sequential([
        SeparableConv1D(48, 4, 1, input_shape=(512, 3)),
        SeparableConv1D(48, 4, 1),
        MaxPooling1D(2),
        SeparableConv1D(64, 4, 1),
        SeparableConv1D(64, 4, 1),
        MaxPooling1D(4),
        SeparableConv1D(80, 4, 1),
        SeparableConv1D(80, 4, 1),
        MaxPooling1D(4),
        Flatten(),
        Dense(nlabels, activation="softmax")
    ])

    model.compile(loss="sparse_categorical_crossentropy",
                  optimizer="nadam",
                  metrics=["accuracy"])

    return model


def debug_do(model):
    print("debug do")
    X_train, y_train = data_create("U1")
    model.fit(X_train, y_train)


def load_data(load=False):
    print("load data")
    if load:
        with open("training.pkl", "rb") as f:
            X, y = pickle.load(f)
    else:
        X, y = data_create(DATA)
        with open("training.pkl", "wb") as f:
            pickle.dump((X, y), f)
    return X, y


def fixing_data(X, y):
    tmp_X = []
    tmp_y = []
    min_indices = X.shape[0]
    for label in [0, 1, 2, 3, 4]:
        indices = np.where(y == label)[0]
        if indices.shape[0] < min_indices:
            min_indices = indices.shape[0]
        tmp_X.append(X[indices])
        tmp_y.append(y[indices])
    for i, (x_data, y_data) in enumerate(zip(tmp_X, tmp_y)):
        tmp_X[i] = x_data[:min_indices]
        tmp_y[i] = y_data[:min_indices]
    return np.concatenate(tmp_X), np.concatenate(tmp_y)


if __name__ == "__main__":
    debug = False
    load = False
    model = build_model()
    callbacks = [
        ModelCheckpoint("./model_best.h5",
                        monitor="val_loss",
                        save_best_only=True,
                        mode="min")
    ]
    if debug:
        debug_do(model)
    else:
        X, y = load_data(load)
        X, y = fixing_data(X, y)
        X_train, X_test, y_train, y_test = train_test_split(X,
                                                            y,
                                                            train_size=0.7)
        X_valid, X_test, y_valid, y_test = train_test_split(X_test,
                                                            y_test,
                                                            train_size=0.5)
        model.fit(X_train,
                  y_train,
                  validation_data=(X_valid, y_valid),
                  epochs=200,
                  batch_size=1000,
                  callbacks=callbacks)
        model = load_model("./model_best.h5")
        y_pred = model.predict_classes(X_test)
        print(classification_report(y_test, y_pred))

※ holdout分割を使っているので、精度が高めに出てしまいます。

精度

              precision    recall  f1-score   support

           0       0.82      0.93      0.87        29
           1       0.87      0.73      0.80        45
           2       0.76      0.88      0.81        40
           3       0.79      0.69      0.74        39
           4       0.93      0.98      0.95        43

    accuracy                           0.84       196
   macro avg       0.83      0.84      0.83       196
weighted avg       0.84      0.84      0.83       196

考察

加速度センサーのみで分類できるということは、それぞれの移動手段において、加速度の傾向に有意な差が存在している可能性があります。

例えば、以下はTrainとCarの加速度をプロットしたものです。

f:id:mathgeekjp:20190719092355p:plain
電車の加速度

f:id:mathgeekjp:20190719092524p:plain
車の加速度

要は、加速度センサーの生データだけを使って分類しても、ある程度はいけるみたいです。おそらく、ジャイロスコープなどの特徴量を追加すればより精度は上がります。また、CNNのネットワークを変えれば精度が上る可能性はあります。

このようにして、加速度センサーだけで移動手段を予測できることがわかりました。

参考

TMD dataset: スマホのセンサー情報で移動手段予測

TMDデータセット1とは、スマホのセンサー情報を用いて移動手段を予測するための無料のデータセットです。

github.com

※ この記事のコードの修正版は上記のgithubページにアップロードしています。修正版の方が簡潔です。

概要

githubにコード2が公開されています。 GitHub - vlomonaco/US-TransportationMode: Transportation Mode Detection with Unconstrained Smartphones Sensors

コードの実行フローは以下です:

  1. 生データのダウンロード。
  2. 生データをウィンドウ分割するなど加工。
  3. 加工したデータを使ってモデリングとテスト。

windowのサイズは5秒となっており、5秒の中から特徴量を抽出し、そのwindowの移動手段を分類します。

特徴量には以下があります:

  • 当該センサーの平均値。
  • 当該センサーの最大値。
  • 当該センサーの最小値。
  • 当該センサーの標準偏差

コード

とはいっても、githubのコードは煩雑すぎるので、もう少しだけ簡単にしてみようと思います。

前処理モジュール(preprocessing.py)

import numpy as np


ACC = "android.sensor.accelerometer"
GYRO = "android.sensor.gyroscope"
SOUND = "sound"


def data_loader(infile, sensor_type):
    with open(infile) as f:
        data = []
        for line in f:
            line = line.strip()
            line = line.split(",")
            if line[1] == sensor_type:
                try:
                    line[0] = int(line[0])
                    if line[0] < 0:
                        line[0] *= -1
                    line[2:] = list(map(float, line[2:]))
                    data.append(line)
                except ValueError:
                    print(infile)
    return data


def window_segmentation(data, window_size=20000):
    first_t = 0.0
    out = []
    row = []
    for d in data:
        if int(d[0]) - first_t < window_size:
            x = np.array(list(map(float, d[2:])))
            row.append(np.sqrt(np.nansum(x**2)))
        else:
            out.append(row)
            row = []
            first_t += window_size
            x = np.array(list(map(float, d[2:])))
            row.append(np.sqrt(np.nansum(x**2)))
    if row:
        out.append(row)
    return out


def feature(window):
    if window:
        return [
            np.nanmean(window),
            np.nanstd(window),
            np.nanmax(window),
            np.nanmin(window)
        ]
    else:
        return [0.0, 0.0, 0.0, 0.0]

データ生成器

import os
import numpy as np
from tqdm import tqdm
from preprocessing import data_loader, window_segmentation, feature, ACC, GYRO, SOUND

root_path = "/root/work/tmd/raw_data/"

TRAIN = [
    "U1", "U2", "U3", "U4", "U5", "U6", "U7", "U8", "U9", "U10", "U11", "U13",
    "U14", "U16", "U12"
]
VALID = ["U15"]
#TEST = ["U12"]

label2id = {"Bus": 0, "Car": 1, "Still": 2, "Train": 3, "Walking": 4}


def data_generate(users, test=False):
    ds = [
        os.path.join(root_path, d) for d in os.listdir(root_path) if d in users
    ]
    X = []
    y = []
    for d in tqdm(ds):
        paths = [
            os.path.join(d, path) for path in os.listdir(d) if ".csv" in path
        ]
        for path in tqdm(paths):
            try:
                acc = data_loader(path, sensor_type=ACC)
                gyro = data_loader(path, sensor_type=GYRO)
                sound = data_loader(path, sensor_type=SOUND)
                acc = window_segmentation(acc)
                gyro = window_segmentation(gyro)
                sound = window_segmentation(sound)
                window_length = min(len(acc), len(gyro))
                window_length = min(len(sound), window_length)
                if window_length == 0:
                    continue
                acc = acc[:window_length]
                gyro = gyro[:window_length]
                sound = sound[:window_length]
            except Exception as e:
                with open("training.log", "a") as f:
                    f.write("file:{}, error: {}".format(path, repr(e)))
                    f.write("\n")
                continue
            labelid = label2id[path.split("_")[3]]
            labels = [labelid for _ in range(window_length)]

            tmp_X = []
            for a, g, s in zip(acc, gyro, sound):
                row = feature(a) + feature(g) + feature(s)
                tmp_X.append(row)
            try:
                assert len(tmp_X) == len(labels)
            except Exception as e:
                with open("training.log", "a") as f:
                    f.write("file:{}, error: {}".format(path, repr(e)))
                    f.write("\n")
                continue
            X += tmp_X
            y += labels

    X = np.array(X)
    y = np.array(y)
    return X, y

訓練・テスト

import pandas as pd
import pickle
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report
from sklearn.model_selection import GridSearchCV
from data_generator import data_generate, TRAIN, VALID

if __name__ == "__main__":
    debug = False

    # parameters for GridSearchCV
    param_grid = {
        "n_estimators": [100],
        "max_depth": [3, 5],
        "min_samples_split": [10, 20],
        "min_samples_leaf": [5, 10, 20],
        "max_leaf_nodes": [20, 40],
        "min_weight_fraction_leaf": [0.1]
    }

    model = RandomForestClassifier(class_weight="balanced")
    grid_search = GridSearchCV(model, param_grid=param_grid)

    if debug:
        X_train, y_train = data_generate("U1")
        X_train = pd.DataFrame(X_train).replace([np.inf, -np.inf, np.nan], 0.0)
        X_train.to_csv("debug.csv")
        model.fit(X_train, y_train)
    else:
        X_train, y_train = data_generate(TRAIN)
        X_test, y_test = data_generate(VALID)
        with open("training.pkl", "wb") as f:
            pickle.dump((X_train, y_train, X_test, y_test), f)
        #with open("training.pkl", "rb") as f:
        #    X_train, y_train, X_test, y_test = pickle.load(f)

        X_train = pd.DataFrame(X_train).replace([np.inf, -np.inf, np.nan], 0.0)
        X_test = pd.DataFrame(X_test).replace([np.inf, -np.inf, np.nan], 0.0)
        grid_search.fit(X_train, y_train)

        model = RandomForestClassifier(class_weight="balanced",
                                       **grid_search.best_params_)
        model.fit(X_train, y_train)
        y_pred = model.predict(X_test)
        print(classification_report(y_test, y_pred))

精度

              precision    recall  f1-score   support

           0       0.10      0.42      0.16        12
           1       0.73      0.43      0.54        51
           2       1.00      0.57      0.73        94
           3       0.65      0.83      0.73        66
           4       0.92      1.00      0.96        44

    accuracy                           0.67       267
   macro avg       0.68      0.65      0.62       267
weighted avg       0.81      0.67      0.71       267

考察

あまり精度がよくありません。前処理でなにかミスしているかもしれません。彼らのgithubのコードを実行すると80%以上の精度がでるので、前処理段階で何かが違っている可能性が高いです。

もし、自前でアノテーションをしたい場合は、以下でアノテーションツールを公開しています。 http://cs.unibo.it/projects/us-tm2017/tutorial.html

ともあれ、センサーデータから移動手段の予測、という問題に挑戦したい場合は、TMDデータセットは無料で公開されているので興味があれば使ってみては。

あるいは、もっと巨大なデータとして以下があります。

Sussex-Huawei Locomotion Dataset

追記

2019/07/18 8:55

訓練スクリプトを以下のように書き換え(balancedデータにする)たら精度が上がったようです。

import pandas as pd
import pickle
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report
from sklearn.model_selection import GridSearchCV, train_test_split
from data_generator import data_generate, DATA


def prepare_models():
    print("prepareing model")
    param_grid = {
        "n_estimators": [100],
        "max_depth": [3, 5],
        "min_samples_split": [10, 20],
        "min_samples_leaf": [5, 10, 20],
        "max_leaf_nodes": [20, 40],
        "min_weight_fraction_leaf": [0.1]
    }

    model = RandomForestClassifier(class_weight="balanced")
    grid_search = GridSearchCV(model, param_grid=param_grid)
    return model, grid_search


def debug_do(model):
    print("debug do")
    X_train, y_train = data_generate("U1")
    X_train = pd.DataFrame(X_train).replace([np.inf, -np.inf, np.nan], 0.0)
    X_train.to_csv("debug.csv")
    model.fit(X_train, y_train)


def load_data(load=False):
    print("load data")
    labels = [0, 1, 2, 3, 4]
    if load:
        with open("training.pkl", "rb") as f:
            X, y = pickle.load(f)
    else:
        X, y = data_generate(DATA)
        with open("training.pkl", "wb") as f:
            pickle.dump((X, y), f)
    return X, y, labels


def prepare_data(X, y, labels):
    print("prepare data")
    df = pd.DataFrame(X).replace([np.inf, -np.inf, np.nan], 0.0)

    df["label"] = y
    min_nlabel = df.shape[0]

    for label in labels:
        tmp = sum(df["label"] == label)
        if tmp < min_nlabel:
            min_nlabel = tmp

    data = [df[df["label"] == label].iloc[:min_nlabel] for label in labels]
    assert data[0].shape[0] == min_nlabel
    print("min_nlabel:", min_nlabel)

    df = pd.concat(data)
    y = df["label"]
    X = df.drop(columns=["label"])

    X_train, X_test, y_train, y_test = train_test_split(X, y)
    return X_train, X_test, y_train, y_test


if __name__ == "__main__":
    debug = False
    load = False
    model, grid_search = prepare_models()

    if debug:
        debug_do(model)
    else:
        X, y, labels = load_data(load)
        X_train, X_test, y_train, y_test = prepare_data(X, y, labels)
        grid_search.fit(X_train, y_train)
        model = RandomForestClassifier(
            class_weight="balanced",
            **grid_search.best_params_)
        model.fit(X_train, y_train)
        y_pred = model.predict(X_test)
        print(classification_report(y_test, y_pred))
              precision    recall  f1-score   support

           0       0.68      0.76      0.72        82
           1       0.83      0.69      0.75        75
           2       0.75      0.93      0.83        57
           3       0.74      0.71      0.72        68
           4       0.99      0.89      0.93        80

    accuracy                           0.79       362
   macro avg       0.80      0.79      0.79       362
weighted avg       0.80      0.79      0.79       362

参考