YOLO813

Python制作GUI可视化程序的尝试

    之前帮朋友写过一个数据预处理的python程序,听说这个需求还是蛮多的,于是便想着能否写一个GUI图形用户界面(Graphical User Interface,简称 GUI,又称图形用户接口)的程序,方便小白用户自定义数据的处理。

    由于我之前也没有接触过GUI编程,所以稍微摸索了下,目前的初步思路是使用pyqt5进行编程,再使用pyinstaller将其打包成windows/mac用户可执行的包。
    本文并非新手教程,只是记录自己制作GUI程序的学习过程,建议参阅下方参考文档第一篇了解pyqt5。如果需要了解更多,目前我买了两本书阅读,《Python GUI设计 从入门到实践》,《PyQt5快速开发与实践》 (这本书版本比较老,适合有一定python基础)。

    先来看下目前的电脑程序界面演示,比较丑陋,后面还会继续改进的,目前只是初步实现了功能定制。本文相当于利用pandas进行数据清洗文章的后续,只不过为小白用户制作了一个电脑软件,让其可以自定义参数清洗数据。

  •     由于使用了pyinstaller打包,所以和普通电脑软件的运行一样,双击即可运行,当然,我保留了命令行界面用于打印测试。
  •     初学PyQt5,考虑到实用性,没有去研究UI的设计,所以前面几个按钮用于填入自定义的参数,因为是针对朋友所写的数据处理程序,所以文本框需要填入的placehold字段我已经在程序中预填入了,当然,针对不同的文件字段可以进行修改。
  •     最后一行,上传xlsx文件,即可输出两份文件,一份是用于检查的demo,一份是删除了多余的字段完整文件。


    代码概述:

    首先安装

pip install PyQt5 -i https://pypi.douban.com/simple

    导入PyQt5的相关类

from PyQt5.QtWidgets import QApplication,QDesktopWidget,QAction, QPushButton,qApp,QFileDialog,QLabel,QHBoxLayout,QVBoxLayout,QWidget,QInputDialog,QCheckBox
from PyQt5.QtCore import Qt

import pandas as pd
import numpy as np

    主框架代码,建立一个DataProcess类用于继承QWidget窗口:

class DataProcess(QWidget):
  pass

if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = DataProcess()
    sys.exit(app.exec_())

    在这个类中,我们将初始化布局,按钮的放置位置,以及当按钮信号发射出来的时候对应槽函数的处理。

def __init__(self):
    super().__init__()
    self.initUI()

    在这个类的初始化中,我们首先继承了父类QWidget的初始化函数,并自定义了initUI函数用于DataProcess类的初始化

def initUI(self):
    self.resize(500, 450)
    self.let_window_center()

    首先我们让窗口的尺寸为500(width)*450,然后定义let_window_center函数让窗口居中,以下函数中首先获取窗口frameGeometry,然后再获得桌面的坐标中心,利用moveCenter函数将窗口移到中心位置,这样无论在多少分辨率的电脑上软件打开位置都将位于中心区域:

def let_window_center(self):
    qr = self.frameGeometry()
    cp = QDesktopWidget().availableGeometry().center()
    qr.moveCenter(cp)

    为了保证pandas读取excel表格时不为空,初始化了几个参数为空

self.na_values = False
self.index_col=False
self.temp_dtype=False

    定义了垂直布局,一般来说,PyQt5中有4种布局方式,水平,垂直,栅格,表单(使用布局的好处在于不用再费心思去计算按钮等组件的大小)

vbox = QVBoxLayout()

vbox.addWidget(self.customIndexFunc())
vbox.addWidget(self.initCustomNumberNanFunc())
vbox.addWidget(self.initVacancyValueFunc())
vbox.addWidget(self.initGroupIndexFunc())
vbox.addWidget(self.initNotGroupFunc())
vbox.addWidget(self.initStandardDeviationEliminationFunc())
vbox.addWidget(self.initUploadFunc())

vbox.addStretch(1)
vbox.addWidget(QLabel("列名索引(建议选择文件中的数字序列)", self))
self.index_value = QLabel("", self)
vbox.addWidget(self.index_value)

    在垂直布局中逐步添加了N个组件(上面的示例函数中返回的其实就是一个QWidget对象),如下图

        例如,customIndexFunc函数

