Строим карту популярности дней рождения с помощью Processing и VK API

Вступление


Несколько дней назад в блоге The Daily Viz была опубликована запись, которая привлекла внимание широкой общественности как пример простой и эффективной визуализации данных.

Визуализация представляла собой карту популярности дней рождения, реализованную как теплокарта (heatmap) в виде календаря. По вертикали располагались числа, по горизонтали — месяцы, и, глядя в эту незамысловатую таблицу, мы могли по насыщенности оттенка судить о том, насколько популярен тот или иной день в году с точки зрения деторождения.

Через какое-то время автор визуализации опубликовал в том же блоге второй пост, извинившись за то, что ввел сообщество в заблуждение, не прокомментировав должным образом исходные данные, использованные в работе над изображением. Проблема была в том, что исходный сет данных не содержал информации о реальном числе родившихся в тот или иной день людей. Информация была дана в другом виде — на каком месте (rank) находится тот или иной день в «рейтинге» популярности дней рождения.

То есть, разница между первой и второй позицией в рейтинге могла быть колоссальной (скажем, в два раза), но отличались бы они все равно только на один тон. Иными словами, визуализация не отражала реальных данных из-за того, что сет содержал лишь производные данные.

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

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

Итак, процесс работы над проектом имеет устойчивую структуру и состоит из трех этапов:
сбор данных > cортировка данных > визуализация данных

Будем следовать этой структуре.

1. Сбор данных


Данные будем извлекать из профилей пользователей социальной сети vk.com. К счастью для нас, некоторые методы ее API являются открытыми и не требуют авторизации приложения, что значительно упрощают задачу.

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

Писать программу мы будем внутри основной функции setup(). Функция draw() нам не понадобится, поскольку программа генерирует статичное изображение и не содержит анимации. Подробнее со структурой программы на Processing можно ознакомиться на сайте проекта. Там же есть описание всех встроенных функций и прекрасный справочник по синтаксису.

Кроме того, мы не будем писать программу, которая выполняет задачу от и до: собирает данные, обрабатывает их и создает визуализацию. Разделим «слона» на несколько модулей, чтобы было проще работать и тратить меньше времени на отладку и устранение ошибок. Т. е. сначала напишем программу, которая собирает данные, соберем с ее помощью данные. Затем отдельно напишем программу, которая на основе сохраненных собранных данных генерирует требуемое изображение.

Итак, пишем болванку-заготовку для программы.

void setup() { //наша основная функция
  
  
  exit(); //выходим из программы
}


Теперь разберемся, как работает VK API. Мы обращаемся к серверу по специальному URL, содержащему параметры нашего запроса:
http://api.vk.com/method/users.get.xml/uids={здесь список id интересующих нас пользователей через запятую}&fields={здесь список названий интересующих нас полей профиля пользователя}


Если мы напишем имя метода без .xml, то получим ответ от сервера в виде строки в формате JSON. Это один из вариантов, но в данном примере мы будем использовать XML. Предположим, мы хотим получить информацию из аккаунта Павла Дурова, основателя vkontakte. Наш адрес:
http://api.vk.com/method/users.get.xml?uids=1&fields=bdate


Id его профиля — 1, интересующее нас поле — день рождения — называется bdate.

Попробуем получить информацию об этом профиле. Используем встроенную функцию loadStrings(), которая в качестве параметра принимает строку с адресом интересующего нас файла, а возвращает содержимое файла в виде массива строк.

void setup() {
  String[] user = loadStrings("http://api.vk.com/method/users.get.xml?uids=1&fields=bdate"); //загружаем информацию
  println(user); //выводим содержимое массива (ответ сервера) в консоль
  
  exit(); //выходим из программы
}


После запуска программы в консоли появится наш ответ от сервера:

[0] "<?xml version="1.0" encoding="utf-8"?>"
[1] "<response list="true">"
[2] " <user>"
[3] "  <uid>1</uid>"
[4] "  <first_name>Павел</first_name>"
[5] "  <last_name>Дуров</last_name>"
[6] "  <bdate>10.10.1984</bdate>"
[7] " </user>"
[8] "</response>"


Числа в квадратных скобках означают номер записи (index) в массиве и не имеет отношения к содержимому массива. Также каждая строка заключена в кавычки. Собственно, то, что находится между кавычками, и есть наше содержимое. Нас интересует поле
<bdate>
(строка [6]). Оно содержит интересующую нас информацию — дату рождения пользователя №1 в понятном формате: 10 число 10 месяца (октября) 1984 года.

