Data Science на JavaScript без Python

Kate

Administrator
Команда форума
Мы уже писали о том, как запустить Python в браузере, а сегодня к старту флагманского курса по Data Science расскажем, как привычные для Python задачи решаются на JavaScript. Если вы знакомы только с JS и хотите попробовать Data Science, не покидая зону комфорта, (или, наоборот, хотите познакомиться с JS), просто хочется необычных экспериментов или нужно интегрировать небольшую управляемую визуализацию о статистике на сайт, читайте подробности под катом.

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


Начальные сведения о JavaScript
Язык программирования JavaScript (далее — JS) считается всеобщим языком сети Интернет, так как его поддерживают все основные веб-браузеры. Другие языки, запускаемые в браузерах, компилируются (или транслируются) в код на JavaScript. Иногда в тонкостях программирования на JS разобраться бывает довольно сложно, но мне этот язык всё равно нравится: преимуществ у него больше, чем недостатков. Язык JavaScript создавался для работы в браузере, но его можно использовать и в других целях, например в качестве встроенного языка или для обеспечения работы серверных приложений.
Я расскажу, как написать программу, которая будет выполняться на платформе Node.js — среде выполнения, предназначенной для запуска приложений JavaScript. В Node.js мне больше всего нравится событийно-ориентированная архитектура, обеспечивающая асинхронное программирование. При таком подходе определённые функции (их обычно называют функциями обратного вызова) можно привязать к определённым событиям и запускать только после наступления привязанного к функции события. Таким образом, разработчику ПО нет необходимости создавать главный цикл приложения: об этом позаботится само вычислительное окружение.

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

Задача программы​

Задачи, решаемые в статье:

  • Считать определённое количество данных из файла CSV, содержащего наборы числовых данных, называемые квартетом Энскомба.
  • Линейно интерполировать данные (т. е. представить их в виде формулы f(x) = m·x + q).
  • Вывести результат как изображение.
Чтобы получить более полное представление об этой задаче, рекомендую прочитать предыдущие статьи из этой серии, в которых рассматривается решение аналогичной задачи с применением Python и GNU Octave и C и C++. Полный исходный код для всех примеров приведён в моём репозитории polyglot_fit на GitLab.

Установка​

Перед запуском примера необходимо установить платформу Node.js вместе с менеджером пакетов npm. Чтобы установить их на Fedora, запустите следующую команду:

sudo dnf install nodejs npm
На Ubuntu:

sudo apt install nodejs npm
Затем для установки необходимых пакетов используйте команду npm. Пакеты устанавливаются в локальный подкаталог node_modules, чтобы Node.js могла найти их. Вот они:

  • CSV Parse — для синтаксического разбора файла CSV;
  • Simple Statistics — для расчёта коэффициента корреляции данных;
  • Regression-js — для определения точек, через которые будет проходить прямая линия;
  • D3-Node — для построения изображений на серверной стороне.
Эта команда npm загрузит пакеты:

npm install csv-parse simple-statistics regression d3-node
Так ставятся комментарии:

// однострочный комментарий
/* многострочный
комментарий */

Загрузка модулей​

Загрузить модули можно с помощью метода require(), он возвращает объект, содержащий функции модуля:

const EventEmitter = require('events');
const fs = require('fs');
const csv = require('csv-parser');
const regression = require('regression');
const ss = require('simple-statistics');
const D3Node = require('d3-node');
Некоторые модули входят в стандартную библиотеку Node.js, устанавливать их не нужно.

Определение переменных​

До использования переменных объявлять их как var, let или const необязательно. Однако, если тип не объявить, система определит их как глобальные. В общем случае использование глобальных переменных не приветствуется: если это не обдумать, то неминуемо возникнут программные ошибки. Переменные могут содержать данные любых типов (даже функции!). Некоторые объекты можно создавать, применив к функции конструктора оператор new:

const inputFileName = "anscombe.csv";
const delimiter = "\t";
const skipHeader = 3;
const columnX = String(0);
const columnY = String(1);

const d3n = new D3Node();
const d3 = d3n.d3;

var data = [];
Данные, считанные из файла CSV, сохраняются в массиве. Массивы в JS динамические, то есть заранее определять их размер не нужно.

Определение функций​

В языке JavaScript определять функции можно несколькими способами. Например, с помощью оператора объявления функции:

function triplify(x) {
return 3 * x;
}

// The function call is:
triplify(3);
Или через выражение, то есть присвоив функцию переменной:

var triplify = function (x) {
return 3 * x;
}

// The function call is still:
triplify(3);
И, наконец, можно использовать стрелочную функцию-выражение — синтаксически усечённую версию функции-выражения, работающую с определёнными ограничениями. Обычно такие стрелочные функции (или, как их ещё называют, функции-стрелки) используются для записи простых однострочных действий, выполняющих элементарные вычисления над своими аргументами:

var triplify = (x) => 3 * x;

// The function call is still:
triplify(3);

Печать вывода​

Вывод на терминал обычно осуществляется через встроенный в стандартную библиотеку Node.js объект console. Метод log() запускает вывод (добавляет новую строку после завершения строки):

