"""Pick another model for the screen already on screen.
Use the regression plot's context menu to choose another model, correction
method, or FDR threshold.
THIS DIALOG STARTS A FIT. Everything else reachable by right-clicking a plot
changes how it looks, so this one has to read differently: it names the model
the table on screen was fitted with, it says WHERE the new run will write, and
it says in advance which settings it is about to reset -- all before the OK
button does anything.
The reset sentence is the one that matters. Switching from a penalised model
to an unpenalised one drops the penalty weight, because the fit refuses to
accept a number it cannot read (:func:`spacr.ml._reject_unused_settings`),
and a user who reads "alpha" on the settings panel afterwards is entitled to
have been told rather than to work it out from a folder name.
"""
from __future__ import annotations
from typing import Optional
from PySide6.QtWidgets import (QComboBox, QDialog, QDialogButtonBox,
QDoubleSpinBox, QFormLayout, QLabel,
QVBoxLayout)
[docs]
class RefitDialog(QDialog):
"""Choose a regression type, a correction and a penalty weight."""
def __init__(self, settings: dict, parent=None):
"""
:param settings: the settings the run on screen used. Read only; the
new dict is built by :func:`spacr.refit.refit_settings`.
:param parent: parent widget; ownership only.
"""
super().__init__(parent)
from ...regression_spec import (REGRESSION_SETTINGS_USED,
REGRESSION_TYPES)
from ...multiple_testing import METHODS
from ...refit import CORRECTION_ALPHA_KEY, CORRECTION_KEY
self.setWindowTitle("Re-fit the screen")
self._settings = dict(settings or {})
self._used = REGRESSION_SETTINGS_USED
layout = QVBoxLayout(self)
current = self._settings.get("regression_type")
intro = QLabel(
f"The table on screen was fitted with <b>{current or 'auto'}</b>. "
f"Re-fitting runs the same data through another model; the run "
f"you are looking at is not touched.")
intro.setWordWrap(True)
layout.addWidget(intro)
form = QFormLayout()
self._type = QComboBox()
self._type.addItem("as before", None)
for name in REGRESSION_TYPES:
self._type.addItem(name, name)
index = self._type.findData(current)
self._type.setCurrentIndex(index if index >= 0 else 0)
self._type.currentIndexChanged.connect(self._refresh)
form.addRow("Regression", self._type)
from ...settings import REGRESSION_LEVELS
self._level = QComboBox()
self._level.addItem("as before", None)
_LEVEL_LABELS = {
"both": "both — guides and genes",
"grna": "gRNA — one coefficient per guide",
"gene": "gene — one coefficient per gene",
}
for name in REGRESSION_LEVELS:
self._level.addItem(_LEVEL_LABELS.get(name, name), name)
current_level = str(self._settings.get("level", "both") or "both")
index = self._level.findData(current_level)
self._level.setCurrentIndex(index if index >= 0 else 0)
self._level.currentIndexChanged.connect(self._refresh)
form.addRow("Level", self._level)
self._correction = QComboBox()
self._correction.addItem("as before", None)
for name, spec in METHODS.items():
self._correction.addItem(
f"{name} — {getattr(spec, 'label', name)}"
if getattr(spec, "label", None) else name, name)
chosen = self._settings.get(CORRECTION_KEY)
index = self._correction.findData(chosen)
self._correction.setCurrentIndex(index if index >= 0 else 0)
self._correction.currentIndexChanged.connect(self._refresh)
form.addRow("Correction", self._correction)
self._fdr_alpha = QDoubleSpinBox()
self._fdr_alpha.setDecimals(4)
self._fdr_alpha.setRange(0.0001, 0.5)
self._fdr_alpha.setSingleStep(0.01)
level = self._settings.get(CORRECTION_ALPHA_KEY)
self._fdr_alpha.setValue(float(level)
if isinstance(level, (int, float)) else 0.05)
self._fdr_alpha.valueChanged.connect(self._refresh)
form.addRow("Significance level", self._fdr_alpha)
self._alpha = QDoubleSpinBox()
self._alpha.setDecimals(4)
self._alpha.setRange(0.0001, 1000.0)
alpha = self._settings.get("alpha")
self._alpha.setValue(float(alpha) if isinstance(alpha, (int, float))
else 1.0)
self._alpha.valueChanged.connect(self._refresh)
self._alpha.setToolTip(
"The penalty weight of a penalised fit — NOT the significance "
"level above. Greyed out for a model "
"that has no penalty, because the number would change nothing "
"there and the run refuses it rather than ignoring it.")
form.addRow("Penalty weight", self._alpha)
layout.addLayout(form)
self._notice = QLabel("")
self._notice.setWordWrap(True)
layout.addWidget(self._notice)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Ok).setText("Re-fit")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self._refresh()
[docs]
def chosen(self) -> dict:
"""``{regression_type, correction_method, level, alpha}`` as picked.
``None`` for anything left at "as before", which is what
:func:`spacr.refit.refit_settings` reads as "leave it alone".
"""
regression_type = self._type.currentData()
alpha = None
if self._alpha.isEnabled():
effective = regression_type or self._settings.get("regression_type")
if "alpha" in self._used.get(effective, ()):
alpha = self._alpha.value()
return {"regression_type": regression_type,
"correction_method": self._correction.currentData(),
"fdr_alpha": self._fdr_alpha.value(),
"level": self._level.currentData(),
"alpha": alpha}
[docs]
def settings(self):
"""``(settings, notes)`` for the new run, or raises ValueError."""
from ...refit import refit_settings
return refit_settings(self._settings, **self.chosen())
def _refresh(self, *_args) -> None:
"""Say what will change, before anything does."""
from ...refit import destination, refit_settings
chosen = self._type.currentData() or self._settings.get(
"regression_type")
self._alpha.setEnabled("alpha" in self._used.get(chosen, ()))
try:
settings, notes = refit_settings(self._settings, **self.chosen())
except ValueError as error:
self._notice.setText(str(error))
self._ok(False)
return
where = destination(settings)
lines = list(notes)
if where is None:
lines.append(
"The output folder cannot be worked out from these settings, "
"so the re-fit is not offered. Check the count data path.")
self._notice.setText(" ".join(lines))
self._ok(False)
return
lines.append(f"Writes to {where}.")
self._notice.setText(" ".join(lines))
self._ok(True)
def _ok(self, enabled: bool) -> None:
"""Enable or disable the dialog's OK button.
:param enabled: whether the current answers are enough to refit.
"""
box = self.findChild(QDialogButtonBox)
if box is not None:
button = box.button(QDialogButtonBox.Ok)
if button is not None:
button.setEnabled(enabled)
[docs]
def ask_refit(settings: dict, parent=None) -> Optional[tuple]:
"""Show the dialog; return ``(settings, notes)`` or None if cancelled.
:param settings: the settings the run on screen used; read only, and passed
to :class:`RefitDialog`.
"""
dialog = RefitDialog(settings, parent)
if dialog.exec() != QDialog.Accepted:
return None
return dialog.settings()
__all__ = ["RefitDialog", "ask_refit"]