Мы договорились собрать 10 000 дат рождения. Что мы делаем? Перебираем id пользователей от 1 до нужного нам числа. Проблема заключается в том, что не все id имеют действующие профили и не все пользователи открывают свою дату рождения. Таким образом, нам нужно два счетчика: первый счетчик будет отсчитывать id пользователей по порядку, а второй будет считать, сколько дат мы действительно собрали, чтобы вовремя остановиться. По опыту, чтобы набрать 10 000 дат, нужно перебрать около 15 000 аккаунтов.

Пишем цикл:

void setup() {
  
  int count = 0; //счетчик успешных обращений к серверу
  
  for (int i = 1; count <= 10000; i++) { //перебираем id, не останавливаемся, пока счетчик успешных обращений меньше или равен 10000
    String[] user = loadStrings("http://api.vk.com/method/users.get.xml?uids=" + str(i) + "&fields=bdate"); //загружаем информацию, подставляя счетчик на место id
    
    for (int j = 0; j < user.length; j++) { //перебираем все строки ответа
      if (user[j].indexOf("<bdate>") != -1) { //если строка содержит интересующее нас поле
        println(i + "\t" + count + "\t" + user[j]); //выводим данные в консоль
        count++; //увеличиваем счетчик успеха на 1
      }
    }
  }
  
  exit(); //выходим из программы
}


Заметьте, что значение счетчика i, когда мы подставляем его в строку, «обернуто» функцией str(). Она нужна для перевода типа данных из числа в строку. Строго говоря, программа поймет что мы от нее хотим и без этой операции, но лучше сразу взять за привычку контролировать такие вещи, как перевод данных из одного типа в другой (в некоторых ситуациях автоматический перевод не работает).

При переборе строк ответа мы используем метод indexOf(), который возвращает местоположение указанной в параметре строки в строке, к которой применяется метод. Если в нашей строке строки-параметра нет, метод возвращает значение -1, чем мы и пользуемся для проверки того, является ли текущая строка нужной нам.

Когда мы выводим интересующие нас данные в консоль, добавим дополнительную информацию: состояние счетчиков, чтобы следить за прогрессом. Значения переменных в скобках функции вывода println() разделены строкой "\t", которая означает символ табуляции.

Если сейчас запустить программу, мы увидим, что значения счетчиков быстро расходятся. В моем случае после перебора 55 id была собрана только 31 дата.

Итак, кажется, всё работает нормально, осталось только заставить программу записывать данные в файл по мере поступления. Для этого создадим объект класса PrintWriter. Он объявляется как обычная переменная, и ему как правило сразу присваивается значение функции createWriter(путь к файлу):
PrintWriter p = createWriter("data/bdates.txt");


В данном случае мы именуем объект «p», привязывая к нему файл по адресу «папка-программы/data/bdates.txt», что позволит нам записывать в этот файл то, что нам нужно. Как мы это делаем? К нашему объекту можно применить метод println(), который работает так же, как одноименная функция, но выводит данные не в консоль, а в указанный файл. Выглядит это так:
p.println(данные);


После того, как мы поработали с нашим файлом, нужно корректно завершить работу с ним, иначе информация в него не запишется. Делается это с помощью такой записи:
p.flush();
p.close();


Эти две функции всегда используются для корректного завершения работы с файлом вместе. Наша программа:

void setup() {
  PrintWriter p = createWriter("data/bdates.txt"); //объект для вывода данных в файл
  
  int count = 0; //счетчик успешных обращений к серверу
  
  for (int i = 1; count <= 10000; i++) { //перебираем id, не останавливаемся, пока счетчик успешных обращений меньше или равен 10000
    String[] user = loadStrings("http://api.vk.com/method/users.get.xml?uids=" + str(i) + "&fields=bdate"); //загружаем информацию, подставляя счётчик на место id
    
    for (int j = 0; j < user.length; j++) { //перебираем все строки ответа
      if (user[j].indexOf("<bdate>") != -1) { //если строка содержит интересующее нас поле
        p.println(user[j]); //записываем результат в файл
        println(count); //выводим счетчик в консоль для наблюдения за прогрессом
        count++; //увеличиваем счетчик успеха на 1
      }
    }
  }
  
  p.flush();
  p.close(); //завершаем работу с файлом
  
  exit(); //выходим из программы
}


При сборе данных мы отказались от вывода значения строки и счетчика id в консоль: вывод слишком большого количества данных в консоль иногда может тормозить работу программы, поэтому лучше ограничиться только необходимым — счетчиком собранных дат.

