Чуть более двух лет назад Google выпустила библиотеку TensorFlow.js, которая расширила возможности TensorFlow из области Python и развертывания на стороне сервера, позволив разработчикам JavaScript создавать и обучать модели машинного обучения в браузерах и веб-приложениях.

В этой статье будут затронуты некоторые основы TensorFlow.js/машинного обучения при создании кода для относительно простой модели для анализа двумерных данных; а именно, квадратные метры и выбросы зданий в Нью-Йорке.

Машинное обучение и TensorFlow

Общая цель машинного обучения — использовать входные данные в виде тензоров для «обучения» модели. Эта модель будет прогнозировать выходные данные или результаты на основе этих входных данных. По мере того, как модель анализирует все больше и больше данных, ее прогнозы результатов будут становиться все более точными.

В общих чертах, шаги для этого в коде:

  1. Получите некоторые данные и визуализируйте корреляцию / тезис
  2. Создать модель
  3. Преобразование данных в тензоры и обучение модели
  4. Делайте прогнозы и тестируйте модель

В следующих разделах будут разобраны некоторые детали и код каждого из этих шагов. Но сначала, что такое тензор?

Тензоры

Тензоры — это основная структура данных TensorFlow.js. Они представляют собой обобщение векторов и матриц для потенциально более высоких измерений. — Документация API TensorFlow.js

Полное описание тензоров, скорее всего, выходит за рамки какой-либо одной статьи. Но в JavaScript тензоры представляют собой массивы размерностей от одного до n. Размерность (или ранг) можно определить по количеству индексов, необходимых для доступа к значению, например:

const 1DTensor = [1, 2, 3] 
//one index needed to access a value; e.g. 1DTensor[1]
const 2DTensor = [ [1], [2], [3] ]
//two indexes needed; 2DTensor[2][0]
const 3DTensor = [ [ [1], [2] ], [ [3], [4] ] ] 
// 3DTensor[0][1][0]
//And so on…

Структура и размерность будут зависеть от того, какие данные вы используете и что вы хотите, чтобы ваша модель анализировала и предсказывала. Для этой модели мы будем использовать 2D-тензоры, потому что наши данные являются двумерными, и модель будет обучена принимать массив массивов одного числа (квадратные метры) и учиться предсказывать одно число (выбросы парниковых газов). Входные данные для обучения нашей модели будут выглядеть следующим образом:

[ ...,
{
squareFootage: 734668,
emissions: 2747.2
},
{
squareFootage: 380000,
emissions: 2752.2
},
... ]

1. Получите некоторые данные и визуализируйте корреляцию/тезис

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

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

С ним гораздо проще работать. Это было достигнуто с помощью кода ниже:

//fetching data from Socrata Open Data API with query parameters to only receive residential buildings in Manhattan
const bldgData = await fetch("https://data.cityofnewyork.us/resource/28fi-3us3.json?largest_property_use_type=Multifamily Housing&$where=starts_with(bbl_10_digits, '1')&$select=dof_gross_floor_area_ft,total_ghg_emissions_metric&$limit=20000");
const bldgDataJSON = await bldgData.json();
//rephrasing keys and turning strings into numbers
const cleanedData = bldgDataJSON.map((bldg) => ({
emissions: Number(bldg.total_ghg_emissions_metric),
squareFootage: Number(bldg.dof_gross_floor_area_ft),
}))
//filtering outlier cases, i.e. buildings over 800,000 sqft and more than 6,000 tons of ghg emissions/year
.filter((bldg) => bldg.emissions != 0
&& bldg.emissions < 6000
&& bldg.emissions !== undefined 
&& bldg.squareFootage !== 0
&& bldg.squareFootage < 800000
&& bldg.squareFootage !== undefined);
//assigning values to x and y axes
const values = await cleanedData.map((d) => ({
x: d.squareFootage,
y: d.emissions,
}));
//rendering scatterplot graph with tfvis
tfvis.render.scatterplot(
{ name: "Square Footage v Emissions" },
{ values },
{
xLabel: "Square Footage",
yLabel: "Emissions",
height: 300,
zoomToFit: false
});

2. Создайте модель

Для этого я создам последовательную модель, что означает, что каждый слой линейно передает следующий слой, и что все выходные данные одного слоя являются входными данными для другого. Слои «сложены», полностью связаны, между ними нет ответвления или пропуска.

Эта модель будет использовать четыре слоя. Входной слой:

model.add(tf.layers.dense({ inputShape: [1], units: 1 }));

Два «скрытых» слоя:

model.add(tf.layers.dense({units: 3}));
model.add(tf.layers.dense({units: 3}));

И выходной слой:

model.add(tf.layers.dense({ units: 1 }));

