LoginSignup
35
39

More than 3 years have passed since last update.

FX予測 : PyTorchのBERTで経済ニュース解析

Posted at

こんにちは @THERE2 です。
現在自然言語処理においては、BERTというディープラーニングのモデルの評価が高いようです。
そこで、BERTを使ってロイターの経済ニュース(英語)を解析し、そのニュースによってFX(USD/JPY)が上がるか下がるかという予測をするモデルを実装してみました。

実装にあたっては、先日購入した「Pytorchによる発展ディープラーニング」を参考にしました。
この本はある程度ディープラーニングの知識、経験がある人にとっては非常にうまくまとまっていてかなりの良書です。ゼロから作るシリーズを読んだ後に取り組むのがオススメです。

image.png

BERTについて

BERTは事前学習済みのモデルを転用できるのが大きなメリットのようです。自然言語や画像のようなデータを一から学習させていくのは大変ですが、大きなデータセットですでに学習済みのモデルをベースにできれば、学習時間を短縮して最初から高い精度を見込めます。
また、BERTは文書解析のコアのモデルの後にレイヤを追加することで文書生成や単語の予測、各単語に対するクラス分類、文書全体に対するクラス分類と様々なタスクを行えます。

今回のモデルでは、コアのモデルでニュース文章を解析し、ニュース毎にFXが上がるか下がるかを予測させるという BertForSequenceClassificationを利用します。

実装にあたって以下のソースコードも参考にしました。

Transformers: State-of-the-art Natural Language Processing for TensorFlow 2.0 and PyTorch.

BERT Fine-Tuning Tutorial with PyTorch

利用データについて

BERTでFX予測をするにあたり、次のようなモデルとしました。
英語版のロイターの経済ニュースのタイトルを利用します。本当は本文を利用したかったのですが、本文を使うとデータの前処理でもBERTのトレーニングでも時間がかかりすぎるので諦めました。

ただ、タイトルだけでも最大255文字あり、主要なキーワードが含まれているので、FXの予測に使うだけであればタイトルだけでも十分かと考えています。

おそらくFXで売買する人達もニュースの本文は目を通さずヘッドラインだけで反応している人が多いと思いますので、それもタイトルだけで十分かと思った理由です。

また、ロイターニュースは英語にしていますが、USD/JPYの為替の予測であれば英語のニュースが必要な情報の大半をカバーしているのと、自然言語処理のモデルは英語での解析が一番進んでいるためです。

学習用のニュースデータは、kaggleで公開されている以下のデータを使いました。

2018年1月から5月までのBloomberg.com, CNBC.com, reuters.com, wsj.com, fortune.comの英語でのfinancial data(経済データ)となります。
この中から、reuters.comのデータを抜出して利用します。

News APIというサービスを使えば、リアルタイムに近い(フリー版は15分のディレイ有り)ニュースデータを取得する事ができます。
しかし、このサービスは残念ながら過去1ヶ月分のデータしか取得できず、学習用としては不十分です。
そのため、今回学習用としてはkaggleのデータを利用する事にしました。

※449ドル払って商用利用にすれば、過去1年分のデータをディレイ無しで利用できるようです。流石に約5万円の出費は厳しいですね。

FXの価格データ

FXの価格データは、上記のニュースと同期間(2018年1月から5月)の時間足データとしました。

次の時間のClose価格を利用して予測をしていきます。

  1. ニュースが公開された時間の次の時間足のclose価格
  2. その時間足の6時間後(6足後)の時間足のclose価格

2の価格が1の価格より高ければ価格アップ(ラベル:1)、2の価格が1の価格より低ければ価格ダウン(ラベル:0)としてラベルデータを用意しました。
これを各ニュース毎にラベルとして付与してニュースタイトルと一緒にBERTに学習させる事とします。

では実装していきましょう。

PyTorchによるBERTの実装

ソースコードの全体は以下のgistに格納しておきました。
https://gist.github.com/THERE2/4518239e7c099e95b3a78432a01eeab9

追加パッケージのインストール

PyTorchではBERTの実装済みのモデルはインストールして利用できます。

「Pytorchによる発展ディープラーニング」では一から実装して説明していますが、ここではパッケージでインストールしたものをそのまま使わせていただきます。

インストールはpipから次のコマンドで可能です。BERTのモデルとBERT用のtokenizerが入ってます。

