Экскурсия по R: часть 1 (неструктурированные данные)

HowTo Инструменты

Это прохождение предполагает, что у пользователя уже установлен R, и он(а) знает, как открывать в нем текстовый редактор и как работает консоль. Также речь пойдет о работе в Windows. Хотя принцип один и тот же, при работе на MacOS и Linux некоторые детали могут отличаться.

В качестве исходного материала взяты данные об убийствах, совершенных в американском городе Балтиморе (Мэрилэнд) в период с 2007 по 2012 гг.  Эти данные были основой одного из заданий в онлайн-курсе Computing for Data Analysis. Задание состояло в том, чтобы написать две функции. Одна из них должна была подсчитывать число убийств, совершенных тем или иным способом; вторая – число убийств в той или иной возрастной категории.
В этот раз попробуем посмотреть, как соотносятся способы убийства и половая принадлежность жертв.
Для начала несколько вводных слов о самих данных. Данные взяты с сайта газеты Baltimore Sun. Там есть целая страница с интерактивным приложением, которое показывает карту с указанием места убийства, расы, возраста и пола жертвы, способ убийства. Однако скачать эти данные с сайта отдельным файлом нельзя. Автор курса Roger Peng извлек эти данные для студентов вручную: просто скопировал их из кода страницы и сохранил в виде текстового документа.

source1

Таким образом, получился файл с неструктурированными данными:

source2

По итогам анализа получилась следующая картина. Из общего количества жертв (более 1200) подавляющее большинство – мужчины. При наиболее распространенное орудие убийства (80% жертв) в обоих случаях – огнестрельное оружие (shooting). Но опять же, среди жертв 95% – мужчины. Собственно, из всех категорий женщины преобладают только в одной – удушение (asphyxiation), где их доля составляет 64%.

Теперь разберем по шагам процесс работы.

Исходный текстовый файл находится здесь: https://spark-public.s3.amazonaws.com/compdata/data/Baltimore_homicides.zip
В таком большом массиве текста невооруженным глазом разобраться трудно. Попробуем посмотреть на него с помощью R.

Прежде всего, убедимся, что файл с интересующими нас данными находится в рабочей папке. Если нет, то файл надо перенести в рабочую папку или изменить папку в самом R. В принципе удобно, когда рабочие скрипты и файлы для R хранятся в одной и той же папке, чтобы не собирать потом необходимые детали по всему жесткому диску. Чтобы проверить, какая папка установлена по умолчанию в R, можно использовать команду getwd().
Установить нужную папку можно с помощью команды setwd(“адрес нужной папки на диске”). А также с помощью меню Session > Set Working Directory > Choose Working Directory в RStudio или File > Change dir… в родной оболочке R.

После того, как всё оказалось на своих местах, можно загрузить файл с данными в R с помощью команды:
data <- readLines(“homicides.txt”)
На первый взгляд, ничего не произошло. На самом деле, если программа не выдала никаких сообщений об ошибке, файл загружен, и рабочей среде R этим данным присвоено имя data. Выбор имени (переменной) совершенно произволен – вместо “data” можно было использовать практически любое другое слово.
Значок <- в R используется для присвоения значения переменной. Таким образом, мы теперь можем использовать созданную переменную data для работы с нашими данными.

Например, мы можем посмотреть, сколько в данных строк:
> length(data)
[1] 1250
Каждая строка соответствует одному из случаев убийств. Мы также можем посмотреть на отдельные строки, указав их индекс (в пределах, соответственно, от 1 до 1250):
> data[1]
[1] "39.311024, -76.674227, iconHomicideShooting, 'p2', '<dl><dt>Leon Nelson</dt><dd class="address">3400 Clifton Ave.<br />Baltimore, MD 21216</dd><dd>black male, 17 years old</dd><dd>Found on January 1, 2007</dd><dd>Victim died at Shock Trauma</dd><dd>Cause: shooting</dd></dl>'"

