Skip to main content

Predicting Stock Prices using classical machine Learning (Time Series #2)

· 16 min read
Hampus Londögård
Open In Colab

In this post/Jupyter Notebook we'll look at stocks, forecasting and predictions using classical machine learning (sklearn) approaches.

⚠️Please be aware that this blog is much easier and nicer to read directly in Colab 👆 or through GitHub!

[CA]: Time Series #2 - Predicting Stock Prices (Time Series) using classical Machine Learning

CA=Competence Afternoon

To learn more about Time Series and how one can analyze them please view the other parts,

  1. Part One - Decomposing & Working with Time Series (theoretical) (Open In Colab)
  2. Part Two - Predicting Stock Prices (Time Series) using classical Machine LearningOpen In Colab
  3. Part Three -Forecasting Cryptocurrency Prices (Time Series) using Deep Learning (PyTorch, Tensorflow/Keras & darts) (Open In Colab)

Predicting Time Series 📈​

Today we will move from learning how to analyze Time Series to actually predicting them using simple models and data.

We'll be predicting Stocks from the top tech companies like Apple & Google.
In part #3 we'll move back to the crypto world!

To be able to predict the data we must understand it and we'll make a minor analysis.

Installation & Imports​

Feel free to ignore the cells and simply run them, the lazy style 🥱

Installing the important libraries...

from IPython.display import clear_output
!pip install -U pandas_datareader
!pip install plotly
!pip install matplotlib==3.1.3

clear_output()

And importing them...

import pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)
import numpy as np # linear algebra
import pandas_datareader as pdr
import seaborn as sns

from datetime import datetime

Minor Analysis​

df = pdr.get_data_yahoo(['AAPL', 'GOOGL', 'AMZN', 'MSFT', 'GE'])
df.head()
AttributesAdj CloseClose...OpenVolume
SymbolsAAPLGOOGLAMZNMSFTGEAAPLGOOGLAMZNMSFTGE...AAPLGOOGLAMZNMSFTGEAAPLGOOGLAMZNMSFTGE
Date
2017-03-1432.807190865.909973852.53002960.125767212.65817334.747501865.909973852.53002964.410004227.230774...34.825001863.750000853.54998864.529999228.92308061236400.01061700.02130600.014280200.02964208.0
2017-03-1533.154175868.390015852.96997160.443142214.24195935.115002868.390015852.96997164.750000228.923080...34.852501867.940002854.33001764.550003227.307693102767200.01332900.02562200.024833800.03268564.0
2017-03-1633.208466870.000000853.41998360.340454214.16998335.172501870.000000853.41998364.639999228.846161...35.180000870.530029855.29998864.750000229.23077476928000.01104500.01842300.020674300.02756910.0
2017-03-1733.043240872.369995852.30999860.555153215.10583534.997501872.369995852.30999864.870003229.846161...35.250000873.679993853.48999064.910004229.615387175540000.01868300.03384400.049219700.05673070.0
2017-03-2033.390213867.909973856.96997160.611160214.09794635.365002867.909973856.96997164.930000228.769226...35.099998869.479980851.51001064.910004230.00000086168000.01542200.02282700.014598100.02454062.0

5 rows × 30 columns

Looks fine, but how much data did we download?
We can view the .index which is a DateTimeIndex and figure out how it stretches.

df.index[0],df.index[-1]
(Timestamp('2017-03-14 00:00:00'), Timestamp('2022-03-11 00:00:00'))

Hmm, 5 years, that should be enough to find some kind of patterns.
Now let us analyze this data further by looking at if the stocks correlate somehow! 🤠

N.B. this analysis was first done by Heidi Mach, it's something I would never have done myself. Really cool results incoming!

df['Adj Close'].corr().style.background_gradient(cmap="Blues")
SymbolsAAPLGOOGLAMZNMSFTGE
Symbols     
AAPL1.0000000.9515640.9349270.978283-0.282471
GOOGL0.9515641.0000000.8663740.960317-0.191266
AMZN0.9349270.8663741.0000000.944168-0.498395
MSFT0.9782830.9603170.9441681.000000-0.373495
GE-0.282471-0.191266-0.498395-0.3734951.000000

Holy macaron, that's a lot more correlated data than I expected! 🙀

The seaborn library has a function called pairplot which plots this correlation, but using the points which is visually interesting in comparison to simply seeing the table above.

df = df['Adj Close']
df = df.drop(columns="GE")
sns.pairplot(df.drop_duplicates())
<seaborn.axisgrid.PairGrid at 0x7f1fbb4ef650>

png

Does this in fact mean what that we can predict prices of a stock based on their competition? The correlation does suggest so.

Let's try it!

First we'll try using a LinearRegression which simply said fits a line to be as close to all points as possible.

linear regression wikipedia

Source: Wikipedia.org

