1 сентября

Книга «Google BigQuery. Всё о хранилищах данных, аналитике и машинном обучении»

Блог компании Издательский дом «Питер»IT-инфраструктураBig DataПрофессиональная литератураМашинное обучение
imageПривет, Хаброжители! Вас пугает необходимость обрабатывать петабайтные наборы данных? Познакомьтесь с Google BigQuery, — системой хранения информации, которая может консолидировать данные по всему предприятию, облегчает интерактивный анализ и позволяет реализовать задачи машинного обучения. Теперь вы можете эффективно хранить, запрашивать, получать и изучать данные в одной удобной среде. Вальяппа Лакшманан и Джордан Тайджани научат вас работать в современном хранилище данных, используя все возможности масштабируемого, безсерверного публичного облака. С этой книгой вы: — Погрузитесь во внутреннее устройство BigQuery — Изучите типы данных, функции и операторы, которые поддерживает Big Query — Оптимизируете запросы и реализуете схемы повышения производительности или снижения затрат — Узнаете о GIS, time travel, DDL / DML, пользовательских функциях и сценариях SQL — Решите множество задач машинного обучения — Узнаете, как защитить данные, отслеживать работу и авторизовать пользователей.

Минимизация сетевых издержек


Минимизация сетевых издержек BigQuery — это региональный сервис, доступный по всему миру. Например, если вы запрашиваете набор данных, хранящийся в регионе EU, запрос будет выполняться на серверах, расположенных в вычислительном центре в Евросоюзе. Чтобы вы могли сохранить результаты запроса в таблице, она должна находиться в наборе данных, который также находится в регионе EU. Однако BigQuery REST API можно вызывать (то есть запустить запрос) из любой точки мира, даже с компьютеров за пределами GCP. При работе с другими ресурсами GCP, такими как Google Cloud Storage или Cloud Pub/Sub, наилучшая производительность достигается, если они находятся в том же регионе, что и набор данных. Поэтому если запрос выполняется из экземпляра Compute Engine или кластера Cloud Dataproc, сетевые издержки окажутся минимальными, если экземпляр или кластер также находятся в том же регионе, что и запрашиваемый набор данных. Обращаясь к BigQuery из-за пределов GCP, учитывайте топологию сети и постарайтесь свести к минимуму число переходов между клиентским компьютером и вычислительным центром GCP, в котором находится набор данных.

Сжатые, неполные ответы

При непосредственном обращении к REST API сетевые издержки можно уменьшить, принимая сжатые, неполные ответы. Для приема сжатых ответов можете указать в HTTP-заголовке, что вы готовы принять gzip-архив, и обеспечить наличие строки «gzip» в заголовке User-Agent, например:

Accept-Encoding: gzip
User-Agent: programName (gzip)

В этом случае все ответы будут сжиматься с помощью gzip. По умолчанию ответы BigQuery содержат все поля, перечисленные в документации. Однако если известно, какая часть ответа нам интересна, мы можем попросить BigQuery послать только эту часть, уменьшив тем самым сетевые издержки. Например, в этой главе мы видели, как получить полную информацию о задании с помощью Jobs API. Если вас интересует только подмножество полного ответа (например, только шаги в плане запроса), можно указать интересующие поля, чтобы ограничить размер ответа:

JOBSURL="https://www.googleapis.com/bigquery/v2/projects/$PROJECT/jobs"
FIELDS="statistics(query(queryPlan(steps)))"
curl --silent \
    -H "Authorization: Bearer $access_token" \
    -H "Accept-Encoding: gzip" \
    -H "User-Agent: get_job_details (gzip)" \
    -X GET \
    "${JOBSURL}/${JOBID}?fields=${FIELDS}" \
| zcat

Обратите внимание: здесь также указано, что мы принимаем сжатые данные gzip.

Объединение нескольких запросов в пакеты