このインストール自体はすぐに終わります。
ただ、最初にBERTのモデルをロードする時に学習済みのモデルをダウンロードするのですが、そのダウンロードがかなり時間かかります。1時間以上かかったでしょうか。根気強く待ちましょう。

一度ダウンロードしておけば次からのモデルのロードはすぐに終わります。

公式ドキュメントへのリンクを貼っておきます。
https://huggingface.co/pytorch-transformers/index.html

pip install pytorch-transformers

また、テキストデータを変換したりミニバッチで取り出したりするのに、torchtextというライブラリを利用しますので、これもインストールしておきます。
同じくpipでインストールします。

pip install torchtext

その他pytorchpandasnumpy等必要になります。

ニュースデータの取得

kaggleから取得したデータをdataフォルダに展開しました。月ごとのフォルダに1記事毎に一つのJSONファイルとして格納されていますので、これらをまとめてpandasDataFrameに格納します。

  • data以下にあるファイルは、blogとnewsの両方があるのですが、今回はnewsしか使わないので、ファイル名をnews_*.jsonで指定して取り込みました。
  • JSONファイルを一つづつ開いてDataFrameに格納していきます。日付型はISO形式からpandasdatetime型に変換しました。タイムゾーンは価格データと合わせるためUTCとしています。
  • テキストは本文は数千文字と長すぎるのでタイトルのみを使う事にしました。タイトルだけでも最大255文字有り、重要な情報がコンパクトにまとまっているため、タイトルだけでも十分効果的だと思ったためです。
  • DataFrameは後から取り出しやすいようにpickle形式で保存しました。
  • ニュース数は全部で30万件弱となります。
import json
import glob
import pandas as pd

# blogは対象外にするので、newsから始まるjsonファイルのみを取り出す。
# 必要な項目のみlistに格納していく。
json_news_files = glob.glob('./data/*/news_*.json')
data = []
for json_file in json_news_files:
    json_data = json.load(open(json_file, 'r'))
    data.append([
        json_data['uuid'],
        pd.to_datetime(json_data['published'], utc=True), #datetime型に変換してutc時間に設定。
        json_data['language'],
        json_data['thread']['country'],
        json_data['thread']['site'],
        json_data['title'],
        json_data['text'],
    ])

# pandasのデータフレームに変換して、カラム名を設定。uuidをindexとする。
df = pd.DataFrame(data)
df.columns = ['uuid', 'published', 'language', 'country', 'site', 'title', 'text']
df = df.set_index('uuid')

df.to_pickle('./pickle/all_news_df.pkl')

FXの価格データの取得

OANDA APIから価格データを取得する方法については、以前に投稿した記事を参照ください。

機械学習でFX:Oanda APIを使ってPythonから自動売買する

今回は、ニュースデータの期間と合わせて、2017年12月〜2018年5月末までの時間足データを取得してPandasDataFrameに格納しpickleで塩漬けしました。

from oandapyV20 import API
from oandapyV20.exceptions import V20Error
from oandapyV20.endpoints.pricing import PricingStream
import oandapyV20.endpoints.orders as orders
import oandapyV20.endpoints.instruments as instruments
import json
import datetime
import pandas as pd

# accountID, access_tokenは各自のコードで書き換えてください。
accountID = my_accountID
access_token = my_access_token
api = API(access_token=access_token, environment="practice")

# Oandaからcandleデータを取得する。
def getCandleDataFromOanda(instrument, api, date_from, date_to, granularity):
    params = {
        "from": date_from.isoformat(),
        "to": date_to.isoformat(),
        "granularity": granularity,
    }

    r = instruments.InstrumentsCandles(instrument=instrument, params=params)
    return api.request(r)

# Oandaからのresponse(JSON形式)をpython list形式に変換する。
def oandaJsonToPythonList(JSONRes):
    data = []
    for res in JSONRes['candles']:
        data.append( [
            datetime.datetime.fromisoformat(res['time'][:19]),
            res['volume'],
            res['mid']['o'],
            res['mid']['h'],
            res['mid']['l'],
            res['mid']['c'],
            ])
    return data

all_data = []
date_from = datetime.datetime(2017, 12, 1)
date_to = datetime.datetime(2018, 6, 1)

