Использование кластеризации k-средних

Я пишу эту статью, чтобы поделиться исследованием, которое я провел в прошлом году в выпускном аспирантском проекте по количественным финансам «Классификация долей через кластеризацию k-средних». Чтобы ознакомиться с полным текстом статьи, посетите SSRN.

В данной работе представлен подход к использованию алгоритма K-средних для классификации акций с целью помочь инвесторам диверсифицировать свои инвестиционные портфели. Он разделен на 4 части:

  1. Знакомство с алгоритмом k-средних
  2. Кластеризация акций по доходности и волатильности
  3. Кластеризация акций по соотношению цена/прибыль и ставке дивидендов
  4. 3-мерный анализ с использованием алгоритма k-средних++ по доходности, волатильности и PER.

Знакомство с алгоритмом k-средних

Термин k-mean был впервые использован Маккуином в 1967 году, хотя идея восходит к Штайнхаусу в 1957 году. K-средние — это неконтролируемый алгоритм классификации (кластеризации), который группирует объекты в k групп на основе их характеристик.

Кластеризация осуществляется путем минимизации суммы расстояний между каждым объектом и центроидом его группы или кластера. Часто используется квадратичное расстояние. Алгоритм состоит из трех шагов:

  1. Инициализация: после того, как выбрано количество групп, k, в пространстве данных устанавливается k центроидов, например, выбирая их случайным образом.
  2. Назначение объектов центроидам: каждому объекту данных присваивается ближайший центроид.
  3. Обновление центроида: положение центроида каждой группы обновляется, принимая в качестве нового центроида положение среднего объекта, принадлежащего к указанной группе.

Шаги 2 и 3 повторяются до тех пор, пока центроиды не перестанут двигаться или не сместятся ниже порогового расстояния на каждом шаге.

Кластеризация акций по доходности и волатильности

Мы анализируем индекс S&P 500 для кластеризации акций на основе доходности и волатильности. Этот индекс включает в себя 500 американских компаний с большой капитализацией из различных секторов, торгуемых на NYSE или Nasdaq. Благодаря тому, что он представляет крупнейшие публично торгуемые фирмы США, он служит подходящим набором данных для алгоритмической кластеризации k-средних.

#Import the libraries that we are going to need to carry out the analysis:
import numpy as np
import pandas as pd
import pandas_datareader as dr
import yfinance as yf

from pylab import plot,show
from matplotlib import pyplot as plt
import plotly.express as px

from numpy.random import rand
from scipy.cluster.vq import kmeans,vq
from math import sqrt
from sklearn.cluster import KMeans
from sklearn import preprocessing

Загрузить данные

Мы рассчитываем среднегодовую доходность и волатильность для каждой компании, получая их скорректированные цены закрытия в период с 01.02.2020 по 02.12.2022 и вставляя их в фрейм данных, который затем пересчитывается в годовом исчислении (при условии 252 рыночных дня в году).

# Define the url
sp500_url = 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies'

# Read in the url and scrape ticker data
data_table = pd.read_html(sp500_url)
tickers = data_table[0]['Symbol'].values.tolist()
tickers = [s.replace('\n', '') for s in tickers]
tickers = [s.replace('.', '-') for s in tickers]
tickers = [s.replace(' ', '') for s in tickers]

# Download prices
prices_list = []
for ticker in tickers:
try:
prices = dr.DataReader(ticker,'yahoo','01/01/2020')['Adj Close']
prices = pd.DataFrame(prices)
prices.columns = [ticker]
prices_list.append(prices)
except:
pass
prices_df = pd.concat(prices_list,axis=1)
prices_df.sort_index(inplace=True)

# Create an empity dataframe
returns = pd.DataFrame()

# Define the column Returns
returns['Returns'] = prices_df.pct_change().mean() * 252

# Define the column Volatility
returns['Volatility'] = prices_df.pct_change().std() * sqrt(252)

Определение оптимального количества кластеров

Метод кривой Локтя — это метод, используемый для определения оптимального числа кластеров для кластеризации K-средних. Метод работает путем построения суммы квадратов ошибок (SSE) для разных значений k (количество кластеров). Оптимальным числом кластеров является значение k, при котором SSE начинает уменьшаться медленнее. Оптимальное количество кластеров определяется путем нахождения локтя или точки, в которой SSE достигает своего минимального значения. В этом случае оптимальное количество гроздей – 4.