Итак, что же представляют собой эти сложенные, полностью связанные слои и что они делают? Они состоят из узлов или «единиц», каждая из которых представляет отдельную функцию набора данных, который передается в модель. Каждый узел собирает, анализирует и/или выводит нормализованные и «взвешенные» тензорные данные в зависимости от их положения в стеке. Структура может быть примерно такой, как показано ниже:

Более подробное объяснение роли слоев в искусственной нейронной сети см. в этой статье Адриана Яворски.

3. Преобразование данных в тензоры

Сначала мы преобразуем данные в тензоры, используя следующие шаги:

  1. Оберните функции и операции в tf.tidy() для предотвращения утечек памяти или неиспользуемых выделений памяти.
  2. Перетасуйте данные с помощью tf.util.shuffle(), чтобы каждый пакет данных, подаваемых в модель, сортировался случайным образом. Это не позволяет модели изучать вещи в зависимости от порядка подачи данных.
  3. Отделите входные данные (квадратные метры) и выходные данные (излучение) от данных, создайте 2D-тензоры для каждого с помощью tf.tensor2d() и обрежьте значения выбросов с помощью clipByValue().
  4. Нормируйте данные в диапазоне от 0 до 1, используя методы .min() и .max(), которые извлекают самое низкое и самое высокое значение из каждого тензора соответственно. Затем применяются методы .sub() и .div() для преобразования квадратных метров и выбросов в значения от 0 до 1. Это делается потому, что большинство моделей TensorFlow предназначены для работы с небольшими числами, с общими диапазонами от 0 до 1. или -1 к 1.
tf.tidy(() => {
tf.util.shuffle(data);
const inputs = data.map((d) => d.squareFootage);
const outputs = data.map((d) => d.emissions);
const inputTensor = tf.tensor2d(inputs, [inputs.length, 1]).clipByValue(0, 900000);
const outputTensor = tf.tensor2d(outputs, [outputs.length, 1]).clipByValue(0, 5000);
const inputMax = inputTensor.max();
const inputMin = inputTensor.min();
const outputMax = outputTensor.max();
const outputMin = outputTensor.min();
const normalizedInputs = inputTensor.sub(inputMin).div(inputMax.sub(inputMin));
const normalizedOutputs = outputTensor.sub(outputMin).div(outputMax.sub(outputMin));
});

4. Обучите модель

В TensorFlow есть два метода, которые мы будем использовать для обучения модели: model.compile() и model.fit().

Метод .compile() подготавливает модель к обучению и оценке. Он принимает объект конфигурации, который включает в себя:

Метод .fit() фактически обучает модель. Он асинхронный и принимает входы, выходы и объект конфигурации, который сам содержит:

  • batchSize: указывает количество выборок данных на обновление градиента. Значение по умолчанию — 32.
  • Эпохи: количество повторений массивов обучающих данных. Я обнаружил, что 10 достаточно для достижения приемлемого MSE.

5. Протестируйте модель

Это снова начнется с tf.tidy() по тем же причинам, что и раньше, и в рамках этого мы сделаем следующее:

  • Используйте .linspace() для возврата равномерно распределенной последовательности чисел за заданный интервал. Поскольку наши нормализованные данные находятся между 0 и 1, linspace() сделает то же самое 100 раз.
  • Используйте метод .predict(), который будет принимать значения linspace ранее в качестве входных данных и преобразовывать их в тензор соответствующей формы; то есть массив из 100 массивов одного значения.
  • Не нормализуйте данные, используя методы .mul(), .sub() и .add().
  • Используйте .dataSync(), чтобы заблокировать поток пользовательского интерфейса, пока значения тензора не будут готовы, что предотвращает потенциальные проблемы с производительностью.
  • Мы создадим прогнозируемые точки из значений x, возвращаемых из .linspace(), и реальные точки из фактических входных данных, используемых для обучения модели, а затем сравним их на другой диаграмме рассеяния.
const [xs, preds] = tf.tidy(() => {
const xs = tf.linspace(0, 1, 100);
const preds = model.predict(xs.reshape([100, 1]));
const unNormXs = xs.mul(inputMax.sub(inputMin)).add(inputMin);
const unNormPreds = preds.mul(labelMax.sub(labelMin)).add(labelMin);
return [unNormXs.dataSync(), unNormPreds.dataSync()];
});
const predictedPoints = Array.from(xs).map((val, i) => {
return { x: val, y: preds[i] };
});
const originalPoints = inputData.map((d) => ({
x: d.squareFootage,
y: d.emissions,
}));
tfvis.render.scatterplot(
{ name: "Model Predictions vs Original Data" },
{
values: [originalPoints, predictedPoints],
series: ["original", "predicted"],
},
{
xLabel: "Square Footage",
yLabel: "Emissions",
height: 300,
}
);

Окончательный результат показан ниже, а оранжевая линия значений представляет предсказанные результаты сверху: