Novinky

Stavba nového PC pre dátovú analytiku a programovanie (nielen) v R 150 150 cleandata

Stavba nového PC pre dátovú analytiku a programovanie (nielen) v R

V tomto blogu sa zameriavam na dôležitú, ale často prehliadanú stránku dátovej analytiky – hardvér. Ukážem, ako môže správne zostavený počítač významne ovplyvniť efektivitu práce s veľkými dátami a náročnými výpočtami. Pozrieme sa na celý proces výberu komponentov a zostavenia nového PC, ktoré som optimalizoval pre dátovú analytiku a programovanie v R.

stavba_pc_blog

Úvod

V tomto blogu by som sa rád podelil o svoje skúsenosti so stavbou môjho nového počítača. Po rokoch používania Dell Optiplex 7050 s procesorom i7 7700, 16 GB RAM a 512 GB SSD som sa rozhodol pre upgrade. Starý počítač bol síce spoľahlivý pre kancelársku prácu, ale pri náročnejších úlohách, ako práca s veľkými dátami či trénovanie modelov strojového učenia, už nestíhal a bol navyše hlučný kvôli zlému airflow-u. Mojím cieľom bolo postaviť výkonný a tichý (a dobre vyzerajúci) počítač, ktorý bude future proof.

Najpodstatnejšou otázkou na začiatku bolo, či ísť do laptopu alebo PC. Obe možnosti majú svoje pre a proti. V prípade laptopu je to jednoznačne prenosnosť. Na druhú stranu je však výkon značne limitovaný (napr. mobilné CPU nemajú rovnaký výkon ako ich PC verzie) a pokiaľ by som trval na niektorých parametroch, cena by bola výrazne vyššia. V mojom prípade prenosnosť (zatiaľ) nie je tak podstatný faktor, keďže prakticky inde ako doma nepracujem na vedľajších projektoch.

Staré a nové PC

Výber komponentov a ich vplyv na výkon pri práci s dátami a ML

Pri výbere som sa zameral na komponenty, ktoré sú výkonné a majú prípadne potenciál na budúci upgrade. Tu je detailný prehľad použitých komponentov spolu s ich kategorizáciou a vplyvom na výkon:

Základná doska: ASUS ROG Strix B650-E Gaming WiFi

ASUS ROG Strix B650-E Gaming WiFi

Táto doska ponúka najnovší AMD socket AM5, podporu pre PCIe 5.0 a DDR5 RAM. Má vynikajúce možnosti pripojenia, vrátane Wi-Fi 6E, Bluetooth 5.2, 2,5GB sieťovkou a množstvo USB portov. Podpora pre najnovšie technológie zabezpečuje vysokú priepustnosť dát a rýchlejšiu komunikáciu medzi komponentmi. Jedna z nevýhod moderných (najmä ASUS) základných dosiek je, že púšťajú zbytočne vysoké napätie do procesora. Toto bol aj môj prípad, preto som musel dodatočne aktualizovať BIOS a spraviť undervolting.

Procesor: AMD Ryzen 9 7900X

AMD Ryzen 9 7900X

12-jadrový procesor s 24 vláknami a vysokou frekvenciou 4,7 GHz, v booste až 5,6 Ghz. Podporuje (len) DDR5 operačné pamäte. V čase keď som vyberal procesor bola dostupná už novšia generácia AMD procesorov rady 9000 so Zen 5 architektúrou. Recenzie boli však skôr “vlažné” a ako lepšia voľba sa stále javí táto staršia generácia procesorov (aj vzhľadom na pomer cena/výkon). V konečnom dôsledku je tento procesor ideálny pre moje potreby. Viac jadier a vlákien umožňuje paralelné spracovanie úloh, čo je pri práci s dátami a trénovaní ML modelov neoceniteľné. Zrýchľuje to výpočty a znižuje čas čakania. Rovnako jednovláknový výkon je skvelý, hoci nie na úrovni procesorov od Intelu.

Tu by som sa ešte pristavil pri otázke, prečo teda nie procesor od Intelu. Mimo toho, že aktuálny socket 1700 je už viac menej výbehový, vyššie rady (i7 a i9) 13. a 14. generácie majú veľké problémy s nestabilitou a oxidáciou.

RAM: 64 GB Kingston Fury DDR5 6000MHz CL30

Kingston Fury DDR5 6000MHz CL30

Jedná sa o vysokorýchlostnú DDR5 pamäť s nízkou latenciou. Malo by sa jednať o ideálne parametre pre vybraný procesor. Default nastavenie je však obmedzené na 4800MHz. Pre využitie plnej rýchlosti je nutné povoliť EXPO I profil v BIOSe.

Veľká kapacita RAM umožňuje pracovať s rozsiahlymi datasetmi priamo v pamäti bez nutnosti swapovania na disk. Rýchlosť a nízka latencia zlepšujú výkon pri spracovaní dát a zvyšujú efektivitu ML algoritmov.

SSD: M.2 WD Black SN770 1 TB

WD Black SN770 1 TB

Rýchly NVMe SSD disk s vysokými rýchlosťami čítania a zápisu.

Rýchle úložisko je kľúčové pre načítanie veľkých dátových súborov a rýchly prístup k nim. To znižuje latenciu pri vstupno-výstupných operáciách a zrýchľuje celkový proces spracovania dát.

Grafická karta: Inno3D GeForce RTX 4060 Ti Twin X2 OC

Inno3D GeForce RTX 4060 Ti Twin X2 OC

Grafická karta nižšej strednej triedy s podporou pre CUDA jadra a ray tracing. Má 8 GB RAM typu GDDR6 a 4352 CUDA jadier.

GPU môže výrazne zrýchliť trénovanie ML modelov, najmä v oblastiach ako hlboké učenie, ale dá sa použiť aj na akceleráciu tradičných algoritmov ako XGBoost alebo LightGBM. V mojom prípade bude jej primárne určenie práve na učenie sa nových knižníc potrebných pre akceleráciu tréningu a optimalizáciu algoritmov a ich hyperparametrov.

Chladenie: NZXT Kraken 240mm AiO

NZXT Kraken 240mm AiO

Výkonné all in one (AiO) vodné chladenie s 240mm radiátorom a displejom zobrazujúcim teplotu CPU.

Udržiava procesor pri nízkych teplotách, čo umožňuje udržať vysoký výkon bez throttlingu. Stabilné teploty sú dôležité pri dlhotrvajúcich výpočtoch a trénovaní modelov. 240 mm rozmer som vyberal s ohľadom na vybranú PC skrinku a možnosť montáže chladenia zvrchu, aby teplý vzduch odvádzal cez radiátor von.

Tento model, podobne ako väčšina AiO chladičov, prichádza s predaplikovanou termálnou pastou. Pri procesoroch AMD rady 7000 aj 9000 je menším problémom ich tvar. Keďže majú na sebe “výrezy”, prebytočná termálna pasta sa do nich môže dostať. Nemala by síce nič poškodiť, ale existuje relatívne lacné riešenie (hoci sa jedná len o kus plastu). Je ním Noctua NA-TPG1.

Alternatívou bolo vzduchové chladenie Noctua NH-D1. Nebol som si však istý či sa vojde do skrinky s relatívne vysokými RAM. Navyše ako človeku, ktorý má rád dáta mi zobrazovanie teplôt na displeji robí radosť.

Zdroj: Seasonic Focus GX-750

Seasonic Focus GX-750

Kvalitný 750W plne modulárny zdroj s 80+ Gold certifikáciou.

Stabilné a spoľahlivé napájanie je nevyhnutné pre bezproblémovú prevádzku systému, najmä pri vysokom zaťažení. Umožňuje tiež budúce upgrady komponentov bez nutnosti výmeny zdroja (napr výkonnejší procesor alebo grafickú kartu).

Skrinka: Fractal Design North