console.log("#### Anscombe's first set with JavaScript in Node.js ####");
Объект console — более мощное средство, чем обычная функция вывода на печать; например, с его помощью можно также выводить на печать предупреждения и сообщения об ошибках. Если нужно вывести значение переменной, оно преобразуется в строку и используется объект console.log():

console.log("Slope: " + slope.toString());

Считывание данных​

В Node.js для ввода/вывода используется весьма интересный подход, причём такой подход может быть как синхронным, так и асинхронным. В первом случае используются блокирующие вызовы функций, а во втором — неблокирующие. При использовании блокирующей функции программа останавливается и ждёт момента, когда функция завершит свою задачу. Неблокирующие же функции не останавливают выполнение программы, а находят способы продолжить его вплоть до завершения собственной задачи.

Узнать, завершилось ли выполнение функции, можно, периодически проверяя её состояние. Иногда функция может сама уведомлять о собственном завершении. В данной статье используется второй подход: задействуется так называемый генератор событий EventEmitter, генерирующий событие, связанное с функцией обратного вызова. При наступлении события запускается функция обратного вызова.

Вначале создайте генератор событий EventEmitter:

const myEmitter = new EventEmitter();
Затем сопоставьте состояние завершения чтения файла с событием myEmitter. Для такого простого примера, правда, вовсе не требуется идти именно этим путём, а можно использовать простой блокирующий вызов — очень мощный метод, который может оказаться весьма полезным в других ситуациях. Перед этим нужно добавить в этот раздел ещё один фрагмент, задействующий для чтения данных библиотеку CSV Parse. Эта библиотека может применять несколько способов чтения данных (конкретный способ вы можете выбрать сами). В этом примере были использованы средства стандартной библиотеки обработки последовательностей данных (stream API) с методом pipe. Для правильной работы библиотеки ей нужно указать ряд параметров, определяемых объектом:

const csvOptions = {'separator': delimiter,
'skipLines': skipHeader,
'headers': false};
Итак, вы определили параметры и теперь можете прочитать файл:

fs.createReadStream(inputFileName)
.pipe(csv(csvOptions))
.on('data', (datum) => data.push({'x': Number(datum[columnX]), 'y': Number(datum[columnY])}))
.on('end', () => myEmitter.emit('reading-end'));
Ниже привожу мои комментарии к строкам этого короткого, но насыщенного фрагмента кода:

  • fs.createReadStream(inputFileName) открывает поток данных, считываемых из файла. Поток постепенно считывает файл по фрагментам.
  • .pipe(csv(csvOptions)) перенаправляет поток в библиотеку CSV Parse, выполняющую нелёгкую задачу чтения и синтаксического разбора файла.
  • .on('data', (datum) => data.push({'x': Number(datum[columnX]), 'y': Number(datum[columnY])}))