First we import LinearRegression through scikit-learn and then we add train_test_split which allows us to split our data into a training and testing dataset.

Whenever you test your Machine Learning or Deep Learning Models you never want to test it on data that it has trained on, as you might've overfitted the data and have a really good result until you see new data points.

The end-goal of a model is to generalize a problem and find the local minima which optimizes the funtion for the data points. By only looking at the same data we can't be sure we generalized correctly.

And the code 👩‍💻

from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import r2_score, mean_absolute_error

non_google_df = df.drop(columns="GOOGL")
X_train, X_valid, y_train, y_valid = train_test_split(non_google_df, df['GOOGL'], test_size=0.2)

clf = LinearRegression()

We got our data divided into valid and train, we got a regression model in our clf.

Let us predict the data and view our r2_score and mean_absolute_error.

💡
r2_score: (coefficient of determination) regression score function.
Best possible score is 1.0 and it can be negative (because the model can be arbitrarily worse). A constant model that always predicts the expected value of y, disregarding the input features, would get a score of 0.0.

mean_absolute_error: Mean absolute error regression loss.

clf.fit(X_train, y_train)
preds = clf.predict(X_valid)

r2_score(y_valid, preds), mean_absolute_error(y_valid, preds)
(0.9431732611282428, 130.75344061010207)

R2=93%R^2 = 93 \% 🥳

That's actually not bad at all, the mean_absolute_error being 129.7 is not very telling. Either we have to view the data to understand the magnituide, or we can apply MAPE which is the Mean Absolute Percentage Error.

Not sure if I'm lazy or simply want to show you the other function 🤔, but I'll use MAPE!

from sklearn.metrics import mean_absolute_percentage_error

mean_absolute_percentage_error(y_valid, preds)
0.0854923639443305

<9%< 9\%
Pretty acceptable considering we have not done anything except deliver data to one of the simplest models that exists!

Let's show this visually!

import plotly.express as px

# px.line(y=[y_valid, preds])
Show Plotly Chart (code cell only visible in active notebook)

Looks pretty good, but it is very messy... Something is off right?

The index is not a DateTimeIndex anymore because we shuffled the data in train_test_split -- a big difference is thereby applied.

y_valid.plot()
<matplotlib.axes._subplots.AxesSubplot at 0x7f1fb5e5f310>

png

y_valid.plot(legend="Valid")
pd.Series(preds, index=y_valid.index).plot(legend="Pred")
<matplotlib.axes._subplots.AxesSubplot at 0x7f1fb5dd5190>

png

Looks pretty fly, but can we take it further?
...yes we can! 😎

I see a few options, the two first being:

  1. Scaling the data as errors at the end are larger than in the beggining based on stocks rising.
  2. LinearRegression is a very simple yet efficient model that we can try to replace.

Let's start with the second point, scikit-learn has a multitude of regression-models, one being RandomForestRegressor that's pretty strong.

from sklearn.ensemble import RandomForestRegressor

clf = RandomForestRegressor()
clf.fit(X_train, y_train)

preds = clf.predict(X_valid)
r2_score(y_valid, preds), mean_absolute_percentage_error(y_valid, preds)
(0.9971090451830482, 0.015725346089653435)

😲R2>99%R^2 >99\%

That's actually crazy. And MAPE is not even 2%.

Let's view it!

y_valid.plot(legend="Valid")
pd.Series(preds, index=y_valid.index).plot(legend="Pred")
<matplotlib.axes._subplots.AxesSubplot at 0x7f1fcaeacad0>

png

That's an incredibly fitted curve.

How is this possible?

  1. We most likely overfit the data.
  2. We are looking at AMZN, AAPL and more data that is highly correlated during the same day as the one we wish to predict.
  • In the end this is a useless task, if we know the prices of today we'd also know GOOGL's prices!
  1. We're using shuffled data, meaning that in a way we've seen the future and past values surrounding the predicted one. This is a regression problem and not really a forecasting problem, which is simpler than forecasting.

Impressive nontheless
Even as I'm aware of all the drawbacks I'm thouroughly impresed by the results we're seeing.

Making it more interesting
We should make use of the previous days data to make sure we are not "cheating".

Let's get on it! 🎯


We'll be able to move, or shift, the data using ˋpd.DataFrame.shiftˋ which shifts the data either forwad (+X+X) or backwards (−X-X).

And while we're at it, let's group this up into a function.

ℹ️
pd.DataFrame.shift: Shift index by desired number of periods with an optional time freq.

def fit_validate_plot(X_train, X_valid, y_train, y_valid):
clf = RandomForestRegressor()
clf.fit(X_train, y_train)

preds = clf.predict(X_valid)
pd.DataFrame({'Valid': y_valid, 'Preds': preds}, index=y_valid.index).plot()

