Введение

В этой статье мы рассмотрим простой, но чрезвычайно универсальный алгоритм, называемый k-ближайшими соседями (k-NN). Сначала мы построим интуицию о внутренней работе k-NN, а затем научимся применять алгоритм к реальным финансовым данным из Данных AlphaWave. Мы будем использовать фундаментальные кредитные данные высокодоходных и инвестиционных облигаций, а также их соответствующие кредитные рейтинги. Используя scikit-learn, мы поэкспериментируем со способами улучшения прогностической способности алгоритма за счет оптимизации параметров и предварительной обработки данных. Scikit-learn — это бесплатная библиотека машинного обучения для языка программирования Python. Наконец, мы реализуем k-NN с нуля, чтобы еще больше укрепить ваше понимание этого алгоритма.

Блокноты Jupyter доступны в Google Colab и Github.

В этом проекте мы используем несколько технологий научных вычислений на основе Python, перечисленных ниже.

import re
import os
import time
import math
import statistics
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import plotly.graph_objects as go
from img import *
import requests
from requests.adapters import HTTPAdapter
from requests.exceptions import ConnectionError
from requests.packages.urllib3.util.retry import Retry
from selenium import webdriver
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import Select
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from datetime import date
from datetime import timedelta
from datetime import datetime as dt
from tqdm import tqdm
from PyPDF2 import PdfFileReader
import io

Обзор машинного обучения

Так как же k-NN вписывается в более широкий контекст машинного обучения? Давайте кратко рассмотрим подкрепление, обучение без учителя и обучение с учителем.

Обучение с подкреплением — это обучение моделей машинного обучения принятию последовательности решений для достижения определенной цели или задачи. Самое известное применение обучения с подкреплением произошло в 1997 году, когда суперкомпьютер IBM Deep Blue победил чемпиона мира по шахматам Гарри Каспарова.

При неконтролируемом обучении перед нами стоит задача выявления закономерностей в данных, которые не помечены и не классифицированы. По сути, цель состоит в том, чтобы изучить структуру данных, чтобы извлечь полезную информацию. Одним из примеров неконтролируемого обучения является анализ главных компонентов (АГК), который представляет собой метод уменьшения размерности данных. Другой пример неконтролируемого обучения — кластеризация методом k-средних, о которой будет рассказано в отдельной статье.

Обучение с учителем применяется, когда мы хотим сопоставить новые входные данные с некоторыми выходными данными. В контексте классификации он назначит метку некоторым входным данным с именем X. В регрессии мы сопоставляем входные данные X с некоторой непрерывной выходной переменной Y, как в одной вариантной функции, y = mx + b.

Таким образом, k-NN является примером обучения с учителем, поскольку он опирается на входные данные метки для изучения функции, которая затем будет предсказывать метки для новых немаркированных данных. Позже мы увидим, что k-NN можно применять как к регрессиям, так и к классификациям. Хотя, как правило, он чаще используется в классификации.

КНН Введение

Так как же работает k-NN? Представьте, что у нас есть две категории. Первая категория определяется красным треугольником, а вторая категория определяется синим квадратом. Как бы мы классифицировали зеленый круг, который находится посередине? Если бы мы смотрели только в окрестности сплошного круга, было бы разумно предположить, что наш зеленый круг принадлежит красному треугольнику, потому что внутри сплошного круга есть два красных треугольника и только один синий квадрат. Однако если бы мы посмотрели за сплошной круг на пунктирный круг, то мы бы выбрали синий квадрат в качестве класса для нашего зеленого круга, потому что у нас есть три синих квадрата и два красных треугольника. Основываясь на большинстве голосов, какой класс является наиболее распространенным, разумно предположить, что зеленый кружок принадлежит классу синего квадрата.

Мы видим, что k относится к количеству ближайших точек к точке, которую мы хотим классифицировать. По своей сути k-NN — один из самых простых алгоритмов машинного обучения. Он использует ранее размеченные данные для создания новых прогнозов на основе неразмеченных данных на основе некоторого показателя сходства, которым в данном примере является расстояние. Алгоритм предполагает, что подобные вещи существуют в непосредственной близости. В нашем примере мы видим, что три точки, ближайшие к зеленому кругу, лежат внутри сплошного круга. В зависимости от значения k алгоритм будет классифицировать новые выборки большинством голосов k соседей.

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

Формула евклидова расстояния

Евклидова диаграмма расстояний

Алгоритм начинается с классификации расстояния зеленого круга от всех точек в наборе данных, помеченных красными треугольниками и синими квадратами. Затем мы сортируем расстояния от наименьшего к наибольшему, чтобы найти k ближайших точек. Наконец, алгоритм присваивает данные классу, к которому принадлежит большинство k точек данных. Если у вас k равно 3, зеленый кружок будет классифицирован как красный треугольник. В примере регрессии, где мы предсказываем числовое значение новой выборки, алгоритм k-NN просто возьмет среднее значение k ближайших соседей.

Этот алгоритм кажется относительно простым, но как выбрать k? В нашем примере классификации треугольников и квадратов, если мы выберем k равным 3, мы классифицируем зеленый круг как красный треугольник. Однако если мы выберем k равным 5, это заставит нас классифицировать зеленый круг как синий квадрат. Результат алгоритма полностью зависит от значения k. К сожалению, нет определенного ответа, когда дело доходит до выбора k. Оптимальное значение k будет варьироваться для каждого набора данных. Наша цель — найти k, который оптимизирует точность алгоритма или минимизирует ошибку. Как и в большинстве алгоритмов машинного обучения, k — это гиперпараметр, поэтому вам решать, какое значение лучше всего подходит для данных. Мы увидим, как это делается на практике.

Вы можете думать о k как о компромиссе между смещением и дисперсией. Смещение — это разница между средним прогнозом модели и правильными значениями, которые мы пытаемся предсказать. При высоком смещении модель имеет тенденцию быть чрезмерно упрощенной, тогда как модель имеет тенденцию быть более сложной при низком смещении. Таким образом, низкое смещение приведет к переоснащению. Например, если мы выберем k равным 1 в нашем алгоритме k-NN, каждый новый образец, который мы попытаемся классифицировать, будет помечен как ближайший сосед, потому что количество соседей равно 1. Если мы выберем очень большое число для k, модель будет неподходящей, потому что мы обобщаем наш прогноз на основе значительно большего числа соседей вокруг нашей контрольной точки.

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

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

Собирайте и визуализируйте данные об облигациях

Прогнозируйте рейтинги облигаций S&P, используя фундаментальные кредитные данные на определенный момент времени.

