В мире анализа данных и машинного обучения качественные табулированные данные играют ключевую роль. Однако далеко не всегда у специалистов есть доступ к реальным данным из-за конфиденциальности, отсутствия информации или необходимости тестирования алгоритмов перед их применением в реальных сценариях. В таких случаях на помощь приходят языковые модели, способные генерировать структурированные таблицы с синтетическими данными.
Тем не менее, не все сгенерированные данные одинаково полезны. Если для тестирования программных решений можно использовать любые правдоподобные данные, то для аналитики и моделирования требуется соблюдать закономерности, характерные для реальных данных. Ошибки в распределениях, несоответствие бизнес-логике или отсутствие взаимосвязей могут привести к неверным выводам и некачественным моделям.
В этом материале мы рассмотрим, как языковые модели могут помочь в создании табличных данных, какие методы повышают реалистичность синтетических данных и какие ограничения стоит учитывать при их использовании.
Реальная задача
Для демонстрации генерации табличных данных с помощью языковых моделей рассмотрим упрощенный сценарий. Наша таблица содержит три колонки: State, Age Group и Ethnicity Group. Для простоты во всех строках значение в колонке State фиксировано и всегда равно "California/CA". Мы будем воссоздавать демографическую статистику Калифорнии, используя данные с сайта US Public Policy Institute of California (PPIC).
Согласно последним оценкам Бюро переписи населения США, Калифорния — один из самых этнически разнообразных штатов и основные группы населения распределены следующим образом:
-
40% — латиноамериканцы
-
34% — белые
-
16% — азиаты или жители островов Тихого океана
-
6% — афроамериканцы
-
3% — представители двух и более рас
-
<1% — коренные американцы или жители Аляски
Кроме того, демографическое распределение зависит от возраста. Среди молодежи до 24 лет латиноамериканцы составляют 51.4%, а среди пожилых людей (65 лет и старше) белые составляют 53.0%.