def customIndexFunc(self):
  customIndexBtn = QPushButton('输入想要指定为文件索引的列名!', self)
  customIndexBtn.setChecked(False)
  customIndexBtn.clicked[bool].connect(self.showIndexValueDialog)
  return customIndexBtn

    新建一个QPushButton对象,通过判断其是否被点击connect到showIndexValueDialog函数,我们再来看下该函数:

def showIndexValueDialog(self):
    separated_values, ok = QInputDialog.getText(self, '一个列名索引(建议选择文件中的数字序列)', '输入想要指定为文件索引的列名:',text="NO")
    if ok:
        self.index_col = str(separated_values)
        print(f"self.index_col : {self.index_col}")
        self.index_value.setText(self.index_col)

    函数中通过QInputDialog组件弹出一个提示窗口,分别为标题,提示语,和预先填入的占位字符

    如果点击了OK,那么就会获取到用户填入的separated_values值,并将该值通过QLabel的setText函数填入到标签index_value中

    上文中,vbox.addStretch(1)相当于为垂直布局增加了1个伸缩量的距离,具体的效果可以自己测试下,然后添加了一个QLabel,因为QLabel继承于QFrame,而QFrame又继承于QWidget,所以充当addWidget函数的参数完全没问题,

    最后在初始化函数中,利用如下函数将其布局添加到窗口中并展示出来

self.setLayout(vbox)
self.show()

    使用pyinstall将文件打包成exe程序即可。


    完整源码放在文末,安装了PyQt5即可运行,下次将会优化软件界面。

 

参考:

https://maicss.gitbook.io/pyqt5-chinese-tutoral/

 

完整代码:

import sys
from PyQt5.QtWidgets import QMainWindow,QApplication,QDesktopWidget,QAction, QPushButton,qApp,QFileDialog,QLabel,QHBoxLayout,QVBoxLayout,QWidget,QInputDialog,QCheckBox
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon
import pandas as pd
import numpy as np