ret = getCandleDataFromOanda("USD_JPY", api, date_from, date_to, "H1")
all_data = oandaJsonToPythonList(ret)

# pandas DataFrameへ変換
df = pd.DataFrame(all_data)
df.columns = ['Datetime', 'Volume', 'Open', 'High', 'Low', 'Close']
df = df.set_index('Datetime')
# pickleファイルへの出力
df.to_pickle('./pickle/USD_JPY_201712_201805.pkl')

前処理の実施

続いて取得したニュースデータと価格データを結合してラベルを設定します。

  • ラベルとしては、各ニュースデータ毎に次の時間足での価格と、その6時間後の価格を比較して、上がっていれば1、下がっていれば0をセットするようにしました。
  • データ件数が多いとBERTのトレーニングに時間がかかるので、対象データをreuters.comに限定し、さらにそのうち30%のみをサンプリングする事にしました。BERTは事前トレーニング済みのモデルのため、データ総数自体はそこまで多くなくてもいいのではないかと思っています。ただ、各月によってニュースの内容も異なるでしょうから、月ごとのバリエーションは必要だと思います。
  • 最後にDataFrameをトレーニング用、バリデーション用、テスト用に60%, 20%, 20%の割合で分割してtsvファイル形式で保存しました。この後使うtorchtextがテキストファイル読み込みを想定しており、DataFrameのままだと面倒そうだったので、いったんテキストファイルで保存しておくことにしました。
import re
import torchtext
import pandas as pd
import numpy as np

# read text data
news_df = pd.read_pickle('./pickle/all_news_df.pkl')
# read candle data
candle_df = pd.read_pickle('./pickle/USD_JPY_201712_201805.pkl')

################## labelの設定 ###################
# labelとして6時間後の価格が上がっているかどうかとするため、6時間後のclose値とのdiffを取る。
# 上がっていればプラス、下がって入ればマイナス値となる。
candle_df['diff_6hours'] = - candle_df['Close'].astype('float64').diff(-6)
candle_df['label'] = 0
# labelに6時間後の価格が上がっているか下がっているかをセット
candle_df.loc[candle_df['diff_6hours']>0, 'label'] = 1

# newsのtimestampから次の時間足のラベルを取得する。
# 例) 2017-12-04 19:35:51のタイムスタンプのニュースであれば、2017-12-04 20:00:00の時間足のclose値に対して6時間後の時間足の価格が上がっているかどうかがラベルとなる。
def get_label_from_candle(x):
    tmp_idx = candle_df.loc[candle_df.index > x.tz_localize(None)].index.min()
    return candle_df.loc[tmp_idx, 'label']

# 各ニュースへのラベル設定。件数が多いので数分かかる。
news_df['label'] = news_df['published'].map(get_label_from_candle)

# BERTでトレーニングするにはボリュームが有りすぎるので、ロイターニュースの30%に絞る
news_df = news_df[news_df.site == 'reuters.com']
news_df = news_df.sample(frac=0.3)

# 学習用、バリデーション用、テスト用の配分
train_size = 0.6
validation_size = 0.2
test_size = 1 - train_size - validation_size

total_num = news_df.shape[0]
train_df = news_df.iloc[:int(total_num*(1-validation_size-test_size))][['title', 'label']]
val_df = news_df.iloc[int(total_num*train_size):int(total_num*(train_size+validation_size))][['title', 'label']] 
test_df = news_df.iloc[int(total_num*(train_size+validation_size)):][['title', 'label']] 

# torchtextのdatasetとして取り込むのに、csvファイル形式に保存。
# ※他にいい方法があると思うが、参考にしている本がCSVファイル形式での記載だったため。
train_df.to_csv('data/dataset_for_torchtext_train.tsv', index=False, sep='\t')
val_df.to_csv('data/dataset_for_torchtext_val.tsv', index=False, sep='\t')
test_df.to_csv('data/dataset_for_torchtext_test.tsv', index=False, sep='\t')

DataLoaderの実装