Fractal Design North

Štýlová skrinka s drevenými prvkami, výborným prietokom vzduchu a možnosťami pre “cable management”. Dobrý prietok vzduchu zlepšuje chladenie komponentov, čo prispieva k stabilite a výkonu systému. Skrinka obsahuje dva 140 mm ventilátory. K nim je nutné ešte dokúpiť aspoň jeden 120 mm ventilátor na odvod vzduchu zo zadnej strany skrinky. Ten som vybral Cooler Master MasterFan Mf120 HALO s ARGB (adresovateľné RGB osvetlenie).

Zostavenie

Inštalácia procesora do socketu AM5 bola jednoduchá, rovnako ako osadenie DDR5 pamätí a M2 SSD disku. Procesor sa dá vložiť len jedným spôsobom, rovnako aj RAM. Len si treba zapamätať, že ak máme dve RAMky, tak osádzame druhý a štvrtý slot. V prípade tejto dosky je táto informácia naznačená aj vľavo dole pri prvom slote. Je to z dôvodu, aby sme využili dual channel. Osadenie prvého a tretieho slotu by umožnilo tiež použitie dual channel, ale neobsadené sloty (2. a 4.) môžu vytvárať odrazy signálu, čo spôsobuje menšie elektrické interferencie, čo môže zhoršiť stabilitu systému, najmä pri vyšších rýchlostiach RAM. Keď je osadený 2. a 4. slot, stopy signálu sa ukončia priamo na pamäťovom module. M2 SSD som zapojil do prvého slotu, hneď pod CPU. Prvý M.2 slot je často priamo pripojený k PCIe linkám procesora, preto komunikácia medzi SSD a CPU prebieha bez sprostredkovania čipsetom základnej dosky. To znižuje latenciu a môže mierne zlepšiť výkon. Priame pripojenie k CPU zaručuje aj to, že SSD má k dispozícii plnú šírku pásma PCIe 4.0 x4 (prvý slot na tejto konkrétnej doske je PCIe 5.0, avšak samotný M2 disk som vybral so staršou generáciou PCIe 4.0) bez zdieľania s inými zariadeniami. Všetky M2 sloty na doske majú aj hliníkové chladiče a šikovné rýchle uchytenie Q-Latch.

Osadená zakladná doska

Väčšiu pozornosť som musel venovať správnemu manažmentu káblov a inštalácii AiO chladenia. Rozmer PC skrinky totiž rozhodne nie je štedrý s osadenou základnou doskou. Ako prvé si treba premyslieť, aké káble budú potrebné a zapojiť ich do zdroja. Až následne vložíme zdroj do skrinky. Ja som ho vkladal s ventilátorom smerom dole, aby nasával chladnejší vzduch z vonka. Skrinka vďaka mriežkam vo vnútri umožnuje aj opačnú orientáciu. Keďže však bude PC umiestnené na stole a nie na koberci, navyše v skrinke je aj zo spodu prachový filter, tak mi to prišlo ako efektívnejšie riešenie. Následne je vhodné vyviesť káble cez otvory do priestoru kde bude osadená základná doska a potom jej samotné vloženie.

Osadený zdroj

Nasledovalo vodné chladenie. Na začiatok bolo nutné odmontovať predinštalované uchytenie na chladič, ktoré prišlo s doskou a nahradiť za dodávané k vodnému chladeniu. V prípade AMD je tento krok ľahší ako pri Intel procesoroch, lebo netreba inštalovať aj backplate. Radiátor a ventilátory som montoval čo najviac dopredu a s orientáciou pre odvod vzduchu zo skrinky. Pred uchytením čerpadla som na procesor dal ešte Noctua NA-TPG1 aby sa teplovodivá pasta nedostala do výrezov na ňom.

Posledný krok bolo vloženie grafickej karty. Táto konkrétna si vyžaduje len odmontovanie dvoch slotov na skrinke, vloženie a zapojenie jedného 8 pinového kábla zo zdroja. Na tejto základnej doske je aj Q-Release tlačidlo pre uvoľnenie grafickej karty, čo je veľmi príjemná vec, najmä ak vyberáte von väčší model GPU.

Osadená PC skrinka

Celá príprava a skladanie mi zabrali asi 2 hodiny. Následná inštalácia Windows 11, nastavenie BIOSu, inštalácia programov a ovládačov výrazne viac. Pár tipov pre Vás:

  • Windows pri inštalácii vyžaduje pripojenie na internet a tento krok sa na prvý pohľad nedá preskočiť. Mne počítač nedetekoval pripojenie na sieť a preto som si dohľadal toto riešenie ako obísť tento krok v inštalácii.
  • V BIOSe si skontrolujte, na akej frekvencii vám bežia operačné pamäte. Je veľmi pravdepodobné, že budú na nižšej frekvencii ako majú v špecifikácii. V prípade AMD procesorov je nutné vybrať EXPO I profil, a tak pretaktovať RAM.
  • Spravte stress test počítača a skontrolujte aké napätie sa dostáva do procesora napr. pomocou HWiNFO. Výrobcovia moderných základných dosiek nie vždy dodržiavajú odporúčania výrobcov procesorov. To môže v (lepšom prípade) skrátiť životnosť CPU. Ak je to nutné, nastavte v BIOSe offset.

Výsledkom je výkonný, stabilný a podľa môjho názoru aj dobre vyzerajúci počítač.

Hotové PC

Benchmarking výkonu

Aby som otestoval a porovnal výkon novej zostavy, vykonal som niekoľko benchmarkov v jazyku R na novej zostave aj na starom Dell Optiplex 7050.

Metódy

Pre porovnanie výkonu starého PC a novej zostavy som sa zameral na dva konkrétne aspekty:

  • Rýchlosť načítania dát: Testoval som načítanie 4,2 GB CSV súboru so 113,6 miliónmi riadkov a 8 stĺpcami pomocou základnej funkcie read.csv(), read_csv() funkcie z knižnice readr a fread() funkcie z data.table. Primárnymi faktormi ovplyvňujúcimi výkon sú rýchlosť SSD (rýchlosť čítania a zápisu) a veľkosť RAM kvôli swapovaniu.

  • Výkon CPU: Pomocou algoritmu na generovanie prvočísel (Sieve of Eratosthenes) som porovnal jednojadrový výkon a viacjadrové paralelné spracovanie. Tento test prebehol v základnej for slučke a tiež v paralelných procesoch s rôznymi počtami vlákien (7 vlákien na oboch zostavách a 23 vlákien na novej zostave). Tento benchmark bol inšpirovaný týmto článkom na medium a touto diskusiou na stackoverflow. Testoval som tri rôzne prístupy:

    • For loop – zameraný na jednojadrový výkon.
    • parLapply – zameraný na viacjadrový výkon.
    • foreach – zameraný na viacjadrový výkon.

Všetky testy boli zbiehané v piatich iteráciách pomocou funkcie mark z knižnice bench:

Benchmark kód
# Required Libraries
library(bench)       # Benchmarking tools
library(readr)       # Fast CSV reader
library(data.table)  # Fast data manipulation
library(dplyr)       # Data manipulation tools
library(doParallel)  # Parallel computing tools

# Part 1: Loading a large CSV file
# This tests the speed of different file loading methods (base R, readr, and data.table),
# with SSD speed and RAM performance being the key factors influencing the results.

# Base R CSV loading
results_csv_load_base <- mark(
  read.csv("custom_1988_2020.csv"),   # Load CSV with base R's read.csv
  iterations = 5,                     # Number of benchmark iterations
  time_unit = 's'                     # Measure time in seconds
) |> 
  select(`min time` = min,            # Extract relevant metrics
         `median time` = median,
         `total time` = total_time,
         `memory allocated` = mem_alloc)

gc()  # Clear memory