При использовании REST API есть возможность объединить несколько вызовов BigQuery API, используя тип содержимого multipart/mixed и вложенные HTTP-запросы в каждой из частей. В теле каждой части указывается HTTP-операция (GET, PUT и т. д.), путь в URL, заголовки и тело. В ответ сервер отправит единственный HTTP-ответ с типом содержимого multipart/mixed, каждая часть которого будет содержать ответ (по порядку) на соответствующий запрос в пакетном запросе. Несмотря на то что ответы возвращаются в определенном порядке, сервер может обрабатывать вызовы в любом порядке. Поэтому пакетный запрос можно рассматривать как группу запросов, выполняемых параллельно. Вот пример отправки пакетного запроса для получения некоторых деталей из планов выполнения последних пяти запросов в нашем проекте. Сначала мы используем инструмент командной строки BigQuery, чтобы получить пять последних успешных заданий:

# 5 последних успешных заданий
JOBS=$(bq ls -j -n 50 | grep SUCCESS | head -5 | awk '{print $1}')

Запрос отправляется в конечную точку BigQuery, предназначенную для обработки пакетов:

BATCHURL="https://www.googleapis.com/batch/bigquery/v2"
JOBSPATH="/projects/$PROJECT/jobs"
FIELDS="statistics(query(queryPlan(steps)))"

В пути URL можно определить отдельные запросы:

request=""
for JOBID in $JOBS; do
read -d '' part << EOF
--batch_part_starts_here
GET ${JOBSPATH}/${JOBID}?fields=${FIELDS}
EOF
request=$(echo "$request"; echo "$part")
done

Затем можно отправить запрос в виде составного запроса:

curl --silent \
   -H "Authorization: Bearer $access_token" \
   -H "Content-Type: multipart/mixed; boundary=batch_part_starts_here" \
   -X POST \
   -d "$request" \
   "${BATCHURL}"

Массовое считывание с использованием BigQuery Storage API

В главе 5 мы обсудили использование BigQuery REST API и клиентских библиотек для перечисления таблиц и получения результатов запросов. REST API возвращает данные в виде записей с разбивкой по страницам, которые лучше подходят для относительно небольших наборов результатов. Однако с появлением машинного обучения и распределенных инструментов извлечения, преобразования и загрузки (Extract, Transform, Load — ETL) теперь внешним инструментам требуется быстрый и эффективный массовый доступ к управляемому хранилищу BigQuery. Такой доступ к массовому чтению обеспечивается в BigQuery Storage API через протокол вызова удаленных процедур (Remote Procedure Call, RPC). С помощью BigQuery Storage API структурированные данные передаются по сети в двоичном формате сериализации, который точнее соответствует колоночному формату хранения данных. Это обеспечивает дополнительное распараллеливание набора результатов между несколькими потребителями.

Конечные пользователи не используют BigQuery Storage API напрямую. Вместо этого они применяют Cloud Dataflow, Cloud Dataproc, TensorFlow, AutoML, и другие инструменты, использующие Storage API для чтения данных напрямую из управляемого хранилища, а не через BigQuery API.

Поскольку Storage API напрямую обращается к хранимым данным, разрешение на доступ к BigQuery Storage API отличается от существующего BigQuery API. Разрешения BigQuery Storage API должны настраиваться независимо от разрешений BigQuery.

BigQuery Storage API предоставляет несколько преимуществ инструментам, читающим данные непосредственно из управляемого хранилища BigQuery. Например, потребители могут читать непересекающиеся наборы записей из таблицы, используя несколько потоков (например, разрешив распределенное чтение данных от разных рабочих серверов в Cloud Dataproc), динамически сегментировать эти потоки (таким способом уменьшая хвостовую задержку, которая может быть серьезной проблемой для заданий MapReduce), выбирать подмножество столбцов для чтения (для передачи в структуры машинного обучения только признаков, используемых моделью), фильтровать значения столбцов (уменьшая объем данных, передаваемых по сети) и при этом гарантировать согласованность мгновенных снимков (то есть читая данные с определенного момента времени).

