Задача возникла из разговора в группе по R (язык программирования):
Для ответа на вопрос нам нужен набор данных с координатами звезд на небе и меткой созвездия, к которому принадлежит звезда. Эта информация берется из репозитория с исходным кодом для программы Stellarium. По этим данным мы проверим, какой из методов кластерного анализа дает результаты, похожие на оригинальные греческие созвездия.
Сначала загрузим необходимые пакеты.
library(maditr)
library(ggplot2)
library(cluster) # для k-medoid
# library(kmed) # для k-medoid, но pam из пакета cluster работает лучше
library(akmeans) # для k-means с косинусной метрикой
Скачаем данные из репозитория Stellarium:
CONSTELLATIONS = "data/constellations.txt"
CONSTELLATION_NAMES = "data/constellation_names.txt"
# файл с координатами звезд
if(!file.exists(CONSTELLATIONS)){
download.file("https://raw.githubusercontent.com/Stellarium/stellarium/master/skycultures/western_SnT/SnT_constellations.txt", CONSTELLATIONS)
}
# файл с расшифровкой названий созвездий
if(!file.exists(CONSTELLATION_NAMES)){
download.file("https://raw.githubusercontent.com/Stellarium/stellarium/master/skycultures/western_SnT/constellation_names.eng.fab", CONSTELLATION_NAMES)
}
Загрузка полных названий созвездий:
constellations_names = fread(CONSTELLATION_NAMES, sep = "", header = FALSE) %>%
let(
V1 = trimws(gsub("_.+$", "", V1)),
V1 = gsub('"', "", V1)
)
constellations_names = strsplit(constellations_names$V1, "\t") %>%
to_df() %>%
setnames(c("abbr", "constellation"))
head(constellations_names)
## abbr constellation
## 1: And Andromeda
## 2: Ant Antlia
## 3: Aps Apus
## 4: Aqr Aquarius
## 5: Aql Aquila
## 6: Ara Ara
Данные в файле с координатами содержатся в позиционном формате, поэтому загрузка данных не совсем прямолинейна. Описание:
1-5 visual magnitude of star
6 <blank>
7-14 RA (hours and decimals, J2000.0)*
15 <blank>
16-23 North polar distance (deg. and dec. J2000.0)*
24 <blank>
25-28 Bayer Greek letter, maybe with superscript
29 code for line weight*
30-32 constellation*
constellations = fread(CONSTELLATIONS, sep = "", header = FALSE, strip.white = FALSE) %>%
take_if(!startsWith(V1, "#"),
magnitude = as.numeric(substr(V1, 1, 5)),
ra = as.numeric(substr(V1, 7, 14)),
npd = as.numeric(substr(V1, 16, 23)),
const_abbr = substr(V1, 30, 32)
) %>%
dt_left_join(constellations_names, by = c(const_abbr = "abbr")) %>%
na.omit() # у нас несколько пропусков, удаляем их
head(constellations)
## const_abbr magnitude ra npd constellation
## 1: And 2.06 0.13981 60.9094 Andromeda
## 2: And 3.28 0.65547 59.1392 Andromeda
## 3: And 2.06 1.16219 54.3794 Andromeda
## 4: And 2.10 2.06500 47.6703 Andromeda
## 5: And 3.62 23.03203 47.6742 Andromeda
## 6: And 4.30 23.63561 46.7319 Andromeda
summary(constellations)
## const_abbr magnitude ra npd
## Length:969 Min. :-1.470 Min. : 0.0637 Min. : 0.7358
## Class :character 1st Qu.: 2.910 1st Qu.: 6.3827 1st Qu.: 60.9094
## Mode :character Median : 3.650 Median :12.6197 Median : 90.4928
## Mean : 3.467 Mean :12.4378 Mean : 92.2207
## 3rd Qu.: 4.200 3rd Qu.:18.1257 3rd Qu.:124.0742
## Max. : 6.720 Max. :23.9986 Max. :173.6678
## constellation
## Length:969
## Class :character
## Mode :character
##
##
##
Список всех созвездий:
dt_count(constellations, constellation, sort = TRUE)
## constellation n
## 1: Hercules 32
## 2: Orion 32
## 3: Centaurus 31
## 4: Ursa Major 29
## 5: Ophiuchus 28
## 6: Perseus 27
## 7: Gemini 25
## 8: Sagittarius 24
## 9: Andromeda 23
## 10: Aquarius 23
## 11: Hydra 22
## 12: Leo 21
## 13: Scorpius 21
## 14: Virgo 21
## 15: Carina 19
## 16: Draco 19
## 17: Aquila 18
## 18: Bootes 17
## 19: Pavo 17
## 20: Pisces 17
## 21: Cygnus 16
## 22: Pegasus 16
## 23: Serpens 16
## 24: Canis Major 15
## 25: Cetus 15
## 26: Lacerta 15
## 27: Lepus 15
## 28: Cepheus 14
## 29: Monoceros 14
## 30: Taurus 14
## 31: Auriga 13
## 32: Crater 13
## 33: Lupus 13
## 34: Puppis 13
## 35: Camelopardalis 10
## 36: Capricornus 10
## 37: Eridanus 10
## 38: Libra 10
## 39: Piscis Austrinus 10
## 40: Ara 9
## 41: Dorado 9
## 42: Grus 9
## 43: Lyra 9
## 44: Phoenix 9
## 45: Vela 9
## 46: Columba 8
## 47: Corona Borealis 8
## 48: Lynx 8
## 49: Ursa Minor 8
## 50: Corvus 7
## 51: Musca 7
## 52: Tucana 7
## 53: Volans 7
## 54: Chamaeleon 6
## 55: Cancer 6
## 56: Crux 6
## 57: Delphinus 6
## 58: Horologium 6
## 59: Indus 6
## 60: Leo Minor 6
## 61: Apus 5
## 62: Aries 5
## 63: Cassiopeia 5
## 64: Corona Australis 5
## 65: Hydrus 5
## 66: Norma 5
## 67: Reticulum 5
## 68: Scutum 5
## 69: Sagitta 5
## 70: Caelum 4
## 71: Coma Berenices 4
## 72: Octans 4
## 73: Pyxis 4
## 74: Sculptor 4
## 75: Sextans 4
## 76: Triangulum 4
## 77: Antlia 3
## 78: Circinus 3
## 79: Equuleus 3
## 80: Fornax 3
## 81: Pictor 3
## 82: Telescopium 3
## 83: Canis Minor 2
## 84: Vulpecula 2
## constellation n
# количество созвездий
n_const = uniqueN(constellations$constellation)
Всего у нас 84 созвездия. Это число нам понадобится, когда мы будем делать кластерный анализ. Нарисуем импровизированную карту звездного неба. Каждый цвет обозначает свое созвездие.
ggplot(constellations) +
geom_point(aes(x = ra, y = npd, alpha = magnitude + 2), color = "orange") +
geom_point(aes(x = ra, y = npd, color = constellation), size = 4, alpha = .3) +
geom_path(aes(x = ra, y = npd, group = constellation)) +
theme_minimal() +
theme(legend.position="none")
Так как кластерный анализ не знает реальных названий созвездий, то нам нужна функция, которая будет сопоставлять номер кластера с созвездием. Она должна делать это таким образом, чтобы максимизировать итоговую accuracy. Мы считаем, что сопоставление должно быть один к одному. Алгоритм такой:
Надо обратить внимание, что в таком подходе некоторые кластеры совсем не попадут в цель.
match_clusters = function(target, predicted){
# функция возвращает вектор с проставленными предсказанными созвездиями
# там, где созвездие предсказнно неверно, будет NA
# данный вектор должен максимизировать accuracy
dt = data.table(target, predicted)
res = dt %>%
take(n = .N, by = .(target, predicted)) %>%
sort_by(-n)
all_targets = unique(res$target)
used_clusters = c()
for(each_target in all_targets){
curr_target = query(res, target == each_target & !(predicted %in% used_clusters))
curr_pred = which.max(res$n[curr_target])[1]
curr_match = res$predicted[curr_target][curr_pred]
used_clusters = c(used_clusters, curr_match)
}
to_join = na.omit(data.table(predicted = used_clusters, matches = all_targets))
(anyDuplicated(to_join$predicted) || anyDuplicated(to_join$matches)) && stop("Что-то пошло не так")
dt_left_join(dt,
to_join,
by = "predicted"
) %>%
query(matches)
}
is_correct = function(target, matched){
# возвращает TRUE для правильно восстановленного target
# FALSE для прочих.
!is.na(matched) & matched == target
}
Делаем три кластерных анализа с разным типом дистанции.
set.seed(123)
constellations = constellations %>%
let(
random_baseline = sample(constellation), # будем сравнивать со случайным угадыванием
res_random_baseline = is_correct(constellation, random_baseline),
constant_baseline = "Hercules", # самое большое созвездие
res_constant_baseline = is_correct(constellation, constant_baseline),
# евклидово расстояние
k_euclid = kmeans(cbind(ra, npd), centers = n_const)$cluster,
const_euclid = match_clusters(constellation, k_euclid),
res_euclid = is_correct(constellation, const_euclid),
# k-medoids
k_medoid = pam(cbind(ra, npd), k = n_const, cluster.only=TRUE),
# k_medoid = fastkmed(dist(cbind(ra, npd)), ncluster = n_const)$cluster,
const_medoid = match_clusters(constellation, k_medoid),
res_medoid = is_correct(constellation, const_medoid),
# k-means с косинусом
k_cosine = norm.sim.ksc(cbind(ra, npd), k = n_const)$cluster,
const_cosine = match_clusters(constellation, k_cosine),
res_cosine = is_correct(constellation, const_cosine),
# все то же самое, но со стандартизацией
# евклидово расстояние
k_scaled_euclid = kmeans(scale(cbind(ra, npd)), centers = n_const)$cluster,
const_scaled_euclid = match_clusters(constellation, k_scaled_euclid),
res_scaled_euclid = is_correct(constellation, const_scaled_euclid),
# k-medoids
k_scaled_medoid = pam(scale(cbind(ra, npd)), k = n_const, cluster.only=TRUE),
# k_scaled_medoid = fastkmed(dist(cbind(ra, npd)), ncluster = n_const)$cluster,
const_scaled_medoid = match_clusters(constellation, k_scaled_medoid),
res_scaled_medoid = is_correct(constellation, const_scaled_medoid),
# k-means с косинусом
k_scaled_cosine = norm.sim.ksc(scale(cbind(ra, npd)), k = n_const)$cluster,
const_scaled_cosine = match_clusters(constellation, k_scaled_cosine),
res_scaled_cosine = is_correct(constellation, const_scaled_cosine)
)
Подводим итоги. Выведем таблицу с accuracy в процентах:
result = take_all(constellations, if(startsWith(.name, "res_")) round(mean(.x)*100,1))
knitr::kable(t(result), col.names = c( "Accuracy, %"))
| Accuracy, % | |
|---|---|
| res_random_baseline | 2.2 |
| res_constant_baseline | 3.3 |
| res_euclid | 39.8 |
| res_medoid | 40.2 |
| res_cosine | 6.4 |
| res_scaled_euclid | 57.2 |
| res_scaled_medoid | 60.8 |
| res_scaled_cosine | 24.4 |
Отрисуем картинку - черным закрашены звезды с правильно восстановленным названием созвездия:
ggplot(constellations) +
geom_point(aes(x = ra, y = npd, color = ifelse(res_scaled_medoid, "correct", "error")), size = 4, alpha = .5) +
scale_color_manual(values = c("correct" = "black", "error" = "orange")) +
geom_path(aes(x = ra, y = npd, group = constellation)) +
theme_minimal() +
theme(legend.position="none")
Наилучшую точность обеспечивает метод k-medoid по стандартизированным данным: 60.8 процентов. Практически такую же точность дает евклидово расстояние. Косинусная мера сходства сильно отстает. Однако все они значительно превосходят случайное угадывание.
Все это не очень помогает ответить на вопрос от booking.com. Можно использовать любой из трех методов кластеризации, они все дают результат, далекий от случайного. Но этот результат не идеально точный, поэтому возожно, что правильный ответ “Ничего из перечисленного”.
В дальнейших изысканиях можно попытаться получить угловые расстояния между звездами для наблюдателя с Земли и использовать их для кластеризации. Так же могут хорошо сработать методы кластеризации на графах. Однако все это пусть останется для будущих исследователей.
Github с исходным кодом и данными: Constellations