[DajSięPoznać#6] F# TextMining, xUnit, Unquote

Wstęp

Poprzednio pisałem o tym, jak zebrać dużą ilość danych crawlując strony z ogłoszeniami. Tym razem nadszedł czas, żeby spróbować przeanalizować ten spory zbiór HTML-i i wyciągnać z nich wszystkie potrzebne dane.

Na podstawie analizy szablonów każdego z portali można niektóre informacje powyciągać pisząc zapytania do drzewa DOM (bo na przykład cena jest zawsze w div-ie o określonym id). Problem pojawia się wtedy, gdy spora ilość ważnych danych może znajdować się w dowolnym miejscu w polach opisowych (na przykład w tytule). I tu właśnie jest miejsce dla prostych metod eksploracji danych (text miningu). Innymi słowy listę dokumentów HTML (z trzech różnych portali) chcemy przetransformować do postaci takich rekordów:

[<Measure>] type PLN

type Advert = {
    Title: String;
    Description: String;
    Md5 : String;
    Url: String;
    TotalPrice : decimal<PLN>;
    PricePerMeter : decimal<PLN/m^2>;
    Area : decimal<m^2>;
    NumberOfRooms : Option<int>;
    Furnished : Option<bool>;
    NewConstruction : Option<bool>;
    BuildingType : Option<string>;
    Tier : Option<string>;
    YearOfConstruction : Option<int>;
    Elevator : Option<bool>;
    Basement: Option<bool>;
    Balcony: Option<bool>;
    Heating: Option<string>;
    Parking: Option<string>;

    mutable Street: Option<string>;
    mutable District: Option<string>;
}

 

Powyższy rekord wykorzystuje jednostki miar definiując wielkości fizyczne. Przykładowo wyliczenie ceny za metr musi powstać w wyniku dzielenia <PLN> przez <m^2>. F# automatycznie przypisze do wyniku takiej operacji jednostkę <PLN/m^2>. Dzięki temu unikamy w runtimie sporej ilości potencjalnych błędów, np. konwersji. Jednostki fizyczne z układu SI zdefiniowane są w FSharp.Data.UnitSystems.SI.UnitSymbols

Klasyfikacja ogłoszeń względem ulic / osiedli

Żaden z analizowanych portali nie ma przypisanego pola do wpisania ulicy czy osiedla, którego dotyczy ogłoszenie. Większość użytkowników zamieszczających ogłoszenia wpisuje je w tytule, a niektórzy w części opisowej. Mając listę wszystkich ulic (jako indeks ElasticSearcha) można spróbować przypisać ogłoszenia do ulic.

Rozpoczynamy od wstępnego przejrzenia próbnego zestawu danych.Bardzo szybko nasuwa się kilka wniosków:

  • nazwa nierzadko poprzedzona jest wyrazami typu: ul|ulica|al|aleja|os|osiedle (kropki zostaną odfiltrowane wcześniej)
  • bardzo często w tytułach obok ulic występują też nazwy dzielnic, które również warto odfiltrować przed analizą
  • na stronie www.wordcounter.com można przeanalizować tytuły pod kątem najczęściej występujących wyrazów i od razu odflitrować słowa typu: mieszkanie, pokoje, sprzedam, centrum
  • statystycznie wychodzi na to, że nazwa ulicy częściej występuje pod koniec niż na początku tytułu, dlatego jeśli chcemy próbować każdy wyraz z tytułu, to mniej requestów wykonamy przetwarzając je od końca
let tryParseStreetFromTitle(title:String) = 
    let tokens = tokenize(title) |> Seq.toArray

    match tryParseStreetByNeighbours(tokens) with
        | None -> blindSearch(tokens)
        | Some(street) -> match searchController.PrefixSearch(street) with
                                | [|x|] -> Some(x)
                                | _ -> blindSearch(tokens)

 

Funkcja tokenize rozdzieli tytuł (zdanie) na sekwencję stringów (wyrazy). Następnie w pierwszej kolejności próbujemy znaleźć wyraz poprzedzony wspomnianymi ul, al, os itd.

Jeżeli taki wyraz znajdziemy, to wysyłamy zapytanie do indeksu ulic, a gdy ten zwróci dokładnie jeden rezultat, to przyjmujemy, że trafiliśmy ulicę. W przypadku niepowodzenia przy którejś ze wspomnianych operacji wykonywane jest blindSearch, czyli próbowanie wszystkich wyrazów po kolei.

let blindSearch tokens = 
    tokens |> removeUnnecessaryWords |> Seq.rev // statystycznie częściej nazwa ulicy występuje na końcu tytułu
           |> Seq.map(fun token -> searchController.PrefixSearch(token))
           |> Seq.filter(fun res -> res.Length = 1)
           |> Seq.map(fun res -> res |>Seq.head)
           |> Seq.tryHead

 

Tutaj w najgorszym przypadku wykona się tyle zapytań, ile wyrazów mamy w zdaniu. Pewną optymalizacją było wykonywanie tych operacji na odwróconej sekwencji. Jeżeli w tytule nie znajdziemy dobrego kandydata, to można jeszcze spróbować poszukiwania w opisie, ale tam nie ma już sensu wyszukiwanie po każdym słowie (bo jest ich zazwyczaj całkiem sporo), więc pozostaje ograniczyć się do analizy sąsiedztwa wyrazów.

let street = match tryParseStreetFromTitle(advert.Value.Title) with
                    | Some(x) -> Some(x)
                    | _ -> tryParseStreetFromDescription(advert.Value.Description)

 

Na testowym zestawie 926 dokumentów udało się jednoznacznie zidentyfikować 525 ulic, co jest niezłym wynikiem biorąc pod uwagę, że część ludzi ogranicza się tylko do podania nazwy dzielnicy. Wynik można by jeszcze nieco poprawić uwzględniając odmianę słów i ich kontekst, np. “mieszkanie przy ul. Mogilskiej”.

xUnit i Unquote

Funkcja do wyciągania ulicy z tytułu dobrze nadaje się do Unit Testowania – ma jedno wejście i jedno dokładnie oczekiwane wyjście. Problem polega na tym, że test-casów będzie bardzo dużo, a nie chcemy kopiować za każdym razem kodu i wymyślać nowe nazwy dla metod. Tu z pomocą przychodzi xUnit i jego atrybut Theory. Dzięki niemu możemy sparametryzować testy jednostkowe i dostarczać zestawy danych jako kolejne atrybuty, nie kopiując niepotrzebnie kodu. Przykładowo:

[<Theory>]
[<InlineData("2 pokoje, 49 m2, os. Sportowe, Nowa Huta", "OSIEDLE SPORTOWE")>]  
[<InlineData("Kraków, Nowa Huta, Osiedle Urocze, os.Urocze", "OSIEDLE UROCZE")>]
[<InlineData("Mieszkanie jednopokojowe - Bochenka", "ULICA ADAMA BOCHENKA")>]
[<InlineData("Kraków, Podgórze Duchackie, Wola Duchacka, Sanocka", "ULICA SANOCKA")>]
[<InlineData("Kraków, Krowodrza, kmieca, Krowodrza, Łobzów", "ULICA KMIECA")>]
[<InlineData("Piękne mieszkanie 2pok 37m2 ul. Pużaka/Azory", "ULICA KAZIMIERZA PUŻAKA")>]
[<InlineData("Mieszkanie 98m2/5700zł/m al. Słowackiego!", "ALEJA JULIUSZA SŁOWACKIEGO")>]
member x.StreetsParser_Test(title:string, expStreetName:string) =
    
    let output =  Byteville.TextMining.tryParseStreetFromTitle(title)

    test <@ output.Value.Name = expStreetName @>

 

Do asercji używam biblioteki Unqoute, którą można doinstalować NuGetem.

 

asd

Przyjmuje ona FSharpowe quoted expressions, dzięki czemu w przypadku niepowodzenia testu dostajemy dokładny zestaw danych, na których wykonano to wyrażenie, przykładowo:

unquote

Przy testach z Theory dzięki temu jesteśmy w stanie bardzo szybko namierzyć niedziałający przypadek.

Advertisements

One thought on “[DajSięPoznać#6] F# TextMining, xUnit, Unquote

  1. Pingback: [DajSięPoznać] Podsumowanie | When the smoke is going down

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s