print(f"""
$R^2$: {r2_score(y_valid, preds)}
MAPE: {mean_absolute_percentage_error(y_valid, preds)}
MAE: {mean_absolute_error(y_valid, preds)}
""")

And making use of it will now be easy! 😍
Refactoring and abstractions are incredibly important.

X_train, X_valid, y_train, y_valid = train_test_split(df.drop(columns="GOOGL").shift(1).iloc[1:], df['GOOGL'].iloc[1:], test_size=0.2)

fit_validate_plot(X_train, X_valid, y_train, y_valid)
  $R^2$: 0.9948464033958241
MAPE: 0.019439064157954267
MAE: 29.527943362281434

png

🤯 this is crazy impressive!

We made the task at hands legit by only using historical data of GOOGL's competitors. The R2R^2 and MAPE is incredible.

It'd be interesting to investigate how badly we overfit the data, but that's for another day.

And how about if we don't shuffle the data? E.g. we do an actual forecast and not regression!

X_train, X_valid, y_train, y_valid = train_test_split(df.drop(columns="GOOGL").shift(1).iloc[1:], df['GOOGL'].iloc[1:], test_size=0.2, shuffle=False)

fit_validate_plot(X_train, X_valid, y_train, y_valid)
  $R^2$: -7.02034763602467
MAPE: 0.24152517366886156
MAE: 660.6506098187159

png

🤯😭

What are we seeing and why?
Regression algorithms/models try to fit a line to multiple points and it should be able to guess what point the data has depending on its features. In our case the regression algorithm has never seen data as high as above y_train.max(), which means it can't guess the data.

Don't trust me? Simply validate by looking at the chart 👆.

What's one way to fix this? Scaling
How will we try to achieve this practically? LogReturn

💡 You can also take the %-difference, which according to Taylors Theorem will approximate the LogReturn.

def log_return(x: pd.DataFrame) -> pd.DataFrame:
return x.apply(lambda x: np.log(x/x.shift(1))).dropna()

log_return(df).head()
SymbolsAAPLGOOGLAMZNMSFT
Date
2017-03-150.0105210.0028600.0005160.005265
2017-03-160.0016360.0018520.000527-0.001700
2017-03-17-0.0049880.002720-0.0013010.003552
2017-03-200.010446-0.0051260.0054530.000924
2017-03-21-0.011518-0.020687-0.016199-0.011151
df_lr = log_return(df)
X_train, X_valid, y_train, y_valid = train_test_split(df_lr.drop(columns="GOOGL").shift(1).iloc[1:], df_lr['GOOGL'].iloc[1:], test_size=0.2, shuffle=False)

fit_validate_plot(X_train, X_valid, y_train, y_valid)
  $R^2$: -0.15979886803424925
MAPE: 33272784735.11796
MAE: 0.01244440133653395

png

Most certainly not perfect... Forecasting seems harder than expected based on our initial results...
And that's really because we weren't forecasting before, we were solving a regression-problem

Perhaps we need to use more data than simply the previous day?

Predicting Based on historical performance​

We might predict based on historical performance.

df.head()
SymbolsAAPLGOOGLAMZNMSFT
Date
2017-03-1432.807190865.909973852.53002960.125767
2017-03-1533.154175868.390015852.96997160.443142
2017-03-1633.208466870.000000853.41998360.340454
2017-03-1733.043240872.369995852.30999860.555153
2017-03-2033.390213867.909973856.96997160.611160
df = df[['GOOGL']]
df.head()
SymbolsGOOGL
Date
2017-03-14865.909973
2017-03-15868.390015
2017-03-16870.000000
2017-03-17872.369995
2017-03-20867.909973

✅ Only Google Data
❌ Historical Data

So what should we do? One way to solve this is to use shift multiple times.

def build_history(df: pd.DataFrame, num_back: int) -> pd.DataFrame:
for i in range(num_back):
df.loc[:, f"t_{i}"] = df['GOOGL'].shift(i + 1)

return df

build_history(df, 3).head()
SymbolsGOOGLt_0t_1t_2
Date
2017-03-14865.909973NaNNaNNaN
2017-03-15868.390015865.909973NaNNaN
2017-03-16870.000000868.390015865.909973NaN
2017-03-17872.369995870.000000868.390015865.909973
2017-03-20867.909973872.369995870.000000868.390015

Notice how t0t_0 is the previous value, t1t_1 two steps back, and so on.
This is actually very memory intense as our data grows X times, one time per time step we build. In part #3 we'll go through how one can solve this issue.

No we need to drop all places where we don't have any history. That is easily achieved by dropping NaN.

ℹ️
pd.DataFrame.dropna: Remove missing values.
axis attribute tells if you wish to drop rows or columns based on NaN, default is row.

df = build_history(df, 7)
df = df.dropna()
df.head()
SymbolsGOOGLt_0t_1t_2t_3t_4t_5t_6
Date
2017-03-23839.650024849.799988850.140015867.909973872.369995870.000000868.390015865.909973
2017-03-24835.140015839.650024849.799988850.140015867.909973872.369995870.000000868.390015
2017-03-27838.510010835.140015839.650024849.799988850.140015867.909973872.369995870.000000
2017-03-28840.630005838.510010835.140015839.650024849.799988850.140015867.909973872.369995
2017-03-29849.869995840.630005838.510010835.140015839.650024849.799988850.140015867.909973

LGTM ✅


Let's scale our data and then make predictions.
As previously,

  1. Scale data
  2. Split data
  3. Fit data
  4. Predict data
  5. Validate
df_lr = log_return(df)
X_train, X_valid, y_train, y_valid = train_test_split(df_lr.iloc[:, 1:], df_lr['GOOGL'], test_size=0.2, shuffle=False)

fit_validate_plot(X_train, X_valid, y_train, y_valid)
  $R^2$: -0.09291251083922969
MAPE: 10166738051.820312
MAE: 0.01198089072877809

png

Not great, not awful. Some self-exercises:

  1. How would we do without scaling?
  2. How would we do without shuffling?
  3. Any other ideas? Try 'em out!
# Test your own ideas

If you didn't try previously, try appling a rolling mean and rerun fit_validate_plot as this should reduce the "swings" and thereby be a little bit more predictable.

💡 pd.DataFrame.Rolling: Provide rolling window calculations.
In other words: We slide a window on our data and do calculations, in our case mean. This window includes window, min_periods, center & more attributes which impacts size of window, how large minimal window can be, and more.

Validating what rolling.mean() does to our data:

df['GOOGL_ROLLING'] = df['GOOGL'].rolling(3).mean()  # Rolling over 3 days mean
df[-100:].plot(y=['GOOGL', 'GOOGL_ROLLING'])
<matplotlib.axes._subplots.AxesSubplot at 0x7f1fb531bc90>

png

Zooming 🔍

df_last_months = df[df.index > datetime(2021, 6, 6)]

# df_last_months.plot(y=['GOOGL', 'GOOGL_ROLLING'], backend='plotly')
Show Plotly Chart (code cell only visible in active notebook)

The curve is very similar, but different.

Self-exercise: Test applying different functions like min, max and expanding window size into more days.

And validating what this does to our prediction.

df_lr = df.pct_change().dropna().rolling(3).mean().dropna()
X_train, X_valid, y_train, y_valid = train_test_split(df_lr.iloc[:, 1:], df_lr['GOOGL'], test_size=0.2, shuffle=False)

fit_validate_plot(X_train, X_valid, y_train, y_valid)
  $R^2$: 0.8997161594986378
MAPE: 0.8209516085248725
MAE: 0.0019317335823510523

png

We're back! 🥳

It's not perfect, but we got something. And we can work with something. We can work with something... :)

Self-exercise: Validat how rolling would affect our non-history-based forecasting

Let's reverse our transformation to see what we'd actually predict in the end.

y_rolling = df['GOOGL'].rolling(3).mean().dropna()
y_train_non_scaled, y_valid_non_scaled = train_test_split(y_rolling, test_size=0.2, shuffle=False)
clf = RandomForestRegressor()
clf.fit(X_train, y_train)
preds = clf.predict(X_valid)

preds = (preds + 1).cumprod() # Cummulative multiplication, first day + 1%, but then we got -1%, that's 1.01 * 0.99
preds = preds * y_train_non_scaled.iloc[-1] # Scaling it up based on the last training value

# pd.DataFrame({'Preds': preds, 'Valid Rolling': y_valid_non_scaled[1:], 'Valid': df['GOOGL'].iloc[-len(preds):]}).plot(backend='plotly')
Show Plotly Chart (code cell only visible in active notebook)

Seems as we're a little low in our predictions, but the curve is followed after all.

What issues are left?

  1. We are not using an AutoRegressive model which might be interesting.
  • More about this in the next session
  1. We are not using the "better" models, e.g. Neural Networks or statistic-model for Time Series like ARIMA.

Personally I'm very pleased with the results and can't wait to get started on part #3!


To learn more about Time Series and how one can analyze them please view the other parts,

  1. Part One - Decomposing & Working with Time Series (theoretical)

  2. Part Two - Predicting Stock Prices (Time Series) using classical Machine LearningOpen In Colab

  3. Part Three -Forecasting Cryptocurrency Prices (Time Series) using Deep Learning (PyTorch, Tensorflow/Keras & darts)


Extra Self Exercises​

  1. Try different window-sizes with rolling
  2. Try different length of history to predict new result on
  3. Test new architectures
  4. Find your own way to improve the results