続いてテキストデータをトークンに分割してミニバッチ毎に取り出すDataLoaderを実装します。ここはtorchtextを利用しています。torchtextの使い方は発展ディープラーニング本に詳しく書いてありました。

  • テキストをトークンに分割するために、インストールしたBertTokenizerを利用します。
  • BERTの事前学習済みモデルとして、bert-base-uncasedを利用します。
  • BertTokenizerにテキストの前処理として、改行コードの削除、記号のスペースへの変換、小文字への変換を加えたものをtokenizer_with_preprocessingとして実装しました。
  • torchtextDataLoaderで、tokenizerを実装したものを指定すると共に、init_tokeneos_tokenpad_tokenunk_tokenを指定しています。これにより、torchtextがテキストを読み込む時に文書にそれぞれ適切なtokenを追加してくれます。
  • torchtextTabularDataset.splitでファイルを読み込んでDataSetを生成します。
  • TEXTオブジェクトにwordを数値に変換する単語リストを登録します。BertTokenizerのvocab属性がその単語のOrderedDictとなっていますのでそれを設定してあげます。ただ、いきなりTEXTオブジェクトに指定するとエラーになってしまうので一旦回避策としてダミーのボキャブラリを作成TEXT.build_vocab(train_ds, min_freq=1)して上書きしています。
  • 最後にtorchtextIteraterDataSetからDataLoaderを生成します。この時、バッチサイズを指定します。
import pandas as pd
import torchtext
import pickle
import string
import re
from torchtext.vocab import Vectors
from pytorch_transformers import BertTokenizer
pre_trained_weights = 'bert-base-uncased'
tokenizer_bert = BertTokenizer.from_pretrained(pre_trained_weights)

def tokenizer_with_preprocessing(text, tokenizer=tokenizer_bert.tokenize):
    #改行の削除
    text = re.sub('\r', '', text)
    text = re.sub('\n', '', text)
    #数字文字の一律0化
    text = re.sub(r'[0-9]', '0', text)
    #カンマ、ピリオド以外の記号をスペースに置換
    for p in string.punctuation:
        if (p == '.') or (p == ","):
            continue
        else:
            text = text.replace(p, " ")
    #ピリオド等の前後にはスペースを入れておく
    text = text.replace("."," . ")
    text = text.replace(","," , ")
    #トークンに分割して返す
    return tokenizer(text.lower())

def get_DataLoaders_and_TEXT(max_length, batch_size):
    #テキストの前処理
    TEXT = torchtext.data.Field(sequential=True, 
                                tokenize=tokenizer_with_preprocessing, 
                                use_vocab=True, 
                                include_lengths=True,
                                batch_first=True,
                                fix_length=max_length,
                                init_token='[CLS]',
                                eos_token='[SEP]',
                                pad_token='[PAD]',
                                unk_token='[UNK]',
                                )
    LABEL = torchtext.data.Field(sequential=False, use_vocab=False)

    #data setの取得
    train_ds, val_ds, test_ds = torchtext.data.TabularDataset.splits(
        path='./data/', 
        train='dataset_for_torchtext_train.tsv',
        validation='dataset_for_torchtext_val.tsv',
        test='dataset_for_torchtext_test.tsv',
        format='tsv',
        skip_header=True,
        fields=[('title', TEXT), ('label', LABEL)]
    )

    # ボキャブラリーの作成
    # エラー回避のため一旦仮で作成し、bertのvocabで上書き
    TEXT.build_vocab(train_ds, min_freq=1)
    TEXT.vocab.stoi = tokenizer_bert.vocab

    # Data loaderの作成
    train_dl = torchtext.data.Iterator(train_ds, batch_size=batch_size, train=True)
    val_dl = torchtext.data.Iterator(val_ds, batch_size=batch_size, train=False, sort=False)
    test_dl = torchtext.data.Iterator(test_ds, batch_size=batch_size, train=False, sort=False)

    return train_dl, val_dl, test_dl, TEXT

トレーニング用コードの実装

ここまでで準備が整ったので、トレーニング用のコードを実装していきます。
ちょっと長いのでソースコードのコメントの番号に沿って簡単に解説します。

#1. パッケージのインポート、定数定義

  • BERTのテキストから分類問題を解くための専用のクラスがありますので、それを利用します。これはBERTのモデルの後にDropout層とLinear層を付けて、CrossEntropylossを計算してそのlosslogitsを返してくれます。コンストラクタの引数で分類クラス数も指定できます。2値分類であればクラス数を2にしてlogitsに対してmaxのindexを取れば推定できます。
  • random seedは42で固定しています。42が望ましい理由があるようなのですが、調べてもよくわかりませんでした。
  • batch sizeは私のGTX1050だと64ではメモリ不足となってしまうので、32としました。