В главе 5 мы рассмотрели использование расширения %%bigquery в Jupyter Notebook для загрузки результатов запросов в объекты DataFrame. Однако в примерах использовались относительно небольшие наборы данных — от десятка до нескольких сотен записей. А можно ли загрузить весь набор данных london_bicycles (24 миллиона записей) в DataFrame? Да, можно, но в этом случае для загрузки данных в DataFrame следует использовать Storage API, а не BigQuery API. Сначала нужно установить клиентскую библиотеку Storage API для Python с поддержкой Avro и pandas. Сделать это можно командой

%pip install google-cloud-bigquery-storage[fastavro,pandas]

Затем остается только использовать расширение %%bigquery, как и прежде, но добавить параметр, требующий использовать Storage API:

%%bigquery df --use_bqstorage_api --project $PROJECT
SELECT 
   start_station_name 
   , end_station_name 
   , start_date 
   , duration
FROM `bigquery-public-data`.london_bicycles.cycle_hire

Обратите внимание, что здесь мы используем способность Storage API предоставлять прямой доступ к отдельным столбцам; необязательно читать всю таблицу BigQuery в объект DataFrame. Если запрос вернет небольшой объем данных, расширение автоматически будет использовать BigQuery API. Поэтому не страшно, если вы всегда будете указывать этот флаг в ячейках блокнота. Чтобы включить флаг --usebqstorageapi во всех ячейках блокнота, можно установить флаг контекста:

import google.cloud.bigquery.magics
google.cloud.bigquery.magics.context.use_bqstorage_api = True

Выбор эффективного формата хранения


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

Внутренние и внешние источники данных

BigQuery поддерживает запросы к внешним источникам, таким как Google Cloud Storage, Cloud Bigtable и Google Sheets, однако максимальная производительность запросов возможна только при использовании собственных таблиц.

В качестве хранилища аналитических данных для всех ваших структурированных и полуструктурированных данных мы советуем использовать BigQuery. Внешние источники данных лучше использовать только для промежуточного хранения (Google Cloud Storage), загрузки в режиме реального времени (Cloud Pub/Sub, Cloud Bigtable) или периодического обновления (Cloud SQL, Cloud Spanner). Далее настройте конвейер данных для загрузки данных по расписанию из этих внешних источников в BigQuery (см. главу 4).

Если вам понадобится запросить данные из Google Cloud Storage, по возможности сохраните их в сжатом колоночном формате (например, Parquet). Используйте форматы на основе записей, такие как JSON или CSV, только в крайнем случае.

Управление жизненным циклом промежуточных корзин

Если вы загружаете данные в BigQuery, предварительно помещая в облачное хранилище Google Cloud Storage, не забудьте удалить их из облачного хранилища после загрузки. Если для загрузки данных в BigQuery используется конвейер ETL (чтобы попутно значительно преобразовать их или оставить только часть данных), у вас может появиться желание сохранить исходные данные в Google Cloud Storage. В таких случаях снизить затраты вам поможет определение правил управления жизненным циклом корзин, понижающих класс хранения в Google Cloud Storage.

Вот как можно включить управление жизненным циклом корзины и настроить автоматическое перемещение данных из объединенных регионов или стандартных классов, возраст которых превысил 30 дней, в хранилище Nearline Storage, а данных, хранящихся в Nearline Storage дольше 90 дней, — в хранилище Coldline Storage:

gsutil lifecycle set lifecycle.yaml gs://some_bucket/

В этом примере файл lifecycle.yaml содержит следующий код:

{
"lifecycle": {
  "rule": [
  {
   "action": {
    "type": "SetStorageClass",
    "storageClass": "NEARLINE"
   },
   "condition": {
    "age": 30,
    "matchesStorageClass": ["MULTI_REGIONAL", "STANDARD"]
   }
 },
 {
  "action": {
   "type": "SetStorageClass",
   "storageClass": "COLDLINE"
  },
  "condition": {
   "age": 90,
   "matchesStorageClass": ["NEARLINE"]
  }
 }
]}}

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

Хранение данных в виде массивов и структур