Столбец рейтинга S&P будет нашей зависимой переменной.

Сначала мы должны получить текущие рейтинги облигаций. Используя скрипт Selenium, который эмулирует нажатия клавиш и щелчки пользователя в браузере, в качестве средства перехода к данным об облигациях FINRA TRACE (Trade Reporting and Compliance Engine), мы можем получить доступ к необходимым данным.

Ниже приведен пример сценария. Если у вас не установлен Selenium, вы можете перейти по соответствующим ссылкам и загрузить их, используя pip в своем терминале. Нам также понадобится chromedriver (имитация управления браузером Chrome Selenium), и для его загрузки с помощью Python вы можете использовать пакет webdriver-manager, который также находится в PyPi.

Вам нужно будет вставить свой собственный путь к вашему chromedriver в блоке кода ниже.

# Selenium script
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument('--headless')
driver = webdriver.Chrome(options=chrome_options, executable_path=r'/PATH/TO/YOUR/chromedriver')
# store starting time
begin = time.time()
# FINRA's TRACE Bond Center
driver.get('http://finra-markets.morningstar.com/BondCenter/Results.jsp')
# click agree
WebDriverWait(driver, 10).until(EC.element_to_be_clickable(
    (By.CSS_SELECTOR, ".button_blue.agree"))).click()
# click edit search
WebDriverWait(driver, 10).until(EC.element_to_be_clickable(
    (By.CSS_SELECTOR, 'a.qs-ui-btn.blue'))).click()
# click advanced search
WebDriverWait(driver, 10).until(EC.element_to_be_clickable(
    (By.CSS_SELECTOR, 'a.ms-display-switcher.hide'))).click()
# select bond ratings
WebDriverWait(driver, 10).until(EC.presence_of_element_located(
    (By.CSS_SELECTOR, 'select.range[name=moodysRating]')))
Select((driver.find_elements_by_css_selector(
    'select.range[name=moodysRating]'))[0]).select_by_visible_text('C')
Select((driver.find_elements_by_css_selector(
    'select.range[name=moodysRating]'))[1]).select_by_visible_text('Aaa')
Select((driver.find_elements_by_css_selector(
    'select.range[name=standardAndPoorsRating]'))[0]).select_by_visible_text('B-')
Select((driver.find_elements_by_css_selector(
    'select.range[name=standardAndPoorsRating]'))[1]).select_by_visible_text('BBB+')
# select Sub-Product Type
WebDriverWait(driver, 10).until(EC.presence_of_element_located(
    (By.CSS_SELECTOR, 'select[name=subProductType]')))
Select((driver.find_elements_by_css_selector(
    'select[name=subProductType]'))[0]).select_by_visible_text('Corporate Bond')
# select Bond Seniority
WebDriverWait(driver, 10).until(EC.presence_of_element_located(
    (By.CSS_SELECTOR, 'select[name=securityDescription]')))
Select((driver.find_elements_by_css_selector(
    'select[name=securityDescription]'))[0]).select_by_visible_text('Senior')
# input Trade Yield
inputElement = driver.find_element(By.XPATH, "(//input[@name='tradeYield'])[1]")
inputElement.send_keys('0.001')
inputElement = driver.find_element(By.XPATH, "(//input[@name='tradeYield'])[2]")
inputElement.send_keys('50')
###############################################
# select Trade Date(MM/DD/YYYY)
inputElement = driver.find_element_by_css_selector('.qs-ui-ipt.range.date[calid="5"]')
ActionChains(driver).click(inputElement).perform()
# Create for loop to click 1 time when targeting the Previous Year Button
for d in range(1):
    previous = driver.find_element_by_css_selector('.py')
    # Make click in that button
    ActionChains(driver).click(previous).perform()
webelem1 = driver.find_element(By.XPATH, "(/html/body/div[4]/div[2]/table/tbody/tr[2]/td[2]/div)")
# webelem1 = driver.find_element_by_css_selector('.dayNum[val="2020-08-03"]')
ActionChains(driver).click(webelem1).perform()
inputElement = driver.find_element_by_css_selector('.qs-ui-ipt.range.date[calid="6"]')
ActionChains(driver).click(inputElement).perform()
webelem2 = driver.find_element_by_css_selector('.dayNum.today')
ActionChains(driver).click(webelem2).perform()
###############################################
# click show results
WebDriverWait(driver, 10).until(EC.element_to_be_clickable(
    (By.CSS_SELECTOR, 'input.button_blue[type=submit]'))).click()
# wait for results
WebDriverWait(driver, 10).until(EC.presence_of_element_located(
    (By.CSS_SELECTOR, '.rtq-grid-row.rtq-grid-rzrow .rtq-grid-cell-ctn')))
# wait for page total
WebDriverWait(driver,10).until(EC.presence_of_all_elements_located((
    By.CSS_SELECTOR, '.qs-pageutil-total')))
time.sleep(3)
# capture total of pages
pages = WebDriverWait(driver,10).until(EC.presence_of_all_elements_located((
    By.CSS_SELECTOR, '.qs-pageutil-total')))[0].text
# isolate the number of pages
pages = pages.split(" ")[1]
print(f'Total pages returned: {pages}')
# create dataframe from scrape
frames = []
for page in tqdm(range(1, int(pages)), position=0, leave=True, desc = "Retrieving Bond Data"):
    bonds = []
# wait for page marker to be on expected page
    WebDriverWait(driver, 10).until(EC.presence_of_element_located(
        (By.CSS_SELECTOR, (f"a.qs-pageutil-btn[value='{str(page)}']"))))
    
    # wait for page next button to load
    WebDriverWait(driver, 10).until(EC.presence_of_element_located(
        (By.CSS_SELECTOR, 'a.qs-pageutil-next')))
    
    # wait for table grid to load
    WebDriverWait(driver, 10).until(EC.presence_of_element_located(
        (By.CSS_SELECTOR, '.rtq-grid-bd')))
    
    # wait for tablerows to load
    WebDriverWait(driver, 10).until(EC.presence_of_element_located(
        (By.CSS_SELECTOR, 'div.rtq-grid-bd > div.rtq-grid-row')))
    
    # wait for table cell to load
    WebDriverWait(driver, 10).until(EC.presence_of_element_located(
        (By.CSS_SELECTOR, 'div.rtq-grid-cell')))
# Wait 3 seconds to ensure all rows load
    time.sleep(3)
    
    # scrape table rows
    headers = [title.text for title in driver.find_elements_by_css_selector(
    '.rtq-grid-row.rtq-grid-rzrow .rtq-grid-cell-ctn')[1:]]