# Readr CSV loading
results_csv_load_readr <- mark(
  read_csv("custom_1988_2020.csv"),   # Load CSV with readr's read_csv
  iterations = 5,
  time_unit = 's'
) |> 
  select(`min time` = min,
         `median time` = median,
         `total time` = total_time,
         `memory allocated` = mem_alloc)

gc()

# Data.table CSV loading
results_csv_load_datatable <- mark(
  fread("custom_1988_2020.csv"),      # Load CSV with data.table's fread
  iterations = 5,
  time_unit = 's'
) |> 
  select(`min time` = min,
         `median time` = median,
         `total time` = total_time,
         `memory allocated` = mem_alloc)

gc()

# Adding a column to distinguish the method used
results_csv_load_base$lib <- "base"
results_csv_load_readr$lib <- "readr"
results_csv_load_datatable$lib <- "fread"

# Combine the results from different loading methods into one data frame
results_data_load <- bind_rows(
  results_csv_load_base,
  results_csv_load_readr,
  results_csv_load_datatable
)

# Save the results for future comparison
write_rds(results_data_load, "results_data_load.RDS")

# Display the results as a table
knitr::kable(
  results_data_load |> 
    mutate(across(c(1, 2, 4), ~ round(.x, 2)))
)

# Part 2: Testing CPU Performance with a Prime Sieve Algorithm
# The sieve function is a CPU-bound operation that generates a list of prime numbers up to n.
# This tests single-thread performance in the for-loop version and multi-thread performance in the parallel versions.

# Prime sieve algorithm (generates primes up to n)
sieve <- function(n) {
  n <- as.integer(n)
  primes <- rep(TRUE, n)
  primes[1] <- FALSE
  last.prime <- 2L
  fsqr <- floor(sqrt(n))
  while (last.prime <= fsqr) {
    primes[seq.int(2L * last.prime, n, last.prime)] <- FALSE
    sel <- which(primes[(last.prime + 1):(fsqr + 1)])
    if (any(sel)) {
      last.prime <- last.prime + min(sel)
    } else last.prime <- fsqr + 1
  }
  which(primes)
}

# Single-threaded for loop
result_for_loop <- mark(
  for (i in 10:5e4) {  
    result[[i]] <- sieve(i)          # Run sieve for each value in the range 10 to 50000
  },
  iterations = 5,
  time_unit = 's'
) |> 
  select(`min time` = min,
         `median time` = median,
         `total time` = total_time,
         `memory allocated` = mem_alloc)

result_for_loop$method <- "for loop"  # Label for results

gc()

# Multi-threading: parLapply with 7 threads (parallel computing using 7 cores)
registerDoParallel(cores = 7)  # Register 7 cores for parallel processing
cl <- makeCluster(7)

result_par7 <- mark(
  parLapply(cl, 10:5e4, sieve),      # Run the sieve function in parallel on 7 cores
  iterations = 5,
  time_unit = 's'
) |> 
  select(`min time` = min,
         `median time` = median,
         `total time` = total_time,
         `memory allocated` = mem_alloc)

stopCluster(cl)                      # Stop the parallel cluster
registerDoSEQ()

gc()
result_par7$method <- "parLapply 7"   # Label for results

# Multi-threading: parLapply with 23 threads (parallel computing using 23 cores)
no_cores <- detectCores() - 1        # Detect available cores minus one
registerDoParallel(cores = no_cores)
cl <- makeCluster(no_cores)

result_par23 <- mark(
  parLapply(cl, 10:5e4, sieve),      # Run the sieve function in parallel on 23 cores
  iterations = 5,
  time_unit = 's'
) |> 
  select(`min time` = min,
         `median time` = median,
         `total time` = total_time,
         `memory allocated` = mem_alloc)

stopCluster(cl)
registerDoSEQ()

gc()
result_par23$method <- "parLapply 23"  # Label for results

# Multi-threading: foreach with 7 threads
cl <- makeCluster(7)
registerDoParallel(7)

result_foreach_7 <- mark(
  foreach(i = 10:5e4) %dopar% sieve(i),  # Run sieve in parallel using foreach on 7 cores
  iterations = 5,
  time_unit = 's'
) |> 
  select(`min time` = min,
         `median time` = median,
         `total time` = total_time,
         `memory allocated` = mem_alloc)

stopCluster(cl)
registerDoSEQ()

gc()
result_foreach_7$method <- "foreach 7"  # Label for results

# Multi-threading: foreach with 23 threads
cl <- makeCluster(no_cores)
registerDoParallel(cl)

result_foreach_23 <- mark(
  foreach(i = 10:5e4) %dopar% sieve(i),  # Run sieve in parallel using foreach on 23 cores
  iterations = 5,
  time_unit = 's'
) |> 
  select(`min time` = min,
         `median time` = median,
         `total time` = total_time,
         `memory allocated` = mem_alloc)

stopCluster(cl)
registerDoSEQ()

gc()
result_foreach_23$method <- "foreach 23"  # Label for results

# Combine CPU benchmark results
result_sieve <- bind_rows(
  result_for_loop,
  result_par7,
  result_par23,
  result_foreach_7,
  result_foreach_23
)

gc()
write_rds(result_sieve, "result_sieve.RDS")

knitr::kable(
  result_sieve |> 
    mutate(across(c(1:4), ~ round(.x, 2)))
)

Výsledky

Načítanie CSV súboru: Na starom Dell Optiplexe 7050 trvalo načítanie súboru nasledovne:

min čas (s) mediánový čas (s) total čas (s) alokovaná pamäť (GB) knižnica
384.19 390.82 1950.43 26.7 base
93.09 93.09 93.09 6.8 readr
10.23 11.83 72.78 7.6 fread

Na novom PC s Ryzen 7900x boli výsledky výrazne lepšie:

min čas (s) mediánový čas (s) total čas (s) alokovaná pamäť (GB) knižnica
148.02 151.14 752.08 26.7 base
15.49 15.49 15.49 6.8 readr
2.28 2.83 13.61 7.6 fread

Dramatické zlepšenie nastalo pri všetkých metódach. Najviac, o 83%, pri readr a funkcii read_csv(). Na novej zostave je teraz táto metóda približne na úrovni fread() na starej zostave. Víťazom v načítaní však jednoznačne ostáva práve fread(). Pri tejto metóde je zlepšenie o 76% a spomínaný súbor načíta za neuveriteľné necelé tri sekundy.

CPU test: Výsledky na Dell Optiplex 7050:

min čas (s) mediánový čas (s) total čas (s) alokovaná pamäť (GB) metóda
48.76 54.92 286.62 20.9 for loop
8.25 8.40 16.80 0.5 parLapply 7
18.79 19.10 96.14 2.5 foreach 7

Výsledky novej zostavy:

min čas (s) mediánový čas (s) total čas (s) alokovaná pamäť (GB) metóda
24.48 26.69 135.55 20.9 for loop
6.64 6.82 13.65 0.5 parLapply 7
5.96 5.96 5.96 0.5 parLapply 23
7.22 7.54 38.00 2.5 foreach 7
7.74 7.88 39.65 2.5 foreach 23

Jednojadrový výkon stúpol o vyše 50%. To je veľmi dobrá správa, kedže R skripty bežia primárne na jednom jadre a paralelizácia sa používa len v prípadoch, keď to dáva zmysel. Paralelné operácie majú totiž určitý “overhead” pri komunikácii medzi vláknami a spracovávaní dát. Ak je tento overhead príliš veľký vzhľadom na zlepšenie z paralelného vykonávania, nemusí byť výkon vždy proporcionálny počtu vlákien, a to vidíme aj na výsledkoch.

Paralelný výkon vzrástol v prípade parLapply o 19% a pri foreach o 61%. Ak si porovnám najvyšší potenciálny výkon na počte dostupných vlákien (7 vs 23), tak sa bavíme o zlepšení 29% pri parLapply a 59% pri foreach. Napriek výrazne väčším zlepšeniam pri foreach je stále rýchlejší parLapply (hoci sa bavíme o desatinách až jednotkách sekúnd), navyše potrebuje výrazne menej pamäte.