# Format the data as a numpy array to feed into the K-Means algorithm
data = np.asarray([np.asarray(returns['Returns']),np.asarray(returns['Volatility'])]).T
X = data
distorsions = []
for k in range(2, 20):
k_means = KMeans(n_clusters=k)
k_means.fit(X)
distorsions.append(k_means.inertia_)
fig = plt.figure(figsize=(15, 5))

plt.plot(range(2, 20), distorsions)
plt.grid(True)
plt.title('Elbow curve')

Кластеризация K-средних

После того, как оптимальное количество кластеров определено, мы приступаем к их созданию. Во-первых, центроиды определяются с помощью библиотеки sklearn. Для создания 4 групп действий алгоритм K-средних итеративно присваивает группам точки данных на основе их сходства характеристик, или «особенностей», в данном случае Average Year Return и Average Year Volatility.

# Computing K-Means with K = 4 (4 clusters)
centroids,_ = kmeans(data,4)

# Assign each sample to a cluster
idx,_ = vq(data,centroids)

# Create a dataframe with the tickers and the clusters that's belong to
details = [(name,cluster) for name, cluster in zip(returns.index,idx)]
details_df = pd.DataFrame(details)

# Rename columns
details_df.columns = ['Ticker','Cluster']

# Create another dataframe with the tickers and data from each stock
clusters_df = returns.reset_index()

# Bring the clusters information from the dataframe 'details_df'
clusters_df['Cluster'] = details_df['Cluster']

# Rename columns
clusters_df.columns = ['Ticker', 'Returns', 'Volatility', 'Cluster']

Алгоритм сначала случайным образом назначает точки данных кластерам, а затем вычисляет центроид каждого кластера, который является средним значением всех точек данных в кластере. Затем он сравнивает точки данных с центроидом и соответственно переназначает их группам. Этот процесс повторяется до тех пор, пока центроид каждого кластера не останется относительно стабильным, после чего алгоритм останавливается, и каждому кластеру присваивается метка. Конечным результатом является набор из 4 групп, каждая из которых содержит акции с одинаковой доходностью и волатильностью.

# Plot the clusters created using Plotly
fig = px.scatter(clusters_df, x="Returns", y="Volatility", color="Cluster", hover_data=["Ticker"])
fig.update(layout_coloraxis_showscale=False)
fig.show()

Обработка остатков

При создании кластеров на точечной диаграмме обнаруживаются четыре выброса или выброса. Выбросы — это точки данных, которые значительно отличаются от остальных точек данных в наборе данных. Часто они могут привести к неточным результатам при использовании алгоритма, поскольку они не соответствуют тому же шаблону, что и другие точки данных. Поэтому важно разделять и удалять выбросы для повышения точности модели.

Удаление выбросов может помочь алгоритму сосредоточиться на наиболее репрезентативных точках данных и уменьшить влияние выбросов на результаты. Это может помочь повысить точность модели и обеспечить правильную группировку точек данных. Удаленные тикеры:

  • МРНК
  • ЭНПХ
  • ТНПА
  • CEG
# Identify and remove the outliers stocks
returns.drop('MRNA',inplace=True)
returns.drop('ENPH',inplace=True)
returns.drop('TSLA',inplace=True)
returns.drop('CEG',inplace=True)

# Recreate data to feed into the algorithm
data = np.asarray([np.asarray(returns['Returns']),np.asarray(returns['Volatility'])]).T

После того, как выбросы устранены, мы повторяем шаги, выполненные для кластеризации с использованием алгоритма K-средних, чтобы получить более точные кластеры.

# Computing K-Means with K = 4 (4 clusters)
centroids,_ = kmeans(data,4)

# Assign each sample to a cluster
idx,_ = vq(data,centroids)

# Create a dataframe with the tickers and the clusters that's belong to
details = [(name,cluster) for name, cluster in zip(returns.index,idx)]
details_df = pd.DataFrame(details)

# Rename columns
details_df.columns = ['Ticker','Cluster']

