Реализация функционала полнофакторного эксперимента

This commit is contained in:
2026-05-06 23:10:55 +05:00
parent 361b934e8a
commit 15193d2403
5 changed files with 751 additions and 212 deletions
+437 -80
View File
@@ -2,16 +2,19 @@ from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QWidget, QMessageBox,
QTableWidget, QTableWidgetItem, QGroupBox,
QSpinBox, QDoubleSpinBox, QComboBox, QLineEdit,
QTextEdit, QTabWidget, QFormLayout)
QTextEdit, QTabWidget, QFormLayout, QCheckBox,
QScrollArea, QFileDialog)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
from PyQt5.QtGui import QColor, QFont
import numpy as np
import csv
class ExperimentDesignWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Планирование эксперимента - Цифровой помощник биохимика")
self.setGeometry(200, 100, 1000, 750)
self.setGeometry(200, 100, 1200, 800)
self.setStyleSheet("""
QMainWindow {
background-color: #f5f5f5;
@@ -27,6 +30,7 @@ class ExperimentDesignWindow(QMainWindow):
subcontrol-origin: margin;
left: 10px;
padding: 0 5px 0 5px;
color: #1565C0;
}
QPushButton {
background-color: #4CAF50;
@@ -42,6 +46,13 @@ class ExperimentDesignWindow(QMainWindow):
QTableWidget {
gridline-color: #ddd;
}
QHeaderView::section {
background-color: #1565C0;
color: white;
padding: 8px;
border: none;
font-weight: bold;
}
""")
self._init_ui()
@@ -71,26 +82,41 @@ class ExperimentDesignWindow(QMainWindow):
factors_group = QGroupBox("Факторы эксперимента (независимые переменные)")
factors_layout = QVBoxLayout()
# Информация
info_label = QLabel("Определите факторы, которые влияют на ваш эксперимент:")
info_label.setStyleSheet("color: #555; font-weight: normal;")
factors_layout.addWidget(info_label)
self.factors_table = QTableWidget()
self.factors_table.setColumnCount(4)
self.factors_table.setHorizontalHeaderLabels(["Фактор", "Нижний уровень (-1)", "Верхний уровень (+1)", "Единица измерения"])
self.factors_table.setRowCount(3)
# Новый порядок колонок: Фактор, Нулевой уровень, Шаг, Верхний (+1), Нижний (-1), Единица измерения
self.factors_table.setColumnCount(6)
self.factors_table.setHorizontalHeaderLabels(["Фактор", "Нулевой уровень (0)", "Шаг",
"Верхний уровень (+1)", "Нижний уровень (-1)", "Единица измерения"])
self.factors_table.setRowCount(2)
# Пример данных
sample_factors = [
["Температура", "25", "37", "°C"],
["pH", "6.5", "7.5", ""],
["Концентрация глюкозы", "5", "20", "г"]
["Температура", "31", "6", "37", "25", "°C"],
["pH", "7.0", "0.5", "7.5", "6.5", ""],
]
for i, factor in enumerate(sample_factors):
for j, value in enumerate(factor):
self.factors_table.setItem(i, j, QTableWidgetItem(value))
item = QTableWidgetItem(value)
if j in [3, 4]: # Верхний и нижний уровень - только для чтения
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
item.setBackground(QColor(240, 240, 240))
self.factors_table.setItem(i, j, item)
# Настройка ширины колонок
self.factors_table.setColumnWidth(0, 150)
self.factors_table.setColumnWidth(1, 120)
self.factors_table.setColumnWidth(2, 80)
self.factors_table.setColumnWidth(3, 120)
self.factors_table.setColumnWidth(4, 120)
self.factors_table.setColumnWidth(5, 100)
# Подключаем сигналы изменения ячеек
self.factors_table.itemChanged.connect(self.on_factor_changed)
factors_layout.addWidget(self.factors_table)
@@ -108,6 +134,27 @@ class ExperimentDesignWindow(QMainWindow):
factors_group.setLayout(factors_layout)
params_layout.addWidget(factors_group)
# Группа настроек эксперимента
settings_group = QGroupBox("Настройки эксперимента")
settings_layout = QHBoxLayout()
center_layout = QHBoxLayout()
center_layout.addWidget(QLabel("Количество центральных точек:"))
self.center_points_spin = QSpinBox()
self.center_points_spin.setRange(0, 10)
self.center_points_spin.setValue(3)
self.center_points_spin.setToolTip("Повторные опыты в нулевой точке для оценки дисперсии")
center_layout.addWidget(self.center_points_spin)
settings_layout.addLayout(center_layout)
self.randomize_check = QCheckBox("Рэндомизировать порядок опытов")
self.randomize_check.setChecked(True)
settings_layout.addWidget(self.randomize_check)
settings_layout.addStretch()
settings_group.setLayout(settings_layout)
params_layout.addWidget(settings_group)
# Группа откликов
responses_group = QGroupBox("Отклики (зависимые переменные)")
responses_layout = QVBoxLayout()
@@ -128,7 +175,6 @@ class ExperimentDesignWindow(QMainWindow):
responses_layout.addWidget(self.responses_table)
# Кнопки для управления откликами
response_buttons = QHBoxLayout()
add_response_btn = QPushButton("+ Добавить отклик")
add_response_btn.clicked.connect(self.add_response_row)
@@ -148,17 +194,38 @@ class ExperimentDesignWindow(QMainWindow):
plan_tab = QWidget()
plan_layout = QVBoxLayout(plan_tab)
plan_info = QLabel("Полнофакторный план эксперимента (Full Factorial Design)")
plan_info = QLabel("Полнофакторный план эксперимента с центральными точками")
plan_info.setAlignment(Qt.AlignCenter)
plan_info.setStyleSheet("font-weight: bold; font-size: 14px; padding: 10px;")
plan_layout.addWidget(plan_info)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
matrix_widget = QWidget()
matrix_layout = QVBoxLayout(matrix_widget)
self.design_matrix = QTableWidget()
plan_layout.addWidget(self.design_matrix)
matrix_layout.addWidget(self.design_matrix)
scroll.setWidget(matrix_widget)
plan_layout.addWidget(scroll)
buttons_layout = QHBoxLayout()
generate_btn = QPushButton("🔄 Сгенерировать план эксперимента")
generate_btn.clicked.connect(self.generate_design_matrix)
plan_layout.addWidget(generate_btn)
buttons_layout.addWidget(generate_btn)
export_btn = QPushButton("📊 Экспорт в CSV")
export_btn.clicked.connect(self.export_to_csv)
buttons_layout.addWidget(export_btn)
buttons_layout.addStretch()
plan_layout.addLayout(buttons_layout)
self.plan_info_label = QLabel("")
self.plan_info_label.setStyleSheet("color: #666; font-size: 12px; padding: 5px;")
plan_layout.addWidget(self.plan_info_label)
tabs.addTab(plan_tab, "📊 Матрица планирования")
@@ -166,58 +233,29 @@ class ExperimentDesignWindow(QMainWindow):
analysis_tab = QWidget()
analysis_layout = QVBoxLayout(analysis_tab)
analysis_info = QLabel("Регрессионный анализ и визуализация результатов")
analysis_info = QLabel("Введите результаты экспериментов для анализа")
analysis_info.setAlignment(Qt.AlignCenter)
analysis_info.setStyleSheet("font-weight: bold; font-size: 14px; padding: 10px;")
analysis_layout.addWidget(analysis_info)
# Заглушка для анализа
placeholder = QLabel("Здесь будет:\n\n"
"• Множественная линейная регрессия\n"
"• Анализ взаимодействий факторов\n"
"• ANOVA (дисперсионный анализ)\n"
"• Построение поверхностей отклика\n"
"• Графики главных эффектов\n"
"• Оптимизация параметров")
placeholder.setAlignment(Qt.AlignCenter)
placeholder.setStyleSheet("color: #666; font-size: 12px; border: 1px dashed #ccc; padding: 20px;")
analysis_layout.addWidget(placeholder)
self.results_table = QTableWidget()
analysis_layout.addWidget(self.results_table)
analyze_btn = QPushButton("📈 Провести анализ (в разработке)")
analyze_btn.clicked.connect(self.show_placeholder_message)
analyze_btn = QPushButton("📈 Провести регрессионный анализ")
analyze_btn.clicked.connect(self.perform_analysis)
analysis_layout.addWidget(analyze_btn)
self.analysis_output = QTextEdit()
self.analysis_output.setReadOnly(True)
self.analysis_output.setMaximumHeight(200)
analysis_layout.addWidget(self.analysis_output)
tabs.addTab(analysis_tab, "📐 Анализ результатов")
# Вкладка 4: Визуализация
viz_tab = QWidget()
viz_layout = QVBoxLayout(viz_tab)
viz_info = QLabel("Интерактивная визуализация данных")
viz_info.setAlignment(Qt.AlignCenter)
viz_info.setStyleSheet("font-weight: bold; font-size: 14px; padding: 10px;")
viz_layout.addWidget(viz_info)
viz_placeholder = QLabel("Здесь будут:\n\n"
"• 2D и 3D графики поверхностей отклика\n"
"• Контурные графики\n"
"• Диаграммы Парето\n"
"• Графики нормальной вероятности")
viz_placeholder.setAlignment(Qt.AlignCenter)
viz_placeholder.setStyleSheet("color: #666; font-size: 12px; border: 1px dashed #ccc; padding: 20px;")
viz_layout.addWidget(viz_placeholder)
tabs.addTab(viz_tab, "📈 Визуализация")
layout.addWidget(tabs)
# Кнопки управления
btn_layout = QHBoxLayout()
export_btn = QPushButton("💾 Экспорт отчёта (PDF/Excel)")
export_btn.clicked.connect(self.show_placeholder_message)
btn_layout.addWidget(export_btn)
save_btn = QPushButton("💿 Сохранить проект")
save_btn.clicked.connect(self.show_placeholder_message)
btn_layout.addWidget(save_btn)
@@ -234,6 +272,36 @@ class ExperimentDesignWindow(QMainWindow):
layout.addLayout(btn_layout)
def on_factor_changed(self, item):
"""При изменении нулевого уровня или шага пересчитываем верхний и нижний уровни"""
row = item.row()
col = item.column()
# Если изменили нулевой уровень (колонка 1) или шаг (колонка 2)
if col in [1, 2]:
try:
center = float(self.factors_table.item(row, 1).text()) if self.factors_table.item(row, 1) else 0
step = float(self.factors_table.item(row, 2).text()) if self.factors_table.item(row, 2) else 0
# Пересчитываем верхний и нижний уровни
high = center + step
low = center - step
# Обновляем ячейки (временно отключаем сигнал)
self.factors_table.blockSignals(True)
high_item = self.factors_table.item(row, 3)
if high_item:
high_item.setText(f"{high:.3f}".rstrip('0').rstrip('.'))
low_item = self.factors_table.item(row, 4)
if low_item:
low_item.setText(f"{low:.3f}".rstrip('0').rstrip('.'))
self.factors_table.blockSignals(False)
except ValueError:
pass
def add_factor_row(self):
"""Добавляет строку для нового фактора"""
row = self.factors_table.rowCount()
@@ -241,7 +309,19 @@ class ExperimentDesignWindow(QMainWindow):
self.factors_table.setItem(row, 0, QTableWidgetItem(f"Фактор_{row+1}"))
self.factors_table.setItem(row, 1, QTableWidgetItem("0"))
self.factors_table.setItem(row, 2, QTableWidgetItem("1"))
self.factors_table.setItem(row, 3, QTableWidgetItem(""))
# Верхний и нижний уровни - только для чтения
high_item = QTableWidgetItem("1")
high_item.setFlags(high_item.flags() & ~Qt.ItemIsEditable)
high_item.setBackground(QColor(240, 240, 240))
self.factors_table.setItem(row, 3, high_item)
low_item = QTableWidgetItem("-1")
low_item.setFlags(low_item.flags() & ~Qt.ItemIsEditable)
low_item.setBackground(QColor(240, 240, 240))
self.factors_table.setItem(row, 4, low_item)
self.factors_table.setItem(row, 5, QTableWidgetItem(""))
def remove_factor_row(self):
"""Удаляет последнюю строку факторов"""
@@ -260,53 +340,330 @@ class ExperimentDesignWindow(QMainWindow):
if self.responses_table.rowCount() > 1:
self.responses_table.removeRow(self.responses_table.rowCount() - 1)
def generate_design_matrix(self):
"""Генерирует матрицу планирования (заглушка)"""
n_factors = self.factors_table.rowCount()
def get_factors_data(self):
"""Получает данные о факторах"""
factors = []
for row in range(self.factors_table.rowCount()):
try:
factor = {
'name': self.factors_table.item(row, 0).text() if self.factors_table.item(row, 0) else "",
'center': float(self.factors_table.item(row, 1).text()) if self.factors_table.item(row, 1) else 0,
'step': float(self.factors_table.item(row, 2).text()) if self.factors_table.item(row, 2) else 0,
'high': float(self.factors_table.item(row, 3).text()) if self.factors_table.item(row, 3) else 0,
'low': float(self.factors_table.item(row, 4).text()) if self.factors_table.item(row, 4) else 0,
'unit': self.factors_table.item(row, 5).text() if self.factors_table.item(row, 5) else ""
}
factors.append(factor)
except (ValueError, AttributeError):
continue
return factors
def calculate_factorial_design(self, factors):
"""Генерирует полнофакторный план 2^k с центральными точками"""
k = len(factors)
if k == 0:
return []
if n_factors == 0:
# Генерируем 2^k комбинаций
n_factorial = 2 ** k
design = []
for i in range(n_factorial):
experiment = {}
for j in range(k):
# Кодированный уровень (-1 или +1)
coded_level = -1 if (i >> (k - 1 - j)) & 1 == 0 else 1
# Переводим в натуральные значения
if coded_level == -1:
natural_value = factors[j]['low']
else:
natural_value = factors[j]['high']
experiment[f"Фактор_{j+1}"] = {
'coded': coded_level,
'natural': natural_value,
'name': factors[j]['name'],
'unit': factors[j]['unit']
}
design.append(experiment)
# Добавляем центральные точки
n_center = self.center_points_spin.value()
for i in range(n_center):
center_experiment = {}
for j in range(k):
center_experiment[f"Фактор_{j+1}"] = {
'coded': 0,
'natural': factors[j]['center'],
'name': factors[j]['name'],
'unit': factors[j]['unit']
}
center_experiment['is_center'] = True
center_experiment['center_num'] = i + 1
design.append(center_experiment)
# Рэндомизация порядка опытов
if self.randomize_check.isChecked():
import random
random.shuffle(design)
return design
def generate_design_matrix(self):
"""Генерирует и отображает матрицу планирования"""
factors = self.get_factors_data()
if len(factors) == 0:
QMessageBox.warning(self, "Предупреждение", "Добавьте хотя бы один фактор!")
return
# Полнофакторный план: 2^k опытов
n_experiments = 2 ** n_factors
# Генерируем план
design = self.calculate_factorial_design(factors)
# Количество опытов
n_experiments = len(design)
n_factors = len(factors)
# Настройка таблицы
self.design_matrix.setRowCount(n_experiments)
self.design_matrix.setColumnCount(n_factors + 1)
self.design_matrix.setColumnCount(n_factors + 2)
# Заголовки
headers = ["Опыт"] + [self.factors_table.item(i, 0).text() if self.factors_table.item(i, 0) else f"Фактор_{i+1}"
for i in range(n_factors)]
headers = ["опыта"] + [f["name"] for f in factors] + ["Тип точки"]
self.design_matrix.setHorizontalHeaderLabels(headers)
# Заполняем матрицу (простой 2^k план)
for exp in range(n_experiments):
# Заполняем матрицу
for exp_idx, experiment in enumerate(design):
# Номер опыта
self.design_matrix.setItem(exp, 0, QTableWidgetItem(str(exp + 1)))
self.design_matrix.setItem(exp_idx, 0, QTableWidgetItem(str(exp_idx + 1)))
# Кодированные уровни факторов (-1 или +1)
for factor in range(n_factors):
level = -1 if (exp // (2 ** factor)) % 2 == 0 else 1
self.design_matrix.setItem(exp, factor + 1, QTableWidgetItem(str(level)))
# Значения факторов
for factor_idx in range(n_factors):
factor_key = f"Фактор_{factor_idx + 1}"
value = experiment[factor_key]['natural']
unit = factors[factor_idx]['unit']
# Форматируем значение
if isinstance(value, float):
if value == int(value):
display_value = str(int(value))
else:
display_value = f"{value:.3f}".rstrip('0').rstrip('.')
else:
display_value = str(value)
if unit:
display_value += f" {unit}"
item = QTableWidgetItem(display_value)
# Подсветка центральных точек
if experiment.get('is_center', False):
item.setBackground(QColor(255, 255, 200))
self.design_matrix.setItem(exp_idx, factor_idx + 1, item)
# Тип точки
if experiment.get('is_center', False):
type_item = QTableWidgetItem(f"Центральная #{experiment['center_num']}")
type_item.setBackground(QColor(255, 255, 200))
else:
# Показываем комбинацию уровней
levels = []
for factor_idx in range(n_factors):
factor_key = f"Фактор_{factor_idx + 1}"
coded = experiment[factor_key]['coded']
levels.append("+" if coded == 1 else "-")
type_item = QTableWidgetItem(f"Факторная ({''.join(levels)})")
self.design_matrix.setItem(exp_idx, n_factors + 1, type_item)
# Настройка ширины колонок
self.design_matrix.resizeColumnsToContents()
# Обновляем информацию
n_factorial = 2 ** n_factors
n_center = self.center_points_spin.value()
self.plan_info_label.setText(
f"📊 План эксперимента: {n_factorial} факторных точек + {n_center} центральных точек = {n_experiments} опытов"
)
# Настраиваем таблицу для ввода результатов
self.setup_results_table(n_experiments)
QMessageBox.information(self, "Успех",
f"Сгенерирован план для {n_factors} факторов\n"
f"Общее количество опытов: {n_experiments}")
f"Факторных точек: {n_factorial}\n"
f"Центральных точек: {n_center}\n"
f"Всего опытов: {n_experiments}\n\n"
f"Центральные точки позволяют оценить дисперсию воспроизводимости")
def setup_results_table(self, n_experiments):
"""Настраивает таблицу для ввода результатов"""
n_responses = self.responses_table.rowCount()
self.results_table.setRowCount(n_experiments)
self.results_table.setColumnCount(n_responses + 1)
# Заголовки
headers = ["№ опыта"] + [self.responses_table.item(i, 0).text() if self.responses_table.item(i, 0) else f"Отклик_{i+1}"
for i in range(n_responses)]
self.results_table.setHorizontalHeaderLabels(headers)
# Заполняем номера опытов
for i in range(n_experiments):
self.results_table.setItem(i, 0, QTableWidgetItem(str(i + 1)))
# Настройка ширины колонок
self.results_table.setColumnWidth(0, 80)
for i in range(n_responses):
self.results_table.setColumnWidth(i + 1, 150)
def export_to_csv(self):
"""Экспортирует матрицу планирования в CSV"""
if self.design_matrix.rowCount() == 0:
QMessageBox.warning(self, "Предупреждение", "Сначала сгенерируйте план эксперимента!")
return
filename, _ = QFileDialog.getSaveFileName(
self,
"Сохранить план эксперимента",
"",
"CSV Files (*.csv);;All Files (*)"
)
if filename:
if not filename.lower().endswith('.csv'):
filename += '.csv'
try:
with open(filename, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.writer(f)
# Заголовки
headers = []
for j in range(self.design_matrix.columnCount()):
header_item = self.design_matrix.horizontalHeaderItem(j)
headers.append(header_item.text() if header_item else f"Колонка_{j+1}")
writer.writerow(headers)
# Данные
for i in range(self.design_matrix.rowCount()):
row = []
for j in range(self.design_matrix.columnCount()):
item = self.design_matrix.item(i, j)
row.append(item.text() if item else "")
writer.writerow(row)
QMessageBox.information(self, "Успех", f"План эксперимента сохранен в {filename}")
except Exception as e:
QMessageBox.critical(self, "Ошибка", f"Не удалось сохранить файл: {str(e)}")
def perform_analysis(self):
"""Проводит регрессионный анализ"""
n_responses = self.responses_table.rowCount()
if n_responses == 0:
QMessageBox.warning(self, "Предупреждение", "Добавьте хотя бы один отклик!")
return
# Собираем результаты
results = []
for i in range(self.results_table.rowCount()):
row_results = []
for j in range(1, self.results_table.columnCount()):
item = self.results_table.item(i, j)
if item and item.text():
try:
row_results.append(float(item.text()))
except ValueError:
row_results.append(None)
else:
row_results.append(None)
results.append(row_results)
# Проверяем, что все результаты введены
missing = False
for i, row in enumerate(results):
for j, val in enumerate(row):
if val is None:
missing = True
self.analysis_output.setText(f"❌ Ошибка: Не введены результаты для опыта {i+1}, отклик {j+1}")
return
# Анализ
self.analysis_output.clear()
self.analysis_output.append("=" * 60)
self.analysis_output.append("РЕЗУЛЬТАТЫ РЕГРЕССИОННОГО АНАЛИЗА")
self.analysis_output.append("=" * 60)
factors = self.get_factors_data()
design = self.calculate_factorial_design(factors)
for resp_idx in range(n_responses):
resp_name = self.responses_table.item(resp_idx, 0).text()
self.analysis_output.append(f"\n📊 Отклик: {resp_name}")
self.analysis_output.append("-" * 40)
# Собираем значения отклика
y_values = [results[i][resp_idx] for i in range(len(results))]
# Среднее значение
mean_y = np.mean(y_values)
self.analysis_output.append(f"Среднее значение: {mean_y:.4f}")
# Дисперсия
variance = np.var(y_values, ddof=1) if len(y_values) > 1 else 0
self.analysis_output.append(f"Общая дисперсия: {variance:.4f}")
# Стандартное отклонение
std_dev = np.std(y_values, ddof=1) if len(y_values) > 1 else 0
self.analysis_output.append(f"Стандартное отклонение: {std_dev:.4f}")
# Коэффициент вариации
if mean_y != 0:
cv = (std_dev / mean_y) * 100
self.analysis_output.append(f"Коэффициент вариации: {cv:.2f}%")
# Разделяем факторные и центральные точки
factorial_y = []
center_y = []
for i, exp in enumerate(design):
if exp.get('is_center', False):
center_y.append(y_values[i])
else:
factorial_y.append(y_values[i])
if len(center_y) > 1:
center_variance = np.var(center_y, ddof=1)
self.analysis_output.append(f"\nЦентральные точки (n={len(center_y)}):")
self.analysis_output.append(f" Среднее: {np.mean(center_y):.4f}")
self.analysis_output.append(f" Дисперсия воспроизводимости: {center_variance:.4f}")
self.analysis_output.append(f" Стандартное отклонение: {np.std(center_y, ddof=1):.4f}")
# Критерий Фишера для проверки адекватности
if len(factorial_y) > 0 and center_variance > 0:
factorial_variance = np.var(factorial_y, ddof=1) if len(factorial_y) > 1 else 0
if factorial_variance > 0:
fisher = max(factorial_variance, center_variance) / min(factorial_variance, center_variance)
self.analysis_output.append(f"\nКритерий Фишера (F-отношение): {fisher:.4f}")
if fisher < 4.0:
self.analysis_output.append(" ✅ Модель адекватна экспериментальным данным")
else:
self.analysis_output.append(" ⚠️ Модель может быть неадекватна, требуется проверка")
self.analysis_output.append("\n" + "=" * 60)
self.analysis_output.append("✅ Анализ завершен")
def show_placeholder_message(self):
"""Показывает сообщение о том, что функция в разработке"""
QMessageBox.information(
self,
"В разработке",
"🧪 Биотехнологические инструменты в стадии активной разработки!\n\n"
"В ближайшее время здесь появится:\n\n"
"✅ Полнофакторный план (2^k факторный дизайн)\n"
"✅ Регрессионный анализ и ANOVA\n"
"🧪 Функция в стадии разработки!\n\nБлижайшие обновления:\n"
"✅ Экспорт в Excel\n"
"✅ Построение поверхностей отклика\n"
"Оптимизация параметров\n"
"Экспорт в Excel и PDF\n"
"✅ Визуализация 2D/3D графиков\n\n"
"Следите за обновлениями!"
"Графики главных эффектов\n"
"Полный регрессионный анализ"
)