tablerows = driver.find_elements_by_css_selector(
        'div.rtq-grid-bd > div.rtq-grid-row')
    for tablerow in tablerows:
        try:
            tablerowdata = tablerow.find_elements_by_css_selector(
                'div.rtq-grid-cell')
            bond = [item.text for item in tablerowdata[1:]]
            bonds.append(bond)
        except:
            pass
# Convert to Dataframe
    df = pd.DataFrame(bonds, columns=headers)
frames.append(df)
try:
        driver.find_element_by_css_selector('a.qs-pageutil-next').click()
    except:
        break
bond_prices_df = pd.concat(frames)
# store end time 
end = time.time()
# total time taken 
print(f"Total runtime of the program is {end - begin} seconds")
bond_prices_df

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

# Save bond dataframe into a pickle file
# bond_prices_df.to_pickle("./bond_prices_df.pkl")
# Load bond dataframe from the saved pickle file
# bond_prices_df = pd.read_pickle("./bond_prices_df.pkl")
# bond_prices_df
# Let's clean up the Symbol column
r = re.compile(r'([a-zA-Z]+)')
bond_prices_df["Symbol"] = bond_prices_df["Symbol"].transform(lambda x: r.match(x).groups()[0])
# Add a Maturity Years column
now = dt.strptime(date.today().strftime('%m/%d/%Y'), '%m/%d/%Y')
bond_prices_df['Maturity'] = pd.to_datetime(bond_prices_df['Maturity']).dt.strftime('%m/%d/%Y')
bond_prices_df["Maturity Years"] = bond_prices_df["Maturity"].transform(
    lambda x: (dt.strptime(x, '%m/%d/%Y') - now).days/360)
# Remove any commas and change string values to numeric values
bond_prices_df[["Coupon", "Price", "Yield", 
                "Maturity Years"]] = bond_prices_df[["Coupon", "Price", "Yield", 
                                                     "Maturity Years"]].apply(lambda x: x.replace('[,]',''))
bond_prices_df[["Coupon", "Price", "Yield", 
                "Maturity Years"]] = bond_prices_df[["Coupon", "Price", "Yield", 
                                                     "Maturity Years"]].apply(pd.to_numeric)

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

# Bond duration using discrete, annual compounding and a flat term structure
def bonds_duration_discrete(maturity_years, coupon, bond_price, interest_rate):
    
    b = 0
    d = 0
    
    times = np.arange(1, round(maturity_years))
    cashflows = [coupon for i in np.arange(1,round(maturity_years))] + [100] # Manually added the repayment of principal
    b = bond_price
    r = interest_rate
    
    for i in range(len(times)):
        
        d += times[i] * cashflows[i] / np.power((1 + r), times[i])
    
    return d / b
# create a new Duration column
bond_prices_df["Duration"] = bond_prices_df.apply(lambda x: bonds_duration_discrete(x["Maturity Years"],
                                                                                    float(x["Coupon"]),
                                                                                    float(x["Price"]),
                                                                                    float(x["Yield"])/100), axis=1)
bond_prices_df

# Generate descriptive statistics for bonds
bond_prices_df.describe()

# Get the list of unique stock tickers from the bond dataframe
prelim_stock_tickers = bond_prices_df["Symbol"].unique().tolist()
# Check the count of unique stock tickers from the bond dataframe
len(prelim_stock_tickers)

Затем мы очистим список участников индекса Russell 3000, чтобы отфильтровать данные об облигациях FINRA TRACE, чтобы продолжить наш анализ только с членами индекса Russell 3000. Приведенный ниже сценарий включает URL-адрес списка участников индекса Russell 3000 на определенную дату. Этот URL-адрес, возможно, потребуется обновить в будущем, чтобы агрегировать текущий список членов Russell 3000 Index на эту дату в будущем.

Используя элементы индекса Russell 3000, которые также включены в данные об облигациях от FINRA TRACE, мы затем воспользуемся конечной точкой Ключевая статистика из API анализа запасов данных AlphaWave, чтобы получить необходимую информацию об акциях.

# Scrape the Russell 3000 Member List
russell_url = 'https://content.ftserussell.com/sites/default/files/ru3000_membershiplist_20210628.pdf'
r = requests.get(russell_url)
f = io.BytesIO(r.content)
reader = PdfFileReader(f)
contents = []
# There are 32 pages we want to scrape from the russell_url
for i in range(32):
    content = reader.getPage(i).extractText().split('\n')
    contents.append(content)
flat_list = [item for sublist in contents for item in sublist]
russell_3000_flat_list = flat_list[1::2]
# items to be removed
unwanted_values = {'Ticker', 'Russell US Indexes', '', '1', '2', '3', '4', '5', '6', '7', '8',
                   '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21',
                   '22', '23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33'}
clean_russell_3000_flat_list = [ele for ele in russell_3000_flat_list if ele not in unwanted_values]
# Check the count of Russell 3000 stock tickers
len(clean_russell_3000_flat_list)

# Return symbols not in the Russell 3000,
# which we will remove from our analysis.
stock_tickers_not_in_russell_3000 = np.setdiff1d(prelim_stock_tickers,clean_russell_3000_flat_list)
# items to be removed
unwanted_stock_tickers = set(stock_tickers_not_in_russell_3000)
# stock tickers found in the Russell 3000
clean_stock_tickers = [ele for ele in prelim_stock_tickers if ele not in unwanted_stock_tickers]
# check the count stock tickers found in the Russell 3000
len(clean_stock_tickers)

# replace tickers that have '.' with '-' so we can use AlphaWave Data APIs
for ticker in range(len(clean_stock_tickers)):
    clean_stock_tickers[ticker] = clean_stock_tickers[ticker].upper().replace(".", "-")
len(clean_stock_tickers)

Мы можем использовать конечную точку Ключевая статистика из API анализа запасов данных AlphaWave, чтобы получить необходимую информацию о запасах.

Чтобы вызвать этот API с помощью Python, вы можете выбрать один из поддерживаемых фрагментов кода Python, представленных в консоли API. Ниже приведен пример вызова API с помощью запросов Python. Вам нужно будет вставить свои собственные данные x-rapidapi-host и x-rapidapi-key в приведенный ниже блок кода.

# Fetch AlphaWave Data's fundamental stock information
key_stats_url = "https://stock-analysis.p.rapidapi.com/api/v1/resources/key-stats"
headers = {
    'x-rapidapi-host': "YOUR_X-RAPIDAPI-HOST_WILL_COPY_DIRECTLY_FROM_RAPIDAPI_PYTHON_CODE_SNIPPETS",
    'x-rapidapi-key': "YOUR_X-RAPIDAPI-KEY_WILL_COPY_DIRECTLY_FROM_RAPIDAPI_PYTHON_CODE_SNIPPETS"
    }