Попробуем разобраться, что здесь к чему. В самом начале – геоданные (39.311024, -76.674227), что, собственно, позволяет отразить это на карте. Чуть дальше – имя жертвы (Leon Nelson). Затем указан адрес, где было обнаружено тело. Затем – раса, пол и возраст (black male, 17 years old). Затем дата. Далее – фактическая диагностированная причина смерти. И наконец способ убийства (Cause: shooting) – в данном случае жертву убили выстрелом.
Посмотрим еще одну произвольную строку:
> data[1082]
[1] "39.29287700000, -76.67432000000, icon_homicide_shooting, 'p1116', '<dl><dt><a href="http://essentials.baltimoresun.com/micro_sun/homicides/victim/1116/charles-lassane">Charles Lassane</a></dt><dd class="address">500 Denison St<br />Baltimore, MD 21229</dd><dd>Race: Black<br />Gender: male<br />Age: 55 years old</dd><dd>Found on June 16, 2011</dd><dd>Victim died at Maryland Shock Trauma Center</dd><dd>Cause: Shooting</dd><dd class="popup-note"><p>One of six victims after gunman opens fire on porch</p></dd></dl>'"

Мы видим, что оформление данных уже немного иное. Например, присутствуют тэги, которых в первом случае не было. В самом конце строки есть примечание – этого в предыдущем случае тоже не было. Однако основные пункты те же: геоданные, адрес, имя, раса, пол, возраст, дата и т.д.

Чтобы извлечь из неструктурированных данных то, что нам нужно, будем использовать регулярные выражения (regular expressions). Регулярные выражения – это способ указать программе, какие именно слова или закономерности в тексте нужно искать. Для этого используются метасимволы – знаки, с помощью которых можно описывать и обобщать закономерности в тексте. Я здесь приведу только те из них, которые потребуются в нашем случае.
[Aa] указывает на то, что искомая буква может быть как строчной, так и прописной. Например, в ответ на запрос dog будут найдены только те сочетания “dog”, которые начинаются со строчной буквы. В свою очередь, по запросу “Dog” будут найдены только сочетания, начинающиеся с заглавной буквы. Запрос [Dd]og будет искать сочетания как Dog, так и dog. В свою очередь, [Dd][Oo][Gg] позволит найти самые разные комбинации: DoG, dOG, dog и т.д.
[^a] Значок ^ внутри квадратных скобок указывает на то, что указанный после него символ нужно исключить из поиска. Точнее, результатом поиска будут такие сочетания, где этого символа в указанной позиции нет. Например, [^k] [Dd]og найдет все случаи сочетаний dog или Dog, перед которыми есть пробел, за исключением тех, где перед пробелом есть символ k. Иными словами, если в тексте есть словосочетание “black dog”, то это случай будет исключен из результатов поиска.

Теперь попробуем найти в наших данных все случаи удушения. Всего способов убийства в этом наборе данных – 6:

  • удушение (asphyxiation)
  • удар тупым предметом (blunt force)
  • выстрел (shooting)
  • удар ножом (stabbing)
  • другое (other)
  • неизвестно (unknown)

Простейший способ убедиться в этом – посмотреть варианты поиска на сайте Baltimore Sun.

Для поиска в R будем использовать функцию grep(). Эта функция ищет соответствия и в качестве результата выдает индексы строк, в которых встречается указанное выражение.
asph <- grep("[Cc]ause: [Aa]sphyxiation", data)
В скобках указаны два аргумента: собственно регулярное выражение в кавычках и дальше, через запятую, данные, где нужно проводить поиск. Результат будет сохранен в переменной asph. Как мы видели, регистр начальных букв в данных может меняться, поэтому мы используем метасимвол []. Также мы используем для поиска не только слово asphyxiation, но всё выражение с cause целиком. Само по себе слово может встретиться отдельно в примечании и уже не в качестве причины. Поэтому надежнее задать более жесткие условия поиска.

Посмотрим, сколько получилось результатов:
> length(asph)
[1] 28

Посмотрим на какой-нибудь из результатов. Для наглядности сохраним все найденные результаты в отдельной переменной:
asph_data <- data[asph]
И посмотрим на первый из этих результатов:
> asph_data[1]
[1] "39.363925, -76.598772, iconHomicideAsphyxiation, 'p5', '<dl><dt>Thomas MacKenney</dt><dd class="address">5900 Northwood Drive<br />Baltimore, MD 21212</dd><dd>black male, 21 years old</dd><dd>Found on January 3, 2007</dd><dd>Victim died at scene</dd><dd>Cause: asphyxiation</dd></dl>'"

