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,

- Part One - Decomposing & Working with Time Series (theoretical) ()
- Part Two - Predicting Stock Prices (Time Series) using classical Machine Learning
- Part Three -Forecasting Cryptocurrency Prices (Time Series) using Deep Learning (PyTorch, Tensorflow/Keras & darts) ()

## 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()

Attributes | Adj Close | Close | ... | Open | Volume | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|

Symbols | AAPL | GOOGL | AMZN | MSFT | GE | AAPL | GOOGL | AMZN | MSFT | GE | ... | AAPL | GOOGL | AMZN | MSFT | GE | AAPL | GOOGL | AMZN | MSFT | GE |

Date | |||||||||||||||||||||

2017-03-14 | 32.807190 | 865.909973 | 852.530029 | 60.125767 | 212.658173 | 34.747501 | 865.909973 | 852.530029 | 64.410004 | 227.230774 | ... | 34.825001 | 863.750000 | 853.549988 | 64.529999 | 228.923080 | 61236400.0 | 1061700.0 | 2130600.0 | 14280200.0 | 2964208.0 |

2017-03-15 | 33.154175 | 868.390015 | 852.969971 | 60.443142 | 214.241959 | 35.115002 | 868.390015 | 852.969971 | 64.750000 | 228.923080 | ... | 34.852501 | 867.940002 | 854.330017 | 64.550003 | 227.307693 | 102767200.0 | 1332900.0 | 2562200.0 | 24833800.0 | 3268564.0 |

2017-03-16 | 33.208466 | 870.000000 | 853.419983 | 60.340454 | 214.169983 | 35.172501 | 870.000000 | 853.419983 | 64.639999 | 228.846161 | ... | 35.180000 | 870.530029 | 855.299988 | 64.750000 | 229.230774 | 76928000.0 | 1104500.0 | 1842300.0 | 20674300.0 | 2756910.0 |

2017-03-17 | 33.043240 | 872.369995 | 852.309998 | 60.555153 | 215.105835 | 34.997501 | 872.369995 | 852.309998 | 64.870003 | 229.846161 | ... | 35.250000 | 873.679993 | 853.489990 | 64.910004 | 229.615387 | 175540000.0 | 1868300.0 | 3384400.0 | 49219700.0 | 5673070.0 |

2017-03-20 | 33.390213 | 867.909973 | 856.969971 | 60.611160 | 214.097946 | 35.365002 | 867.909973 | 856.969971 | 64.930000 | 228.769226 | ... | 35.099998 | 869.479980 | 851.510010 | 64.910004 | 230.000000 | 86168000.0 | 1542200.0 | 2282700.0 | 14598100.0 | 2454062.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")`

Symbols | AAPL | GOOGL | AMZN | MSFT | GE |
---|---|---|---|---|---|

Symbols | |||||

AAPL | 1.000000 | 0.951564 | 0.934927 | 0.978283 | -0.282471 |

GOOGL | 0.951564 | 1.000000 | 0.866374 | 0.960317 | -0.191266 |

AMZN | 0.934927 | 0.866374 | 1.000000 | 0.944168 | -0.498395 |

MSFT | 0.978283 | 0.960317 | 0.944168 | 1.000000 | -0.373495 |

GE | -0.282471 | -0.191266 | -0.498395 | -0.373495 | 1.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>`

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.

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)`

**$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\%$

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>`

`y_valid.plot(legend="Valid")`

pd.Series(preds, index=y_valid.index).plot(legend="Pred")

`<matplotlib.axes._subplots.AxesSubplot at 0x7f1fb5dd5190>`

Looks pretty fly, but can we take it further?

...yes we can! 😎

I see a few options, the two first being:

- Scaling the data as errors at the end are larger than in the beggining based on stocks rising.
`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)`

😲$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>`

That's an incredibly fitted curve.

**How is this possible?**

- We most likely overfit the data.
- 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!

- 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$) or backwards ($-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

🤯 this is crazy impressive!

We made the task at hands legit by only using historical data of `GOOGL`

's competitors. The $R^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

🤯😭

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()

Symbols | AAPL | GOOGL | AMZN | MSFT |
---|---|---|---|---|

Date | ||||

2017-03-15 | 0.010521 | 0.002860 | 0.000516 | 0.005265 |

2017-03-16 | 0.001636 | 0.001852 | 0.000527 | -0.001700 |

2017-03-17 | -0.004988 | 0.002720 | -0.001301 | 0.003552 |

2017-03-20 | 0.010446 | -0.005126 | 0.005453 | 0.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

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()`

Symbols | AAPL | GOOGL | AMZN | MSFT |
---|---|---|---|---|

Date | ||||

2017-03-14 | 32.807190 | 865.909973 | 852.530029 | 60.125767 |

2017-03-15 | 33.154175 | 868.390015 | 852.969971 | 60.443142 |

2017-03-16 | 33.208466 | 870.000000 | 853.419983 | 60.340454 |

2017-03-17 | 33.043240 | 872.369995 | 852.309998 | 60.555153 |

2017-03-20 | 33.390213 | 867.909973 | 856.969971 | 60.611160 |

`df = df[['GOOGL']]`

df.head()

Symbols | GOOGL |
---|---|

Date | |

2017-03-14 | 865.909973 |

2017-03-15 | 868.390015 |

2017-03-16 | 870.000000 |

2017-03-17 | 872.369995 |

2017-03-20 | 867.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()

Symbols | GOOGL | t_0 | t_1 | t_2 |
---|---|---|---|---|

Date | ||||

2017-03-14 | 865.909973 | NaN | NaN | NaN |

2017-03-15 | 868.390015 | 865.909973 | NaN | NaN |

2017-03-16 | 870.000000 | 868.390015 | 865.909973 | NaN |

2017-03-17 | 872.369995 | 870.000000 | 868.390015 | 865.909973 |

2017-03-20 | 867.909973 | 872.369995 | 870.000000 | 868.390015 |

Notice how $t_0$ is the previous value, $t_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()

Symbols | GOOGL | t_0 | t_1 | t_2 | t_3 | t_4 | t_5 | t_6 |
---|---|---|---|---|---|---|---|---|

Date | ||||||||

2017-03-23 | 839.650024 | 849.799988 | 850.140015 | 867.909973 | 872.369995 | 870.000000 | 868.390015 | 865.909973 |

2017-03-24 | 835.140015 | 839.650024 | 849.799988 | 850.140015 | 867.909973 | 872.369995 | 870.000000 | 868.390015 |

2017-03-27 | 838.510010 | 835.140015 | 839.650024 | 849.799988 | 850.140015 | 867.909973 | 872.369995 | 870.000000 |

2017-03-28 | 840.630005 | 838.510010 | 835.140015 | 839.650024 | 849.799988 | 850.140015 | 867.909973 | 872.369995 |

2017-03-29 | 849.869995 | 840.630005 | 838.510010 | 835.140015 | 839.650024 | 849.799988 | 850.140015 | 867.909973 |

LGTM ✅

Let's scale our data and then make predictions.

As previously,

- Scale data
- Split data
- Fit data
- Predict data
- 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

Not great, not awful. Some self-exercises:

- How would we do without scaling?
- How would we do without shuffling?
- 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>`

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

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?**

- We are not using an
`AutoRegressive`

model which might be interesting.

- More about this in the next session

- 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,

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

Part Two - Predicting Stock Prices (Time Series) using classical Machine Learning

#### Extra Self Exercises

- Try different window-sizes with
`rolling`

- Try different length of history to predict new result on
- Test new architectures
- Find your own way to improve the results