Zrejme ste si všimli, že pri foreach došlo na 23 vláknach k poklesu o 2% oproti 7 vláknam. Toto je ukážka toho, že overhead z príliš veľkého počtu vlákien, zdieľané zdroje (operačná pamäť a cache), konkurencia jadier a málo alebo žiadne zdroje pre operačný systém a ostatné procesy môže mitigovať benefit viacerých jadier. V prípade operačnej pamäte sa môžeme stretnúť s odporúčaniami na 2 až 4 GB RAM na vlákno, v prípade tejto zostavy je pomer 2,7. Budúce rozšírenie na 128 GB by v niektorých prípadoch mohlo teda pomôcť.

Záver

Stavba vlastného počítača bola pre mňa novou a obohacujúcou skúsenosťou. Výber komponentov s dôrazom na výkon pri práci s dátami a ML sa ukázal ako správny krok. Navyše pri benchmarkovaní výkonu som mohol vidieť ako rôzne metódy majú výrazne odlišné nároky na systém, čo je často opomínaný faktor pri škálovaní ML riešení. Pri deploymente do cloudu môže mať zanedbanie týchto faktorov aj značný finančný dopad.

Po spustení a otestovaní zostavy som veľmi spokojný z dosiahnutého výkonu. Systém je výrazne rýchlejší a zároveň tichší. Teploty na procesore sú pri bežnej práci s dátami na úrovni okolo 60°C, pri náročnejších úlohách ako napr. trénovanie ML modelov dosahovala aj 95°C, ale zatiaľ som nenarazil na throttling (preventívne zníženie taktu procesora, aby sa predišlo prehriatiu alebo poškodeniu).

Taktiež oceňujem estetickú stránku – biele RGB osvetlenie a displej na AiO chladiči pridávajú zostave moderný vzhľad. Odporúčam každému, kto má podobné potreby, aby zvážil stavbu vlastného PC – investícia do výkonu a budúcnosti sa určite oplatí.

Forecasting so strojovým učením 150 150 cleandata

Forecasting so strojovým učením

V tomto blogu sa zameriavam na moje prvé skúsenosti s účasťou v forecast súťaži, kde som sa rozhodol otestovať nové metódy. Namiesto tradičných štatistických prístupov som sa sústredil na použitie modelov strojového učenia (ML). Hlavným cieľom bolo preskúmať, ako môžu moderné ML metódy zlepšiť presnosť predikcií v porovnaní s bežne používanými štatistickými technikami.

kaggle_forecast_zhrnutie

Moje prvé skúsenosti a lekcie z forecast súťaže od rohlik.cz

Nedávno som sa po prvýkrát zúčastnil forecastovacej súťaže na Kaggle. Organizovala ju spoločnosť Rohlik.cz. Aj keď môj výsledok bol pre mňa osobne sklamaním, keďže som skončil v 27. percentile, súťaž mi priniesla množstvo cenných skúseností a ponaučení, ktoré stoja za zmienku.

Prvé skúšanie ML na forecasting

Táto súťaž bola mojou prvou príležitosťou vyskúšať forecasting pomocou strojového učenia. Doteraz som využíval najmä tradičné štatistické metódy ako ARIMA a ETS. Tentokrát som sa rozhodol pre strojové učenie z nasledujúcich dôvodov:

  • Cieľom bolo forecastovať denné objednávky. S tradičnými metódami mám dobrú skúsenosť, pokiaľ sa jedná napr. o mesačnú agregáciu a mám dostupné historické dáta za dostatočne dlhé obdobie (niekoľko rokov). V tomto prípade sme mali dostupné údaje za relatívne krátke historické obdobie.
  • Strojové učenie bolo víťaznou stratégiou v mnohých predchádzajúcich súťažiach ako napr. M4, M5 alebo v súťaži organizovanej spoločnosťou Intermarché. Preto považujem za dôležité mať praktickú skúsenosť aj s týmto spôsobom forecastovania.
  • Už dlhšie som sa chystal vyskúšať knižnicu modeltime a ďalšie, k nej doplnkové knižnice.
  • Praktická skúsenosť s novou metódou mi pomôže aj v pracovnej sfére.

Priebeh a výsledok

Na súťaži som pracoval vo voľnom čase, čo znamenalo, že som musel prioritizovať, čomu sa chcem venovať.

Základ by ideálne bol rozsiahly a kvalitný feature engineering. Spätne musím povedať, že som mu mal venovať viac času. Rozdiely v konečnom hodnotení boli relatívne malé a verím, že práve lepšie features by ma vedeli posunúť na vyššiu priečku. Avšak hlavným cieľom bolo vyskúšať nové knižnice a metódy, takže som venoval až neprimerane veľa času (ak by sa jednalo o reálny projekt) ich implementácii.

Hodnotená metrika bola MAPE. Nie je to ideálna metrika, keďže jednotlivé sklady, pre ktoré sa robili forecasty, mali veľmi rozdielny objem objednávok (od dolných tisícov po vyse 11 tisíc) a teda 10% odchýlka v jednom sklade mohla byť 200 objednávok a 4% odchýlka v inom 400 objednávok. Moja výsledná hodnota MAPE bola 0,0489, pričom v priebežnom public leaderboarde bola 0.0396 a v cross-validácii 0,0331. Napriek tomuto zhoršeniu sa mi priečka posunula len o 8 miest dole.

Problém so súťažami na Kaggle je, že mnoho súťažiacich skopíruje dobré uverejnené riešenia iných a nahrajú výsledky. Na grafe nižšie je efekt pekne viditeľný v intervale 4,6% až 4,9% a bol často kritizovaný v diskusiách. Nie je nič zlé na inšpirovaní sa u iných, avšak skopírovanie celého kódu s kozmetickými úpravami je neetické. V grafe som zvýraznil interval, v ktorom sa nachádzalo moje riešenie. Objektívne však menej ako 5% odchýlku na denných predajoch pre 7 skladov na nasledujúcich 60 dní považujem za dobrý výsledok. Graf tiež dobre ilustruje ako blízko boli riešenia pri sebe. Šírka intervalov je 0,25 percenta.

Distribúcia výsledných MAPE v private leaderboarde

Testovanie rôznych prístupov

Zvolil som dva rôzne prístupy, ktoré som chcel otestovať.

Prvý bol tradičnejší workflow – feature engineering, zadefinovanie modelu, tuning hyperparametrov s cross-validáciou a následný testing na testovacej sade dát. Použil som tri rôzne algoritmy: XGBoost, LightGBM a Random Forest. Každý z týchto modelov má svoje vlastné silné stránky, no výsledky ukázali, že v priemere najlepší bol LightGBM, mierne horší XGBoost a po nich s väčším prepadom v presnosti Random Forest. Keďže sa však výsledky jednotlivých modelov mierne líšili podľa skladu, pre ktorý bol forecast robený, rozhodol som sa použiť ensemble prístup s meta learner modelom (použitý algoritmus bol GLMnet). Tento model použil výsledky z troch algoritmov plus dve premenné – sklad a deň v týždni. Je celkom bežné, že aj obyčajné spriemerovanie nezávislých forecastov zlepší výslednú presnosť.

Druhý prístup bol rekurzívny ML model. Ten obsahuje všetky kroky z predchádzajúceho workflowu, ale pridáva sa funkcia, ktorá dopočítava niektoré hodnoty z predchádzajúcich dní. Napr. predaje v predchádzajúci deň, 7 dní dozadu, dva týždne dozadu atď. Tento proces je pomalší, lebo vyžaduje, aby sa robil forecast po jednom dni, ten sa následne “doplní” do dátového setu a vstupuje do nasledujúceho forecastu už ako prediktor. Jedná sa teda o iteratívny proces. Keďže tento model postupne predikoval hodnoty pre jednotlivé časové obdobia na základe predchádzajúcich predikcií, jeho presnosť závisí od presnosti predchádzajúcich predikcií, čo prináša určité riziká.

