Як швидко створити MVP ML-моделі для AutoRia, що дозволяє продавати авто швидше та дорожче

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Мене звуть Максим Бочок. Я Senior Data Scientist в компанії ITRex group. Сьогодні я покажу як швидко розробити прототип моделі машинного навчання для сервісу продажу авто.

Версія моделі, описана в цій статті (v0.1), дозволяє прогнозувати ціну машини на основі лицевої фотографії, року випуску, моделі та марки машини. Наступні версії:

  • Дозволяють прогнозувати час продажу.
  • Використовують інші предиктори, включаючи всі фотографії оголошення з attention фактором вибору фотографій (для багатьох людей важливим є не тільки зовнішній вигляд машини, модель, марка та опис, а ще і як виглядає салон).
  • Включають кращу deep learning модель та кращий training setup.
  • Рекомендують, що можна змінити в оголошенні, щоб авто продалось швидше та дорожче.

Саме цю версію моделі можна використовувати в деплої при виставленні оголошення та показувати продавцю прогнозовану вартість машини після кожного апдейту оголошення. Це дозволяє йому продати машину за більшу ціну. Таку модель можна розробити для будь якого маркетплейсу.

MVP моделі розроблено для власного дослідження, дані не поширюються і не використовуються в комерційних цілях, персональні ідентифікатори не збираються.

Крок 1. Збір даних

Збір даних був зроблений локально на MacBook Pro 8-ядерним процесором Intel Core i9.

Завантаження необхідних бібліотек для збору даних:

import requests
from lxml import etree
import urllib
from bs4 import BeautifulSoup
from tqdm import tqdm
import pandas as pd

Збір посилань:

def get_all_links():
    links = []
    for page_id in tqdm(range(2, 5000)):
        url = f"https://auto.ria.com/uk/legkovie/?page={page_id}"
        page = requests.get(url)
        soup = BeautifulSoup(page.content, "html.parser")
        dom = etree.HTML(str(soup))
        for item_id in range(1,11):
            try:
                elems = dom.xpath(f'//*[@id="searchResults"]/section[{item_id}]/div[4]/div[2]/div[1]/div/a')
                l = elems[0].attrib['href']
                links.append(l)
            except:
                pass
    
    return links


links = get_all_links()

Збір даних за посиланнями:

def get_car_info(url):
    res = {}
    page = requests.get(url)
    soup = BeautifulSoup(page.content, "html.parser")
    dom = etree.HTML(str(soup))
    picture_elem = dom.xpath('//*[@id="photosBlock"]/div[1]/div[1]/div[1]/picture/img')
    picture_link = picture_elem[0].attrib['src']
    picture_name = picture_link.split('/')[-1]
    urllib.request.urlretrieve(picture_link, f'data/photos/{picture_name}')
    #wget.download(picture_link, f'data/photos/{picture_link}')
    res['picture_name'] = picture_name
    
    price_elem = dom.xpath('//*[@id="showLeftBarView"]/section[1]/div[1]/strong/text()')[0]
    price = ''.join([s for s in price_elem.split() if s.isdigit()])
    res['price'] = int(price)
    
    head_elem = dom.xpath('//*[@id="heading-cars"]/div/h1/text()')[0]
    head_elem = head_elem.split(' ')
    year = int(head_elem[-1])
    brand = head_elem[0]
    model = head_elem[1]
    res['year'] = int(head_elem[-1])
    res['brand'] = head_elem[0]
    res['model'] = head_elem[1]

    return res 


data = []
for i in tqdm(range(len(links))):
    try:
        res = get_car_info(links[i])
    except:
        continue
    data.append(res)
    
    if i % 1000 == 0:
        df = pd.DataFrame(data=data, columns=data[0].keys())
        df.to_csv('data/data.csv', index=False)
        
df = pd.DataFrame(data=data, columns=data[0].keys())
df.to_csv('data/data.csv', index=False)

Крок 2. Створення та навчання моделі

Завантаження необхідних бібліотек для навчання моделі:

import torch
from PIL import Image
import torchvision.transforms as transforms
import pandas as pd
from sklearn.preprocessing import MinMaxScaler
from tqdm import tqdm
import random
from operator import itemgetter
import matplotlib.pyplot as plt
import numpy as np
import zipfile

Для навчання моделі використаний звичайний Google Colab з підтримкою GPU. Оскільки збір даних відбувався локально, потрібно перенести та розпакувати архів з даними:

!ls -al drive/MyDrive/data.zip
!nvidia-smi -L
with zipfile.ZipFile('drive/MyDrive/data.zip', 'r') as zip_ref:
   zip_ref.extractall('data')

