PL/R в PostgreSQL
Привет, Хабр!
PL/R — это процедурный язык для PostgreSQL, который позволяет писать функции на языке R. Когда хочется использовать все богатство статистических пакетов и алгоритмов R, не покидая привычного SQL‑окружения, PL/R приходит на помощь. Плюс ко всему: нет необходимости создавать отдельные сервисы для анализа, что уменьшает задержки и упрощает архитектуру приложения.
Создаем расширение:
CREATE EXTENSION IF NOT EXISTS plr;
Основы создания функций на PL/R: от простого к сложному
Базовый пример
Начнем с самого простого примера, который поможет понять синтаксис PL/R и основы работы с аргументами.
CREATE OR REPLACE FUNCTION add_numbers(a double precision, b double precision)
RETURNS double precision AS $$
# Просто складываем два числа – прям как на пальцах
return(a + b)
$$ LANGUAGE plr;
Запустим её:
SELECT add_numbers(2.5, 3.5); -- Результат: 6.0
Так можно вызывать R‑код из SQL.
Работа с массивами
PL/R позволяет передавать массивы данных и обрабатывать их как в обычном R. Пример функции, которая принимает массив чисел и возвращает массив их квадратов:
CREATE OR REPLACE FUNCTION square_elements(numbers numeric[])
RETURNS numeric[] AS $$
# Преобразуем входной массив в вектор R
num_vector <- as.numeric(numbers)
# Вычисляем квадрат каждого элемента
squared <- num_vector^2
# Возвращаем результат обратно в виде массива
return(squared)
$$ LANGUAGE plr;
Запускаем функцию:
SELECT square_elements(ARRAY[1, 2, 3, 4, 5]) AS squared_values;
Увидим: {1, 4, 9, 16, 25}
.
Возврат таблиц
В PL/R можно возвращать не только скаляры или массивы, но и целые таблицы. Представьте ситуацию, когда у вас есть два массива — с названиями товаров и их ценами. Функция должна вернуть таблицу с названием, ценой и рассчитанной скидкой (скажем, 10% от цены).
CREATE OR REPLACE FUNCTION product_discounts(names text[], prices numeric[])
RETURNS TABLE(product_name text, price numeric, discount numeric) AS $$
# Преобразуем входные массивы в векторы R
name_vec <- as.character(names)
price_vec <- as.numeric(prices)
# Вычисляем скидку в 10%
discount_vec <- price_vec * 0.10
# Формируем результирующий data.frame
result <- data.frame(product_name = name_vec, price = price_vec, discount = discount_vec)
return(result)
$$ LANGUAGE plr;
Вызов функции:
SELECT * FROM product_discounts(
ARRAY['Товар A', 'Товар B', 'Товар C'],
ARRAY[100.0, 250.0, 400.0]
);
Результатом станет таблица с тремя колонками, где для каждого продукта указана цена и вычисленная скидка.
Валидация входных данных и обработка ошибок
Ни в коем случае не забывайте про проверки — некорректные данные могут привести к катастрофе. Например, если массив пустой, стоит выбросить ошибку. Функция, вычисляющая среднее значение с проверкой:
CREATE OR REPLACE FUNCTION safe_mean(numbers numeric[])
RETURNS numeric AS $$
# Если массив пуст или равен NULL, генерируем ошибку
if (is.null(numbers) || length(numbers) == 0) {
plr.error("Пустой массив чисел: вычисление среднего невозможно!")
}
num_vector <- as.numeric(numbers)
mean_value <- mean(num_vector)
return(mean_value)
$$ LANGUAGE plr;
При вызове с пустым массивом PostgreSQL вернет ошибку.
Логирование и отладка
Когда функция начинает вести себя не так, как ожидается, полезно добавить логирование. PL/R позволяет выводить сообщения в логи PostgreSQL через plr.notice(). Пример функции с логированием:
CREATE OR REPLACE FUNCTION debug_add_numbers(a double precision, b double precision)
RETURNS double precision AS $$
# Логируем входные параметры
plr.notice("Запуск debug_add_numbers: a = " %+% a %+% ", b = " %+% b)
result <- a + b
# Логируем результат
plr.notice("Результат сложения: " %+% result)
return(result)
$$ LANGUAGE plr;
Запустив эту функцию, увидите сообщения в логах.
Преобразование типов и динамическая обработка данных
Иногда данные приходят не в том формате, который нужен. Например, массив дат может прийти в виде строк, и их надо преобразовать в формат Date. Рассмотрим функцию, вычисляющую разницу между самой ранней и самой поздней датой:
CREATE OR REPLACE FUNCTION date_range(dates text[])
RETURNS integer AS $$
# Преобразуем строки в объекты Date
date_vec <- as.Date(dates)
if (length(date_vec) == 0) {
plr.error("Массив дат пустой!")
}
min_date <- min(date_vec)
max_date <- max(date_vec)
day_diff <- as.integer(max_date - min_date)
# Логируем промежуточные результаты
plr.notice("Минимальная дата: " %+% min_date)
plr.notice("Максимальная дата: " %+% max_date)
plr.notice("Разница в днях: " %+% day_diff)
return(day_diff)
$$ LANGUAGE plr;
Вызов:
SELECT date_range(ARRAY['2023-01-01', '2023-02-15', '2023-03-10']) AS day_difference;
Совмещение массивов и возврата таблиц: нормализация данных
Иногда нужно одновременно принимать массивы, выполнять над ними вычисления и возвращать таблицу. Например, функция, которая принимает массивы имён и значений, нормализует значения и возвращает таблицу с исходными и нормализованными данными:
CREATE OR REPLACE FUNCTION normalize_values(names text[], values numeric[])
RETURNS TABLE(name text, original_value numeric, normalized_value numeric) AS $$
# Проверяем, что массивы совпадают по длине
if (length(names) != length(values)) {
plr.error("Массивы names и values должны иметь одинаковую длину")
}
name_vec <- as.character(names)
value_vec <- as.numeric(values)
# Нормализация: (x - min) / (max - min)
min_val <- min(value_vec)
max_val <- max(value_vec)
normalized <- (value_vec - min_val) / (max_val - min_val)
result <- data.frame(
name = name_vec,
original_value = value_vec,
normalized_value = normalized
)
return(result)
$$ LANGUAGE plr;
Вызов:
SELECT * FROM normalize_values(
ARRAY['Alpha', 'Beta', 'Gamma', 'Delta'],
ARRAY[10, 20, 15, 25]
);
Результатом станет таблица, готовая для дальнейшего анализа или визуализации.
Примеры применения PL/R
Линейная регрессия для прогнозирования продаж
Предположим, есть таблица с данными по продажам:
CREATE TABLE sales (
sale_date DATE NOT NULL,
amount NUMERIC NOT NULL
);
INSERT INTO sales (sale_date, amount) VALUES
('2023-01-01', 100),
('2023-01-02', 120),
('2023-01-03', 130),
('2023-01-04', 150),
('2023-01-05', 160),
('2023-01-06', 170),
('2023-01-07', 200);
Функция линейной регрессии принимает два массива — даты и суммы продаж, преобразует даты в числовой формат, строит модель и возвращает коэффициенты:
CREATE OR REPLACE FUNCTION linear_regression(dates date[], amounts numeric[])
RETURNS TABLE(intercept numeric, slope numeric) AS $$
numeric_dates <- as.numeric(as.Date(dates))
numeric_amounts <- as.numeric(amounts)
model <- lm(numeric_amounts ~ numeric_dates)
coefs <- coef(model)
return(data.frame(intercept = coefs[1], slope = coefs[2]))
$$ LANGUAGE plr;
Вызов:
WITH data AS (
SELECT array_agg(sale_date ORDER BY sale_date) AS dates,
array_agg(amount ORDER BY sale_date) AS amounts
FROM sales
)
SELECT * FROM data, linear_regression(dates, amounts);
Получив коэффициенты, можно прогнозировать будущие продажи, просто подставив нужную дату в формулу.
Прогнозирование с ARIMA с использованием пакета forecast
Если нужно предсказать будущие значения временного ряда, можно использовать модель ARIMA. Пример функции, которая принимает массив сумм продаж и возвращает прогноз на заданное количество периодов:
CREATE OR REPLACE FUNCTION forecast_arima(amounts numeric[], forecast_period integer)
RETURNS numeric[] AS $$
if (!require("forecast", quietly = TRUE)) {
install.packages("forecast", repos="http://cran.r-project.org")
library(forecast)
}
ts_data <- ts(as.numeric(amounts), frequency = 7)
model <- auto.arima(ts_data)
fc <- forecast(model, h = forecast_period)
return(as.numeric(fc$mean))
$$ LANGUAGE plr;
Запуск:
WITH daily_sales AS (
SELECT array_agg(amount ORDER BY sale_date) AS amounts
FROM sales
WHERE sale_date BETWEEN '2023-01-01' AND '2023-01-07'
)
SELECT forecast_arima(amounts, 7) AS predicted_sales
FROM daily_sales;
Так можно оперативно получить прогноз продаж, не покидая базы данных.
Кластеризация геоданных: алгоритм k-means
Рассмотрим задачу кластеризации пользователей по координатам. Допустим, есть таблица с данными пользователей:
CREATE TABLE users (
id serial PRIMARY KEY,
name text,
longitude double precision,
latitude double precision,
active boolean DEFAULT true
);
INSERT INTO users (name, longitude, latitude) VALUES
('User1', 37.62, 55.75),
('User2', 37.60, 55.76),
('User3', 37.64, 55.74),
('User4', 37.65, 55.73),
('User5', 37.70, 55.72);
Функция для кластеризации:
CREATE OR REPLACE FUNCTION kmeans_clustering(coords double precision[][], clusters integer)
RETURNS TABLE(cluster_id integer, centroid_x double precision, centroid_y double precision) AS $$
if (length(coords) == 0) {
plr.error("Пустой массив координат")
}
data_matrix <- do.call(rbind, coords)
km_result <- kmeans(data_matrix, centers = clusters)
centers <- km_result$centers
result <- data.frame(
cluster_id = 1:nrow(centers),
centroid_x = centers[,1],
centroid_y = centers[,2]
)
return(result)
$$ LANGUAGE plr;
Вызов:
WITH user_coords AS (
SELECT array_agg(ARRAY[longitude, latitude]) AS coords
FROM users
WHERE active = true
)
SELECT * FROM user_coords, kmeans_clustering(coords, 2);
Результатом станут кластеры с координатами центроидов.
Классификация клиентов с randomForest
В качестве бонуса — пример машинного обучения для классификации клиентов. Допустим, есть таблица клиентов:
CREATE TABLE customers (
id serial PRIMARY KEY,
age integer,
income numeric,
loyalty_score numeric,
churn boolean
);
INSERT INTO customers (age, income, loyalty_score, churn) VALUES
(25, 30000, 0.7, false),
(45, 70000, 0.9, false),
(35, 50000, 0.5, true),
(50, 90000, 0.8, false),
(30, 40000, 0.6, true);
Функция для классификации на основе randomForest:
CREATE OR REPLACE FUNCTION classify_customers(
ages integer[], incomes numeric[], scores numeric[], churns boolean[]
)
RETURNS TABLE(id integer, predicted_churn integer) AS $$
if (!require("randomForest", quietly = TRUE)) {
install.packages("randomForest", repos="http://cran.r-project.org")
library(randomForest)
}
df <- data.frame(
age = as.numeric(ages),
income = as.numeric(incomes),
loyalty = as.numeric(scores),
churn = as.factor(churns)
)
model <- randomForest(churn ~ age + income + loyalty, data = df, ntree = 100)
predictions <- predict(model, df)
result <- data.frame(id = 1:length(predictions), predicted_churn = as.integer(predictions) - 1)
return(result)
$$ LANGUAGE plr;
Вызов функции:
WITH customer_data AS (
SELECT array_agg(age ORDER BY id) AS ages,
array_agg(income ORDER BY id) AS incomes,
array_agg(loyalty_score ORDER BY id) AS scores,
array_agg(churn ORDER BY id) AS churns
FROM customers
)
SELECT * FROM customer_data, classify_customers(ages, incomes, scores, churns);
В заключение напоминаю про открытые уроки по PostgreSQL, которые пройдут в марте в Otus:
4 марта. От PostgreSQL к Arenadata DB. Подробнее
24 марта. Безопасность в PostgreSQL: защита данных, управление доступом и аудит. Подробнее
Больше уроков по аналитике и не только смотрите в календаре мероприятий.