Jedným špecifikom forecastov časových radov je, že cross-validácia (CV) by mala rešpektovať kontinuitu času. Time series cross-validation môže mať niekoľko podôb, napr. sliding alebo expanding window, testovacie dáta môžu priamo nadväzovať na trénovacie alebo môžeme aplikovať lag. Spoločná myšlienka za nimi však je, že pri trénovaní modelov pre predpovedanie časových radov by v prípade klasických metód ako k-fold CV dochádzalo k tzv. data leakage (trénovanie modelu na dátach z budúcnosti a teda na informáciách, ktoré by model nemal mať). Vačšina riešení, ktoré sú zverejnené sa však tejto téme nevenuje a rovno uvádza hodnoty hyperpametrov, ktoré boli aplikované.

Dôležitosť feature engineeringu

Jedným z najzaujímavejších zistení bolo, že viacero z top riešení v súťaži používalo len jeden model – LightGBM. Najlepšie riešenie nemá príliš dobrú dokumentáciu, avšak toto riešenie skončilo na 2. mieste a potvrdilo, že niekedy menej je viac. Tento úspech ukazuje, že najdôležitejším krokom je správny feature engineering. V niektorých prípadoch je lepšie investovať viac času do správnej prípravy dát, než do komplikovaných modelov, ktoré sú v podstate „blackboxom“. V tejto súťaži sa navyše nedalo príliš spoliehať na priebežné výsledky v public leaderboarde, keďže validačný set dát mal len 31 % z už tak krátkeho forecastovaného obdobia. Namiesto toho bolo lepšie dôverovať výsledkom vlastnej cross-validácie.

Záver

Aj keď som nedosiahol výsledky v aké som dúfal (byť aspoň v top 20 percent), súťaž mi dala možnosť vyskúšať si nové techniky, zdokonaliť svoje schopnosti v oblasti strojového učenia a získať cenné skúsenosti. Ak sa zúčastňujete podobných súťaží, nezabudnite, že aj keď váš model nedosiahne top skóre, stále sa môžete veľa naučiť a získať inšpiráciu z vyššie umiestnených riešení.

Web scraping pomocou jazyka R 150 150 cleandata

Web scraping pomocou jazyka R

Ako použiť voľne dostupné dáta pre vlastnú analýzu? V tomto blogovom príspevku prejdem procesom zvaným web scraping. Vďaka nemu môžeme analyzovať voľne dostupné dáta z internetu.

webscraping-sk

Upozornenie


Predtým než sa dostanem k téme tohto blogu, upozorňujem, že tento článok slúži výhradne k informačným účelom a akékoľvek informácie uvedené nižšie nie sú právne rady. Z tohto dôvodu pred akýmkoľvek zbieraním údajov z webu by ste mali získať vhodnú profesionálnu právnu radu týkajúcu sa vášho konkrétneho prípadu.

Web scraping


V tomto blogovom príspevku prejdem procesom zvaným web scraping s využitím programovacieho jazyka R. Predtým než sa pustím do samotného procesu, chcel by som sa trochu venovať téme z trochu širšej prespektívny.
Web scraping je proces získavania obsahu alebo (väčšinou) štruktúrovaných údajov z webových stránok automatizovaným spôsobom (a obvykle vo veľkom množstve). Táto definícia prirodzene vyvoláva otázku o legalite takéhoto procesu. V zásade web scraping nie je ilegálny alebo zakázaný sám osebe (v EÚ, k júnu 2024). Avšak, používanie nástrojov na sťahovanie údajov je z právneho hľadiska riskantné z niekoľkých dôvodov:

  • Porušenie duševného vlastníctva
  • Porušenie zmluvy
  • Obavy o ochranu osobných údajov

Pre zminimalizovanie obáv by malo scrapovanie prebiehať diskrétne, rešpektovať podmienky používania webových stránok, v rámci procesu by ste mali kontrolovať, či stránky používajú protokol robots.txt na oznámenie, že scrapovanie je zakázané, vyhnúť sa scrapovaniu osobných údajov a, ak je to nevyhnutné, uistiť sa, že nedochádza k porušeniu GDPR, taktiež sa vyhnúť scrapovaniu súkromných alebo utajovaných informácií (Zdroj).
Existujú niektoré všeobecné etické zásady, ktoré by ste mali dodržiavať, keď chcete scrapovať údaje z webu. Najčastejšie spomínané sú:

  • Ak existuje verejné API, ktoré poskytuje požadované údaje, použite ho namiesto scrapovania.
  • Sťahujte údaje v rozumnom tempe, aby scrapovanie nebolo škodlivé pre server a nemohlo byť zamieňané za DDoS útok.
  • Rešpektujte duševné vlastníctvo iných. Použite údaje na vytvorenie nového hodnotného obsahu, nie na duplikovanie a predávanie ich ako vlastné alebo nelegálne predávanie.
  • Nepoužívajte scrapovanie osobných alebo súkromných údajov alebo dokumentov, rešpektujte GDPR.
  • Skontrolujte súbor robots.txt, aby ste zistili, ako by mal byť web prehľadávaný.
  • Zdieľajte to, čo môžete. Ak sú údaje, ktoré ste scrapovali, verejne dostupné, alebo ste získali povolenie na ich zdieľanie, zverejnite ich pre iných (napríklad na GitHub alebo Kaggle). Ak ste napísali webový scraper na prístup k nim, zdieľajte jeho kód.
  • Hľadajte spôsoby, ako vrátiť hodnotu webovým stránkam, ktoré scrapujete, napríklad odkazovaním na stránku v článku alebo príspevku, aby ste na ňu priviedli návštevníkov.
  • Ak sa jedná o súkromný projekt (ako tento), počkajte kým sa štruktúra stránky zmení a kód nie je možné použiť bez ďalšej úpravy.

Niekoľko článkov venujúcim sa téme môžete nájsť na towardsdatascience.com, Data Fluency github page alebo scrapingrobot page.
Príklad komerčného využitia web scrapingu je napr. Apify, ktoré ponúka množstvo produktov a riešení v oblasti web scrapingu.
V tomto článku budem scrapovať údaje zo stránky Nehnutelnosti, ktorá sa špecializuje na realitné inzeráty a služby. Pre dodržanie etických zásad scrapovania som prijal niekoľko preventívnych opatrení:

  • Táto konkrétna stránka používa protokol robots.txt. Nescrapujem žiadnu časť, ktorá je zakázaná a vo vybraných častiach kódu som pridal funkciu Sys.sleep(), aby som spomalil proces a žiadal údaje s primeranou frekvenciou.
  • Scrapujem len verejné údaje, ktoré potrebujem na ďaľšiu analýzu a vytvorenie ML modelu.
  • Výsledný dataset je dostupný na Kaggle.

    Robots.txt protokol stránky Nehnuteľnosti.sk

Proces scrapovania


Použité knižnice


Ako obvykle, začínam s načítaním balíkov potrebných pre tento projekt. Používam na to packman knižnicu a funkciu p_load(), ktorá ma dve výhody oproti základnej funkcii library():

  • ak potrebnú knižnicu nemám nainštalovanú, funkcia to rovno napraví
  • môžem načítať viacero knižníc naraz
Knižnice
if (!require("pacman")) {
  install.packages("pacman")
}
pacman::p_load(
  tidyverse,
  rvest, # scraping, part of tidyverse
  httr, # working with html
  RSelenium, # scraping in Google Chrome
  netstat, # free_port()
  doParallel, # parallel processing
  furrr # future map
)

