はじめに
株価予測に関する研究はゴマンとあります。試しに、Google scholar で「stock price prediction」と検索してみると、2025 年だけで 16,600 報の論文・学会予稿がヒットします。
多数の研究のなかには、問題設定とアプローチ次第でうまく結果を出している研究があるのかもしれませんが、私は基本的に 「株価の予測は無理筋」 と考えています。無数の隠れ変数から謎の影響を受けまくってカオティックな動きをする複雑系なんて、予測できるわけがない。
ただ、どこまで無理筋なのかは試してみなければ分かりません。
そこで、今回は 「翌日の株価(終値)を三値分類で予測する」 という問題設定のもと、なにがどこまでできるのかを評価してみたいと思います。
前提
東京株式市場に上場している各銘柄の OHLCV データ(1年分ほど)がデータベースに格納されていて、自在に取り出して使えることを前提とします。
この株価データは、yfinance
パッケージを使うことで簡単に取得できるので、ここではその取得方法については言及しません。これを専門に解説した他の記事を読んでください。
問題設定
翌営業日の終値が、当日の終値に対して、
- 1%上昇した
- 変わらなかった
- 1%下落した
の三値分類を行います。
例えば、当日の終値が 1000 円だった場合、翌営業日の終値が、
- 1010 円以上になれば2
- 990 円〜1010 円に止まれば1
- 990 円未満になれば0
をラベルとして与えてモデルを学習させ、未知の翌日終値を予測しようとする。
—— これが今回の問題設定です。
実装
ここから具体的な実装を見ていきます。
メインルーチン
まずはメインルーチンからです。ここでは、
- データの準備
- モデルの訓練・検証
- ラベルの予測
- 結果の評価
という流れで処理を進めます。
if __name__ == "__main__":
# クラスのインスタンス化
val = Validation()
# 上昇幅・下落幅の定義(1%)
per = 0.01
# データの準備
dict_df_learn, dict_df_test, dict_df_close = val.prepare_data()
# モデルの訓練・検証
model = val.fit(dict_df_learn, dict_df_close, per)
# ラベルの予測
df_result = val.predict(model, dict_df_test, dict_df_close, per)
pred = df_result["pred"]
answer = df_result["answer"]
# 結果の評価
report = classification_report(
answer,
pred,
target_names=["Negative(0)", "Neutral(1)", "Positive(2)"],
labels=[0, 1, 2],
)
print(report)
データの準備
学習用(訓練・検証を両方含む)、テスト用、答え計算用のデータを準備します。
ここで重要なことは、スケーラーの扱いです。つまり、
- 訓練用データのみを使ってスケーラーを学習させていること
- 銘柄ごとにスケーラーを学習させ、正規化していること
です。
scaler.fit_transform
メソッドをデータ全体に適用している例をたまに見かけるのですが、これでは検証用データとテストデータに答えが漏洩することになります。
なので、ここでは、訓練用データ(df_training
)のみに fit
を適用した後、学習用データ全体(dict_df_learn[code]
)とテストデータ(dict_df_test[code]
)を正規化しています。
なお、全銘柄に対して1つのスケーラーを使うことも検討したのですが、結局やめにしました。スケーラーにそこまでの汎化能力が期待できず、実際に最終性能も良くなかったからです。
class Validation:
...
def prepare_data(self):
# データ正規化用のスケーラーを準備
scaler = StandardScaler()
dict_df_learn = {} # 学習用
dict_df_test = {} # テスト用
dict_df_close = {} # 答え計算用
for code in self.codes:
# 株価データの読み込みとテクニカル指標の追加(特徴抽出)
df = self.load_stock_data(code)
df = self.add_technical_indicators(df)
# データを準備
df_learn = df.iloc[:-2]
df_test = df.iloc[:-1].tail(self.window)
dict_df_close[code] = df["close"]
# 訓練用データのみを使ってスケーラーを学習させる
train_split_index = int(len(df_learn) * self.train_split_ratio)
df_training = df_learn.iloc[:train_split_index]
scaler.fit(df_training)
# 学習用データ全体(訓練用+検証用)とテストデータを正規化する
dict_df_learn[code] = pd.DataFrame(
scaler.transform(df_learn), index=df_learn.index
)
dict_df_test[code] = pd.DataFrame(
scaler.transform(df_test), index=df_test.index
)
return dict_df_learn, dict_df_test, dict_df_close
self.add_technical_indicator
メソッドでは、OHLCV データから特徴(移動平均、MACD、ボリンジャーバンドなど)を計算し、最終的に 21 次元の特徴ベクトルを構成します(長くなるので具体的なコードは割愛)。
また、self.window
は時系列の長さで、今回は 30
に設定しました。したがって、後述の LSTM に対しては、30 x 21
の大きさの行列が入力となります。
モデルの訓練・検証
準備した学習用データを使って、モデルを訓練・検証します。
元データから window
の長さ(=30
)だけ順次データを切り出し、それに対する正解ラベルを計算してリストに格納します。
def fit(self, dict_df_learn, dict_df_close, per):
# データ格納用のリスト
list_X_train, list_y_train = [], []
list_X_val, list_y_val = [], []
# 時系列の長さ
window = self.window
for code in dict_df_learn.keys():
df_scaled = dict_df_learn[code]
df_close = dict_df_close[code]
X, y = [], []
for i in range(len(df_scaled) - window + 1):
# i日から(i+window)日までのデータを切り出す
window_X = df_scaled.iloc[i : i + window]
last_date_of_window = window_X.index[-1]
loc = df_close.index.get_loc(last_date_of_window)
# 本日の終値と明日の終値から正解ラベルを作る
current_close = df_close.iloc[loc]
future_close = df_close.iloc[loc + 1]
label = self.create_label(current_close, future_close, per)
X.append(window_X)
y.append(label)
# 訓練用データと検証用データを構成する
split_index = int(len(X) * self.train_split_ratio)
list_X_train.extend(X[:split_index])
list_y_train.extend(y[:split_index])
list_X_val.extend(X[split_index:])
list_y_val.extend(y[split_index:])
X_train, y_train = np.array(list_X_train), np.array(list_y_train)
X_val, y_val = np.array(list_X_val), np.array(list_y_val)
# 学習安定化のためにシャッフルする
X_train, y_train = shuffle(X_train, y_train, random_state=42)
# モデルの学習
model = self.compile_model(X_train.shape[1], X_train.shape[2])
model.fit(
X_train,
y_train,
batch_size=128,
epochs=30,
validation_data=(X_val, y_val),
callbacks=[EarlyStopping(patience=3)],
)
return model
なお、正解ラベルの計算は、下記のように行います。
def create_label(self, current_price, future_price, per):
price_change_ratio = (future_price - current_price) / current_price
if price_change_ratio >= per:
return 2 # 上昇
elif price_change_ratio <= -per:
return 0 # 下落
else:
return 1 # 横ばい
モデルには LSTM を用います。
構成はいろいろと考えられますが、今回はいったん下記のようにしました。
def compile_model(self, shape1, shape2):
model = Sequential()
model.add(InputLayer(shape=(shape1, shape2)))
model.add(Bidirectional(LSTM(200)))
model.add(Dropout(0.3))
model.add(Dense(256, activation="relu"))
model.add(Dropout(0.3))
model.add(Dense(3, activation="softmax"))
model.compile(
optimizer=Adam(learning_rate=0.001),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
return model
ラベルの予測
最後に、テストデータに対するラベルを予測します。
モデルは 0, 1, 2 の各ラベルに対する確率を出力するので、argmax
の計算結果を予測されたラベルとします。
これを答え(answer
)とペアにしてリストに格納します。
def predict(self, model, dict_df_test, dict_df_close, per):
list_result = []
for code in dict_df_test.keys():
df = dict_df_test[code]
cl = dict_df_close[code]
window_X = df
array_X = np.array(window_X)
# テストラベルの予測
y_pred = model.predict(np.array([array_X]), verbose=0)
pred = np.argmax(y_pred, axis=1).item()
last_date_of_window = window_X.index[-1]
loc = cl.index.get_loc(last_date_of_window)
current_close = cl.iloc[loc]
future_close = cl.iloc[loc + 1]
# 答えの計算
answer = self.create_label(current_close, future_close, per)
list_result.append([pred, answer])
return pd.DataFrame(list_result, columns=["pred", "answer"])
評価結果
さて、気になる評価結果は、、、
precision recall f1-score support
Negative(0) 0.21 0.28 0.24 229
Neutral(1) 0.64 0.88 0.74 1248
Positive(2) 0.38 0.01 0.02 555
accuracy 0.57 2032
macro avg 0.41 0.39 0.33 2032
weighted avg 0.52 0.57 0.49 2032
この結果を Gemini にブチ込んだところ、下記のような講評を得ました。
<Gemini の講評>
お疲れ様です。結果、拝見しました。
「ダメだ。。」とお感じになるお気持ち、非常によく分かります。これは典型的な、そして多くの人が直面する金融時系列予測の難しさです。
この結果は、モデルが 「ほとんどの状況を『横ばい(1)』と予測する」 という、非常に消極的な戦略を学習してしまったことを示しています。
現状分析: なぜこうなったのか?
「横ばい」予測への強い偏り:
Neutral(1)の再現率(recall)が0.88と非常に高いです。これは、モデルが「とりあえず『横ばい』と予測しておけば、88%は正解できる」と学習してしまったことを意味します。support(データ数)が1248と最も多いクラスなので、モデルにとっては最も安全な答えなのです。
「上昇」シグナルを完全に無視:
最も致命的なのがPositive(2)の再現率(recall)が0.01という点です。これは、実際に価格が上昇したケースの99%を見逃していることを意味します。モデルは利益の出るパターンを全く学習できていません。
結論:
モデルは 「上昇か下落かを判断するのは難しすぎる。一番数が多い『横ばい』と答えておけば、全体の正解率はそこそこ高くなる」 という、いわば思考停止の状態に陥っています。これでは取引モデルとしては機能しません。
<講評ここまで>
「お疲れ様です」という絶妙な労いの後、まことに厳しい結果分析を的確に返してくれました。Gemini 賢いですね。
おわりに
少なくともこの問題設定とアプローチでは、ロクな結果にならないことが分かりました。
改善方法としては、特徴量を工夫するとか、モデルの構成を単純にしてみるとか、二値分類(上がるか、上がらないか)に変更してみるとか、そのあたりしか思いつきませんが、それにしてもこの性能が劇的に改善するとは思えません。
......というか、こんな簡単な方法で結果が出る程度の問題であれば、世の中には大金持ちで溢れていますよね。
Gemini の言うとおり、金融時系列予測の難しさを痛感した夏の夜でした。