Кроме прочих общедоступных наборов данных, в BigQuery имеется набор данных с информацией о циклонических штормах (ураганах, тайфунах, циклонах и т. д.), полученной метеорологическими службами по всему миру. Циклонические штормы могут длиться до нескольких недель, и замеры их метеорологических показателей производятся примерно каждые три часа. Предположим, что вы решили найти в этом наборе данных все штормы, случившиеся в 2018 году, максимальную скорость ветра, достигнутую каждым штормом, а также время и местоположение шторма, когда эта максимальная скорость была достигнута. Все эти сведения из общедоступного набора данных извлекает следующий запрос:

SELECT
  sid, number, basin, name,
  ARRAY_AGG(STRUCT(iso_time, usa_latitude, usa_longitude, usa_wind) ORDER BY
usa_wind DESC LIMIT 1)[OFFSET(0)].*
FROM
  `bigquery-public-data`.noaa_hurricanes.hurricanes
WHERE
  season = '2018'
GROUP BY
  sid, number, basin, name
ORDER BY number ASC

Запрос извлекает идентификатор шторма (sid), его порядковый номер в сезоне, бассейн и имя шторма (если оно было присвоено), а затем находит массив наблюдений, выполненных для этого шторма, ранжируя наблюдения в порядке убывания скорости ветра и выбирая максимальную скорость для каждого шторма. Сами штормы упорядочены по порядковому номеру. Результат включает 88 записей и выглядит примерно так:


Запрос выполнялся 1.4 секунды и обработал 41.7 Мбайт. Первая запись описывает шторм Болавен (Bolaven), достигший максимальной скорости 29 м/с 2 января 2018 года в 18:00 UTC.

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

CREATE OR REPLACE TABLE ch07.hurricanes_nested AS

SELECT sid, season, number, basin, name, iso_time, nature, usa_sshs,
    STRUCT(usa_latitude AS latitude, usa_longitude AS longitude, usa_wind AS
wind, usa_pressure AS pressure) AS usa,
    STRUCT(tokyo_latitude AS latitude, tokyo_longitude AS longitude,
tokyo_wind AS wind, tokyo_pressure AS pressure) AS tokyo,
    ... AS cma,
    ... AS hko,
    ... AS newdelhi,
    ... AS reunion,
    ... bom,
    ... AS wellington,
    ... nadi
FROM `bigquery-public-data`.noaa_hurricanes.hurricanes

Запросы к этой таблице выглядят так же, как запросы к исходной таблице, но с незначитльным изменением имен столбцов (usa.latitude вместо usa_latitude):

SELECT
  sid, number, basin, name,
  ARRAY_AGG(STRUCT(iso_time, usa.latitude, usa.longitude, usa.wind) ORDER BY
usa.wind DESC LIMIT 1)[OFFSET(0)].*
FROM
  ch07.hurricanes_nested
WHERE
  season = '2018'
GROUP BY
  sid, number, basin, name
ORDER BY number ASC

Этот запрос обрабатывает тот же объем данных и выполняется в течение того же времени, что и исходный, использующий общедоступный набор данных. Применение вложенных полей (структур) не меняет скорости или стоимости запроса, но может сделать запрос более читабельным. Поскольку существует множество наблюдений одного и того же шторма в течение его продолжительности, мы можем изменить хранилище так, чтобы уместить в одну запись весь массив наблюдений для каждого шторма:

CREATE OR REPLACE TABLE ch07.hurricanes_nested_track AS

SELECT sid, season, number, basin, name,
 ARRAY_AGG(
   STRUCT(
    iso_time,
    nature,
    usa_sshs,
    STRUCT(usa_latitude AS latitude, usa_longitude AS longitude, usa_wind AS
wind, usa_pressure AS pressure) AS usa,
    STRUCT(tokyo_latitude AS latitude, tokyo_longitude AS longitude,
      tokyo_wind AS wind, tokyo_pressure AS pressure) AS tokyo,
    ... AS cma,
    ... AS hko,
    ... AS newdelhi,
    ... AS reunion,
    ... bom,
    ... AS wellington,
    ... nadi
  ) ORDER BY iso_time ASC ) AS obs