Далі йде базова реалізація методів для обробки даних. Наступні версії моделі включають покращену трансформація зображень, покращену обробку табличних даних та створений ітератор завантаження даних:

def get_image_to_tensor(path):
   image = Image.open(path)
   transform = transforms.Compose([
       transforms.PILToTensor(),
       transforms.Resize((224, 224)),
       transforms.ConvertImageDtype(torch.float),
   ])
   return transform(image)


def load_filtering_normalization():
   df = pd.read_csv('data/data/data.csv')
   df = df[(df['price'] < 100_000) & (df['year'] > 2000)]
   scaler = MinMaxScaler()
   df[['year', 'price']] = scaler.fit_transform(df[['year', 'price']])
   return df[:15000]


def create_dataloader():
   df = load_filtering_normalization()
   onehot_brand = pd.get_dummies(df['brand'])
   onehot_model = pd.get_dummies(df['model'])
   onehot_df = pd.concat([onehot_brand, onehot_model], axis=1)
   list_numeric = [torch.tensor(i, dtype=torch.float) for i in df['year'].values.reshape(-1,1).tolist()]
   list_onehot = [torch.tensor(i, dtype=torch.float) for i in onehot_df.values.tolist()]
   list_target = [torch.tensor(i, dtype=torch.float) for i in df.price.tolist()]
   list_resnet = [get_image_to_tensor(f"data/data/photos/{i}") for i in tqdm(df.picture_name.tolist())]
   return {'numeric': list_numeric, 'onehot': list_onehot, 'resnet': list_resnet, 'target': list_target}


dataloader = create_dataloader()

Створення дуже простої моделі прогнозу ціни. Покращення — Layer Normalization, dropouts, покращена побудова архітектури моделі і т. п.

class Model(torch.nn.Module):
   def __init__(self, onehot_dim, out_for_each_branch=100):
       super().__init__()
       self.resnet_model = torch.hub.load('pytorch/vision:v0.10.0', 'resnet50', pretrained=True)
       resnet_blocks = list(self.resnet_model.children())
       self.resnet_last_fc = torch.nn.Linear(resnet_blocks[-1].out_features, out_for_each_branch)
      
       numeric_fc1 = torch.nn.Linear(1, 50)
       numeric_fc2 = torch.nn.Linear(50, 500)
       numeric_fc3 = torch.nn.Linear(500, out_for_each_branch)
       numeric_lst = [numeric_fc1, numeric_fc2, numeric_fc3]
       self.numeric_branch = torch.nn.Sequential(*numeric_lst)
      
       onehot_fc1 = torch.nn.Linear(onehot_dim, 1000)
       onehot_fc2 = torch.nn.Linear(1000, 300)
       onehot_fc3 = torch.nn.Linear(300, out_for_each_branch)
       onehot_lst = [onehot_fc1, onehot_fc2, onehot_fc3]
       self.onehot_branch = torch.nn.Sequential(*onehot_lst)
      
       self.fc_last1 = torch.nn.Linear(300, 20)
       self.fc_last2 = torch.nn.Linear(20, 1)
      
   def forward(self, sample):
       resnet_out = self.resnet_model(sample['resnet'])
       resnet_out = self.resnet_last_fc(resnet_out)
       numeric_out = self.numeric_branch(sample['numeric'])
       onehot_out = self.onehot_branch(sample['onehot'])
       cat = torch.cat((resnet_out, numeric_out, onehot_out), dim=-1)
       out = self.fc_last1(cat)
       out = self.fc_last2(out)
       return out


batch_size = 32
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = Model(onehot_dim=dataloader['onehot'][0].shape[0]).to(device)

Навчання моделі

Навчання моделі виконується протягом ~ 15 000 ітерацій. Базові покращення — додаткові складові функції втрат (loss function), train-val-test split, Optimizer Scheduler, Cross validation, логування, автоматичний вибір кращої моделі і т. п.

loss_fn = torch.nn.MSELoss()
opt = torch.optim.AdamW(model.parameters(), lr=0.001, betas=(0.9, 0.999))
losses = []
for it in range(100_000):
   opt.zero_grad()
   indeces = random.sample(range(len(dataloader['onehot'])), batch_size)
   sample_numeric = torch.stack(itemgetter(*indeces)(dataloader['numeric'])).to(device)
   sample_onehot = torch.stack(itemgetter(*indeces)(dataloader['onehot'])).to(device)
   try:
     sample_resnet = torch.stack(itemgetter(*indeces)(dataloader['resnet'])).to(device)
   except:
     continue
   sample_target = torch.stack(itemgetter(*indeces)(dataloader['target'])).to(device)
   sample = {'numeric': sample_numeric, 'onehot': sample_onehot, 'resnet': sample_resnet}
   output = model(sample)
   loss = loss_fn(output, sample_target.unsqueeze(-1))
  
   loss.backward()
   opt.step()
   losses.append(loss.item())
   if it % 100 == 0:
       print(it, np.mean(losses[-1000:]))
       print(output, sample_target.unsqueeze(-1))
   if it % 1000 == 0:
       torch.save(model.state_dict(), 'drive/MyDrive/autotia_model')