Поясню, что означает каждая часть строки:

  • (datum) => ... определяет функцию, в которую будет передаваться каждая строка файла CSV;
  • data.push(... добавляет только что считанные данные к массиву данных;
  • {'x': ..., 'y': ...} создаёт новую точку данных с членами x и y;
  • Number(datum[columnX]) преобразует элемент в столбце X (columnX) в число;
  • .on('end', () => myEmitter.emit('reading-end')); использует созданный вами генератор событий myEmitter для уведомления о завершении чтения файла.
После того как myEmitter сгенерирует событие завершения считывания, вы будете знать, что синтаксический разбор файла полностью завершён и что его содержимое передано в массив данных.

Анализ данных​

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

myEmitter.on('reading-end', function () {
const fit_data = data.map((datum) => [datum.x, datum.y]);

const result = regression.linear(fit_data);
const slope = result.equation[0];
const intercept = result.equation[1];

console.log("Slope: " + slope.toString());
console.log("Intercept: " + intercept.toString());

const x = data.map((datum) => datum.x);
const y = data.map((datum) => datum.y);

const r_value = ss.sampleCorrelation(x, y);

console.log("Correlation coefficient: " + r_value.toString());

myEmitter.emit('analysis-end', data, slope, intercept);
});
Библиотеки статистических вычислений могут работать с данными в различных форматах, поэтому к массиву данных необходимо применить метод map(). Этот метод создаёт новый массив из существующего и применяет функцию к каждому его элементу. В этом примере имеет смысл использовать лаконичные стрелочные функции. После завершения анализа можно дождаться нового события и возобновить цикл в новой функции обратного вызова. Данная функция также способна непосредственно выводить данные на график, но в этом примере я решил поручить задачу вывода другой функции, так как процесс анализа может оказаться очень длительным. При генерировании события analysis-end соответствующие данные из этой функции также передаются в следующую функцию обратного вызова.

Вывод изображения​

D3.js — чрезвычайно мощная библиотека функций графического отображения данных. С функциями библиотеки работать довольно сложно, и, возможно, кому-то они покажутся трудными для понимания, но это лучший вариант с открытым исходным кодом, который мне удалось найти для вывода графики на серверной части. Больше всего в библиотеке D3.js мне импонирует то, что она может работать с изображениями SVG. D3.js была разработана для запуска в веб-браузере, поэтому предполагается, что она должна работать с определённой веб-страницей. Работа на серверной части выполняется совсем в другой среде, поэтому работать придётся с виртуальной веб-страницей. К счастью, к нашим услугам пакет D3-Node, предельно упрощающий весь процесс.

Начните с определения некоторых важных количественных параметров, которые потребуются в дальнейшем:

const figDPI = 100;
const figWidth = 7 * figDPI;
const figHeight = figWidth / 16 * 9;
const margins = {top: 20, right: 20, bottom: 50, left: 50};

let plotWidth = figWidth - margins.left - margins.right;
let plotHeight = figHeight - margins.top - margins.bottom;

let minX = d3.min(data, (datum) => datum.x);
let maxX = d3.max(data, (datum) => datum.x);
let minY = d3.min(data, (datum) => datum.y);
let maxY = d3.max(data, (datum) => datum.y);
Вам предстоит преобразовать координаты данных в координаты графика (изображения). Для такого преобразования можно использовать шкалы: область шкалы — это пространство данных, в котором выбираются точки данных, а диапазон шкалы — это пространство изображения, где помещаются точки:

let scaleX = d3.scaleLinear()
.range([0, plotWidth])
.domain([minX - 1, maxX + 1]);
let scaleY = d3.scaleLinear()
.range([plotHeight, 0])
.domain([minY - 1, maxY + 1]);

const axisX = d3.axisBottom(scaleX).ticks(10);
const axisY = d3.axisLeft(scaleY).ticks(10);
Обратите внимание, что диапазон шкалы y инвертирован, так как в стандарте SVG начало шкалы y располагается сверху. После определения шкал начинайте рисовать график на только что созданном изображении SVG:

let svg = d3n.createSVG(figWidth, figHeight)

svg.attr('background-color', 'white');

svg.append("rect")
.attr("width", figWidth)
.attr("height", figHeight)
.attr("fill", 'white');
Вначале нарисуйте интерполирующую линию, добавив к изображению SVG элемент line:

svg.append("g")
.attr('transform', `translate(${margins.left}, ${margins.top})`)
.append("line")
.attr("x1", scaleX(minX - 1))
.attr("y1", scaleY((minX - 1) * slope + intercept))
.attr("x2", scaleX(maxX + 1))
.attr("y2", scaleY((maxX + 1) * slope + intercept))
.attr("stroke", "#1f77b4");
Затем для всех точек данных в нужных местах добавьте элемент circle. Главной особенностью библиотеки D3.js является то, что она связывает данные с элементами SVG, для этого воспользуйтесь методом data(). Метод enter() сообщает библиотеке, какие именно действия выполнять с новыми связанными данными:

svg.append("g")
.attr('transform', `translate(${margins.left}, ${margins.top})`)
.selectAll("circle")
.data(data)
.enter()
.append("circle")
.classed("circle", true)
.attr("cx", (d) => scaleX(d.x))
.attr("cy", (d) => scaleY(d.y))
.attr("r", 3)
.attr("fill", "#ff7f0e");
Последние выводимые на изображение элементы — это оси с соответствующими метками; они выводятся как наложение на линии и окружности графика:

svg.append("g")
.attr('transform', `translate(${margins.left}, ${margins.top + plotHeight})`)
.call(axisX);

svg.append("g")
.append("text")
.attr("transform", `translate(${margins.left + 0.5 * plotWidth}, ${margins.top + plotHeight + 0.7 * margins.bottom})`)
.style("text-anchor", "middle")
.text("X");

svg.append("g")
.attr('transform', `translate(${margins.left}, ${margins.top})`)
.call(axisY);

svg.append("g")
.attr("transform", `translate(${0.5 * margins.left}, ${margins.top + 0.5 * plotHeight})`)
.append("text")
.attr("transform", "rotate(-90)")
.style("text-anchor", "middle")
.text("Y");
И последнее действие — сохранение графика в файл SVG. Я выбрал вариант синхронной записи файла, чтобы продемонстрировать работу второго подхода:

fs.writeFileSync("fit_node.svg", d3n.svgString());

Результаты​

Запуск скрипта предельно прост:

node fitting_node.js
Результат его выполнения:

#### Anscombe's first set with JavaScript in Node.js ####
Slope: 0.5
Intercept: 3
Correlation coefficient: 0.8164205163448399
Вот результирующее изображение, созданное мной с помощью библиотек D3.js и Node.js:

Заключение​

8e42e50dbf905a9fc6bc9958009a58ab.jpg

Конечно же, есть варианты: Python можно запускать прямо из Node.JS, или же обращаться к объектам JS в коде Python через PyNode. Подобно этому специалист из любой области может стать дата-сайентистом: первая специализация поможет видеть данные в своей области знаний изнутри, понимать скрытые причины появления данных и то, как извлечь из них пользу.

Источник статьи: https://habr.com/ru/company/skillfactory/blog/568292/
 
Сверху