Мы попробуем воспроизвести эти данные, используя три различных метода генерации табличных данных с помощью языковых моделей, а именно OpenAI GPT-4o-mini через API:
-
Прямое генерирование набора данных по запросу. Самый простой способ — просто попросить LLM сгенерировать таблицу нужного размера и структуры.
-
Генерация данных построчно (точнее, по ячейкам). В этом методе модель генерирует каждую ячейку отдельно, используя предыдущие сгенерированные ячейки в той же строке как контекст.
-
Запрос вероятностей значений колонок. Мы можем попросить модель сначала определить вероятности для каждой категории, а затем сделать выборки, основываясь на полученных вероятностях.
В следующем разделе мы рассмотрим, как эти методы работают на практике, сравним их результаты и обсудим, какой из них лучше подходит для генерации синтетических демографических данных.
Инициализация и дополнительные функции
Сначала импортируем все необходимые библиотеки, инициализируем OpenAI клиента (необходим OpenAI API токен; в принципе, можно использовать любую языковую модель, локальную или удаленную, например, на GroqCloud и т.п.).
import json
import time
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from collections import defaultdict
from tqdm.auto import tqdm
from openai import OpenAI
# Параметры для OpenAI API
OPENAI_API_KEY = ""
OPENAI_MODEL = 'gpt-4o-mini'
TEMPERATURE = 0.95
TOP_P = 1.0
SAMPLE_SIZE = 10000
openai_client = OpenAI(api_key=OPENAI_API_KEY)
# Описание характеристик данных
data_description = 'The data contains the information on US population.'
target_features = {
"State": {
"categories": ['California/CA'],
"prompt": "State of residence",
"dtype": "category"
},
"Age Group": {
"categories": ["Children (0-17)", "College-going age (18–24)", "Prime-working age (25–54)", "Adults (55–64)", "65 and older"],
"prompt": "Age group of individuals in California",
"dtype": "category"
},
"Ethnicity Group": {
"categories": ["Latino", "White", "Asian/Pacific Islander", "Black", "Native American", "Multiracial/Other"],
"prompt": "Ethnicity of individuals in California",
"dtype": "category"
}
}
def get_llm_output(user_prompt: str, system_prompt: str, max_tokens: int) -> dict:
"""
Отправляет запрос к LLM и ожидает JSON-ответа.
"""
output = openai_client.chat.completions.create(
model=OPENAI_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
response_format={"type": "json_object"},
temperature=TEMPERATURE,
top_p=TOP_P,
max_tokens=max_tokens
)
return json.loads(output.choices[0].message.content)
# Цветовая палитра для визуализации
palette = list(np.flip(["#CA4F1A", "#293B54", "#CCCB74", "#93D1E4", "#A98BC0", "#B3B3B1"]))
def plot_distribution(samples: pd.DataFrame, target_features: dict) -> None:
"""
Строит график распределения данных по возрастным и этническим группам.
"""
ax = sns.histplot(
data=samples,
y="Age Group", hue="Ethnicity Group", hue_order=np.flip(target_features["Ethnicity Group"].get("categories")), palette=palette,
multiple="fill", stat="proportion",
discrete=True, shrink=.8
)
plt.xticks(rotation=45)
sns.move_legend(ax, loc='upper center', bbox_to_anchor=(0.5, 1.2), ncol=6, fancybox=False)
plt.show()
Этот код включает в себя:
-
Запрос к LLM
get_llm_output()
— функция для отправки запроса к языковой модели. -
Описание характеристик данных: target_features — словарь с возможными значениями для State, Age Group и Ethnicity Group.
-
Визуализация:
plot_distribution()
— строит график распределения этнических групп в зависимости от возрастной категории.
Теперь можно переходить к разбору различных методов генерации данных!
Метод 1: Простая генерация полного набора данных с помощью LLM
Самый прямолинейный способ создания синтетических данных с помощью языковой модели — это запросить у неё сразу весь набор данных нужного размера и структуры. В этом методе мы просто передаём модели параметры таблицы, включая названия колонок, возможные значения, ограничения и ожидаемый формат вывода.
Настройка системы (System Prompt)
Первый шаг — задать системе правильный контекст. Нам нужно, чтобы модель работала в режиме генерации статистических данных и возвращала результат в строго заданном формате, без лишних пояснений или форматирования. Для этого мы используем system prompt:
SYSTEM_PROMPT = """You are an expert in statistics. The output should be limited
strictly to the JSON structure without any additional explanations or
formatting."""
Этот системный промпт гарантирует, что модель сосредоточится только на генерации данных, а не будет добавлять комментарии или оформлять ответ в свободной форме.
Основной запрос (Data Generation Prompt)
Теперь формируем запрос к модели, в котором указываем:
-
Размер выборки (SAMPLE_SIZE) — количество строк в таблице.
-
Колонки:
-
State
(всегда "California/CA"). -
Age Group
(категории возрастных групп). -
Ethnicity Group
(категории этнических групп, отражающие демографию Калифорнии).
-
-
Формат вывода — JSON, без лишнего текста.
Пример запроса:
prompt = f'''Generate a table with exactly {SAMPLE_SIZE} records with columns 'State', 'Age Group', and 'Ethnicity Group'.
'State' contains identical values, all set to 'California/CA'.
'Age Group' should be sampled from the categories '{target_features['Age Group']['categories']}'.
'Ethnicity Group' should be sampled from the categories '{target_features['Ethnicity Group']['categories']}'
reflecting population in 'State' of California/CA.
Only return the JSON object. Do not include any additional text, explanations, or formatting.'''
start_time = time.time()
results_task_1 = get_llm_output(user_prompt=prompt, system_prompt=SYSTEM_PROMPT, max_tokens=10_000)
stop_time = time.time()
print(f'Total Generation Time: {stop_time-start_time:.2f}s')
После выполнения такого запроса модель должна сгенерировать JSON, похожий на следующий:
[
{"State": "California", "Age Group": "0-24", "Ethnicity Group": "Latino"},
{"State": "California", "Age Group": "25-64", "Ethnicity Group": "White"},
{"State": "California", "Age Group": "65+", "Ethnicity Group": "White"},
{"State": "California", "Age Group": "0-24", "Ethnicity Group": "Asian American or Pacific Islander"},
{"State": "California", "Age Group": "25-64", "Ethnicity Group": "Latino"},
{"State": "California", "Age Group": "65+", "Ethnicity Group": "Black"},
...
]
После конвертации в Pandas датафрейм и построения графика распределения:
# convert to Pandas dataframe
results_task_1_df = pd.DataFrame(results_task_1['records'])
# plot the results
plot_distribution(samples=results_task_1_df, target_features=target_features)
получаем примерно следующую картину:

Как видно из рисунка этот метод довольно плохо отражает реальную популяцию в Калифорнии.
Итак, преимущества и недостатки метода
-
Простота – достаточно одного запроса, чтобы получить сразу весь набор данных.
-
Быстрота – модель сразу возвращает готовую таблицу.
-
Ограниченный контроль – сложно гарантировать, что сгенерированные данные точно соответствуют реальным статистическим распределениям.
-
Фиксированные вероятности – модель может не учитывать корреляции между возрастными и этническими группами.
-
Ограниченное количество строк и колонок – такой способ не позволит сгенерировать реалистичные таблицы с большим количеством строк.
Метод 2: Генерация данных построчно или по ячейкам
Во втором подходе мы запрашиваем модель генерировать значения по одной строке или даже по одной ячейке за раз. Это повышает точность, так как позволяет модели учитывать контекст и корреляции.
На этот раз мы сообщаем модели, что она должна генерировать данные по одной колонке за раз, используя знания о демографии Калифорнии.
SYSTEM_PROMPT = """You are an expert in population statistics, and I need
your help generating a dataset, column by column. The first column will
always be California, and I need you to iteratively choose the value
of the next column based on some pre-defined set of possible values.
To pick each value, please use your knowledge of the population statistics
from California. The dataset should be as diverse as the population
in California is with respect to the variables we're generating, the age group
and ethnicity. Answer in JSON format."""
Этот системный промпт даёт модели задачу постепенно заполнять строки, используя информацию о распределении населения.
Чтобы выбрать значение для каждой колонки, мы передаём модели следующий запрос:
def create_prompt(user_prompt: str, description: str, context: str, categories: str) -> str:
prompt = f"""Based on the provided context and categories, please add a new value for the column {user_prompt}. The context is as follows:
Context: {context}
Categories: {", ".join(categories)}
You have to choose one category from the above list and provide the value for the column {user_prompt}.
The output should be limited strictly to the chosen category without any additional explanations or formatting."""
return prompt
Этот запрос делает следующее:
-
Объясняет модели, что она должна выбрать одно значение для заданной колонки.
-
Даёт контекст (например, уже выбранные значения для других колонок в строке).
-
Перечисляет возможные категории для выбора.
-
Указывает, что выходные данные должны содержать только одно значение без лишнего текста.
Теперь для генерации данных мы итеративно заполняем каждую строку:
import time
from tqdm import tqdm # Добавляет индикатор выполнения
start_time = time.time()
results_task_2 = []
for row_id in tqdm(range(SAMPLE_SIZE), total=SAMPLE_SIZE):
context = "{"
record = dict()
for feat_id, (feat, prop) in enumerate(target_features.items()):
categories = prop["categories"]
user_prompt = create_prompt(user_prompt=feat, description=data_description, categories=categories, context=context)
d_out = get_llm_output(user_prompt=user_prompt, system_prompt=SYSTEM_PROMPT, max_tokens=100)
record.update(d_out)
a, b = zip(*d_out.items())
key = a[0]
value = b[0]
context += f"{key}: {value}, "
results_task_2.append(record)
stop_time = time.time()
print(f'Total Generation Time: {stop_time-start_time:.2f}s')
Как это работает
-
Мы создаём пустую запись (`record`), которую будем заполнять по колонкам.
-
Для каждой строки мы поочерёдно запрашиваем у модели значения для колонок Age Group и Ethnicity Group, передавая ей контекст уже выбранных значений.
После генерации мы получим примерно следующую картину распределения:

Полученные данные выглядят немного лучше, чем в первом случае, но все же дают неправильное распределение.
Преимущества и недостатки метода:
-
Более точное распределение – каждая колонка выбирается на основе контекста, снижая ошибки.
-
Гибкость – можно легко менять порядок генерации колонок или добавлять зависимости.
-
Дольше по времени – модель делает несколько отдельных вызовов, а не один.
Метод построчной генерации лучше сохраняет реалистичность данных и позволяет точнее контролировать распределения. Однако он требует больше запросов к модели, что может увеличить время и стоимость генерации. При использовании этого метода всегда необходимо отправить большое количество запросов, равное количеству ячеек в таблице (`n_columns * n_row`; например, для создания таблицы с 5 колонкам и 1 миллионом строк надо послать 5 миллионов запросов и получить 5 миллионов ответов!), что на практике оказывается очень дорого.
Метод 3: Использование условных вероятностей для генерации данных
Третий метод основан на запросе к языковой модели о вероятностном распределении значений для колонок, вместо того чтобы запрашивать конкретные значения для каждой строки. Это более эффективный подход, так как позволяет снизить количество вызовов модели и уменьшить вычислительные затраты.
Основная идея метода:
-
Вместо генерации каждого значения отдельно, мы сначала запрашиваем у модели вероятностное распределение значений (категорий) в зависимости от контекста.
-
После получения вероятностей мы используем их для случайного выбора значений (sampling) при построении набора данных.
-
Контекстная зависимость: н-р, возрастная группа (
Age Group
) влияет на распределение этнических групп (Ethnicity Group
). Например, среди молодых людей (0-24) больше латиноамериканцев, а среди пожилых (65+) — больше белых.
Настройка системы (System Prompt)
Задаём модели роль эксперта по демографии Калифорнии, который оценивает вероятности этнических групп в зависимости от возрастной группы.
SYSTEM_PROMPT = """You are an expert in statistics and in California
demographics. I need you to use all your knowledge about the age groups and
ethnicities of people living in California.
The output should be limited strictly to the JSON structure without any
additional explanations or formatting."""
Запрос для генерации вероятностей (Probability Distribution Prompting)
Мы передаём модели описание данных и запрашиваем, н-р, вероятности для каждой этнической группы, учитывая контекст возраста. Для этого мы используем следующий запрос:
def create_prompt(user_prompt: str, description: str, context: str, categories: str) -> str:
prompt = f"""Based on the provided data description, context, and categories, estimate the probability distribution for {user_prompt}.
## Context: {context}
## Data Description: {description}
## Categories: {categories}
Return the results in JSON format, with each category as a key and its corresponding normalized probability as a value. Ensure the categories in the response match exactly as given.
## Results:"""
return prompt
Теперь делаем запрос к модели для получения распределения вероятностей:
import time
from collections import defaultdict
import numpy as np
start_prompt_time = time.time()
llm_outputs = {}
for age_group in target_features['Age Group']['categories']:
prompt = create_prompt(
user_prompt='Ethnicity Group',
description=data_description,
context=f'State is California/CA. Age Group is {age_group}',
categories=target_features['Ethnicity Group']['categories']
)
d_out = get_llm_output(user_prompt=prompt, system_prompt=PT, max_tokens=100)
# Возможная нормализация (если LLM выдала нестрогое распределение)
total_prob = sum(d_out.values())
llm_outputs[age_group] = {k: v / total_prob for k, v in d_out.items()}
stop_prompt_time = time.time()
total_prompt_time = stop_prompt_time - start_prompt_time
print(f'Total Prompting Time: {total_prompt_time:.2f}s')
Как это работает:
-
Мы передаём модели контекст (например, что возрастная группа = "0-24").
-
Модель должна вернуть JSON, где каждой категории соответствует вероятность (все суммы вероятностей = 1; ренормализация, если нужно).
-
Вероятности используются для стохастического выбора значений в будущем.
Ожидаемый JSON-ответ от модели:
{
"0-24": {"Latino": 0.514, "White": 0.200, "Asian American or Pacific Islander": 0.160, "Black": 0.060, "Multiracial": 0.030, "Native American or Alaska Native": 0.006},
"25-64": {"Latino": 0.400, "White": 0.340, "Asian American or Pacific Islander": 0.160, "Black": 0.060, "Multiracial": 0.030, "Native American or Alaska Native": 0.006},
"65+": {"Latino": 0.200, "White": 0.530, "Asian American or Pacific Islander": 0.160, "Black": 0.060, "Multiracial": 0.030, "Native American or Alaska Native": 0.006}
}
Теперь, когда у нас есть вероятности, мы используем их для генерации.
start_sampling_time = time.time()
results_task_3 = defaultdict(list)
for age_group, probabilities in llm_outputs.items():
results_task_3['State'].extend(['California/CA'] * SAMPLE_SIZE)
results_task_3['Age Group'].extend([age_group] * SAMPLE_SIZE)
results_task_3['Ethnicity Group'].extend(
np.random.choice(
a=list(probabilities.keys()),
p=[float(v) for v in probabilities.values()],
size=SAMPLE_SIZE
)
)
stop_sampling_time = time.time()
total_generation_time = (stop_sampling_time - start_sampling_time) + total_prompt_time
print(f'Total Generation (Prompting and Sampling) Time: {total_generation_time:.2f}s')
Как это работает:
-
Используем сохранённые вероятности.
-
Для каждой возрастной группы повторяем
SAMPLE_SIZE
раз выбор из этнических групп на основе вероятностей. -
Используем
np.random.choice()
для стохастического выбора этнической группы в каждой строке.
Полученные данные теперь выглядят достаточно близко к тому, что должно быть в реальности!

