k47.cz

mastodon twitter RSS
bandcamp explorer

Kosinová podobnost

24. 9. 2012 (aktualizováno 24. 10. 2019) — k47 (CC by-sa)

Kosinová podobnost (cosine similarity) je míra podobnosti dvou vektorů, která se získá výpočtem kosinu úhlu těchto vektorů.

Toho se dá využít pro zjištění podobnosti dvou dokumentů. V takovém případě budou vektory reprezentovat četnost jednotlivých slov (popřípadě n-gramů, tagů nebo jakékoli jiné feature).


Řečí matematiky: skalární součin vektorů vydělený součinem jejich velikostí.1

Na první pohled se může zdát, že takto jednoduchý postup dokáže odhalit jenom téměř identické dokumenty s několika málo změnami nebo přeházenými odstavci, ale není tomu tak. Můžeme předpokládat, že ve dvou různých dokumentech, které pojednávají o stejném tématu, budou použita přibližně stejná slov v přibližně stejném rozložení.

Nejlepší na tom celém je, že kosinová podobnost skutečně funguje. Má sice určité nedostatky (k nim se dostanu později), ale funguje.

Následuje ukázková naivní implementace kosinové podobnosti ve Scale, která data načte a spočítá podobnost. Kód je velice pomalý, ale aspoň je přehledný. Optimalizovaná verze (která reprezentuje řídké vektoryYale formátu/CSR) je asi 20× rychlejší.

type WordFreq = Map[String, Int]

def dot(a: WordFreq, b: WordFreq): Double =
  a.map { case (k, v) => if (b.contains(k)) v * b(k) else 0 }.sum

def magnitude(ws: WordFreq): Double =
  math.sqrt(ws.values.map(x => (x * x).toDouble).sum)

def cossim(a: WordFreq, b: WordFreq): Double =
  dot(a, b) / (magnitude(a) * magnitude(b))

def metric(cossim: Double): Double =
  1 - 2 * math.acos(cossim) / math.Pi


val threadsData = io.Source.fromFile("r9k-cossim.data").getLines.take(500)

val threads: Vector[(Int, WordFreq)] = threadsData.map { line =>
  val Array(id, txt) = line.split("\t", 2)

  val words = txt.toLowerCase.replaceAll("\\[ntr\\]", " ").split("\\W+").filter { w => w.length >= 3 && !(w matches "\\d+") }
  val frequency = words.groupBy(identity).mapValues(_.size)

  (id.toInt, frequency)
} toVector


val similarities =
  for (Seq((aId, aWords), (bId, bWords)) <- threads.combinations(2))
    yield (aId, bId, metric(cossim(aWords, bWords)))

similarities.filter { case (_, _, sim) => sim > 0.85 } foreach println

Nedostatky:

Jasným nedostatkem algoritmu je prostý fakt, že nebere v potaz synonyma (a v případě češtiny skloňování/časování) a váhu jednotlivých slov.

Pak také produkuje jenom málo falešných pozitiv, ale zato má sklony k falešným negativům. Když vyhodnotí dva dokumenty jako stejné, je malá šance, že se tématicky rozcházejí (mnohoznačná slova nejsou podle mě tak velký problém), ale často se stane, že vůbec nepozná dokumenty, které se o stejném tématu baví trochu jinak (synonyma).


Další krok je jednoznačně tf*idf.


Další čtení:

Pozn:

  1. pokud A i B jsou bitmapy, pak se dá kosinová podobnost implementovat efektivně jako:
     bitcount(A and B)
---------------------------
√bitcount(A) * √bitcount(B)

píše k47 & hosté, ascii@k47.cz