Казалось бы, что еще нужно? Можно запускать программу! И да, и нет. При опросе удаленного сервера всегда нужно иметь в виду, что иногда сервер не отвечает. Представим, что мы отправили запрос к серверу, ждем ответа и не получаем его. Через какое-то время программа решит, что сервер «лежит» и просто продолжит выполняться дальше. Что будет? Ведь мы не получили данные о пользователе, наш массив пустой. Если программа к нему обратится, программа выдаст в консоль сообщение об ошибке и остановится. Этого может и не произойти, но может и произойти, и тогда придется снова запускать программу, ждать и молиться о том, чтобы сервер ответил на все 15 000 наших запросов.

Чтобы не полагаться на слепую судьбу, была изобретена обрабока ошибок. Ошибки обрабатываются с помощью вот такой записи:

try {
  //здесь код, который может вызвать сбой
} catch (здесь тип ошибки) {
  //здесь код, который выполняется в случае, если ошибка произошла
}


Программа с обработкой ошибок:

void setup() {
  PrintWriter p = createWriter("data/bdates.txt"); //объект для вывода данных в файл
  
  int count = 0; //счетчик успешных обращений к серверу
  
  for (int i = 1; count <= 10000; i++) { //перебираем id, не останавливаемся, пока счетчик успешных обращений меньше или равен 10000
    String[] user = loadStrings("http://api.vk.com/method/users.get.xml?uids=" + str(i) + "&fields=bdate"); //загружаем информацию, подставляя счётчик на место id
    
    try {
      for (int j = 0; j < user.length; j++) { //перебираем все строки ответа
        if (user[j].indexOf("<bdate>") != -1) { //если строка содержит интересующее нас поле
          p.println(user[j]); //записываем результат в файл
          println(count); //выводим счетчик в консоль для наблюдения за прогрессом
          count++; //увеличиваем счетчик успеха на 1
        }
      }
    } catch (Exception e) {}
  }
  
  p.flush();
  p.close(); //завершаем работу с файлом
  
  exit(); //выходим из программы
}


Теперь, если возникает ошибка при обращении к массиву (если массив пустой), выполнится код… никакого кода не выполнится, программа выведет сообщение об ошибке, но не остановится. Мы просто игнорируем ошибку и идем дальше — всего-то придется запросить информацию еще одного пользователя. Тип ошибки указан Exception, это значит, что мы «ловим» любые ошибки, которые возникнут. Запись e после типа ошибки требуется, потому что программе нужна какая-то переменная, в которую можно записать информацию об ошибке. Мы можем обращаться к этой переменной при обработке ошибок, однако в данном случае это не нужно.

2. Сортировка данных


Через какое-то время (обычно не больше получаса) после запуска программы она завершится и мы увидим в консоли заветное число 10 000. Это значит, что данные собраны и можно начинать сортировку. Откроем файл в текстовом редакторе и посмотрим на результат наших трудов:

Что не так? Ага, мы совсем забыли, что записывали в файл данные вместе с XML-тегами. Не беда! В любом текстовом редакторе есть функция автозамена, с помощью которой можно почистить наш файл от лишней информации. Строго говоря, мы могли бы программно «отловить» лишнее уже на этапе сбора данных, но в принципе, для простоты и экономии времени не зазорно воспользоваться любым доступным инструментом.



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

3. Визуализация данных


А теперь займемся отрисовкой. Сначала нам нужно открыть файл и посчитать, сколько же пользователей родилось в каждый отдельный день. Для открытия файла используем старую знакомую функцию loadStrings(). Для того, чтобы хранить количество пользователей, родившихся в определенный день, используем двухмерный массив натуральный чисел:
int[][] table = new int[12][31]


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

Как будет работать наша программа? Мы должны взять дату, определить, какой в ней содержится день и месяц, и увеличить соответствующую ячейку массива на единицу.

Для того, чтобы разбить строку на числа дня, месяца и года, мы будем использовать метод split(). Он возвращает массив строк, а принимает в качестве аргумента строку-разделитель: инструкция
String[] s = "00010010".split("1");

присвоит массиву s значение
[0] "000"
[1] "00"
[2] "0"


Что это означает для нашей практики? Мы берем строку массива и делим ее с помощью символа точки в качестве разделителя. Есть одна техническая проблема: символ точки зарезервирован в качестве обозначения любого символа. Поэтому вместо "." в качестве аргумента мы передаем "\\." — такая запись обозначает нужный нам символ точки. Получается так:

void setup() {
  String[] file = loadStrings("data/bdates.txt"); //загружаем файл с данными
  int[][] table = new int[12][31];
  
  for (int i = 0; i < file.length; i++) { //перебираем все строки файла
    String[] date = file[i].split("\\."); //переводим строку в массив, содержащий числа даты
    
    
  }
  
  exit(); //выходим из программы
}