#2. DataLoaderの取得

  • 事前に定義したメソッドget_DataLoaders_and_TEXTを使ってDataLoaderを取得します。
  • 取得したDataLoaderdataloaders_dictにまとめておきます。

#3. Bertモデルの読み込み

  • BERTの事前学習済みモデルを読み込みます。分類クラス数を2で指定しています。
  • BERTはEncoder層が12層あるのですが、全部再学習すると時間がかかりすぎるので、1〜11層までは固定param.requires_grad = Falseで学習対象外としています。 ※デフォルトは全ての層が学習対象です。
  • いったん全て学習対象外param.requires_grad = Falseとした上で、Encoder層の12層目とLinearClassification層を学習対象param.requires_grad = Trueで更新するようにしました。

#4. Optimizerの設定

  • OptimizerはAdamにしています。インストールしたBERTのパッケージにBertAdamというのが含まれているようなのですが、よく分からなかったので使っていません。
  • 重み減衰(weight decay)は入れた方が良いようなのですが、実装がややこしくなるので入れていません。
  • lossファンクションを定義していませんが、これは読み込んでいるBertForSequenceClassificationの中で定義しているためです。モデルの中ではCrossEntropyLossが利用されています。

# 5. BERTモデルでの予測とlossの計算、backpropの実行

  • BertForSequenceClassificationのforwardには、inputデータとlabelデータの両方を渡します。labelデータを渡す事で、BertForSequenceClassificationCrossEntropyLossでlossを計算して返してくれます。
  • token_type_idsは、1文が複数のセンテンスに分かれているわけではなければ不要なのでNoneです。
  • attention_maskも、学習済みモデルを利用し、1〜11層まで固定しているため特に必要無いと重いNoneとしています。
  • 戻り値はリストとなっており、1つ目がloss、2つ目がlogitsです。後は設定に応じてhidden stateやattentionsなどが帰ってきます。
  • _, preds = torch.max(logits, 1)logitsの最大値とそのインデックスが取得できます。ここではindexのみが必要なので、そのindexをpredsに格納しています。
  • あとはlosspredsを使ってbackpropしてAccuracyを計算してログ出力しています。

# 6. testデータでの検証

  • メインループではepoch毎にトレーニングデータとバリデーションデータを交互に処理して学習させていきました。
  • 最後学習が終わった後に、学習済みモデルを使ってテストデータで精度を確認します。

# 7. torchモデルを保存しておく

  • 学習済みモデルを再利用できるように、保存しておきます。torch.save(net_trained.state_dict(), 'weights/bert_net_trainded.model')
# 1. パッケージのインポート、定数定義
import random
import math
import numpy as np
import json
import torch
import torch.nn as nn
import torch.optim as optim
import torch.utils.data as data
import torch.nn.functional as F
from dataloader import get_DataLoaders_and_TEXT
from pytorch_transformers import BertForSequenceClassification

torch.manual_seed(42)
np.random.seed(42)
random.seed(42)
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
max_length=256
batch_size=32
pre_trained_weights = 'bert-base-uncased'

# 2. data loaderの取得
train_dl, val_dl, test_dl, TEXT = get_DataLoaders_and_TEXT(
    max_length=max_length,
    batch_size=batch_size
)

dataloaders_dict = {"train":train_dl, "val": val_dl}

# 3. Bertモデルの読み込み
net = BertForSequenceClassification.from_pretrained(pre_trained_weights, num_labels=2)
net.to(device)

# Bertの1〜11段目は更新せず、12段目とSequenceClassificationのLayerのみトレーニングする。
# 一旦全部のパラメータのrequires_gradをFalseで更新
for name, param in net.named_parameters():
    param.requires_grad = False

# Bert encoderの最終レイヤのrequires_gradをTrueで更新
for name, param in net.bert.encoder.layer[-1].named_parameters():
    param.requires_grad = True

# 最後のclassificationレイヤのrequires_gradをTrueで更新
for name, param in net.classifier.named_parameters():
    param.requires_grad = True

# 4. Optimizerの設定
optimizer = optim.Adam([
    {'params': net.bert.encoder.layer[-1].parameters(), 'lr': 5e-5},
    {'params': net.classifier.parameters(), 'lr': 5e-5}], betas=(0.9, 0.999))