Висновки

У цій статті описана ідея створення сервісу для маркетплейсів та її дуже базова реалізація на прикладі аutoria. Приємного Діп Льорнінгу!

Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU

👍ПодобаєтьсяСподобалось15
До обраногоВ обраному6
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Це просто іграшка, яка не має реального кейсу застосування. Сітка тобі запропонувала ціну, а прийшов покупець або експерт, і найшов в тому помийному відрі косяків на пару тисяч зелених, от тобі і сітка.

Сітка допомагає робити так, щоб цих косяків на пару тисяч зелених не було

Хорошая статья, спасибо. Только один вопрос — кому и зачем это надо? Или задам вопрос по другому, зачем моделировать одну «нишу» рынка одной товарной группы не учитывая всей секторальной составляющей этой группы товара? Конечно, может я далек от целей этой разработки, но в узко экономическом, денежном и конъюктурном «выражении» она работать не будет. Может только на уровне «местечковой Авториа».

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

ну я ж хочу продати машину, а не футболку + в Україні сервісом ауторія користується більшість людей зацікавлених в покупці/продажу машини

Ну продажа через «Авториа» ( престанище перекупщиков и металлоломщиков) очень сомнительна. Как ориентир по цене может и подойдет но для продажи безполезен.

На чому написати приведений код?

І наскільки прогноз ціни співпадає з API від Auto RIA?
api-docs-v2.readthedocs.io/...​d_cars/average-price.html

1. А яка точність моделі ? Як якісно вона вміє прогнозувати ціну? додайте, будь ласка, логи навчання ( як там змінювався лосс і точність )
2. Підкажіть, будь ласка, як саме фільтрувалися фотографії? ну хтось там фару сфоткав, а хтось всю автівку ...

на основі лицевої фотографії

 — як от це відфільтрувати ?

MSE:
itx 3000 0.15856
itx 6000 0.04133
itx 9000 0.01128
itx 12000 0.0061
itx 15000 0.0037
Частина тест батчу на 15000й ітерації:
Предикт: [0.0318], [0.2943], [0.1273], [0.0284], [0.2378], [0.0863], [0.5413], [0.1289], [0.1349]
Ллейбли: [0.0306], [0.1824], [0.1221], [0.0326], [0.2028], [0.0841], [0.5315], [0.1305], [0.1315]
Видно, що деякі машини коштують не по ринку (аутлаєри)

А на рахунок перевірки машина чи ні, скоріше всього клієнт не буде ставити фару на лицевій фотографії, але можна роботи перевірку моделью, наприклад резнет, натренованою класифікувати машина/не машина

А вихід моделі це відсоток від якоїсь максимальної ціни? Що взято за максимум?

1. Так
2. Просто максимальна ціна в датасеті

В розширеному пошуку є вибір авто які помічені як продані, чи можливо там взяти якісь корсині дані?

Якщо деплоїть ауторіа, то це їх дані, тому звісно можна.
Також можна використовувати такі дані як додаткову задачу при навчанні нейронної мережі (додаткова складова функції втрат). Або ж просто використовувати більшу вагу на такі кейси, так як ми позначаємо цим, що продані машини мають більш достовірну кінцеву ціну

а цей алгоритм може спілкуватися з перекупами?

Якщо деплоїть ауторія, то такий алгоритм зменшує можливість перепродати машину за більшу ціну, так як продавець уже виставляє максимально можливу ціну за допомогою таких важелів.

Цікаво було б побачити трохи аналізу знайдених даних.
На мою думку (трошки аналізував предметну область) варто б було додати ще декілька фіч, що підвищили б якість моделі: тип палива, трансмісія

А ще можна прибрати фото машини, залишити тільки модель і рік випуску — перевірити чи фото впливає на щось чи ні, бо ж зовнішній вигляд не визначає комплектацію і відповідно ціну. Натомість можна додати клас комплектації: базова, середня, топова

Це просто горизонтальний розвиток частини сбору и процесінгу данних

Підписатись на коментарі