Преимущества и недостатки метода:
-
Более высокая эффективность – модель запрашивается существенно меньшее количество раз, чем в методе 2.
-
Избегаем токенового смещения – модель выдаёт вероятности сразу, а не по одной записи для всей колонки.
-
Гибкость – можно легко менять вероятности или добавлять новые контексты.
-
Возможны небольшие ошибки в распределении – если языковая модель выдаёт "неточные" вероятности, могут потребоваться корректировки.
Этот метод оптимален, если нам нужно генерировать много данных, но сохранить контроль над их распределением.
Заключение
Использование метода генерации данных на основе условных вероятностей делает процесс более реалистичным и эффективным. В отличие от простого выбора следующего токена (как в авто-регрессивных моделях), этот подход основывается на "знаниях" модели, полученных во время предобучения. Это позволяет получать более точные данные, сохраняя естественные корреляции и распределения.
Кроме того, такой метод может адаптироваться к новым категориям и значениям, сохраняя логику данных (конечно, в пределах знаний модели). А при необходимости его можно даже доработать с помощью дообучения, чтобы настроить генерацию под конкретный датасет.
Дополнительно к более высокой точности, метод также является более эффективным и быстрым. Например, в случае теста с данными Калифорнии (State
– всегда "California/CA") нам требуется всего один запрос для получения распределения возрастных групп или этнических групп. Далее, в зависимости от того, что было сгенерировано первым (возраст или этническая группа), нам нужно сделать только 5 или 6 дополнительных запросов, чтобы получить вероятности для второй колонки. После этого весь процесс сводится к обычному сэмплированию на основе полученных распределений.
Таким образом, независимо от количества записей (тысячи, миллионы, миллиарды), метод требует всего 5-6 запросов к LLM, а вся дальнейшая генерация выполняется простую выборку на основе распределений вероятностей.
Однако, при увеличении числа колонок и категорий размер пространства поиска растёт, и количество запросов может приближаться к тому, что мы видели во втором методе. Тем не менее, даже в этом случае данный метод остаётся более эффективным, чем генерация каждой строки отдельно, поскольку основные вычислительные затраты приходятся на разовый запрос вероятностей, а не на каждую запись.
В итоге этот подход демонстрирует баланс между точностью и вычислительной эффективностью, делая его оптимальным вариантом для генерации реалистичных синтетических данных в больших масштабах.
Автор: psitronic