# Create another dataframe with the tickers and data from each stock
clusters_df = returns.reset_index()

# Bring the clusters information from the dataframe 'details_df'
clusters_df['Cluster'] = details_df['Cluster']

# Rename columns
clusters_df.columns = ['Ticker', 'Returns', 'Volatility', 'Cluster']

# Plot the clusters created using Plotly
fig = px.scatter(clusters_df, x="Returns", y="Volatility", color="Cluster", hover_data=["Ticker"])
fig.update(layout_coloraxis_showscale=False)
fig.show()

На графике показано 4 кластера, которые были сгенерированы с использованием алгоритма K-средних с 2 переменными: средняя годовая доходность и средняя годовая волатильность. Эти переменные используются для измерения риска и доходности акций. 4 кластера представляют собой 4 группы действий с разным уровнем риска и доходности в исследуемый период.

Кластеризация полезна для выявления одноранговых групп среди акций, что позволяет проводить дифференциацию между акциями с разным уровнем риска и доходности. Это полезно для инвесторов, стремящихся диверсифицировать свои инвестиционные портфели, поскольку позволяет им идентифицировать группы акций с разным уровнем риска и доходности.

Инвесторы могут использовать 4 кластера для выбора сочетания акций с разным уровнем риска и доходности в зависимости от своих инвестиционных целей. Это поможет им диверсифицировать свой портфель и снизить риск своих инвестиций, поскольку они будут инвестировать в различные активы с разным уровнем риска.

Кластеризация акций по соотношению цена/прибыль и ставке дивидендов

По словам Чжао Гао:

В машинном обучении разработка некоторых алгоритмов уже достаточно зрелая, […] например, алгоритм k-Means. Указанный алгоритм может быть применен к инвестициям в переменный доход и достижению очень хороших результатов. Например, выделение двух типов компаний на рынке. С одной стороны, зрелые компании или «стоимостные акции», как правило, имеют низкие коэффициенты P/E и высокие ставки дивидендов. Вторая категория, «растущие» компании — это компании с широкими перспективами развития, но также и с неопределенностью в будущем, как правило, имеют высокие коэффициенты P/E и низкие дивидендные ставки. Если вы сможете точно отличить акции голубых фишек от быстрорастущих акций на рынке, вы сможете стать хорошим ориентиром для инвесторов. (Чжао Гао 2020).

Следуя этой концептуальной линии, можно применить кластеризацию, аналогичную той, которая проводилась ранее, заменяя переменные «Средняя годовая доходность» и «Средняя годовая волатильность» на PER (соотношение цена-прибыль) и «Дивидендная ставка» (дивидендная доходность). Таким образом, мы могли бы различать «стоимостные» компании и «растущие» компании.

Загрузить данные

Отношение цены к прибыли (P/E) — это относительный мультипликатор оценки, основанный на фактической прибыли за последние 12 месяцев. Он рассчитывается путем деления текущей цены акций на прибыль на акцию (EPS) за последние 12 месяцев. В то время как ставка дивидендов, или ставка дивидендов, представляет собой сумму денежных средств, которую компания ежегодно возвращает своим акционерам в процентах от рыночной стоимости компании.

# Download trailingPE and dividendRate 
trailingPE_list = []
dividendRate_list = []

for t in tickers:

tick = yf.Ticker(t)
ticker_info = tick.info

try:
trailingPE = ticker_info['trailingPE']
trailingPE_list.append(trailingPE)
except:
trailingPE_list.append('na')

try:
dividendRate = ticker_info['dividendRate']
dividendRate_list.append(dividendRate)
except:
dividendRate_list.append('na')

# Create a datafrane to contain the data
sp_features_df = pd.DataFrame()

# Add the ticker, trailingPE and dividendRate data
sp_features_df['Ticker'] = tickers
sp_features_df['trailingPE'] = trailingPE_list
sp_features_df['dividendRate'] = dividendRate_list

# Shares with 'na' as dividend rate has no dividend so we have to assign 0 as dividend rate in this cases
sp_features_df["dividendRate"] = sp_features_df["dividendRate"].fillna(0)

# filter shares with 'na' as trailingPE
df_mask = sp_features_df['trailingPE'] != 'na'
sp_features_df = sp_features_df[df_mask]