Теперь в ячейке date[0] содержится строка с номером дня в месяце, а в date[1] — номер месяца. Мы должны увеличить соответствующую ячейку массива table на единицу:
table[int(table[1])-1][int(table[0])-1]++;


Указывая адрес ячейки соответствующий дате, мы переводим строку в число с помощью функции int(), а также отнимаем единицу. Зачем отнимать единицу? Затем, что отсчет ячеек массива начинается с нуля. Мы указали длину 12, это значит, что ячейки массива имеют нумерацию от 0 до 11. В отличие от месяцев, которые нумеруются от 1 до 12. Об этом несоответствии необходимо помнить.

Правильно? Правильно, да не совсем. Если сейчас запустить программу, она выдаст ошибку. Дело в том, что наш сет данных не идеален. По какой-то неведомой причине у некоторых пользователей в поле даты рождения стоят какие-то непотребные числа вроде 666.666 или 32.13.888888888. Иногда можно даже встретить пользователя, который родился, к примеру, минус пятого декабря. Чтобы их отсортировать, нужно отбросить значения месяцев больше 12 и значения дней больше 31, а также все значения меньше или равные нулю:

if ((int(date[1]) <= 12) && (int(date[1]) > 0) && (int(date[0]) <= 31) && (int(date[0]) > 0)) { //если с числом все в порядке
  table[int(date[1])-1][int(date[0])-1]++; //увеличиваем ячейку таблицы на 1
}


Программа целиком:

void setup() {
  String[] file = loadStrings("data/bdates.txt"); //загружаем файл с данными
  int[][] table = new int[12][31];
  
  for (int i = 0; i < file.length; i++) { //перебираем все строки файла
    String[] date = file[i].split("\\."); //переводим строку в массив, содержащий числа даты
    
    if ((int(date[1]) <= 12) && (int(date[1]) > 0) && (int(date[0]) <= 31) && (int(date[0]) > 0)) { //если с числом все в порядке
      table[int(date[1])-1][int(date[0])-1]++; //увеличиваем ячейку таблицы на 1
    }
  }
  
  exit(); //выходим из программы
}


Теперь, когда данные наконец собраны и хранятся в памяти программы, можно наконец-то приступить к творчеству — рисованию. Сначала определимся с цветом, которым мы будем рисовать: я взял фирменный синий цвет VK: RGB 54, 99, 142. Объявим переменную-цвет, чтобы не писать каждый раз три заветных числа:
color c = color(54, 99, 142);


Также нам нужно (по традиции, в самом начале программы) решить, какой ширины и высоты будет наше изображение. Для этого напишем функцию:
size(ширина, высота);


Какая у нас будет ширина и высота? Предположим, каждая ячейка теплокарты будет шириной 40 пикселей плюс один пиксель для отступа между ячейками. Месяцы откладываем по ширине. Не забываем про отступ от край (10 пикселей). Получается 20+41*12. Если не хочется считать в уме или открывать приложение-калькулятор, можно просто написать это выражение как аргумент функции println(20+41*12); и получить ответ — 512. Это ширина изображения. С учетом высоты ячейки в 20 пикселей и такого же отступа от края, получаем:
size(512, 671);


Теперь временно уберем команду exit(); в конце программы, чтобы мы не выходили из программы после завершения, и запустим выполнение кода:

void setup() {
  size(512, 671); //устанавливаем размер
  background(255); //цвет фона - белый
  
  String[] file = loadStrings("data/bdates.txt"); //загружаем файл с данными
  int[][] table = new int[12][31];
  
  for (int i = 0; i < file.length; i++) { //перебираем все строки файла
    String[] date = file[i].split("\\."); //переводим строку в массив, содержащий числа даты
    
    if ((int(date[1]) <= 12) && (int(date[1]) > 0) && (int(date[0]) <= 31) && (int(date[0]) > 0)) { //если с числом все в порядке
      table[int(date[1])-1][int(date[0])-1]++; //увеличиваем ячейку таблицы на 1
    }
  }
  
  color c = color(54, 99, 142); //цвет
}


После указания размера кадра я добавил команду установить белый фон: если мы указываем цвет одним числом, то он распознается как оттенки серого от 0 (черный) до 255 (белый). При запуске программы должно открыться окно с белым фоном нужного нам размера.

Начнем, наконец, рисовать. Как мы рисуем? Пробегаемся по массиву table — по каждой строке (месяц) и в каждой строке (день этого месяца) по ячейкам. Рисуем в нужном месте и нужным цветом прямоугольник 40 на 20. Как вычисляется позиция X? 10(отступ) + 41(ширина+зазор между) * i(счетчик месяцев). Позиция Y? 10(отступ) + 21(высота+зазор между) * j(счетчик дней). Прямоугольник рисуется функцией rect(x, y, ширина, высота);

rect(10+41*i, 10+21*j, 40, 20);


Программа:

void setup() {
  size(512, 671); //устанавливаем размер
  background(255); //цвет фона - белый
  
  String[] file = loadStrings("data/bdates.txt"); //загружаем файл с данными
  int[][] table = new int[12][31];
  
  for (int i = 0; i < file.length; i++) { //перебираем все строки файла
    String[] date = file[i].split("\\."); //переводим строку в массив, содержащий числа даты
    
    if ((int(date[1]) <= 12) && (int(date[1]) > 0) && (int(date[0]) <= 31) && (int(date[0]) > 0)) { //если с числом все в порядке
      table[int(date[1])-1][int(date[0])-1]++; //увеличиваем ячейку таблицы на 1
    }
  }
  
  color c = color(54, 99, 142); //цвет
  
  for (int i = 0; i < table.length; i++) { //пробегаемся по месяцам
    for (int j = 0; j < table[i].length; j++) { //пробегаемся по дням
      rect(10+41*i, 10+21*j, 40, 20); //рисуем прямоугольник в нужной позиции
    }
  }
}


Если запустить этот код, мы получим поле, странным образом расчерченное прямоугольниками с обводкой. Сначала уберем обводку, добавив перед рисованием команду noStroke();. Теперь установим наш цвет в качестве заливки: fill( c );

Прекрасно. Теперь площадь замощена красивыми синими плиточками с белыми промежутками. Дальше нам нужно каким-то образом закодировать значения таблицы в цвет заливки. Сделаем это с помощью прозрачности. Прозрачность цвета принимает значения от 0 до 255. Запись fill(c, 10); даст едва заметный синеватый оттенок, а запись fill(c, 240); даст почти что полностью насыщенный синий цвет. Итак, диапазон прозрачностей — 0..255. Диапазон значений в нашем массиве гораздо больше (или меньше). Предположим, мы знаем максимальное значение в массиве. Минимальным, понятное дело, будет ноль. Нам нужно как-то вписать значение из массива в диапазон 0..255, как бы уменьшив (увеличив) масштаб. Для этого существует функция map(значение, начало исходного диапазона, конец исходного диапазона, начало нового диапазона, конец нового диапазона):

map(table[i][j], 0, 1000, 0, 255);


Здесь мы сделали предположение, что максимальное значение массива — 1000. Тогда при значении table[i][j] в 1000 функция вернет 255, а при значении 0 — вернет ноль.

Как же рассчитать минимальное и максимальное значение двухмерного массива? Для одномерного массива существуют функции соответственно min() и max(). Используем их. Пробежимся циклом по «месяцам» и сравним минимальное и максимальное значение каждого «месяца» (который воспринимается средой как одномерный массив) с переменными, хранящими текущее минимальное или максимальное значение в массиве. И не забудем еще одну важную вещь: иногда в сете данных встречались некорректные даты, т.е. кто-то мог указать дату рождения 31 ноября или 30 февраля. Чтобы этот факт нам не мешал, установим значение всех несуществующих дат на ноль.

table[1][29] = 0; //30 февраля
  table[1][30] = 0; //31 февраля
  table[3][30] = 0; //31 апреля
  table[5][30] = 0; //31 июня
  table[8][30] = 0; //31 сентября
  table[10][30] = 0; //31 ноября
  
  int mi = table[0][0]; //минимальное значение
  int ma = table[0][0]; //максимальное значение
  
  for (int i = 0; i < table.length; i++) {
    if ((min(table[i]) < mi) && (min(table[i]) > 0)) { //если минимальное значение этой строки меньше текущего минимума и больше нуля
      mi = min(table[i]); //сделать это значение минимумом
    }
    if (max(table[i]) > ma) { //если максимальное значение этой строки больше текущего максимума
      ma = max(table[i]); //сделать это значение максимумом
    }
  }
  
  println(mi + " " + ma); //выводим значения


У меня значения получились 14 и 47. В принципе, это не важно, потому что мы можем использовать значения переменных. Теперь нам надо при каждом обращении к ячейке таблицы, т.е. перед рисованием каждого прямоугольника, установить свою заливку:

void setup() {
  size(512, 671); //устанавливаем размер
  background(255); //цвет фона - белый
  
  String[] file = loadStrings("data/bdates.txt"); //загружаем файл с данными
  int[][] table = new int[12][31];
  
  for (int i = 0; i < file.length; i++) { //перебираем все строки файла
    String[] date = file[i].split("\\."); //переводим строку в массив, содержащий числа даты
    
    if ((int(date[1]) <= 12) && (int(date[1]) > 0) && (int(date[0]) <= 31) && (int(date[0]) > 0)) { //если с числом все в порядке
      table[int(date[1])-1][int(date[0])-1]++; //увеличиваем ячейку таблицы на 1
    }
  }
  
  table[1][29] = 0; //30 февраля
  table[1][30] = 0; //31 февраля
  table[3][30] = 0; //31 апреля
  table[5][30] = 0; //31 июня
  table[8][30] = 0; //31 сентября
  table[10][30] = 0; //31 ноября
  
  int mi = table[0][0]; //минимальное значение
  int ma = table[0][0]; //максимальное значение
  
  for (int i = 0; i < table.length; i++) {
    if ((min(table[i]) < mi) && (min(table[i]) > 0)) { //если минимальное значение этой строки меньше текущего минимума и больше нуля
      mi = min(table[i]); //сделать это значение минимумом
    }
    if (max(table[i]) > ma) { //если максимальное значение этой строки больше текущего максимума
      ma = max(table[i]); //сделать это значение максимумом
    }
  }
  
  color c = color(54, 99, 142);
  
  noStroke();
  for (int i = 0; i < table.length; i++) { //пробегаемся по месяцам
    for (int j = 0; j < table[i].length; j++) { //пробегаемся по дням
      fill(c, map(table[i][j], 0, ma, 0, 255)); //считаем заливку
      rect(10+41*i, 10+21*j, 40, 20); //рисуем прямоугольник в нужной позиции
    }
  }
}


Что мы видим после запуска программы? Плиточки стали разного цвета, в зависимости от количества родившихся в тот или иной день. Также мы видим, что 29 февраля имеет довольно отчетливый цвет. Очевидно, что количество родившихся в этот день минимально, а это значит, что мы теряем большую часть диапазона цветов, доступного для отображения (значения начинаются с 14, а у нас минимум стоит на 0 — это значит, мы не используем значения прозрачности навскидку примерно от 0 до 85. Непорядок. Поставим минимальным значением в функции map() не ноль, а 12, чтобы плиточка 29 февраля была едва заметна. Из-за того, что наш минимум теперь составляет 12, а не ноль, прозрачность тех плиточек, которые имеют значение 0, будет отрицательной. А поскольку при отрицательных значениях прозрачность откатывается циклически (-5 — это все равно что 250!), получится, что несуществующие дни будут не белыми, а темными. Добавим условие, при котором «нулевые» дни вообще не рисуются:

void setup() {
  size(512, 671); //устанавливаем размер
  background(255); //цвет фона - белый
  
  String[] file = loadStrings("data/bdates.txt"); //загружаем файл с данными
  int[][] table = new int[12][31];
  
  for (int i = 0; i < file.length; i++) { //перебираем все строки файла
    String[] date = file[i].split("\\."); //переводим строку в массив, содержащий числа даты
    
    if ((int(date[1]) <= 12) && (int(date[1]) > 0) && (int(date[0]) <= 31) && (int(date[0]) > 0)) { //если с числом все в порядке
      table[int(date[1])-1][int(date[0])-1]++; //увеличиваем ячейку таблицы на 1
    }
  }
  
  table[1][29] = 0; //30 февраля
  table[1][30] = 0; //31 февраля
  table[3][30] = 0; //31 апреля
  table[5][30] = 0; //31 июня
  table[8][30] = 0; //31 сентября
  table[10][30] = 0; //31 ноября
  
  int mi = table[0][0]; //минимальное значение
  int ma = table[0][0]; //максимальное значение
  
  for (int i = 0; i < table.length; i++) {
    if ((min(table[i]) < mi) && (min(table[i]) > 0)) { //если минимальное значение этой строки меньше текущего минимума и больше нуля
      mi = min(table[i]); //сделать это значение минимумом
    }
    if (max(table[i]) > ma) { //если максимальное значение этой строки больше текущего максимума
      ma = max(table[i]); //сделать это значение максимумом
    }
  }
  
  color c = color(54, 99, 142);
  
  noStroke();
  for (int i = 0; i < table.length; i++) { //пробегаемся по месяцам
    for (int j = 0; j < table[i].length; j++) { //пробегаемся по дням
      if (table[i][j] > 0) {
        fill(c, map(table[i][j], 12, ma, 0, 255)); //считаем заливку
        rect(10+41*i, 10+21*j, 40, 20); //рисуем прямоугольник в нужной позиции
      }
    }
  }
}




Но что мы видим? Среди окружающих дней как-то особенно выделяется 1 января. Та же тенденция сохраняется на гораздо больших числах пользователей. Когда я собирал данные по 300 000 аккаунтов, 1 января точно так же сияло глубоким синим, а остальные цвета были бледными. Очевидно, такое явление связано с действиями пользователей, которые, не желая публиковать свой реальный день рождения, выбирают первое число в списке. Отделить действительно родившихся в Новый год от жалких симулянтов не представляется возможным. Чтобы выровнять сет, просто удалим оттуда данные, присоив ячейке table[0][0] значение ноль. Чтобы сохранить картинку, используем команду saveFrame(«frame.jpg»); в самом конце программы. У нас появится соответствующий файл в папке с программой.

Код программы полностью:

void setup() {
  size(512, 671); //устанавливаем размер
  background(255); //цвет фона - белый
  
  String[] file = loadStrings("data/bdates.txt"); //загружаем файл с данными
  int[][] table = new int[12][31];
  
  for (int i = 0; i < file.length; i++) { //перебираем все строки файла
    String[] date = file[i].split("\\."); //переводим строку в массив, содержащий числа даты
    
    if ((int(date[1]) <= 12) && (int(date[1]) > 0) && (int(date[0]) <= 31) && (int(date[0]) > 0)) { //если с числом все в порядке
      table[int(date[1])-1][int(date[0])-1]++; //увеличиваем ячейку таблицы на 1
    }
  }
  
  table[0][0] = 0; //1 января
  table[1][29] = 0; //30 февраля
  table[1][30] = 0; //31 февраля
  table[3][30] = 0; //31 апреля
  table[5][30] = 0; //31 июня
  table[8][30] = 0; //31 сентября
  table[10][30] = 0; //31 ноября
  
  int mi = table[0][0]; //минимальное значение
  int ma = table[0][0]; //максимальное значение
  
  for (int i = 0; i < table.length; i++) {
    if ((min(table[i]) < mi) && (min(table[i]) > 0)) { //если минимальное значение этой строки меньше текущего минимума и больше нуля
      mi = min(table[i]); //сделать это значение минимумом
    }
    if (max(table[i]) > ma) { //если максимальное значение этой строки больше текущего максимума
      ma = max(table[i]); //сделать это значение максимумом
    }
  }
  
  color c = color(54, 99, 142);
  
  noStroke();
  for (int i = 0; i < table.length; i++) { //пробегаемся по месяцам
    for (int j = 0; j < table[i].length; j++) { //пробегаемся по дням
      if (table[i][j] > 0) {
        fill(c, map(table[i][j], 12, ma, 0, 255)); //считаем заливку
        rect(10+41*i, 10+21*j, 40, 20); //рисуем прямоугольник в нужной позиции
      }
    }
  }
  
  saveFrame("frame.jpg"); //сохраняемся
}




Готово! Из получившейся картинки пока что не особенно понятны, как сейчас говорят, тренды, потому что мы собрали слишком мало данных. Вот картинка для 300 000 аккаунтов (нет, я не ждал для сбора данных 100 лет, а использовал асинхронные запросы к серверу — может быть, я когда-нибудь напишу о реализации их в Processing), на которой ясно видна тенденция (хотя и не очень яркая):



А анализ полученной визуализации ложится на ваши плечи! ;]
Поделиться публикацией
Похожие публикации
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 22
  • +4
    Спасибо за труд.

    Одно замечание — по-моему не верно отображать линейные данные в виде таблицы. Получается, что между например 31-м января и 1-м февраля лежит целая таблица, а разница всего 1 день. Корректнее было бы отображать данные в виде графика или расположить дни по порядку, относительно целого года, это нагляднее.
    • +2
      Пожалуйста.

      Что касается выбора отображения, то тут было два фактора. Во-первых, задача была сделать так же, как в популярном блоге, только на основе самостоятельно собранных верных данных.

      Во-вторых, не нужно забывать, что визуализация строится не только для отображения данных, но и для помощи в анализе. В данном случае нам может понадобиться сравнить данные не только по месяцам (по вертикали).

      По месяцам мы видим, что небольшой подъем наблюдается с апреля по август, а последние четыре месяца относительно светлые.

      Но если смотреть по горизонтали, дням, то мы тоже можем увидеть тенденции. Они опять же связаны не с рождаемостью, а с поведением пользователей соцсети: 1 апреля, июня и августа (не говоря уже о 1 января) «родилось» больше всего пользователей. Кроме того, некоторые «пики» рождаемости приходятся на «красивые» числа, кратные пяти.

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

      На обычной гистограмме
      image
      мы бы этого никогда не разглядели.
      • 0
        Есть ощущение, что если мы исследуем не психологию пользователей, которые выставляют случайные даты рождения в своем профиле, а именно распределение ДР в году, то гистограмма с недельной детализацией была бы очень в тему.

        Дневная гистограмма выглядит зашумленной.
    • 0
      1 апреля
      • –1
        Почему даже 29 февраля больше Дней Рождений, чем у 1 января? Конечно, у некоторых после 31 декабря идет сразу 2 января, но 1 января всё же существует, это не вымысел.
        • +2
          В статье написано, почему нас пришлось удалить данные по 1 января:

          «Но что мы видим? Среди окружающих дней как-то особенно выделяется 1 января. Та же тенденция сохраняется на гораздо больших числах пользователей. Когда я собирал данные по 300 000 аккаунтов, 1 января точно так же сияло глубоким синим, а остальные цвета были бледными. Очевидно, такое явление связано с действиями пользователей, которые, не желая публиковать свой реальный день рождения, выбирают первое число в списке. Отделить действительно родившихся в Новый год от жалких симулянтов не представляется возможным.»
          • 0
            Извиняюсь, сначала графики смотрел, а потом читал.
            Поставим минимальным значением в функции map() не ноль, а 12, чтобы плиточка 29 февраля была едва заметна. Из-за того, что наш минимум теперь составляет 12, а не ноль, прозрачность тех плиточек, которые имеют значение 0, будет отрицательной. А поскольку при отрицательных значениях прозрачность откатывается циклически (-5 — это все равно что 250!), получится, что несуществующие дни будут не белыми, а темными. Добавим условие, при котором «нулевые» дни вообще не рисуются
            А не прощё было умножить 29 февраля на 4?

            А 1 января, раз там значение в два раза больше от положенного поставить 300000/365, а не ноль. И с 1 апреля тоже так же сделать.
            • +1
              А не прощё было умножить 29 февраля на 4?

              А 1 января, раз там значение в два раза больше от положенного поставить 300000/365, а не ноль. И с 1 апреля тоже так же сделать.

              Нет. Это фальсификация сета данных, нельзя так делать. Можно исключить часть данных и оговорить это в комментарии к графику, но подделывать данные — ни в коем случае, иначе весь смысл анализа теряется.
        • +1
          А вот я действительно родился 1-го января и помню на каком-то из сервисов скрипт даже ругался на то что я ничего из списка не выбирал, а оставил значения по умолчанию…
          • 0
            Больше всего людей родилось 1 апреля, в день дурака, я так понимаю…
            • 0
              Или хотят чтобы мы так думали.
              • 0
                Сами вы такой :)
                Это «День Юмора» — как называют его у нас в Одессе
                И я родился 1 апреля
              • 0
                Интегральный график по месяцам нарисовали бы что ли, тенденция явно прослеживается. И по-моему стоило бы добавить к диаграмме еще одну, сдвинув все значения на 280 дней.
                • 0
                  Никогда не слышал про интегральный график — это как?

                  >> И по-моему стоило бы добавить к диаграмме еще одну, сдвинув все значения на 280 дней.
                  Делал, здесь не стал описывать: пришлось бы объяснять, как сдвинуть все значения на 280 дней — и так портянка получилась.
                  • 0
                    Упс, не туда комментарий улетел.
                • 0
                  >Никогда не слышал про интегральный график — это как?

                  Под этим понятием я имел в виду график просуммированного по месяцам количества дней рождений.
                  • 0
                    image

                    Да, тенденция налицо, хотя гораздо интереснее смотреть по дням — найти «свой» день рождения.
                  • 0
                    а теперь провести анализ, рожденные в какие дни люди имеют статус за мужем или в активном поиске.
                    • 0
                          for (int j = 0; j < user.length; j++) { //перебираем все строки ответа
                            if (user[j].indexOf("<bdate>") != -1) { //если строка содержит интересующее нас поле

                      Что не так? Ага, мы совсем забыли, что записывали в файл данные вместе с XML-тегами. Не беда! В любом текстовом редакторе есть функция автозамена, с помощью которой можно почистить наш файл от лишней информации.

                      Крайне странно, что автор так и не научился работать с XML парсером, не говоря уже об XPath.
                      • 0
                        Автор старался насколько это возможно ужать описание программы и работать со стандартными средствами Processing. Кроме того, гораздо приятнее сначала самому пощупать XML и понять, как оно работает, чем пользоваться чужим парсером (что не возбраняется для работы над реальными проектами, где важны скорость и удобство).
                      • 0
                        Попробовал запустить код который относится к визуализации данных и он почему-то ничего не отрисовывает.
                        Выводит значения переменных mi и ma как: 0 0.

                         Подскажите, пожалуйста, в чем может быть проблема?

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.