class DataProcess(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()
   
    def initUI(self):
        self.resize(500, 450)
        self.let_window_center()
        # 自定义初始参数
        self.na_values = False
        self.index_col=False
        self.temp_dtype=False
        vbox = QVBoxLayout()

        # 添加按钮
        vbox.addWidget(self.customIndexFunc())
        vbox.addWidget(self.initCustomNumberNanFunc())
        vbox.addWidget(self.initVacancyValueFunc())
        vbox.addWidget(self.initGroupIndexFunc())
        vbox.addWidget(self.initNotGroupFunc())
        vbox.addWidget(self.initStandardDeviationEliminationFunc())
        vbox.addWidget(self.initUploadFunc())

        vbox.addStretch(1)
        cb = QCheckBox('some log', self)
        cb.stateChanged.connect(self.helpFunc)
        self.log_text = QLabel("")
        vbox.addWidget(cb)
        vbox.addWidget(self.log_text)

        vbox.addStretch(1)
        vbox.addWidget(QLabel("列名索引(建议选择文件中的数字序列)", self))
        self.index_value = QLabel("", self)
        vbox.addWidget(self.index_value)

        vbox.addStretch(1)
        vbox.addWidget(QLabel("以下列名中的数值0将会被判断为空缺值", self))
        self.zero_vacancy_value = QLabel("", self)
        vbox.addWidget(self.zero_vacancy_value)

        vbox.addStretch(1)
        vbox.addWidget(QLabel("自定义空缺值(字符串)", self))
        self.vacancy_value = QLabel("", self)
        vbox.addWidget(self.vacancy_value)

        vbox.addStretch(1)
        vbox.addWidget(QLabel("需要聚合的列名", self))
        self.group_index_label = QLabel("", self)
        vbox.addWidget(self.group_index_label)

        vbox.addStretch(1)
        vbox.addWidget(QLabel("不需要参与计算均值、均差的列名", self))
        self.not_group_index_label = QLabel("", self)
        vbox.addWidget(self.not_group_index_label)

        vbox.addStretch(1)
        vbox.addWidget(QLabel("不参加离平均值三个标准差之外的数据剔除的列名", self))
        self.standard_deviation_elimination_label = QLabel("", self)
        vbox.addWidget(self.standard_deviation_elimination_label)

        vbox.addStretch(1)
        vbox.addWidget(QLabel("文件列名", self))
        self.data_columns = QLabel("", self)
        vbox.addWidget(self.data_columns)

        vbox.addWidget(QLabel("文件信息", self))
        self.data_info = QLabel("", self)
        vbox.addWidget(self.data_info)

        vbox.addStretch(5)
        self.setLayout(vbox)
        self.setWindowTitle("数据处理程序v0.2-张小飞")
        self.show()

    def helpFunc(self, state):
        my_log =\
        """**************************
            v0.2 -- 20211021
            * 增加了自定义文件索引的列名,如果不填写,则为默认索引。
            * 可以定义某几列为0的数值为缺失值了!
            原理为在读取xlsx文件时利用pandas将所定义的列先转为了object对象,
            由于pandas对于object对象不会进行均值或者方差计算,
            故后续又将所有的列转换成了float类型,可能这里会存在问题,暂时未知。
            * 增加自定义空缺值。
            * 增加聚合列名。
            * 不需要参与计算平均值和方差的数据请填入相关字段名。
            * 上传文件即可。
            * 由于是为了帮助朋友高效处理数据,故选项中,部分数据字段已经预填入,直接点击之后再回车即可。
            ----------------
            v0.1
            * 只能按照死规则输入xlsx文件,输出文件\n**************************
        """
        if state == Qt.Checked:
            self.log_text.setText(my_log)
        else:            
            self.log_text.setText("")

    def customIndexFunc(self):
        customIndexBtn = QPushButton('输入想要指定为文件索引的列名!', self)
        customIndexBtn.setChecked(False)
        customIndexBtn.clicked[bool].connect(self.showIndexValueDialog)
        return customIndexBtn

    def initCustomNumberNanFunc(self):
        customNumberNanBtn = QPushButton('指定0为空缺值的列名!', self)
        customNumberNanBtn.setChecked(False)
        customNumberNanBtn.clicked[bool].connect(self.showZeroNanDialog)
        return customNumberNanBtn

    def initVacancyValueFunc(self):
        vacancyBtn = QPushButton('填入空缺值,以英文逗号分隔!',self)
        vacancyBtn.clicked[bool].connect(self.showStrNanDialog)
        return vacancyBtn

    def initGroupIndexFunc(self):
        groupIndexBtn = QPushButton('输入想要聚合的列名', self)
        groupIndexBtn.clicked[bool].connect(self.groupIndexDialog)
        return groupIndexBtn

    def initNotGroupFunc(self):
        notGroupIndexBtn = QPushButton('输入不需要聚合计算平均值和均值方差列名', self)
        notGroupIndexBtn.clicked[bool].connect(self.notGroupIndexDialog)
        return notGroupIndexBtn

    def initStandardDeviationEliminationFunc(self):
        StandardDeviationEliminationBtn = QPushButton('不参加离平均值三个标准差之外的数据剔除的列名,以下列不会被离群值统计', self)
        StandardDeviationEliminationBtn.clicked[bool].connect(self.StandardDeviationEliminationDailog)
        return StandardDeviationEliminationBtn

    def initUploadFunc(self):
        uploadBtn = QPushButton('Upload .xlsx File!', self)
        uploadBtn.setChecked(False)
        # uploadBtn.adjustSize()
        # uploadBtn.move(10,40)
        uploadBtn.clicked[bool].connect(self.uploadFile)
        return uploadBtn

    def showIndexValueDialog(self):
        separated_values, ok = QInputDialog.getText(self, '一个列名索引(建议选择文件中的数字序列)', '输入想要指定为文件索引的列名:',text="NO")
        if ok:
            self.index_col = str(separated_values)
            self.index_value.setText(self.index_col)

    def showZeroNanDialog(self):
        separated_values, ok = QInputDialog.getText(self, 'NAN', '填入希望指定数字0为空缺值的列名,以英文逗号分隔:',text="TRT,FC,FP")
        if ok:
            _ = separated_values.split(',')
            self.zero_vacancy_value.setText(str(_ ))
            # 将需要指定0为空缺值的列转换为object对象,生成字典
            self.temp_dtype = {}
            for i in _ :
                self.temp_dtype.update({i:"object"})

    def showStrNanDialog(self):
        separated_values, ok = QInputDialog.getText(self, 'NAN', "填入空缺值,以英文逗号分隔(默认以下会被判断为空缺值:'', '#N/A', '#N/A N/A', '#NA', '-1.#IND', '-1.#QNAN', '-NaN', '-nan', '1.#IND', '1.#QNAN', '<NA>', 'N/A', 'NA', 'NULL', 'NaN', 'n/a', 'nan', 'null')",text=".,0")
        if ok:
            self.na_values = separated_values.split(',')
            self.vacancy_value.setText(str(self.na_values))

    def groupIndexDialog(self):
        separated_values, ok = QInputDialog.getText(self, '聚合INDEX', '输入需要聚合的列名,以英文逗号分隔:',text='SUB,CON,IA_ID')
        if ok:
            self.group_index_list = separated_values.split(',')
            self.group_index_label.setText(str(self.group_index_list))

    def notGroupIndexDialog(self):
        separated_values, ok = QInputDialog.getText(self, '不参加计算的INDEX', '输入不需要计算的列名,以英文逗号分隔:',text='ITEM,CON,IA_ID,SKIP')
        if ok:
            self.not_group_index_list = separated_values.split(',')
            self.not_group_index_label.setText(str(self.not_group_index_list))
   
    def StandardDeviationEliminationDailog(self):
        separated_values, ok = QInputDialog.getText(self, '离群值列名', '离平均值三个标准差之外的这个数据剔除标准,以英文逗号分隔:',text='CON,SUB,IA_ID,GROUP,ITEM,SKIP')
        if ok:
            self.standard_deviation_elimination_list = separated_values.split(',')
            self.standard_deviation_elimination_label.setText(str(self.standard_deviation_elimination_list))

    def uploadFile(self):
        self.fileOpenFunc()

    def fileOpenFunc(self):
        fname = QFileDialog.getOpenFileName(self, 'Open File - 张小飞', 'home')
        pd.set_option('display.width', None)
        if fname[0]:
            filename = fname[0].split('/')[-1].replace(".xlsx","")
            filepath = fname[0].replace(fname[0].split('/')[-1],"")
            try:
                df = pd.read_excel(fname[0],na_values=self.na_values, index_col=self.index_col, dtype=self.temp_dtype)
                # 还需要将所有的object类型转换成float类型,否则均值计算不会将其纳入其中
                for i in df.columns.values:
                    try:
                        df[i] = df[i].astype(np.float)
                    except:
                        pass
                df.dropna(axis='columns', how='all', inplace=True)
                self.data_columns.setText(str(df.columns.values))
                self.data_info.setText(str(df.describe(include='all')))
                self.data_columns.adjustSize()

                # 开始处理数据逻辑
                means_columns = df.groupby(self.group_index_list)[[x for x in df.columns if x not in self.not_group_index_list]].mean()
                mean_data = pd.merge(df,pd.DataFrame(means_columns), on=self.group_index_list,suffixes=('','_mean'))
                mean_data = mean_data.sort_index(axis="columns").fillna(method='bfill', axis=1)

                std_columns = df.groupby(self.group_index_list)[[x for x in df.columns if x not in self.not_group_index_list]].std()
                std_data = pd.merge(mean_data,pd.DataFrame(std_columns), on=self.group_index_list,suffixes=('','_std')).sort_index(axis="columns")
               
                self.processStd(std_data,self.standard_deviation_elimination_list)
                std_data.sort_index(axis='columns',inplace=True)

                #输出
                std_data.set_index(self.group_index_list,append=True).to_excel(filepath + filename+'_demo_存在标识符+平均值+标准差方便检查.xlsx')

                std_data.drop(columns=[x for x in std_data.columns if '_mean' in x or '_std' in x], inplace=True)

                output = self.extract_data(std_data)
                output.drop(columns=[x for x in output.columns if 'TriStd' in x], inplace=True)
                output.set_index(self.group_index_list,append=True).to_excel(filepath + filename+'_final_version.xlsx')
            except:
                print("something wrong!")
                qApp.quit

    def let_window_center(self):
        qr = self.frameGeometry()
        cp = QDesktopWidget().availableGeometry().center()
        qr.moveCenter(cp)

    def processStd(self, data,column_list,std_suffix='_std',mean_suffix = "_mean"):
        _ = []
        for x in data.columns:
            if x not in column_list and std_suffix not in x and mean_suffix not in x:
                _.append(x)
        for i in _:
            condi1 = data[i+ mean_suffix] + 3*data[i+std_suffix]
            condi2 = data[i+ mean_suffix] - 3*data[i+std_suffix]
            new_column = pd.DataFrame( np.where( (data[i] > condi1) | (data[i] < condi2), True, False))
            data[i+"_TriStd"] = new_column

    def extract_data(self, data, suffix='_TriStd'):
        _ = [x for x in data.columns if suffix in x]
        for i in _:
            data = data[data[i] != True]
        return data

if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = DataProcess()
    sys.exit(app.exec_())