Теперь найдем среди этих результатов те, где жертва – женщина.
female <- grep("[Ff]emale", data[asph])
В случае с жертвами-мужчинами важно обратить внимание на то, что сочетание male входит в состав слова female. Чтобы сузить область поиска, добавим в выражение пробел перед словом:
male <- grep(" [Mm]ale", data[asph])
Посмотрим, сколько получилось результатов:
> length(female)
[1] 18
> length(male)
[1] 10

В сумме получается, как и следовало ожидать, 28.

На этом этапе у нас, собственно, уже готова главная часть рабочего механизма. Аналогичную операцию можно произвести со всеми остальными способами убийства.

Теперь нужно решить, что делать с этими результатами. Прежде всего, хотелось бы вместо неструктурированного текста иметь дело с упорядоченной таблицей. В этой таблице должно быть две переменные с интересующими нас категориальными (нечисловыми) данными: пол и способ убийства. Чтобы в дальнейшем работать с этими данными, таблица должна отражать все имеющиеся случаи, в которых содержатся такие параметры, как пол и способ убийства. Случаи, в которых нет информации хотя бы по одному из этих параметров, мы не рассматриваем. Иными словами, в нашей таблице должно быть два столбца, соответствующих каждому из параметров, и число строк, соответствующее числу релевантных случаев. То есть в столбце со способом убийства каждый из способов будет упомянут в отдельной строке столько раз, сколько он встречается в данных. И в соседнем столбце будет соответствующее указание на то, какого пола жертва. То есть:
...
asphyxiation female
asphyxiation female
asphyxiation female
asphyxiation female
asphyxiation female
asphyxiation male
asphyxiation male
asphyxiation male
asphyxiation male
...

Как это сделать технически?
В сущности, нас интересует именно количество случаев по каждому из параметров. Соответственно, наша задача в том, чтобы в каждом случае способу убийства было присвоено соответствующее значение в таблице. И чтобы внутри каждой группы по способу результатам было присвоено значение male или female.
Возьмем в качестве примера всю ту же группу случаев удушения. Код, позволяющий находить указания способа убийства и пола, у нас уже есть. Теперь каждому случаю удушения присвоим значение “asphyxiation”. Что бы не делать этого вручную, используем for-loop:
for (i in asph) {
i <- "asphyxiation"
}

В переводе на человеческий язык это означает: каждому элементу i в группе asph нужно присвоить значение “asphyxiation”. Но этого недостаточно. Чтобы с этим можно было работать, все эти случаи нужно собрать в однородную последовательность – в вектор. Для этого перед for-loop создадим пустой вектор:
cause <- character(0)
А в саму for-loop добавим механизм для наполнения этого пустого вектора. В итоге всё вместе взятое будет выглядеть так:
cause <- character(0)
for (i in asph) {
i <- "asphyxiation"
cause <- c(i, cause)
}

В результате у нас появился новый вектор cause, который полностью состоит из слова “asphyxiation”, повторяющегося столько раз, сколько в наборе данных представлено случаев удушения. Проверим:
> length(cause)
[1] 28

Всё верно, длина вектора (который будет одной из двух колонок в нашей упорядоченной таблице) соответствует числу найденных нами случаев.

Теперь проделаем то же самое с полом, но, конечно, только внутри той подгруппы данных, которую мы выделили по признаку asphyxiation:
genfem <- character(0)
for (i in female) {
i <- "female"
genfem <- c(i, genfem)
}
genmal <- character(0)
for (i in male) {
i <- "male"
genmal <- c(i, genmal)
}

Итак, мы получили еще два вектора – один состоящий из слов “female”, второй – “male”. Они станут частями второй колонки – пол (gender). Но для этого нам сначала придется объединить их в один вектор:
gentotal <- c(genfem, genmal)
Наша вторая колонка – пол – готова. Теперь она состоит из вектора, в котором есть слова и “female”, и “male”, причем их число соответствует числу случаев удушения мужчин и случаев удушения женщин, а в сумме они, как и следовало ожидать, соответствуют числу случаев удушения в наборе данных.
Остался последний шаг: полученные нами векторы нужно представить в виде таблицы – сохраним её в переменной output:
output <- data.frame(Cause = cause, Gender = gentotal)

Аналогичным образом можно представить в виде таблицы и все остальные случаи по соответствующим способам убийства. Для того, чтобы этим удобнее было пользоваться, сделаем из этого функцию:
gendercause<- function(cause) {
# здесь находится код для извлечения данных по всем шести
# причинам и создания таблиц для каждого случая
return(output)
}

Посмотрим, как функция работает:
> stab <- gendercause("stabbing")
> head(stab)
Cause Gender
1 stabbing female
2 stabbing female
3 stabbing female
4 stabbing female
5 stabbing female
6 stabbing female

На примере “stabbing” работает.
gendercause("other")
gendercause("unknown")
gendercause("blunt force")
gendercause("shooting")

Всё работает за исключением одного случая: обрабатывая запрос по “shooting” программа выдает ошибку:

shooting_error_edt

Длина векторов Cause и Gender не совпадает. В векторе Cause 1003 значения, а в векторе Gender – 1004. Таблица может быть создана только из векторов равной длины – отсюда и ошибка. Иными словами, в векторе Gender одно значение – лишнее. Чтобы его исключить, надо задать более жесткие параметры поиска. А чтобы это сделать, имеет смысл сначала установить, где именно встретился повтор, чтобы увидеть его контекст. Чтобы найти эту строку, сузим для начала область поиска. Мы, разумеется, будем искать только среди тех данных, где орудие убийства – огнестрельное оружие:
shoot <- grep("[Cc]ause: [Ss]hooting", data)
shd <- data[shoot]

Теперь ограничим область поиска еще сильнее, то есть по группам female и male внутри shd.
female <- grep("[Ff]emale", shd)
fdata <- shd[female]

В переменной fdata мы сохранили все строки, отвечающие двум условиям: в них встречается выражения "[Cc]ause: [Ss]hooting" и "[Ff]emale". Посмотрим, нет ли в этих данных также и выражения " [Mm]ale":
> male <- grep(" [Mm]ale", fdata)
> length(male)
[1] 1

Вот оно. Посмотрим на саму эту строку:
> fdata[male]
[1] "39.24700440000, -76.62878500000, icon_homicide_shooting, 'p956', '<dl><dt><a href="http://essentials.baltimoresun.com/micro_sun/homicides/victim/956/randol-bumcombe">Randol Bumcombe</a></dt><dd class="address">2700 Claflin Ct<br />Baltimore, MD 21225</dd><dd>Race: Black<br />Gender: female<br />Age: 24 years old</dd><dd>Found on October 5, 2010</dd><dd>Victim died at Maryland Shock Trauma Center</dd><dd>Cause: Shooting</dd><dd class="popup-note"><p>Double-shooting; other victim, an adult male, expected to survive.</p></dd></dl>'"

Действительно, в примечании к этому случаю упоминается an adult male – вторая жертва атаки.
Когда мы видим контекст, проще задать более точные параметры поиска. Теперь в группе “shooting” мы исключим сочетание t + пробел + male:
male <- grep("[^t] [Mm]ale", data[shoot])

Заменяем соответствующую строку в коде функции. Теперь ошибка устранена.

Последнее, что нам осталось сделать для окончательной подготовке данных к работе – это собрать их в единую сводную таблицу. Пока что наша функция создает отдельные таблицы по каждому способу. Можно, конечно, собрать её результаты и свести их в таблицу вручную. Однако можно также написать короткую функцию, которая это сделает автоматически.

getdataframe <- function () {
data <- readLines("homicides.txt")
causes <- c("unknown", "other", "stabbing", "shooting",
"blunt force", "asphyxiation")
df <- data.frame(Cause = character(),
Gender = character(),
stringsAsFactors=FALSE)
for (i in causes) {
cause <- i
result <- gendercause(cause)
df <- rbind(result, df)
}
return(df)
}

Эта функция сама подставляет все имеющиеся способы в функцию gendercause() и собирает результаты (то есть таблицы) в единую таблицу. Таким образом, данные подготовлены к работе.
Следующий этап работы – собственно, визуализация этих данных. Об этом пойдет речь во второй части.

Полностью код gendercause.R находится здесь, а getdataframe.R – здесь.