# Convert trailingPE numbers to float type
sp_features_df['trailingPE'] = sp_features_df['trailingPE'].astype(float)

# Removes the rows that contains NULL values
sp_features_df=sp_features_df.dropna()

Определение оптимального количества кластеров

После того, как данные о соотношении цены и прибыли и ставке дивидендов были получены, мы можем повторно применить метод Локтя для определения оптимального количества кластеров

# Format the data as a numpy array to feed into the K-Means algorithm
data = np.asarray([np.asarray(sp_features_df['trailingPE']),np.asarray(sp_features_df['dividendRate'])]).T
X = data
distorsions = []
for k in range(2, 20):
k_means = KMeans(n_clusters=k)
k_means.fit(X)
distorsions.append(k_means.inertia_)
fig = plt.figure(figsize=(15, 5))

plt.plot(range(2, 20), distorsions)
plt.grid(True)
plt.title('Elbow curve')

Оптимальное количество гроздей – 3.

Кластеризация K-средних

После того, как оптимальное количество кластеров определено, мы приступаем к их созданию. Во-первых, центроиды определяются с помощью библиотеки sklearn. Для создания групп действий алгоритм K-средних итеративно присваивает группам точки данных на основе их сходства характеристик или «особенностей», в данном случае Price-Earnings Ratio и Dividend Rate.

# Computing K-Means with K = 3 (3 clusters)
centroids,_ = kmeans(data,3)

# Assign each sample to a cluster
idx,_ = vq(data,centroids)

# Create the clusters from the numpy array 'data'
cluster_1 = data[idx==0,0],data[idx==0,1]
cluster_2 = data[idx==1,0],data[idx==1,1]
cluster_3 = data[idx==2,0],data[idx==2,1]

# Create a dataframe with the tickers and the clusters that's belong to
details = [(name,cluster) for name, cluster in zip(sp_features_df.index,idx)]
details_df = pd.DataFrame(details)

# Rename columns
details_df.columns = ['Ticker','Cluster']

# Create another dataframe with the tickers and data from each stock
clusters_df = sp_features_df

# Bring the clusters information from the dataframe 'details_df'
clusters_df['Cluster'] = details_df['Cluster']

# Rename columns
clusters_df.columns = ['Ticker', 'trailingPE', 'dividendRate', 'marketCap', 'Cluster']

# Plot the clusters created using Plotly
fig = px.scatter(clusters_df, x="dividendRate", y="trailingPE", color="Cluster", hover_data=["Ticker"])
fig.update(layout_coloraxis_showscale=False)
fig.show()

При кластеризации в первом приближении путем отслеживания цены к прибыли (P/E) и дивидендной ставки очевидно наличие выбросов и чрезмерной дисперсии среди наблюдений, поэтому мы переходим к фильтрации действий и нормализации данных для устранения этих искажений.

Обработка остатков

В результате мы видим разрозненную и нечеткую кластеризацию. Таким образом, устранение выбросов и нормализация данных будут необходимы для достижения более точных кластеров.

Во-первых, мы применяем фильтр, чтобы включить только акции с соотношением цены к прибыли менее 200 и ставкой дивидендов менее 5.

df_mask = (sp_features_df['trailingPE'] < 200) & (sp_features_df['dividendRate'] < 5)
sp_features_df = sp_features_df[df_mask]

Затем мы применяем MaxAbsScaler. MaxAbsScaler масштабирует каждую функцию по максимальному абсолютному значению. Этот оценщик масштабирует и преобразует каждый признак по отдельности таким образом, что максимальное абсолютное значение каждого признака в обучающем наборе будет равно 1. Он не смещает / не центрирует данные и, таким образом, не разрушает разреженность.

# Import the MaxAbsScaler class 
max_abs_scaler = preprocessing.MaxAbsScaler()

# Extract the 'trailingPE' column and reshape it to a column vector
trailingPE_array = np.array(sp_features_df['trailingPE'].values).reshape(-1,1)

# Extract the 'dividendRate' column and reshape it to a column vector
dividendRate_array = np.array(sp_features_df['dividendRate'].values).reshape(-1,1)