retry_strategy = Retry(total=3, backoff_factor=10, status_forcelist=[429, 500, 502, 503, 504], method_whitelist=["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"])
rapid_api_adapter = HTTPAdapter(max_retries=retry_strategy)
http = requests.Session()
http.mount("https://", rapid_api_adapter)
alphawave_data = []
for ticker in tqdm(clean_stock_tickers, position=0, leave=True, desc = "Retrieving AlphaWave Data Stock Info"):
    
    querystring = {"ticker":ticker}
    time.sleep(3)
    
    try:
        
        # Get Key Stats
        key_stats_response = http.get(key_stats_url, headers=key_stats_headers, params=querystring, timeout=(5, 5))
        key_stats_response.raise_for_status()
        key_stats_df = pd.DataFrame.from_dict(key_stats_response.json())
        key_stats_df = key_stats_df.transpose()
operating_margin = key_stats_df.loc[r'Operating margin (ttm)'][0]
        current_ratio = key_stats_df.loc[r'Current ratio (mrq)'][0]
        ev_revenue = key_stats_df.loc[r'Enterprise value/revenue '][0]
        roa = key_stats_df.loc[r'Return on assets (ttm)'][0]
        roe = key_stats_df.loc[r'Return on equity (ttm)'][0]
# Create Dataframe
        df = pd.DataFrame({'Operating Margin': operating_margin,
                           'Current ratio': current_ratio,
                           'EV/Revenue': ev_revenue,
                           'Return on Assets': roa,
                           'Return on Equity': roe},
                          index=[ticker])
alphawave_data.append(df)
except requests.exceptions.RequestException as err:
        print ("OOps: Something Else",err)
    except requests.exceptions.HTTPError as errh:
        print ("Http Error:",errh)
    except requests.exceptions.ConnectionError as errc:
        print ("Error Connecting:",errc)
    except requests.exceptions.Timeout as errt:
        print ("Timeout Error:",errt)
        
    except:
        pass
result_alphawave_df = pd.concat(alphawave_data, ignore_index=False)
result_alphawave_df

# Save the alphawave dataframe into a pickle file
# result_alphawave_df.to_pickle("./result_alphawave_df.pkl")
# Load the alphawave dataframe from the saved pickle file
# result_alphawave_df = pd.read_pickle("./result_alphawave_df.pkl")
# result_alphawave_df
# Let's create a Symbol column and reset the index
result_alphawave_df.reset_index(inplace=True)
result_alphawave_df = result_alphawave_df.rename(columns={"index":"Symbol"})
result_alphawave_df

Теперь давайте добавим информацию об акциях AlphaWave Data к данным об облигациях FINRA TRACE.

# Add the AlphaWave Data Stock info to the bond dataframe
data = pd.merge(bond_prices_df, 
                     result_alphawave_df, 
                     on ='Symbol', 
                     how ='left')
data

Затем давайте сохраним объединенные кадры данных в файл рассола, если мы хотим. Мы также очистим данные, отбросим пропущенные значения и создадим случайные выборки облигаций с рейтингом BBB, BB и B для использования в нашем алгоритме k-NN.

# Save the combined dataframe into a pickle file
# data.to_pickle("./data.pkl")
# Load the alphawave dataframe from the saved pickle file
# data = pd.read_pickle("./data.pkl")
# data
# Remove the - and + signs in order to create three ratings buckets BBB, BB, and B
data[["S&P"]] = data[["S&P"]].apply(lambda x: x.str.replace('[-+]','', regex=True))
# Remove missing values
data = data.dropna()
data.head()

# Get the dataframe shape
data.shape

# Get counts of each rating
pd.DataFrame( data['S&P'].value_counts() ).sort_index()

# Remove any commas and % characters, change string values to numeric values
data[["Operating Margin", 
      "Return on Assets", 
      "Return on Equity"]] = data[["Operating Margin", 
                                   "Return on Assets", 
                                   "Return on Equity"]].apply(lambda x: x.str.replace('[,]','', regex=True))
data[["Operating Margin", 
      "Return on Assets", 
      "Return on Equity"]] = data[["Operating Margin", 
                                   "Return on Assets", 
                                   "Return on Equity"]].apply(lambda x: x.str.replace('[%]','', regex=True))
data[["Coupon", "Yield"]] = data[["Coupon", "Yield"]].apply(lambda x: x/100)
data[["Operating Margin", 
      "Return on Assets", 
      "Return on Equity"]] = data[["Operating Margin", 
                                   "Return on Assets", 
                                   "Return on Equity"]].apply(pd.to_numeric)
data[["Operating Margin", 
      "Return on Assets", 
      "Return on Equity"]] = data[["Operating Margin", 
                                   "Return on Assets", 
                                   "Return on Equity"]].apply(lambda x: x/100)
data[["Coupon", "Price", "Yield", "Maturity Years", 
    "Duration", "Operating Margin", "Current ratio", "EV/Revenue", 
    "Return on Assets", "Return on Equity"]] = data[[
    "Coupon", "Price", "Yield", "Maturity Years", 
    "Duration", "Operating Margin", "Current ratio", "EV/Revenue", 
    "Return on Assets", "Return on Equity"]].apply(pd.to_numeric)
data.head()

Теперь мы создаем выборку объединенных фреймов данных, включающую равное количество облигаций с рейтингами BBB, BB и B.

# The below code will sample the DataFrame and return only one sample per Symbol for the total 75 desired samples.
# create a sample of the combined dataframes
df_bbb = data[data['S&P'] == 'BBB'].groupby('Symbol', group_keys=False).apply(lambda data: data.sample(1))
df_bbb = df_bbb.sample(n=75)
df_bb = data[data['S&P'] == 'BB'].groupby('Symbol', group_keys=False).apply(lambda data: data.sample(1))
df_bb = df_bb.sample(n=75)
df_b = data[data['S&P'] == 'B'].groupby('Symbol', group_keys=False).apply(lambda data: data.sample(1))
df_b = df_b.sample(n=75)
df_ratings = pd.concat([df_b, df_bb, df_bbb])
df_ratings.head()

# Generate descriptive statistics
df_ratings.describe()

Давайте теперь применим k-NN на практике к фундаментальным кредитным данным. Прежде чем мы начнем анализировать данные, давайте выполним винсоризацию всего набора данных. Winsorization — это способ минимизировать влияние выбросов на ваши данные. Чтобы применить винсоризацию ко всему фрейму данных, мы можем использовать функцию винсоризации из mstats.

