Разработка AI-системы для государственных закупок Public Procurement AI
Государственные закупки — сфера с высоким уровнем злоупотреблений: картельные сговоры, аффилированность поставщиков, завышение НМЦК. ML-система анализирует закупки по ЕИС (Единая информационная система) и выявляет признаки нарушений, недоступные при ручном аудите.
Данные и источники
Информационная база закупок:
data_sources = {
'eis_zakupki_gov_ru': {
'api': 'Открытые данные ЕИС API (44-ФЗ, 223-ФЗ)',
'entities': ['ContractNotice', 'ContractAward', 'Supplier', 'OKPD2'],
'volume': '~4 млн закупок в год'
},
'egrul_fns': {
'source': 'ЕГРЮЛ ФНС — данные о юрлицах',
'use': 'связи между поставщиками, аффилированность, учредители'
},
'rosstat': {
'source': 'финансовая отчётность компаний',
'use': 'реальные возможности поставщика vs. объём контракта'
},
'sudrf_ru': {
'source': 'арбитражные дела',
'use': 'история судебных споров поставщиков'
}
}
Детекция картельного сговора
Признаки картеля на торгах:
import pandas as pd
import numpy as np
from itertools import combinations
def detect_collusion_in_auction(auction_bids: pd.DataFrame,
auction_id: str) -> dict:
"""
Классические паттерны картеля:
1. Cover bidding: один выигрывает, остальные делают заведомо проигрышные ставки
2. Bid suppression: конкуренты отказываются от участия
3. Bid rotation: участники по очереди побеждают в разных лотах
4. Market allocation: географическое или секторальное разделение
"""
bids = auction_bids[auction_bids['auction_id'] == auction_id]
if len(bids) < 2:
return {'status': 'insufficient_bidders'}
nmck = bids['start_price'].max() # НМЦК — начальная (максимальная) цена
# Снижение цены каждого участника
bids['reduction_pct'] = (nmck - bids['bid_price']) / nmck * 100
bids_sorted = bids.sort_values('bid_price')
# Признак 1: минимальный шаг снижения от предыдущего участника
# Cover bids: остальные снижаются ровно на 0.5% (разрешённый минимум)
reductions = bids_sorted['bid_price'].diff().abs() / bids_sorted['bid_price'].shift(1)
min_step_count = (reductions < 0.006).sum() # почти все снизились на минимум
# Признак 2: победитель снизил на большой %, остальные — на минимум
winner_reduction = bids_sorted.iloc[0]['reduction_pct']
losers_reductions = bids_sorted.iloc[1:]['reduction_pct']
reduction_gap = winner_reduction - losers_reductions.mean()
# Признак 3: временные паттерны подачи заявок
if 'bid_timestamp' in bids.columns:
time_deltas = bids['bid_timestamp'].sort_values().diff().dt.total_seconds()
very_fast_submissions = (time_deltas < 60).sum() # < 1 минуты между заявками
else:
very_fast_submissions = 0
collusion_indicators = {
'cover_bids': min_step_count >= len(bids) - 2,
'large_reduction_gap': reduction_gap > 10,
'fast_sequential_bids': very_fast_submissions > 1,
'few_participants': len(bids) <= 2
}
collusion_score = sum(collusion_indicators.values()) / len(collusion_indicators)
return {
'auction_id': auction_id,
'n_bidders': len(bids),
'winner_reduction_pct': round(winner_reduction, 2),
'collusion_indicators': collusion_indicators,
'collusion_score': round(collusion_score, 2),
'flag_for_review': collusion_score > 0.5
}
Анализ аффилированности поставщиков
Граф связей через реестры:
import networkx as nx
def build_supplier_affiliation_graph(suppliers: pd.DataFrame,
egrul_data: pd.DataFrame) -> nx.Graph:
"""
Связи: общие учредители, адреса, телефоны, директора.
Аффилированные компании = один бенефициар = конкурент только формально.
"""
G = nx.Graph()
for _, supplier in suppliers.iterrows():
G.add_node(supplier['inn'], type='supplier', name=supplier['company_name'])
# Связи через общих учредителей
founders_data = egrul_data.groupby('founder_inn')['company_inn'].apply(list)
for founder, companies in founders_data.items():
if len(companies) > 1:
for c1, c2 in combinations(companies, 2):
if G.has_node(c1) and G.has_node(c2):
G.add_edge(c1, c2, relation='common_founder', founder_inn=founder)
# Связи через адрес регистрации (массовые регистрации)
address_counts = egrul_data.groupby('legal_address')['company_inn'].apply(list)
for address, companies in address_counts.items():
if len(companies) > 5: # адрес массовой регистрации
for c1, c2 in combinations(companies, 2):
if G.has_node(c1) and G.has_node(c2):
G.add_edge(c1, c2, relation='shared_address')
return G
def find_affiliated_bidders(auction_bidders: list, affiliation_graph: nx.Graph) -> dict:
"""
Проверяем: есть ли прямая связь между участниками?
"""
affiliated_pairs = []
for i, bidder1 in enumerate(auction_bidders):
for bidder2 in auction_bidders[i+1:]:
if affiliation_graph.has_edge(bidder1, bidder2):
path = nx.shortest_path(affiliation_graph, bidder1, bidder2)
affiliated_pairs.append({
'bidder1': bidder1,
'bidder2': bidder2,
'connection_path': path,
'connection_type': affiliation_graph[bidder1][bidder2].get('relation')
})
return {
'affiliated_pairs': affiliated_pairs,
'has_affiliation': len(affiliated_pairs) > 0,
'risk_level': 'high' if len(affiliated_pairs) > 0 else 'low'
}
Анализ НМЦК (обоснование цены)
Детекция завышенной НМЦК:
from sklearn.ensemble import GradientBoostingRegressor
def detect_inflated_nmck(procurement: dict, similar_procurements: pd.DataFrame) -> dict:
"""
НМЦК должна отражать рыночную стоимость.
Сравниваем с аналогичными закупками: тот же ОКПД2, регион, объём.
"""
# Фильтрация аналогов
comparable = similar_procurements[
(similar_procurements['okpd2_code'].str[:4] == procurement['okpd2_code'][:4]) &
(similar_procurements['region_code'] == procurement['region_code']) &
(similar_procurements['quantity'] >= procurement['quantity'] * 0.5) &
(similar_procurements['quantity'] <= procurement['quantity'] * 2.0)
]
if len(comparable) < 5:
return {'status': 'insufficient_comparables'}
unit_prices = comparable['contract_price'] / comparable['quantity']
expected_unit_price = unit_prices.median()
std_unit_price = unit_prices.std()
nmck_unit = procurement['nmck'] / procurement['quantity']
z_score = (nmck_unit - expected_unit_price) / (std_unit_price + 1e-9)
overpricing_pct = (nmck_unit - expected_unit_price) / expected_unit_price * 100
return {
'nmck_unit_price': round(nmck_unit, 2),
'market_median_price': round(expected_unit_price, 2),
'overpricing_pct': round(overpricing_pct, 1),
'z_score': round(z_score, 2),
'inflation_detected': z_score > 3,
'comparable_contracts_n': len(comparable)
}
Bid Rotation — паттерн выигрышей
Статистический тест на ротацию победителей:
from scipy.stats import chi2_contingency
def detect_bid_rotation(procurement_history: pd.DataFrame,
supplier_group: list) -> dict:
"""
Картельная ротация: каждый поставщик выигрывает "свою" долю лотов.
Статистически: равномерное распределение побед при видимой конкуренции.
"""
group_wins = procurement_history[
procurement_history['winner_inn'].isin(supplier_group)
]['winner_inn'].value_counts()
if len(group_wins) < 2:
return {'status': 'insufficient_data'}
# Chi-square тест: равномерность распределения побед
observed = group_wins.values
expected = np.full_like(observed, np.mean(observed), dtype=float)
chi2_stat, p_value = chi2_contingency([observed, expected])[:2]
# Низкий chi2 = слишком равномерное распределение = подозрительно
rotation_detected = chi2_stat < 2 and p_value > 0.9
return {
'supplier_win_counts': group_wins.to_dict(),
'chi2_statistic': round(chi2_stat, 3),
'p_value': round(p_value, 3),
'rotation_detected': rotation_detected,
'interpretation': 'Слишком равномерное распределение побед' if rotation_detected else 'Распределение нормальное'
}
Интеграция с регуляторами: Экспорт выявленных нарушений в форматах ФАС России (антикартельный отдел), Счётная Палата, Росфинмониторинг. REST API для интеграции с системами автоматизированного контроля закупок (АСФК, АЦК-Финансы).
Сроки: Базовая аналитика коллюзии + НМЦК сравнение + аффилированность по ЕГРЮЛ — 5-6 недель. Граф аффилированности, bid rotation тест, ML-модель на признаках коррупции, ФАС API — 3-4 месяца.