FROM `bigquery-public-data`.noaa_hurricanes.hurricanes
GROUP BY sid, season, number, basin, name

Обратите внимание, что теперь мы храним sid, season и другие характеристики шторма в виде скалярных столбцов, потому что они не меняются в зависимости от его продолжительности.

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

SELECT
  number, name, basin,
  (SELECT AS STRUCT iso_time, usa.latitude, usa.longitude, usa.wind
     FROM UNNEST(obs) ORDER BY usa.wind DESC LIMIT 1).*
FROM ch07.hurricanes_nested_track
WHERE season = '2018'
ORDER BY number ASC

Этот запрос вернет тот же результат, но на этот раз обработает только 14.7 Мбайт (снижение стоимости в три раза) и завершится за одну секунду (увеличение скорости на 30%). Чем обусловлено это улучшение производительности? Когда данные хранятся в виде массива, количество записей в таблице резко сокращается (с 682 000 до 14 000),2 потому что теперь на один шторм приходится только одна запись, а не много записей — по одной для каждого наблюдения. Затем, когда мы фильтруем строки по сезону, BigQuery может одновременно отбрасывать множество связанных наблюдений, как показано на рис. 7.13.


Еще одно преимущество — отсутствие необходимости дублировать записи с данными, когда в одной таблице хранятся наблюдения с разными уровнями детализации. В одной таблице можно хранить как данные об изменении широты и долготы штормов, так и высокоуровневые данные, такие как название штормов и сезон. А поскольку BigQuery хранит табличные данные по столбцам с использованием сжатия, запрашивать и обрабатывать высокоуровневые данные можно, не опасаясь затрат за работу с детальными данными, — теперь они хранятся в виде отдельного массива значений для каждого шторма.

Например, чтобы узнать число штормов по годам, можно запросить только нужные столбцы:

WITH hurricane_detail AS (
SELECT sid, season, number, basin, name,
 ARRAY_AGG(
  STRUCT(
    iso_time,
    nature,
    usa_sshs,
    STRUCT(usa_latitude AS latitude, usa_longitude AS longitude, usa_wind AS
wind, usa_pressure AS pressure) AS usa,
    STRUCT(tokyo_latitude AS latitude, tokyo_longitude AS longitude,
        tokyo_wind
AS wind, tokyo_pressure AS pressure) AS tokyo
  ) ORDER BY iso_time ASC ) AS obs
FROM `bigquery-public-data`.noaa_hurricanes.hurricanes
GROUP BY sid, season, number, basin, name
)
SELECT
  COUNT(sid) AS count_of_storms,
  season
FROM hurricane_detail
GROUP BY season
ORDER BY season DESC

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

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

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

Практика применения массивов

Как показывает опыт, для успешного применения вложенных повторяющихся полей требуется некоторая практика. Образцовый набор данных Google Analytics в BigQuery идеально подходит для этой цели. Самый простой способ идентифицировать вложенные данные в схеме — найти слово RECORD в столбце Type, которое соответствует типу данных STRUCT, и слово REPEATED в столбце Mode, как показано ниже:


В этом примере поле TOTALS имеет тип STRUCT (но не повторяется), а HITS — имеет тип STRUCT и повторяется. В этом есть определенный смысл, потому что Google Analytics отслеживает данные сеанса посетителя (visitor) на уровне агрегирования (одно значение сеанса для totals.hits) и на уровне детализации (отдельные значения hit.time для каждой страницы и изображения, полученные с вашего сайта). Хранение данных на этих разных уровнях детализации без дублирования visitorId в записях возможно только в случае применения массивов. После сохранения данных в повторяющемся формате с массивами вам нужно предусмотреть развертывание этих данных в своих запросах с помощью UNNEST, например:

SELECT DISTINCT
  visitId
  , totals.pageviews
  , totals.timeOnsite
  , trafficSource.source
  , device.browser
  , device.isMobile
  , h.page.pageTitle