Сведите к минимуму влияние выбросов, выполнив Winsorization

Winsorization преобразует данные, ограничивая экстремальные значения, как правило, устанавливая все выбросы в указанный процентиль данных.

from scipy.stats import mstats
# Winsorize top 1% and bottom 1% of points 
def using_mstats(s):
    return mstats.winsorize(s, limits = [0.01, 0.01])
# Apply on our ratings df
df_ratings = df_ratings.apply(using_mstats, axis = 0)

Далее, давайте визуализируем классы. Применяя функцию value_counts к столбцу «S&P», мы видим, что классы сбалансированы. Идеально поддерживать баланс классов, будь то k-NN или любой другой алгоритм классификации.

# Make sure classes are balanced
df_ratings['S&P'].value_counts()

k-NN для классификации

Теперь отфильтруем данные только по облигациям с рейтингом BBB и B. BBB — это инвестиционный уровень для облигаций, а B — для облигаций с высокой доходностью.

Начните с 2-классовой классификации

Пока фильтруйте наши данные только по рейтингам BBB и B.

# Filter our data for BBB & B ratings only
df_BBBandB = df_ratings[df_ratings['S&P'].isin(['BBB','B'])]

Для простоты предположим, что столбец рейтинга S&P зависит только от EV/Revenue и Current Ratio.

Далее, давайте визуализируем наши данные в виде точечной диаграммы. Мы передаем наши первые два параметра, которые мы собираемся использовать для прогнозирования кредитного рейтинга (EV/Revenue, Current Ratio). Мы передаем рейтинг облигаций как цвет точек данных точечной диаграммы.

# Visualize scatterplot
plt.figure(figsize=(11,7))
plt.style.use("dark_background")
g = sns.scatterplot(x='EV/Revenue', y='Current ratio', hue='S&P', 
                    data=df_BBBandB, s=40, palette=['blue','orange'])
# Some random point we want to classify
plt.scatter(4.2, 2.66, marker='o', s=80, color='red')

Для построения этой модели мы будем использовать sklearn — популярное программное обеспечение для машинного обучения. Начнем с импорта библиотек KNeighborsClassifier, train_test_split и precision_score.

Учебная библиотека Scikit — k-NN

sklearn.neighbors.KNeighborsClassifier

from sklearn.neighbors import KNeighborsClassifier
# Split arrays or matrices into random train and test subsets
from sklearn.model_selection import train_test_split
# Computes accuracy of the algorithm on the test data 
from sklearn.metrics import accuracy_score

Используйте EV/Revenue и Current Ratio для прогнозирования рейтингов Bond S&P.

Вот список шагов, которым мы будем следовать.

1. Определите атрибуты (независимые) и метки (зависимые)

Сначала определим зависимые и независимые переменные. Независимые переменные называются функциями и иногда называются атрибутами. Зависимая переменная — это выход; или, другими словами, то, что мы пытаемся предсказать. В нашем примере независимыми переменными являются EV/Revenue и Current Ratio, а зависимой переменной или целью является рейтинг S&P.

2. Разделите данные на обучение и тестирование

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

3. Обучите модель

В третьей части мы обучаем нашу модель.

4. Сделайте прогноз

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

5. Оцените точность прогноза

После того, как мы сделали прогноз, мы должны оценить точность алгоритма. В настройке классификации k-NN мы собираемся использовать показатель точности.

Давайте определим наши независимые переменные или функции именем X, а целевую переменную именем Y. Мы передаем X и Y в train_test_split, чтобы разделить эти данные случайным образом 70/30. Это означает, что 70% данных поступают на этап обучения, а 30% данных — на этап тестирования.

Мы можем визуализировать размер данных, а также разделение между X_train, X_test, y_train и y_test.

# Create features or independent variables
X = df_BBBandB[['EV/Revenue','Current ratio']]
# Our target or dependent variable
y = df_BBBandB['S&P']
# Create test and train data sets, split data randomly into 30% test and 70% train
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.3, random_state = 2)
X_train.head(3)

y_test.head(3)

# 70% train, 30% test
print("X_train size =",X_train.shape[0])
print("X_test size  = ",X_test.shape[0])
print("y_test size  = ",y_test.shape[0])
print("y_train size = ",y_train.shape[0])

Затем идем по своим следам.

Сначала мы инициализируем алгоритм k-NN. Затем мы подгоняем или обучаем алгоритм. Затем делаем прогноз. Наконец, мы вычисляем показатель точности, используя функцию precision_score из scikit-learn.

# Initialize knn model
knn = KNeighborsClassifier()
# train knn algorithm on training data
knn.fit(X_train, y_train)
# Predict dependent variable,Rating using test data
predictions = knn.predict(X_test)
# Compute accuracy 
accuracy = accuracy_score(y_test, predictions)
print('Accuracy: {:.2f}'.format(accuracy))

# Visualize misclassified data
pred = pd.Series(predictions, index = y_test.index, name = 'Predicted')
pred_and_actual = pd.concat([y_test, pred], axis = 1)
pred_and_actual['Misclassified'] = pred_and_actual['S&P'] != pred_and_actual['Predicted']
pred_and_actual[pred_and_actual['Misclassified'] == True].head(5)

Точность легко рассчитать: количество правильно классифицированных рейтингов/длина тестовых данных

correctly_predicted = pred_and_actual[pred_and_actual['Misclassified'] != True].shape[0]
#Accuracy
print(round((correctly_predicted / len(y_test)), 2))

# First misclassified bond
misclassified_bond = pred_and_actual[pred_and_actual['Misclassified'] == True].index[0]
# Fundamentals of the misclassified bond
df_ratings.loc[misclassified_bond][['EV/Revenue','Current ratio']]

Мы видим, что оценка точности здесь кажется нормальной, по крайней мере, для стандартного метода. Но, как вы помните, параметр k, который представляет собой количество ближайших точек, которые мы будем учитывать при составлении нашего прогноза, имеет решающее значение. Какое бы k мы ни выбрали, оно окажет огромное влияние на точность и прогностическую силу k-NN.

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

Нахождение оптимального k

Здесь мы создаем два списка для точности поезда и точности теста. Затем мы разделяем наши данные на обучение и тестирование. Затем мы обучаем наш классификатор k-NN для нашего диапазона значений k. Затем мы строим точность поезда и точность теста для каждого числа ближайших соседей. В этом примере мы построим график для нашего диапазона k от 1 до 15. Значение по умолчанию для k в k-NN равно 5.

