From 79c659bbbce1051047b2c1cb7e2294a8b6ab2355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20M=C3=BCller?= Date: Fri, 12 Feb 2021 17:18:24 +0100 Subject: [PATCH] feat: rudimentary LMM analysis (#55) --- CHANGELOG | 3 +- shapeout2/gui/compute/__init__.py | 1 + shapeout2/gui/compute/comp_lme4.py | 86 ++++++ shapeout2/gui/compute/comp_lme4.ui | 194 +++++++++++++ shapeout2/gui/compute/comp_lme4_dataset.py | 50 ++++ shapeout2/gui/compute/comp_lme4_dataset.ui | 92 +++++++ shapeout2/gui/compute/comp_lme4_results.py | 99 +++++++ shapeout2/gui/compute/comp_lme4_results.ui | 255 ++++++++++++++++++ shapeout2/gui/main.py | 19 +- shapeout2/gui/main.ui | 11 +- shapeout2/img/icon-theme/shapeout2/rlang.svg | 108 ++++++++ .../shapeout2/statistical_significance.svg | 106 ++++++++ shapeout2/pipeline/dataslot.py | 3 +- 13 files changed, 1017 insertions(+), 10 deletions(-) create mode 100644 shapeout2/gui/compute/comp_lme4.py create mode 100644 shapeout2/gui/compute/comp_lme4.ui create mode 100644 shapeout2/gui/compute/comp_lme4_dataset.py create mode 100644 shapeout2/gui/compute/comp_lme4_dataset.ui create mode 100644 shapeout2/gui/compute/comp_lme4_results.py create mode 100644 shapeout2/gui/compute/comp_lme4_results.ui create mode 100644 shapeout2/img/icon-theme/shapeout2/rlang.svg create mode 100644 shapeout2/img/icon-theme/shapeout2/statistical_significance.svg diff --git a/CHANGELOG b/CHANGELOG index 267beee..866cae7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,5 @@ -2.4.16 +2.5.0 + - feat: implement linear-mixed effects models with R/lme4 (#55) - ci: overhauled build process 2.4.15 - ci: fix rtd build diff --git a/shapeout2/gui/compute/__init__.py b/shapeout2/gui/compute/__init__.py index f270a14..2b5764b 100644 --- a/shapeout2/gui/compute/__init__.py +++ b/shapeout2/gui/compute/__init__.py @@ -1,2 +1,3 @@ # flake8: noqa: F401 +from .comp_lme4 import ComputeSignificance from .comp_stats import ComputeStatistics diff --git a/shapeout2/gui/compute/comp_lme4.py b/shapeout2/gui/compute/comp_lme4.py new file mode 100644 index 0000000..2bdea4f --- /dev/null +++ b/shapeout2/gui/compute/comp_lme4.py @@ -0,0 +1,86 @@ +import pkg_resources +import webbrowser + +from dclab import lme4 +from PyQt5 import uic, QtCore, QtGui, QtWidgets + +from .comp_lme4_dataset import LME4Dataset +from .comp_lme4_results import Rlme4ResultsDialog + + +class ComputeSignificance(QtWidgets.QDialog): + def __init__(self, parent, pipeline, *args, **kwargs): + super(ComputeSignificance, self).__init__(parent, *args, **kwargs) + path_ui = pkg_resources.resource_filename( + "shapeout2.gui.compute", "comp_lme4.ui") + uic.loadUi(path_ui, self) + # set pipeline + self.pipeline = pipeline + + # populate feature combo box + feats, labs = pipeline.get_features(scalar=True, label_sort=True, + union=False, ret_labels=True) + for feat, lab in zip(feats, labs): + self.comboBox_feat.addItem(lab, feat) + + # populate datasets + self.datasets = [] + for slot in self.pipeline.slots: + dw = LME4Dataset(self, slot=slot) + self.dataset_layout.addWidget(dw) + self.datasets.append(dw) + spacer = QtWidgets.QSpacerItem(20, 0, + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding) + self.dataset_layout.addItem(spacer) + self.update() + + # button signals + btn_close = self.buttonBox.button(QtGui.QDialogButtonBox.Close) + btn_close.clicked.connect(self.on_close) + btn_close.setToolTip("Close this dialog") + closeicon = QtGui.QIcon.fromTheme("dialog-close") + btn_close.setIcon(closeicon) + btn_openlme4 = self.buttonBox.button(QtGui.QDialogButtonBox.Apply) + btn_openlme4.clicked.connect(self.on_lme4) + btn_openlme4.setToolTip("Perform lme4 analysis") + btn_openlme4.setText("Run R-lme4") + picon = QtGui.QIcon.fromTheme("rlang") + btn_openlme4.setIcon(picon) + btn_help = self.buttonBox.button(QtGui.QDialogButtonBox.Help) + btn_help.clicked.connect(self.on_help) + btn_help.setToolTip("View R-lme4 Quick Guide online") + helpicon = QtGui.QIcon.fromTheme("documentinfo") + btn_help.setIcon(helpicon) + + @property + def feature(self): + return self.comboBox_feat.currentData() + + @property + def model(self): + if self.radioButton_lmer.isChecked(): + return "lmer" + else: + return "glmer+loglink" + + @QtCore.pyqtSlot() + def on_lme4(self): + """Run lme4 analysis""" + rlme4 = lme4.Rlme4(model=self.model, feature=self.feature) + for wds in self.datasets: + wds.add_to_rlme4(self.pipeline, rlme4) + result = rlme4.fit() + dlg = Rlme4ResultsDialog(self, result) + dlg.exec_() + + @QtCore.pyqtSlot() + def on_close(self): + """Close window""" + self.close() + + @QtCore.pyqtSlot() + def on_help(self): + """Show Shape-Out 2 docs""" + webbrowser.open( + "https://dclab.readthedocs.io/en/stable/sec_av_lme4.html") diff --git a/shapeout2/gui/compute/comp_lme4.ui b/shapeout2/gui/compute/comp_lme4.ui new file mode 100644 index 0000000..b41dbc1 --- /dev/null +++ b/shapeout2/gui/compute/comp_lme4.ui @@ -0,0 +1,194 @@ + + + Dialog + + + + 0 + 0 + 593 + 544 + + + + Compute statistical significance with R-lme4 + + + + ../../../../../.designer/backup../../../../../.designer/backup + + + + + + Compute the statistical significance using linear mixed-effects models + + + + + + + + + Model: + + + + + + + linear mixed-effects model + + + lmer + + + true + + + + + + + generalized linear mixed-effects model with a log-link function + + + glmer+loglink + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Feature: + + + + + + + + 0 + 0 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Datasets: + + + + + + + Qt::ScrollBarAlwaysOff + + + QAbstractScrollArea::AdjustToContents + + + true + + + + + 0 + 0 + 573 + 383 + + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Apply|QDialogButtonBox::Close|QDialogButtonBox::Help + + + false + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/shapeout2/gui/compute/comp_lme4_dataset.py b/shapeout2/gui/compute/comp_lme4_dataset.py new file mode 100644 index 0000000..9439e22 --- /dev/null +++ b/shapeout2/gui/compute/comp_lme4_dataset.py @@ -0,0 +1,50 @@ +import pkg_resources + +from PyQt5 import uic, QtGui, QtWidgets + +from ... import meta_tool + + +class LME4Dataset(QtWidgets.QDialog): + def __init__(self, parent, slot, *args, **kwargs): + super(LME4Dataset, self).__init__(parent, *args, **kwargs) + path_ui = pkg_resources.resource_filename( + "shapeout2.gui.compute", "comp_lme4_dataset.ui") + uic.loadUi(path_ui, self) + + self.identifier = slot.identifier + + # set dataset label + self.checkBox_dataset.setText(slot.name) + + # set region icon + region = meta_tool.get_info(slot.path, + section="setup", + key="chip region") + icon = QtGui.QIcon.fromTheme("region_{}".format(region)) + pixmap = icon.pixmap(16) + self.label_region.setPixmap(pixmap) + self.label_region.setToolTip(region) + + def add_to_rlme4(self, pipeline, rlme4): + """Add the dataset to an Rlme4 analysis + + Parameters + ---------- + pipeline: shapeout2.pipeline.core.Pipeline + The pipeline from which to extract the filtered dataset + using `self.identifier`. + rlme4: dclab.lme4.wrapr.Rlme4 + The analysis to which to append this dataset. + + Notes + ----- + If the check box is not checked, then the dataset is ignored. + """ + if self.checkBox_dataset.isChecked(): + ds_index = pipeline.slot_ids.index(self.identifier) + ds = pipeline.get_dataset(ds_index) + group_id = self.comboBox_group.currentIndex() + group = "control" if group_id == 0 else "treatment" + repetition = self.spinBox_repeat.value() + rlme4.add_dataset(ds=ds, group=group, repetition=repetition) diff --git a/shapeout2/gui/compute/comp_lme4_dataset.ui b/shapeout2/gui/compute/comp_lme4_dataset.ui new file mode 100644 index 0000000..baa6ff1 --- /dev/null +++ b/shapeout2/gui/compute/comp_lme4_dataset.ui @@ -0,0 +1,92 @@ + + + Form + + + + 0 + 0 + 563 + 26 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + CheckBox + + + true + + + + + + + pxm + + + Qt::AlignCenter + + + + + + + group + + + + control + + + + + treatment + + + + + + + + repetition + + + rep. + + + 1 + + + 42 + + + + + + + + diff --git a/shapeout2/gui/compute/comp_lme4_results.py b/shapeout2/gui/compute/comp_lme4_results.py new file mode 100644 index 0000000..8ae0d7a --- /dev/null +++ b/shapeout2/gui/compute/comp_lme4_results.py @@ -0,0 +1,99 @@ +import pathlib +import pkg_resources + +from PyQt5 import uic, QtCore, QtGui, QtWidgets + + +class Rlme4ResultsDialog(QtWidgets.QDialog): + def __init__(self, parent, rlme4_results, *args, **kwargs): + super(Rlme4ResultsDialog, self).__init__(parent, *args, **kwargs) + path_ui = pkg_resources.resource_filename( + "shapeout2.gui.compute", "comp_lme4_results.ui") + uic.loadUi(path_ui, self) + + res = rlme4_results + + # parameters + self.label_model.setText(res["model"]) + self.label_feature.setText(res["feature"]) + if res["is differential"]: + self.label_differential.setText("Yes") + else: + self.label_differential.setText("No") + + # results + if res["model converged"]: + self.label_yes.show() + self.label_no.hide() + else: + self.label_yes.hide() + self.label_no.show() + self.lineEdit_pvalue.setText("{:f}".format(res["anova p-value"])) + self.lineEdit_intercept.setText( + "{:f}".format(res["fixed effects intercept"])) + self.lineEdit_treatment.setText( + "{:f}".format(res["fixed effects treatment"])) + + # summary text + summary = [] + summary += ["Model summary"] + summary += ["-------------"] + summary += self.parse_r_model_summary( + str(res["r model summary"]).split("\n")) + summary += ["Coefficient table"] + summary += ["-----------------"] + summary += str(res["r model coefficients"]).split("\n") + summary += ["Anova test"] + summary += ["----------"] + summary += str(res["r anova"]).split("\n") + excludelines = [ + "$repetition", + 'attr(,"clas', + '[1] "coef.m', + "Data: struc", + ] + summary = [ll for ll in summary if not ll[:11] in excludelines] + + self.summary = summary + self.plainTextEdit.setPlainText("\n".join(summary)) + + # button signals + btn_close = self.buttonBox.button(QtGui.QDialogButtonBox.Close) + btn_close.clicked.connect(self.on_close) + btn_close.setToolTip("Close this dialog") + closeicon = QtGui.QIcon.fromTheme("dialog-close") + btn_close.setIcon(closeicon) + btn_openlme4 = self.buttonBox.button(QtGui.QDialogButtonBox.Apply) + btn_openlme4.clicked.connect(self.on_save) + btn_openlme4.setToolTip("Save report as text file") + btn_openlme4.setText("Save report (.txt)") + + @staticmethod + def parse_r_model_summary(slist): + """Parse model summary from R (remove data lines)""" + use = [] + just_data = False + for line in slist: + if line.startswith(" Data: "): + just_data = True + elif line.startswith("REML criterion "): + just_data = False + if not just_data: + use.append(line) + return use + + @QtCore.pyqtSlot() + def on_close(self): + """Close window""" + self.close() + + @QtCore.pyqtSlot() + def on_save(self): + """Save summary text as .txt file""" + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Save summary", ".", "*.txt (*.txt files)") + if path: + path = pathlib.Path(path) + if path.suffix != ".txt": + path = path.with_name(path.name + ".txt") + path.write_text("\r\n".join(self.summary)) diff --git a/shapeout2/gui/compute/comp_lme4_results.ui b/shapeout2/gui/compute/comp_lme4_results.ui new file mode 100644 index 0000000..793f7df --- /dev/null +++ b/shapeout2/gui/compute/comp_lme4_results.ui @@ -0,0 +1,255 @@ + + + Dialog + + + + 0 + 0 + 684 + 573 + + + + Dialog + + + + + + + + Parameters + + + + + + Yes/No + + + + + + + model + + + + + + + feat + + + + + + + Model + + + + + + + Differential + + + + + + + Feature + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 30 + + + + + + + + + + + Results + + + + + + Fixed effect treatment + + + + + + + nan + + + true + + + + + + + nan + + + true + + + + + + + Anova p-value + + + + + + + Fixed effect intercept + + + + + + + Model converged + + + + + + + + + QLabel { +color: green; +font-weight:600} + + + Yes + + + + + + + QLabel { +color: red; +font-weight:600} + + + No + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + + + nan + + + true + + + + + + + + + + + + + Monospace + + + + true + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Apply|QDialogButtonBox::Close + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 227 + 553 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 295 + 559 + + + 286 + 274 + + + + + diff --git a/shapeout2/gui/main.py b/shapeout2/gui/main.py index 21a8831..d27e285 100644 --- a/shapeout2/gui/main.py +++ b/shapeout2/gui/main.py @@ -94,11 +94,14 @@ def __init__(self): self.actionChangeDatasetOrder.triggered.connect( self.on_action_change_dataset_order) self.actionPreferences.triggered.connect(self.on_action_preferences) + # Bulk action menu + self.actionComputeEmodulus.triggered.connect( + self.on_action_compute_emodulus) # Compute menu self.actionComputeStatistics.triggered.connect( self.on_action_compute_statistics) - self.actionComputeEmodulus.triggered.connect( - self.on_action_compute_emodulus) + self.actionComputeSignificance.triggered.connect( + self.on_action_compute_significance) # Export menu self.actionExportData.triggered.connect(self.on_action_export_data) self.actionExportFilter.triggered.connect(self.on_action_export_filter) @@ -535,15 +538,19 @@ def on_action_check_update_finished(self, mdict): msg.setText(text) msg.exec_() - def on_action_compute_statistics(self): - dlg = compute.ComputeStatistics(self, pipeline=self.pipeline) - dlg.exec() - def on_action_compute_emodulus(self): dlg = bulk.BulkActionEmodulus(self, pipeline=self.pipeline) dlg.pipeline_changed.connect(self.adopt_pipeline) dlg.exec() + def on_action_compute_significance(self): + dlg = compute.ComputeSignificance(self, pipeline=self.pipeline) + dlg.exec() + + def on_action_compute_statistics(self): + dlg = compute.ComputeStatistics(self, pipeline=self.pipeline) + dlg.exec() + @QtCore.pyqtSlot() def on_action_clear(self, assume_yes=False): if assume_yes: diff --git a/shapeout2/gui/main.ui b/shapeout2/gui/main.ui index f3f0a9e..31666f9 100644 --- a/shapeout2/gui/main.ui +++ b/shapeout2/gui/main.ui @@ -271,6 +271,7 @@ &Compute + @@ -420,7 +421,7 @@ .. - &Statistics... + Statistical &data... @@ -462,6 +463,14 @@ &Change dataset order... + + + + + + Statistical &significance... + + diff --git a/shapeout2/img/icon-theme/shapeout2/rlang.svg b/shapeout2/img/icon-theme/shapeout2/rlang.svg new file mode 100644 index 0000000..d6446c8 --- /dev/null +++ b/shapeout2/img/icon-theme/shapeout2/rlang.svg @@ -0,0 +1,108 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/shapeout2/img/icon-theme/shapeout2/statistical_significance.svg b/shapeout2/img/icon-theme/shapeout2/statistical_significance.svg new file mode 100644 index 0000000..2b6275c --- /dev/null +++ b/shapeout2/img/icon-theme/shapeout2/statistical_significance.svg @@ -0,0 +1,106 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/shapeout2/pipeline/dataslot.py b/shapeout2/pipeline/dataslot.py index cdc363b..16bccda 100644 --- a/shapeout2/pipeline/dataslot.py +++ b/shapeout2/pipeline/dataslot.py @@ -114,8 +114,7 @@ def __setstate__(self, state): @staticmethod def get_slot(slot_id): - """Get the slot with the given identifier. - """ + """Get the slot with the given identifier""" return Dataslot._instances[slot_id] @staticmethod