V tomto projekte máme tri skupiny balíkov:

  • na načítanie údajov a manipuláciu s nimi – rio, tidyverse
  • na web scrapovanie – rvest, RSelenium, netstat, httr. Poznámka: V čase uverejnenia tohto príspevku bola dostupná už aj nová funkcia v knižnici rvest read_html_live(). Podľa popisu by mohla aspon čiastočne nahradiť RSelenium.
  • na paralelné spracovanie – doParallel a furrr

Webové elementy


Bez ohľadu na to, ktorý programovací jazyk alebo balík si vyberiete na scrapovanie, musíte byť schopní nájsť elementy v zdrojovom kóde webovej stránky. To môžete jednoducho urobiť vo vašom webovom prehliadači (pre potreby tohto blogu používam Google Chrome). Stlačte CRTL + SHIFT + I na otvorenie nástrojov vývojára. Teraz, keď sa pohybujete kurzorom po kóde v okne, dynamicky vám ukáže, ktorá časť stránky je s ňou spojená. Jednoduchším spôsobom, ako získať správnu referenciu na element (alebo inú časť stránky), je stlačiť CRTL + SHIFT + C a vybrať priamo na stránke požadovaný element.

DevTools v Google Chrome


Ak ste našli správny element, musíte skopírovať jeho CSS selektor alebo cestu XPath. Obe možnosti môžu byť použité ako argumenty v rvest a RSelenium.

Kopírovanie XPath web elementu


Teraz ste pripravení získať obsah zo stránky. Výsledok, ak ste všetko správne urobili, môže mať rôzne formy. Môže to byť jedna hodnota, reťazec, zoznam atď. Na základe toho buď presnejšie špecifikujete, ktorú časť obsahu potrebujete, alebo pracujete s výsledkom v R a používate funkcie na manipuláciu s dátami s cieľom získania požadovaných informácií.

Scraping časť I. – rvest


Jedným z najbežnejších balíkov na web scrapovanie v R je rvest. Poskytuje funkcie na prístup k verejnej webovej stránke a na vyhľadávanie špecifických prvkov pomocou selektorov CSS a XPath. Tento balík nespúšťa javascript, čo znamená, že načíta html stránky rýchlejšie, ale vynechá všetky prvky načítané javascriptom po pôvodnom načítaní stránky. Preto je tento balík dobrá voľba, ak scrapujete statické stránky.

V tomto príklade začínam vytvorením premennej pre zdrojovú URL adresu: https://www.nehnutelnosti.sk/slovensko/byty/predaj/?p[param1][from]=1000&p[param1][to]=&p[page]=

Následne skontrolujem počet stránok:

Number of pages with search results


A nakoniec pripravím a spustím multisession na získanie ceny, adresy, typu nehnuteľnosti a najdôležitejšie – odkazu na inzerát, ktorý bude použitý v ďalšej časti s balíkom RSelenium. Pridal som aj plan(sequential) na zastavenie multisession, ale musím priznať, že nie som tak znalý paralelného programovania v R, aby som úplne pochopil dôležitosť tohto kroku.

Scraping statických dát
# apartments page
site <- "https://www.nehnutelnosti.sk/slovensko/byty/predaj/?p[param1][from]=1000&p[param1][to]=&p[page]="

# scrape the number of pages
number_of_pages <- read_html(site) %>%
  html_nodes(xpath = '//*[@id="content"]/div[7]/div/div/div[1]/div[17]/div/div/ul/li[5]') %>%
  html_elements("a") %>%
  html_text(trim = TRUE) %>%
  as.numeric()

# create a cluster of worker processes (cores)
plan(multisession, workers = 6)

advertisements <- future_map_dfr(1:number_of_pages, function(i) {
  page_content <- read_html(paste0(site, i))
  
  price <- page_content %>%
    html_nodes(xpath = '//*[@class="advertisement-item--content__price col-auto pl-0 pl-md-3 pr-0 text-right mt-2 mt-md-0 align-self-end"]') %>%
    html_attr("data-adv-price")
  
  type_of_real_estate <- page_content %>%
    html_nodes(xpath = '//*[@class="advertisement-item--content__info"]') %>%
    html_text2()
  
  address <- page_content %>%
    html_nodes(xpath = '//*[@class="advertisement-item--content__info d-block text-truncate"]') %>%
    html_text2()
  
  link <- page_content %>%
    html_nodes(xpath = '//*[@class="mb-0 d-none d-md-block"]') %>%
    html_nodes("a") %>%
    html_attr("href")
  
  tibble(price = price, type_of_real_estate = type_of_real_estate, address = address, link = link)
})

plan(sequential)

Scraping časť II. – RSelenium


RSelenium poskytuje súbor R väzieb pre Selenium 2.0 WebDriver. Na rozdiel od rvest spúšťa skutočný webový prehliadač, takže načíta akýkoľvek javascript obsiahnutý na webovej stránke. S týmto balíkom budete schopní interagovať so stránkou, napríklad posúvať, klikať na tlačidlo, vyplňovať vstupné formuláre atď. Na druhej strane je použitie tohto balíka náročnejšie, vyžaduje inštalovaný jazyk Java vo vašom systéme, a ja som sa stretol s viacerými problémami, kým som ho správne spustil. Viac sa tejto téme budem venovať v jednom z budúcich blogov.
Začínam definovaním niekoľkých pomocných funkcií. Jedna je na neúspešnú navigáciu na stránku, k čomu obvykle dochádzalo, keď som čítal príliš veľa stránok a musel som vymazať históriu prehliadania. Niekedy táto funkcia spôsobila nekonečnú slučku, ktorá bežala niekoľko hodín, než som si to všimol (napríklad počas noci), ale aspoň kód nezhavaroval. Tiež som skúsil vytvoriť funkciu na vymazanie histórie v prípade neúspešnej navigácie, ale nefungovala, a som spokojný s týmto súčasným riešením. Druhá je na spracovanie chyby pri hľadaní elementu. V takýchto prípadoch táto funkcia vráti NA. Posledná je podobná predchádzajúcej, ale v tomto prípade vráti NA, ak sa element nenájde.
V skripte tiež používam funkciu tryCatch() na vrátenie NA v prípade chyby.

Definovanie funkcií
# Define a function that handles the errors in page load
# Delete all cookies from the last 24 hours
clearCookies <- function(remDr) {
  remDr$deleteAllCookies()
}

navigate_with_retry <- function(link, remDr) {
  success <- FALSE
  while (!success) {
    tryCatch(
      {
        remDr$navigate(link)
        Sys.sleep(5)
        success <- TRUE
      },
      error = function(e) {
        cat("Failed to navigate to", link, "- Retrying in 10 seconds...\n")
        clearCookies(remDr)
        Sys.sleep(10)
      }
    )
  }
}

# Define a wrapper function that handles the errors in element search
safe_find_element <- possibly(function(page, xpath) {
  page$findElement(using = "xpath", xpath)
}, NA)

# function to get text or return NA if element not found
get_text_or_na <- function(nodes) {
  tryCatch(
    {
      text <- nodes %>%
        html_text2() %>%
        as.character() # %>%
      # str_trim() %>%
      # str_squish()
      if (text == "") NA else text
    },
    error = function(e) {
      NA
    }
  )
}


Teraz rozdeľujem odkazy na inzeráty do 10 setov. Tento krok nie je nutný, pretože kód funguje, ale robím to v prípade neočakávanej chyby počas procesu scrapovania, aby som zachránil aspoň časť údajov. Tento krok som zaradil až po jednej veľmi zlej skúsenosti. Ďalšia časť scrapovania totiž trvá viac ako dva dni a môžete mi veriť, že nechcete stratiť celý pokrok kvôli výpadku WiFi. Taktiež vytváram prázdny dataframe so všetkými možnými stĺpcami, ktorý sa bude napĺňať dátami.

Rozdelenie dát do menších celkov
# Additional info from web

# number of splits
num_splits <- 10
split_size <- ceiling(nrow(advertisements) / num_splits)

# split the data frame into subsets
advertisments_list <- split(advertisements, rep(1:num_splits, each = split_size, length.out = nrow(advertisements)))

for (i in seq_along(advertisments_list)) {
  assign(paste0("advertisements_", i), advertisments_list[[i]])
}

# create empty dataframe outside of the loop to hold additional info
additional_info_df <- tibble(
  link = character(),
  info_text = character(),
  additional_characteristics = character(),
  index_of_living = character(),
  environment = character(),
  quality_of_living = character(),
  safety = character(),
  transport = character(),
  services = character(),
  relax = character(),
  info_details = character(),
  stringsAsFactors = FALSE
)


Až teraz prichádzam k samotnému scrapovaniu s použitím RSelenium. Používam len samotnú knižnicu RSelenium, existuje tiež možnosť použiť Docker na spustenie servera Selenium a pripojenie sa k tejto inštancii pomocou RSelenium, túto možnosť som však nepoužil.
Ako už bolo spomenuté, na jeho spustenie musím dodržať niekoľko krokov. Na nastavenie servera Selenium a prehliadača musíte použiť funkciu rsDriver() a volať $client na vytvorenie klienta. Funkcia rsDriver očakáva niekoľko argumentov:

  • browser – používam Chrome, takže argument je “chrome”,
  • chromever – tento argument je často zdroj chýb. Jeho riešenie popíšem v samostatnom BLOGU, ale v tomto prípade píšem verziu “119.0.6045.105”,
  • verbose – nastavujem FALSE,
  • port – port, na ktorom sa má spúšťať. Používam funkciu z balíka netstat free_port(random = TRUE) pre automatický výber voľného portu.

Následne skriptom otvorím prehliadač, maximalizujem okno, prejdem na stránku Nehnuteľnosti a prijmem súbory cookie. Potom je nutné manuálne prihlásenie, aby sa zobrazili hodnoty jednotlivých komponentov “indexu bývania”. Tento krok by sa dal zautomatizovať, avšak je to jednorazová aktivita, takže ju ponechávam takto. Keď je toto všetko hotové, skutočné scrapovanie prebieha v cykle. Nejdem do všetkých detailov, ale logika je dosť jednoduchá, skript:

  1. Prejde na odkaz v cykle.

  2. Posunie sa na (výšku stránky/10*4,2), aby sa spustil javascript na zobrazenie indexu bývania (táto hĺbka posuvu je založená na manuálnych testoch, napriek tomu musím nájsť inú metódu alebo spustiť viacero posunov, aby sa script skutočne spustil na všetkých načítaných stránkach).

  3. Počká 3 sekundy na vykonanie javascriptu a spomalenie procesu (aby sa zbytočne nezaťažovala stránka).

  4. Načíta obsah stránky.

  5. Ak sa stránka nedá načítať, pridá prázdny záznam do dataframe-u.

  6. Ak sa stránka dá načítať, prejde html stránky, scrapuje nasledujúce informácie a pridá nové riadky k dataframe-u:

    • info_text – celý text z inzerátu. Momentálne nie je používaný v ML modeli, ale plánujem použiť NLP na získanie kľúčových slov/tém a vytvorenie wordcloud v Shiny appke.
    • info_details – obsahuje 4 premenné a bude vyčistený v nasledujúcich krokoch. Premenné sú oddelené symbolom “”.
    • index_of_living – hodnota od 0 do 10, vypočítava ju slovenský startup City Performer. Zohľadňuje šesť kategórií: prostredie, kvalita bývania, bezpečnosť, doprava, služby a oddych.
    • additional_characteristics – obsahuje viacero premenných, v nasledujúcich krokoch vyberiem 12 z nich. Premenné sú oddelené symbolom "\n".
  7. Zatvorí klienta a zastaví server.


Scraping pomocou RSelenium
for (i in 1:10) {
  # get the current dataframe
  current_df <- get(paste0("advertisements_", i))

  # start the server
  rs_driver_object <- rsDriver(
    browser = "chrome",
    chromever = "119.0.6045.105",
    verbose = FALSE,
    port = free_port(random = TRUE)
  )

  # create a client object
  remDr <- rs_driver_object$client

  # open a browser
  remDr$open()
  remDr$maxWindowSize()
  
  # navigate to a website
  remDr$navigate("https://www.nehnutelnosti.sk/")
  Sys.sleep(5) # wait for 5 seconds

  # accept cookies
# switch to cookie iframe
remDr$switchToFrame(remDr$findElement(using = "xpath", '//*[@id="sp_message_iframe_920334"]'))
remDr$findElement(using = "xpath", '//*[@id="notice"]/div[2]/button')$clickElement()

# switch back to default frame
remDr$switchToFrame(NA)

# MANUAL LOG IN

  # loop through each link in the current dataframe
  for (link in current_df$link) {
    info_text <- NA
    additional_characteristics <- NA
    index_of_living <- NA
    environment <- NA
    quality_of_living <- NA
    safety <- NA
    transport <- NA
    services <- NA
    relax <- NA
    info_details <- NA

    navigate_with_retry(link, remDr)
    #remDr$executeScript("document.body.style.zoom = '50%';")
    height <- as.numeric(remDr$executeScript("return document.documentElement.scrollHeight"))/10*4.2 # Scroll to load index of living
    remDr$executeScript(paste("window.scrollTo(0, ", height, ");")) # scroll to living index
    
    
    Sys.sleep(1)
    page <- safe_find_element(remDr, '//*[@id="map-filter-container"]')

    if (is.na(page)) {
      new_row <- tibble(
        link = link,
        info_text = NA,
        additional_characteristics = NA,
        index_of_living = NA,
        environment = NA,
        quality_of_living = NA,
        safety = NA,
        transport = NA,
        services = NA,
        relax = NA,
        info_details = NA
      )

      # bind new row to additional info dataframe
      additional_info_df <- rbind(additional_info_df, new_row)
    } else {
      page_html <- page$getElementAttribute("outerHTML")
      page_html <- read_html(page_html[[1]])

      info_text <- page_html %>%
        html_nodes(xpath = '//*[contains(concat( " ", @class, " " ), concat( " ", "text-inner", " " ))]') %>%
        get_text_or_na()

      info_details <- page_html %>%
        html_nodes(xpath = '//*[@id="map-filter-container"]/div[2]/div/div[1]/div[2]/div[5]/ul') %>%
        html_text2()
      
      tryCatch(
        {
          index_of_living <- page_html %>%
            html_nodes(xpath = '//*[@id="totalCityperformerWrapper"]/div/p[1]/span') %>%
            get_text_or_na()
        },
        error = function(e) {
          index_of_living <- NA
        }
      )

      tryCatch(
        {
          environment <- page_html %>%
            html_nodes(xpath = '//*[@id="map-filter-container"]/div[2]/div/div[1]/div[4]/div[1]/div[1]/div/div[2]/div[2]/div[1]/div[1]/div[2]/span[1]/span') %>%
            get_text_or_na()
        },
        error = function(e) {
          environment <- NA
        }
      )
      
      tryCatch(
        {
          quality_of_living <- page_html %>%
            html_nodes(xpath = '//*[@id="map-filter-container"]/div[2]/div/div[1]/div[4]/div[1]/div[1]/div/div[2]/div[2]/div[1]/div[2]/div[2]/span[1]/span') %>%
            get_text_or_na()
        },
        error = function(e) {
          quality_of_living <- NA
        }
      )
      
      tryCatch(
        {
          safety <- page_html %>%
            html_nodes(xpath = '//*[@id="map-filter-container"]/div[2]/div/div[1]/div[4]/div[1]/div[1]/div/div[2]/div[2]/div[1]/div[3]/div[2]/span[1]/span') %>%
            get_text_or_na()
        },
        error = function(e) {
          safety <- NA
        }
      )
      
      tryCatch(
        {
          transport <- page_html %>%
            html_nodes(xpath = '//*[@id="map-filter-container"]/div[2]/div/div[1]/div[4]/div[1]/div[1]/div/div[2]/div[2]/div[2]/div[1]/div[2]/span[1]/span') %>%
            get_text_or_na()
        },
        error = function(e) {
          transport <- NA
        }
      )
      
      tryCatch(
        {
          services <- page_html %>%
            html_nodes(xpath = '//*[@id="map-filter-container"]/div[2]/div/div[1]/div[4]/div[1]/div[1]/div/div[2]/div[2]/div[2]/div[2]/div[2]/span[1]/span') %>%
            get_text_or_na()
        },
        error = function(e) {
          services <- NA
        }
      )
      
      tryCatch(
        {
          relax <- page_html %>%
            html_nodes(xpath = '//*[@id="map-filter-container"]/div[2]/div/div[1]/div[4]/div[1]/div[1]/div/div[2]/div[2]/div[2]/div[3]/div[2]/span[1]/span') %>%
            get_text_or_na()
        },
        error = function(e) {
          relax <- NA
        }
      )
      
     tryCatch(
        {
          additional_characteristics <- page_html %>%
            html_nodes(xpath = '//*[@id="additional-features-modal-button"]/ul') %>%
            html_text2() # %>%
          # str_squish()
        },
        error = function(e) {
          additional_characteristics <- NA
        }
      )

      new_row <- tibble(
        link = link, info_text = info_text,
        additional_characteristics = additional_characteristics,
        index_of_living = index_of_living,
        environment = environment,
        quality_of_living = quality_of_living,
        safety = safety,
        transport = transport,
        services = services,
        relax = relax,
        info_details = info_details
      )

      # bind new row to additional info dataframe
      additional_info_df <- rbind(additional_info_df, new_row)
    }
  }
}

# close remote driver
rs_driver_object$client$close()
rs_driver_object$server$stop()
rm(rs_driver_object, remDr)
gc()

Data wrangling


Scrapovanie je hotové a pokračujem s manipuláciou s dátami, aby som ich dal do (prvotnej) použiteľnej formy. Najprv upravujem adresu, aby som všetky okresy dostal do prvého stĺpca. Táto adresa sa neskôr používa na geokódovanie, ktoré opisujem v nasledujúcom blogu. Cena je taktiež upravená na číslo, aby sa mohla použiť v ML modeli.

Úprava dát 1
advertisements_cleaned <- advertisements %>%
  separate(type_of_real_estate, c("type", "area"), sep = " • ", remove = TRUE) %>% 
  separate(address, c("a", "b", "c"), sep = ", ", remove = TRUE) %>%
  unite("address", c(5, 4, 3), sep = ", ", na.rm = TRUE, remove = TRUE) %>% # reordering to keep all districts in first column
  mutate(
    price = str_replace_all(str_replace_all(price, " €", ""), " ", "") %>%
      as.integer(),
    address0 = address
  ) %>%
  separate(address0, c("district", "municipality", "street"), sep = ", ") %>%
  select(-street)


Teraz potrebujem rozdeliť hodnoty stĺpcov additional_characteristics a info_details na viac zmysluplných premenných. Preto som vytvoril dva zoznamy: characteristics1 a characteristics2. Každý z nich obsahuje názov premenných, ktoré chcem extrahovať. Používam tieto zoznamy na vytvorenie prázdneho dataframe-u, aby som sa uistil, že sú všetky stĺpce prítomné. Z additional_info_df vyberiem additional_characteristics a info_details a rozdelím hodnoty pomocou "\n" ako oddeľovača. Ďalej definujem dve funkcie: get_characteristics1 a get_characteristics2, ktoré tieto hodnoty mapujú na príslušné stĺpce. Nakoniec spájam output_df_characteristics1, output_df_characteristics2, vybrané stĺpce z additional_info_df a pripájam ich k advertisements_cleaned podľa linku inzerátu.

Úprava dát 2
# get additional information from scraped data
# First list of additional info details
characteristics1 <- c(
  "Stav", # condition
  "Úžit. plocha", # land area
  "Energie", # energy costs
  "Provízia zahrnutá v cene"
)

characteristics1_df <- data.frame(characteristics1, value = NA)
# Second list of additional info details
characteristics2 <- c(
  "Počet izieb/miestností", # number of rooms
  "Orientácia", # orientation
  "Rok výstavby", # built year
  "Rok poslednej rekonštrukcie", # year of last reconstruction
  "Energetický certifikát", # energy certificate
  "Počet nadzemných podlaží", # number of floors
  "Podlažie", # floor
  "Výťah", # lift
  "Typ konštrukcie", # construction type
  "Počet balkónov", # number of balconies
  "Počet lodžií", # number of loggias
  "Pivnica" # cellar
)

characteristics2_df <- data.frame(characteristics2, value = NA)

characteristics_wrangler <- additional_info_df %>%
  mutate(
    chars1_list = str_split(info_details, "\n"),
    chars2_list = str_split(additional_characteristics, "\n")
  ) %>%
  select(-additional_characteristics, -info_details)

get_characteristics1 <- function(x) {
  temp_df <- x %>%
    unlist() %>%
    as.data.frame()
  temp_df <- rename(temp_df, chars = .)
  temp_df <- temp_df %>%
    separate_wider_delim(chars,
      delim = ": ",
      names = c(
        "info",
        "status"
      )
    ) %>%
    filter(info %in% characteristics1) %>%
    full_join(characteristics1_df, join_by("info" == "characteristics1"), keep = FALSE) %>%
    select(-value) %>%
    pivot_wider(names_from = info, values_from = status)
  return(temp_df)
}

get_characteristics2 <- function(x) {
  temp_df <- x %>%
    unlist() %>%
    as.data.frame()
  temp_df <- rename(temp_df, chars = .)
  temp_df <- temp_df %>%
    separate_wider_delim(chars,
      delim = ": ",
      names = c(
        "info",
        "status"
      )
    ) %>%
    filter(info %in% characteristics2) %>%
    full_join(characteristics2_df, join_by("info" == "characteristics2"), keep = FALSE) %>%
    select(-value) %>%
    pivot_wider(names_from = info, values_from = status)
  return(temp_df)
}

# Apply get_characteristics1() and get_characteristics2() to each row in additional_info_df and combine the results
output_df_characteristics1 <- map_dfr(characteristics_wrangler$chars1_list, get_characteristics1)
output_df_characteristics2 <- map_dfr(characteristics_wrangler$chars2_list, get_characteristics2)

# Add the new columns to additional_info_df
additional_info_df_complete <- cbind(
  additional_info_df %>%
    mutate(index_of_living = str_replace_all(index_of_living, " /", "")) %>%
    select(c(link, 
             info_text, 
             index_of_living,
             environment,
             quality_of_living,
             safety,
             transport,
             services,
             relax)) %>% 
    mutate(flag = "x"), 
  output_df_characteristics1,
  output_df_characteristics2
)


advertisements_complete <- advertisements_cleaned %>%
  left_join(additional_info_df_complete, by = "link", multiple = "first") %>%
  filter(!is.na(flag)) %>% 
  select(-flag, -c, -type)


Posledný krok je uloženie dát vo formáte RDS.

Uloženie súborov
saveRDS(additional_info_df, "data/additional_info_df.RDS")
saveRDS(advertisements, "data/advertisements.RDS")

# create separate df for text analyses
text_long <- advertisements_complete$info_text

saveRDS(text_long, file = "data/text_long.rds")

saveRDS(advertisements_complete, file = "data/advertisements_complete.rds")