Следует помнить одну вещь: когда вы выбираете небольшое значение для k, например 1, в данных поезда, оно сильно перекрывает данные, как вы можете видеть на графике. Точность данных поезда очень высока, когда k равно 1. Однако алгоритм плохо работает на тестовых данных, как показано синей линией, когда k равно 1. Это связано с тем, что модель фиксирует все нюансы в обучать данные, когда k равно 1, но модель не может обобщить новые данные. Мы называем это низким смещением и высокой дисперсией.

Большое значение k приведет к чрезмерному упрощению модели или ее недостаточному соответствию. Это пример высокого смещения и низкой дисперсии.

В классификаторе k-NN количество соседей по умолчанию = 5

Стратегия поиска k: попробуйте разные значения k и постройте график зависимости k от результатов метрики производительности.

# Create lists to capture train and test accuracy values
train_accuracy = []
test_accuracy = []
# Split date into train and test, set random_state = 2 so test/train data is same as above 
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.3, random_state = 2)
# Loop over values of k
for k in np.arange(1, 16):
    knn = KNeighborsClassifier(n_neighbors = k)
    knn.fit(X_train, y_train)
    train_accuracy.append(knn.score(X_train, y_train))
    test_accuracy.append(knn.score(X_test, y_test))
    
#Plot
fig = go.Figure()
fig.add_trace(go.Scatter(x=np.arange(1,16), y=test_accuracy, name='Test Accuracy'))
fig.add_trace(go.Scatter(x=np.arange(1,16), y=train_accuracy, name='Train Accuracy'))
fig.update_layout(template='plotly_dark', xaxis_title="K neighbors",
                  title='Accuracy for different k values',
                  yaxis_title='Accuracy')
fig.show(width=600, height=600)

Улучшение производительности k-NN — Масштабирование функций

Оптимальное k = 5, что дает точность 0,69, но можем ли мы добиться большего?

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

Существует два распространенных метода трансформации. Первый — это нормализация, при которой значения масштабируются таким образом, чтобы они находились в диапазоне от 0 до 1. Стандартизация — это еще один метод, при котором среднее значение вычитается из каждого значения и делится на стандартное отклонение значений. Нормализация также называется масштабированием Min-Max, что мы и будем использовать в этом примере.

В k-NN числовые признаки должны иметь одинаковый масштаб.

# Generate descriptive statistics
df_BBBandB[['EV/Revenue','Current ratio']].describe()

Как видите, EV/Revenue и Current Ratio имеют несколько разные масштабы. Это можно увидеть, посмотрев на значения максимального, минимального и стандартного отклонения. Попробуем выровнять их величину. Мы вычитаем минимальное значение из каждого значения, а затем делим каждое значение на разницу между максимальным и минимальным значением.

В качестве альтернативы scikit-learn имеет функцию, которая принимает кадр данных функций X и возвращает нормализованный массив, который затем можно использовать в нашем алгоритме.

Нормализация — масштабирование значений в данных в диапазоне [0,1]

# Normalize by hand 
X = df_BBBandB[['EV/Revenue','Current ratio']]
y = df_BBBandB[['S&P']]
X_norm = (X - X.min()) / (X.max() - X.min())
df_norm = pd.concat([X_norm, y], axis = 1)

Давайте построим наши недавно нормализованные данные в формате точечной диаграммы. Мы видим, что значения нормализованных данных немного более равномерны и не так разбросаны по масштабу.

# Visualize scatter plot after normalization
plt.figure(figsize = (11,7))
plt.style.use("dark_background")
plt.title("Normalized Data")
g = sns.scatterplot(x='EV/Revenue', y='Current ratio', hue='S&P', 
                    data=df_norm, s=40,palette=['blue','orange'])
plt.xlim(0,0.3)

Давайте обучим нашу модель, используя новые нормализованные данные. Мы используем sklearn.preprocessing.MinMaxScaler для преобразования наших данных. Затем мы обучаем нашу модель, делаем прогноз и проверяем точность.

Используйте sklearn.preprocessing.MinMaxScaler для масштабирования функций до диапазона [0,1].

# Recall our features and categorical value
X = df_BBBandB[['EV/Revenue','Current ratio']]
y = df_BBBandB['S&P']
# Import Scaler
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
# Normalize all features
X_normalized = scaler.fit_transform(X)
# Split normalized data into train and test
X_train, X_test, y_train, y_test = train_test_split(X_normalized, y, test_size = 0.3, 
                                            random_state = 2)
# Use previous optimal k or set n_neighbors to defualt for now
knn = KNeighborsClassifier()
knn.fit(X_train, y_train.values.ravel())
predictions = knn.predict(X_test)
accuracy = accuracy_score(y_test, predictions)
print('Accuracy: {:.2f}'.format(accuracy))

Как вы помните, значение по умолчанию для k в k-NN равно 5, поэтому давайте посмотрим, как работает алгоритм, используя новые нормализованные данные для диапазона значений k от 1 до 15. Для этого мы наносим значения точности для поезда. и проверьте диапазон значений k от 1 до 15.

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

# Let's find k
train_accuracy = []
test_accuracy = []
for k in np.arange(1,16):
    
    knn = KNeighborsClassifier(n_neighbors = k)
    knn.fit(X_train, y_train.values.ravel())
    train_accuracy.append(knn.score(X_train, y_train))
    test_accuracy.append(knn.score(X_test, y_test))
    
#Plot
fig = go.Figure()
fig.add_trace(go.Scatter(x=np.arange(1,16), y=test_accuracy, name='Test Accuracy'))
fig.add_trace(go.Scatter(x=np.arange(1,20), y=train_accuracy, name='Train Accuracy'))
fig.update_layout(template='plotly_dark', xaxis_title="K neighbors", title='Accuracy for different k values',
                  yaxis_title="Accuracy")
fig.show(width=600, height=600)

Визуализация вероятностей для каждого класса

Еще одна интересная особенность k-NN заключается в том, что мы можем визуализировать вероятности каждого класса. Это означает, что вы также можете думать об этом как о вероятностной модели принятия решений (вроде).

Здесь мы создаем сетчатую сетку, обучаем модель и вместо того, чтобы предсказывать категории, мы предсказываем вероятности. Мы используем KNeighborsClassifier.predict_proba. Темно-синие области представляют высокую вероятность того, что облигация имеет рейтинг BBB, тогда как темно-красные области представляют низкую вероятность того, что облигация имеет рейтинг BBB.

Z — вероятность того, что соответствующие значения x и y относятся к категории BBB.