FROM
  `bigquery-public-data`.google_analytics_sample.ga_sessions_20170801,
  UNNEST(hits) AS h
WHERE
  totals.timeOnSite IS NOT NULL AND h.page.pageTitle =
'Shopping Cart'
ORDER BY pageviews DESC
LIMIT 10
Операция развертывания разбивает массив на части, например превращает [1,2,3,4,5] в отдельные записи:
[1,
2
3
4
5]

После этого можно выполнять обычные операции SQL, такие как WHERE, чтобы отфильтровать попадания на страницы с такими заголовками, как «Shopping Cart». Попробуйте!

С другой стороны, общедоступный набор данных с информацией об операциях фиксации в GitHub (bigquery-publicdata.githubrepos.commits) использует вложенное повторяющееся поле (reponame) для хранения списка репозиториев, затронутых операцией фиксации. Оно не меняется с течением времени и обеспечивает ускорение запросов, которые выполняют фильтрацию по любому другому полю.

Хранение данных в виде географических типов

В общедоступном наборе со вспомогательными данными в BigQuery имеется таблица границ зон действия почтовых индексов США (bigquery-public-data.utilityus.zipcodearea) и еще одна таблица с многоугольниками, описывающими границы городов США (bigquery-publicdata.utilityus.uscitiesarea). Столбец с границами зон действия почтовых индексов (zipcodegeom) представляет собой строку, тогда как столбец с границами городов (city_geom) представлен географическим типом.

Из этих двух таблиц можно получить список всех почтовых индексов для Санта-Фе (Santa Fe) в Нью-Мексико (New Mexico), как показано ниже:

SELECT name, zipcode
FROM `bigquery-public-data`.utility_us.zipcode_area
JOIN `bigquery-public-data`.utility_us.us_cities_area
ON ST_INTERSECTS(ST_GeogFromText(zipcode_geom), city_geom)
WHERE name LIKE '%Santa Fe%'

Этот запрос выполняется 51.9 секунды, обрабатывает 305.5 Мбайт данных и возвращает следующие результаты:


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

Мы можем попробовать изменить таблицу почтовых индексов, выполнив эту операцию заранее, и сохранить геометрию в виде значения типа GEOGRAPHY:

CREATE OR REPLACE TABLE ch07.zipcode_area AS
SELECT 
  * REPLACE(ST_GeogFromText(zipcode_geom) AS zipcode_geom)
FROM 
  `bigquery-public-data`.utility_us.zipcode_area

REPLACE (см. предыдущий запрос) — это удобный способ заменить столбец из выражения SELECT *.
Новый набор данных имеет размер 131.8 Мбайт, что значительно больше 116.5 Мбайт в исходной таблице. Однако запросы к этой таблице могут использовать охват S2 и выполняться намного быстрее. Например, следующий запрос выполняется 5.3 секунды (увеличение скорости в 10 раз) и обрабатывает 320.8 Мбайт (небольшое увеличение стоимости при использовании тарифного плана «до востребования»):

SELECT name, zipcode
FROM ch07.zipcode_area
JOIN `bigquery-public-data`.utility_us.us_cities_area
ON ST_INTERSECTS(zipcode_geom, city_geom)
WHERE name LIKE '%Santa Fe%'

Преимущества в производительности, которые дает хранение географических данных в столбце типа GEOGRAPHY, более чем убедительны. Вот почему набор данных utilityus устарел (он все еще доступен для сохранения работоспособности уже написанных запросов). Мы советуем использовать таблицу bigquery-public-data.geousboundaries.uszip_codes, в которой географическая информация хранится в столбце типа GEOGRAPHY и постоянно обновляется.

» Более подробно с книгой можно ознакомиться на сайте издательства
» Оглавление
» Отрывок

Для Хаброжителей скидка 25% по купону — Google

По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
Теги:книга
Хабы: Блог компании Издательский дом «Питер» IT-инфраструктура Big Data Профессиональная литература Машинное обучение
+3
2,7k 25
Комментировать
Лучшие публикации за сутки