# Extract the 'marketCap' column and reshape it to a column vector
marketCap_array = np.array(sp_features_df['marketCap'].values).reshape(-1,1)

# Apply the MaxAbsScaler and store the normalized values in new columns
sp_features_df['trailingPE_norm'] = max_abs_scaler.fit_transform(trailingPE_array)
sp_features_df['dividendRate_norm'] = max_abs_scaler.fit_transform(dividendRate_array)
sp_features_df['marketCap_norm'] = max_abs_scaler.fit_transform(marketCap_array)

MaxAbsScaler мы снова выполняем метод Elbow с нормализованными переменными в качестве входных данных:

# Format the data as a numpy array to feed into the K-Means algorithm
data = np.asarray([np.asarray(sp_features_df['trailingPE_norm']),np.asarray(sp_features_df['dividendRate_norm'])]).T
X = data
distorsions = []
for k in range(2, 20):
    k_means = KMeans(n_clusters=k)
    k_means.fit(X)
    distorsions.append(k_means.inertia_)
fig = plt.figure(figsize=(15, 5))

plt.plot(range(2, 20), distorsions)
plt.grid(True)
plt.title('Elbow curve')

После внесения соответствующих изменений можно получить 4 кластера, сгенерированных алгоритмом K-средних в соответствии с скользящей ценой к прибыли (P/E) и ставкой дивидендов по каждой акции.

# Computing K-Means with K = 4 (4 clusters)
centroids,_ = kmeans(data,4)

# Assign each sample to a cluster
idx,_ = vq(data,centroids)

# Create a dataframe with the tickers and the clusters that's belong to
details = [(name,cluster) for name, cluster in zip(sp_features_df.index,idx)]
details_df = pd.DataFrame(details)

clusters_df = pd.DataFrame()
clusters_df['Ticker'] = sp_features_df['Ticker']
clusters_df['trailingPE_norm'] = sp_features_df['trailingPE_norm']
clusters_df['dividendRate_norm'] = sp_features_df['dividendRate_norm']
clusters_df['marketCap_norm'] = sp_features_df['marketCap_norm']
clusters_df['Cluster'] = details_df[1].values

# Plot the clusters created using Plotly
fig = px.scatter(clusters_df, x="dividendRate_norm", y="trailingPE_norm", color="Cluster", hover_data=["Ticker"])
fig.update(layout_coloraxis_showscale=False)
fig.show()

Можно графически проверить, что алгоритм присвоил больший вес переменной дивидендной ставки при создании кластеров. Таким образом, выделяют 4 набора действий: C1 с Null или очень низким, C2 с низким, C3 со средне-высоким и C4 с высокой ставкой дивиденда.

3-мерная кластеризация с K-средними++

Мы можем расширить анализ акций S&P500, применив биннинг k-средних++. Этот алгоритм обеспечивает более интеллектуальную инициализацию центроидов и улучшает качество кластеризации. За исключением инициализации, остальная часть алгоритма такая же, как и стандартный алгоритм K-средних. То есть, K-средние++ — это стандартный алгоритм K-средних вместе с более интеллектуальной инициализацией центроидов.

Для кластеризации можно учесть 3 переменные. При кластеризации K-средних точки группируются в кластеры в зависимости от расстояния между точками. Это означает, что для расширения кластеризации с двух измерений до трех необходимо добавить к данным третье измерение и также вычислить расстояние между точками в этом измерении.

Кластеры теперь определяются как наборы данных, которые имеют одинаковые расстояния во всех трех измерениях, а не только в двух. Это позволяет лучше разделять кластеры и лучше распределять данные.

Загрузить данные

Как и в первом приложении, выбраны данные Average Yearualized Return и Average Year Volatility, но теперь для 3-мерного анализа добавлена и переменная Price to Book:

# Download priceToBook and marketCap
priceToBook_list = []
marketCap_list = []
tickers_clean = tickers

for t in tickers:

try:

tick = yf.Ticker(t)
ticker_info = tick.info

priceToBook = ticker_info['priceToBook']
marketCap = ticker_info['marketCap']

priceToBook_list.append(priceToBook)
marketCap_list.append(marketCap)

except:

tickers_clean = tickers.remove(ticker)
print('The stock ticker {} is not on database'.format(ticker))

# Create a datafrane to contain the data
priceToBook_df = pd.DataFrame()

# Add the ticker, priceToBook and marketCap data
priceToBook_df['Ticker'] = tickers_clean
priceToBook_df['priceToBook'] = priceToBook_list
priceToBook_df['marketCap'] = marketCap_list

# Merge dataframes
clusters3d_df = pd.merge(clusters_df, priceToBook_df)

# Removes the rows that contains NULL values
clusters3d_df.dropna(inplace=True)

# Drop the column with the old clusterization
clusters3d_df.drop(['Cluster'], axis=1, inplace=True)

# Order columns
clusters3d_df = clusters3d_df[['Ticker', 'marketCap', 'Returns', 'Volatility', 'priceToBook']]

Определение оптимального количества кластеров

После получения данных о средней годовой доходности, средней волатильности в годовом исчислении и цене к книге мы можем повторно применить метод Локтя для определения оптимального количества кластеров

Оптимальное количество гроздей – 3.

Кластеризация K-средних

После того, как оптимальное количество кластеров определено, мы приступаем к их созданию. Во-первых, центроиды определяются с помощью библиотеки sklearn. Для создания групп действий алгоритм K-средних итеративно присваивает группам точки данных на основе их сходства характеристик или «особенностей», в данном случае средней годовой доходности, средней годовой волатильности и цены в книге.

# Create clusters
k_means_optimum = KMeans(n_clusters = 3, init = 'k-means++', random_state=42)
y = k_means_optimum.fit_predict(X)

# Plot 3D graph with plotly
clusters3d_df['cluster'] = y

fig = px.scatter_3d(clusters3d_df, x='Returns', y='Volatility', z='priceToBook',
color='cluster', hover_data=["Ticker"])
fig.show()

Обработка остатков

Опять же, отметим наличие выбросов. Мы удаляем их по отдельности и повторяем шаги, чтобы снова сгруппировать их

# Identify and remove the outliers stocks
clusters3d_df.drop(clusters3d_df[(clusters3d_df['Ticker'] == 'HD')].index, inplace=True)
clusters3d_df.drop(clusters3d_df[(clusters3d_df['Ticker'] == 'CL')].index, inplace=True)

# Recreate data to feed into the algorithm
data3d = np.asarray([np.asarray(clusters3d_df['Returns']), np.asarray(clusters3d_df['Volatility']), np.asarray(clusters3d_df['priceToBook'])]).T
X = data3d

#elbow method
distorsions = []
for i in range(1,20):
k_means = KMeans(n_clusters=i,init='k-means++', random_state=42)
k_means.fit(X)
distorsions.append(k_means.inertia_)

#plot elbow curve
fig = plt.figure(figsize=(15, 5))
plt.plot(np.arange(1,20),distorsions)
plt.xlabel('Clusters')
plt.ylabel('SSE')
plt.title('Elbow curve')
plt.grid(True)

plt.show()

После внесения соответствующих изменений можно получить 3 кластера, сгенерированных алгоритмом K-средних++ в соответствии со средней годовой доходностью, средней годовой волатильностью и ценой к балансу каждой акции.

# Initialize KMeans model with 3 clusters
k_means_optimum = KMeans(n_clusters = 3, init = 'k-means++', random_state=42)

# Cluster data using KMeans and store labels in 'y'
y = k_means_optimum.fit_predict(X)

# Add 'cluster' column to dataframe with cluster labels from 'y'
clusters3d_df['cluster'] = y

# Create 3D scatter plot with plotly express, color-coded by cluster label and with 'Ticker' tooltip
fig = px.scatter_3d(clusters3d_df, x='priceToBook', y='Returns', z='Volatility', color='cluster', hover_data=["Ticker"])

# Display plot
fig.show()

Наконец, путем кластеризации по алгоритму K-means++ получены 3 набора действий, сгруппированных по 3 исследуемым переменным (средняя доходность в годовом исчислении, средняя волатильность в годовом исчислении и цена к балансу). Бросается в глаза наличие акций с высокими индексами Price to Book. Эта информация может быть полезна при создании инвестиционного портфеля.

Источник