KNeighborsClassifier.predict_proba() возвращает оценки вероятности для тестовых данных X

mesh_size = .02
margin = 0.0
# Split our data into train and test
X_train, X_test, y_train, y_test = train_test_split(X_normalized, y, test_size = .3, random_state = 4)
# Create a mesh grid on which we will run our model
X = X_normalized.copy()
x_min, x_max = X[:, 0].min() - margin, X[:, 0].max() + margin
y_min, y_max = X[:, 1].min() - margin, X[:, 1].max() + margin
xrange = np.arange(x_min, x_max, mesh_size)
yrange = np.arange(y_min, y_max, mesh_size)
xx, yy = np.meshgrid(xrange, yrange)
# Fit KNN on the train data
knn.fit(X_train, y_train.values.ravel())
# Predict probabilities
Z = knn.predict_proba(np.c_[xx.ravel(), yy.ravel()])[:, 1]
Z = Z.reshape(xx.shape)
# Plot figure
fig = go.Figure(data = [go.Contour(x=xrange, y=yrange, z=Z, colorscale='RdBu')])
fig.update_layout(template='plotly_dark', xaxis_title="EV/Revenue", title='Probability Estimates',
                  yaxis_title="Current ratio")
fig.show()

Глядя на точечную диаграмму, созданную ранее после нормализации, видно, что в области в правом нижнем углу находится большинство облигаций с рейтингом BBB. Вероятность того, что облигация имеет рейтинг BBB в этом регионе, выше. И наоборот, в левой части диаграммы рассеяния находится большинство облигаций с рейтингом B. Вероятность того, что облигация имеет рейтинг BBB, ниже в этом регионе, поэтому график вероятности окрашивает этот регион в красный цвет.

# Visualize scatter plot after normalization
plt.figure(figsize = (11,7))
plt.style.use("dark_background")
plt.title("Normalized Data")
g = sns.scatterplot(x='EV/Revenue', y='Current ratio', hue='S&P', 
                    data=df_norm, s=40,palette=['blue','orange'])
plt.xlim(0,0.3)

Попробуйте самостоятельно классифицировать B и BBB, используя все признаки (а не только отношение EV/Revenue и Current ratio). Вы должны увидеть увеличение точности с введением большего количества переменных.

Примените k-NN к большему подмножеству кредитных данных

Увеличьте количество классов и функций

Теперь, когда мы разработали интуицию для нашего рабочего процесса, давайте дадим k-NN более сложную задачу. Часто при применении k-NN к финансовым приложениям данные могут иметь больше категорий и больше функций, которые можно использовать для прогнозирования.

В этом примере мы собираемся расширить наши возможности. Мы также собираемся увеличить количество категорий, которые мы пытаемся прогнозировать, чтобы включить облигации с рейтингом BB. Мы предскажем, будет ли облигация иметь рейтинг BBB, BB или B.

# Visualize our dataset
df_ratings.head(3)

# Columns from the data set 
features = ['Current ratio', 'Operating Margin', 'Return on Assets', 'EV/Revenue']
# Numerical features or independent variables
X = df_ratings[features]
# Dependent variable, B, BB & BBB
y = df_ratings['S&P']
y.value_counts()

1. Нормализация функций

Сначала нормализуем данные.

2. Разделите данные на обучение и тестирование

Затем мы разделяем данные на поезд и снова тестируем.

3. Обучить алгоритм k-NN на числовых столбцах

Обучаем нашу модель.

4. Предсказать

Затем мы делаем прогноз на тестовых данных и вычисляем показатель точности.

5. Оцените точность прогноза

После того, как мы сделали прогноз, мы должны оценить точность алгоритма.

# Normalize independent cols of data
X_normalized = scaler.fit_transform(X)
# Split into train and test
X_train, X_test, y_train, y_test = train_test_split(X_normalized, y, test_size = 0.3, random_state = 2)
# Initialize model
knn = KNeighborsClassifier()
# Train
knn.fit(X_train, y_train)
# Predict
predictions = knn.predict(X_test)
# Compute accuracy
accuracy = accuracy_score(y_test, predictions)
print('Accuracy: {:.2f}'.format(accuracy))

Давайте визуализируем нашу точность для диапазона значений k.

# Try different values for k
train_accuracy = []
test_accuracy = []
for k in np.arange(1,16):
    knn = KNeighborsClassifier(n_neighbors = k)
    knn.fit(X_train, y_train)
    train_accuracy.append(knn.score(X_train, y_train))
    test_accuracy.append(knn.score(X_test, y_test))
    
fig = go.Figure()
fig.add_trace(go.Scatter(x=np.arange(1,16), y=test_accuracy, name='Test Accuracy'))
fig.add_trace(go.Scatter(x=np.arange(1,16), y=train_accuracy, name='Train Accuracy'))
fig.update_layout(template='plotly_dark', xaxis_title="K neighbors", title='Accuracy for different k values',
                  yaxis_title="Accuracy")
fig.show(width=600, height=600)

Одной из возможных причин снижения точности является введение новой категории BB. Функции, такие как фундаментальные данные, теперь с большей вероятностью перекрываются между BB и исходными значениями BBB и B.

Другие возможные причины снижения точности заключаются в том, что либо мы используем неправильные функции, либо k-NN не является правильной моделью для использования в данном случае. Например, k-NN часто используется в качестве базовой модели для измерения более сложных алгоритмов, таких как машины опорных векторов (SVM) и нейронные сети (NN).

k-NN для регрессии

В последнем примере давайте посмотрим на k-NN в настройке регрессии. Хотя k-NN чаще используется в классификации, стоит взглянуть на то, как k-NN можно применять в регрессии. Напомним, что в регрессионном тесте мы предсказываем непрерывные значения, а в классификационном тесте мы прогнозируем категории или метки. Мы будем использовать sklearn.neighbors.KNeighborsRegressor и использовать те же функции в данных, но мы изменим зависимое значение на Yield.

Как вы помните, в примере с классификацией мы рассчитали расстояние между тестовой точкой и всеми точками. Затем мы получили k ближайших соседей и отсортировали их по расстоянию. Если k равно 3, как в нашем теоретическом разделе, где мы рассматривали треугольники и квадраты, и было два треугольника и один квадрат, то мы бы классифицировали новую точку как треугольник.

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

Давайте снова выполним шаги, когда мы определили наши значения X и Y.

1. Мы нормализовали данные.

2. Разбиваем данные на обучающие и тестовые.

3. Обучаем алгоритм.

4. Делаем прогноз.

5. Оцениваем точность предсказания.

sklearn.neighbours.KNeighboursRegressor

# Import KNN regressor 
from sklearn.neighbors import KNeighborsRegressor
from sklearn.metrics import mean_squared_error
# Keep the same columns as features
features = ['Current ratio', 'Operating Margin', 'Return on Assets', 'EV/Revenue']
y = df_ratings['Yield']
X = df_ratings[features]
# Normalize data
X_normalized = scaler.fit_transform(X)
# Split data into train and test
X_train, X_test, y_train, y_test = train_test_split(X_normalized, y, test_size = 0.3, 
                                                    random_state = 2)
# Initialize model
knn = KNeighborsRegressor()
# train
knn.fit(X_train, y_train)
# Predict
predictions = knn.predict(X_test)

Но как нам оценить результат? Метрика точности, которую мы использовали ранее, больше не имеет смысла для этого типа задач. Вот здесь и пригодится среднеквадратическая ошибка (RMSE). RMSE — это стандартный способ измерения ошибки модели, пытающейся предсказать числовые или количественные данные.

Среднеквадратическая ошибка (RMSE) измеряет ошибку в числовых прогнозах.

предсказание (i) — предсказанное значение для i-го наблюдения

Actual(i) — наблюдаемые (истинные) данные

N — общее количество наблюдений

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

В нашем примере мы подключаем как прогнозы модели, так и y-тест к mean_squared_error, который мы импортировали из scikit-learn. Результат покажет нам, на сколько процентных пунктов в десятичном формате мы в среднем отклоняемся от истинной доходности (например, 0,01 = 1%).

RMSE = math.sqrt(mean_squared_error(y_test, predictions))
print('Root Mean Square Error: {:.4f}'.format(RMSE))

Обратите внимание, что RMSE выражается в тех же единицах, что и значение, которое мы пытаемся предсказать, поэтому в нашем случае ошибка представлена ​​в десятичном формате процентов (например, 0,01 = 1%).

Чтобы найти оптимальное значение k, мы строим среднеквадратичную ошибку в зависимости от значений k. В этом случае мы ищем значение k, которое приведет к наименьшему RMSE.

# Plot RMSE for values of k to see which k minimizes the error
rmse_value = []
for k in np.arange(1,16):
    
    knn = KNeighborsRegressor(n_neighbors = k)
    knn.fit(X_train, y_train)
    pred = knn.predict(X_test)
    error = math.sqrt(mean_squared_error(y_test, pred))
    rmse_value.append(error)
    
#Plot only for test set
fig = go.Figure()
fig.add_trace(go.Scatter(x=np.arange(1,16), y=rmse_value, name='Test Accuracy'))
#fig.add_trace(go.Scatter(x=np.arange(1,20),y = train_accuracy, name = 'Train Accuracy'))
fig.update_layout(template='plotly_dark', xaxis_title="K neighbors", title='RMSE for different k values',
                  yaxis_title="RMSE")
fig.show(width=600, height=600)

Итак, теперь мы увидели, как можно применить k-NN в условиях регрессии. Основываясь на этом примере, становится совершенно ясно, что k-NN может быть не лучшей моделью, когда речь идет о прогнозировании доходности.

В чем могут быть причины такой плохой работы модели? Одна из причин может заключаться в том, что выбранные переменные могут не обладать достаточной прогностической силой, когда речь идет о прогнозировании доходности. Также вспомните, что делает k-NN в условиях регрессии. Он берет среднее значение k ближайших соседей. Таким образом, по сути, он берет среднее значение ближайших доходностей, которые, вероятно, значительно различаются даже для облигаций, имеющих схожие фундаментальные характеристики.

Краткое изложение того, что мы узнали о k-NN.

Алгоритм k-NN не является параметрическим, поэтому он не делает никаких предположений о базовых данных. Как показывают приведенные выше примеры, k-NN — это простой в реализации алгоритм, который очень интуитивно понятен. Мы также узнали, что k-NN работает как для регрессии, так и для классификации, хотя на практике алгоритм чаще используется для классификации.

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

Дополнительные ресурсы

к-нн с нуля

1. Евклидово расстояние

2. Найдите соседей

3. Делайте прогнозы

#Filter our data for BBB & B ratings only
df_BBBandB = df_ratings[df_ratings['S&P'].isin(['BBB','B'])]
df_BBBandB.head(3)

# Convert data to numpy array
test_data = df_BBBandB[['EV/Revenue','Current ratio','S&P']].to_numpy()
  1. Вычислить евклидово расстояние между двумя векторами
def euclidean_distance(point1, point2):
    sum_squared_distance = 0
    for i in range(len(point1) - 1):
        sum_squared_distance += math.pow(point1[i] - point2[i], 2)
    return math.sqrt(sum_squared_distance)

Визуализируйте 1-ю строку в test_data

# 1st row in the test_data
test_row = test_data[5]
test_row

2. Получить k ближайших соседей

def locate_neighbors(train, test_row, k):
    distances = []
    for train_row in train:
        dist = euclidean_distance(test_row, train_row)
        distances.append((train_row, dist))
    distances.sort(key = lambda tup : tup[1])
    neighbors = []
    for i in range(k):
        neighbors.append(distances[i][0])
    return neighbors

3. Предсказать

def make_prediction(train, test_row, k):
    neighbors = locate_neighbors(train, test_row , k)
    output = [row[-1] for row in neighbors]
    prediction = max(set(output), key = output.count)
    return prediction
prediction = make_prediction(test_data, test_row, 3)
print( "Expected:", test_data[0][-1], 'Predicted:', prediction)

Библиотеки Python

Scikit train_test_split: https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html

Scikit k-NN: https://scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsClassifier.html

Scikit k-NN Regressor: https://scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsRegressor.html#sklearn.neighbors.KNeighborsRegressor

Нормализация Scikit: https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.MinMaxScaler.html

Полезные сообщения в блоге

Машинное обучение для инвестирования: https://hdonnelly6.medium.com/list/machine-learning-for-investing-7f2690bb1826

Полный обзор проекта машинного обучения на Python: https://towardsdatascience.com/a-complete-machine-learning-walk-through-in-python-part-one-c62152f39420

k-NN с нуля: https://machinelearningmastery.com/tutorial-to-implement-k-nearest-neighbors-in-python-from-scratch/

Нормализация против стандартизации: https://towardsdatascience.com/normalization-vs-standardization-quantitative-analysis-a91e8a79cebf

Практическое введение: https://www.analyticsvidhya.com/blog/2018/08/k-nearest-neighbor-introduction-regression-python/