def train_model(net, dataloaders_dict, optimizer, num_epochs):
    net.to(device)
    torch.backends.cudnn.benchmark = True

    for epoch in range(num_epochs):
        for phase in ['train', 'val']:
            if phase == 'train':
                net.train()
            else:
                net.eval()

            epoch_loss = 0.0
            epoch_corrects = 0
            batch_processed_num = 0

            # データローダーからミニバッチを取り出す
            for batch in (dataloaders_dict[phase]):
                inputs = batch.title[0].to(device)
                labels = batch.label.to(device)

                # optimizerの初期化
                optimizer.zero_grad()

                with torch.set_grad_enabled(phase=='train'):
                   # 5. BERTモデルでの予測とlossの計算、backpropの実行
                    outputs = net(inputs, token_type_ids=None, attention_mask=None, labels=labels)
                    # loss and accuracy
                    loss, logits = outputs[:2]
                    _, preds = torch.max(logits, 1)

                    if phase =='train':
                        loss.backward()
                        optimizer.step()

                    curr_loss = loss.item() * inputs.size(0)
                    epoch_loss += curr_loss
                    curr_corrects = (torch.sum(preds==labels.data)).to('cpu').numpy() / inputs.size(0)
                    epoch_corrects += torch.sum(preds==labels.data)

                batch_processed_num += 1
                if batch_processed_num % 10 == 0 and batch_processed_num != 0:
                    print('Processed : ', batch_processed_num * batch_size, ' Loss : ', curr_loss, ' Accuracy : ', curr_corrects)

            # loss and corrects per epoch
            epoch_loss = epoch_loss / len(dataloaders_dict[phase].dataset)
            epoch_acc = epoch_corrects.double() / len(dataloaders_dict[phase].dataset)

            print('Epoch {}/{} | {:^5} | Loss:{:.4f} Acc:{:.4f}'.format(epoch+1, num_epochs, phase, epoch_loss, epoch_acc))

    return net

# trainingの実施
num_epochs = 3
net_trained = train_model(net, dataloaders_dict, optimizer, num_epochs=num_epochs)

# 6. testデータでの検証
net_trained.eval()
net_trained.to(device)
epoch_corrects = 0

for batch in (test_dl):
    inputs = batch.title[0].to(device)
    labels = batch.label.to(device)

    with torch.set_grad_enabled(False):
        # input to BertForSequenceClassifier
        outputs = net_trained(inputs, token_type_ids=None, attention_mask=None, labels=labels)
        # loss and accuracy
        loss, logits = outputs[:2]
        _, preds = torch.max(logits, 1)
        epoch_corrects += torch.sum(preds == labels.data)

epoch_acc = epoch_corrects.double() / len(test_dl.dataset)
print('Correct rate {} records : {:.4f}'.format(len(test_dl.dataset), epoch_acc))

# 7. torchモデルを保存しておく
torch.save(net_trained.state_dict(), 'weights/bert_net_trainded.model')

実行結果の確認

かなり時間がかかりますが、3 epoch回してみました。

Validation結果では、Lossがわずかに下がっていますが、Accuracyはいったん下がった後に上がっています。51.4%のAccuracyは悪くないですが、このままトレーニングを続けていくと精度があがっていくでしょうか。

※validationの結果のみ抜粋
Epoch 1/3 |  val  | Loss:0.6932 Acc:0.4959
Epoch 2/3 |  val  | Loss:0.6939 Acc:0.4859
Epoch 3/3 |  val  | Loss:0.6928 Acc:0.5141

テストデータでの検証結果は以下のようになりました。12万件弱で50.46%。完全にランダムよりは若干良さそうですが誤差の範囲内かもしれません。

Correct rate 11779 records : 0.5046

もっと時間をかけて学習させてみたり、学習用データを増やしたり、予測を6時間後から前後させてみたりと工夫する事で精度があがっていく可能性はあると思います。

また、このニュース解析だけではなく、通常の価格データやテクニカル指標と組み合わせる事で精度を上げていくことが出来るかも知れませんね。

次の記事では機械学習の最強モデルという評判のLightGBMでFX予測に取り組んでみたいと思います。

@THERE2

35
39
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
39