1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

特徴量エンジニアリングWebアプリケーション

Last updated at Posted at 2025-02-13

特徴量エンジニアリングツール

就活等で成果物として記録します。(githubの扱いにまだ慣れていない)
pythonのFlaskを用いて開発を行いました。

主な機能は...

・CSVファイルのプレビュー

・データの基本情報(df.info)の表示

・列ごとの欠損値数、欠損率の表示

・列削除

・平均値・中央値・最頻値による欠損値補完

・勾配ブースティングによる欠損値補完

勾配ブースティングについては、ユーザーが任意の特徴量、パラメータをある程度選択できます。

requirements.txt

requirements.txt
blinker==1.9.0
click==8.1.8
colorama==0.4.6
Flask==3.1.0
Flask-SQLAlchemy==3.1.1
greenlet==3.1.1
itsdangerous==2.2.0
Jinja2==3.1.5
joblib==1.4.2
MarkupSafe==3.0.2
numpy==2.2.2
pandas==2.2.3
python-dateutil==2.9.0.post0
pytz==2025.1
scikit-learn==1.6.1
scipy==1.15.1
six==1.17.0
SQLAlchemy==2.0.38
threadpoolctl==3.5.0
typing_extensions==4.12.2
tzdata==2025.1
Werkzeug==3.1.3
xgboost==2.1.4

csv_processor.py

ファイルの読み込みから各種機能の主な部分がコーディングされています。
csv_processor.py
from xgboost import XGBRegressor, XGBClassifier
from sklearn.preprocessing import LabelEncoder
import pandas as pd
import numpy as np
import io

class CSVProcessor:
    def __init__(self, file_input):
        
        if isinstance(file_input, str):
            # ファイルパスを指定してデータフレームを作成
            self.df = pd.read_csv(file_input)
        
        else:
            byte_stream = file_input.read() # バイトストリームを取得
            decode_string = byte_stream.decode('utf-8') # バイトストリームをデコード
            string_io = io.StringIO(decode_string) # デコードした文字列をStringIOオブジェクトに変換
            self.df = pd.read_csv(string_io) # StringIOオブジェクトからデータフレームを作成
        
        self.encorders = {}
    
    def get_preview(self, rows=50):
        return self.df.head(rows).to_html(classes='table table-bordered table-striped', index=False)
    
    def get_basic_info(self):
        # データフレームの情報を取得
        info_buffer = io.StringIO() # StringIOオブジェクト作成
        self.df.info(buf=info_buffer) # データフレームの情報を取得
        return info_buffer.getvalue().replace("\n", "<br>") # 改行を<br>に変換
    
    def get_missing_info(self):
        # 欠損情報のデータフレーム作成
        total_rows = len(self.df) # データフレームの行数
        missing_count = self.df.isnull().sum() # 欠損数
        missing_percentage = (missing_count / total_rows * 100).round(2) # 欠損率

        missing_info_df = pd.DataFrame({
            '列名': missing_count.index,
            '欠損数': missing_count.values,
            '欠損率 (%)': missing_percentage.values
        })

        # 余計な空白を削除
        missing_info_df.columns = missing_info_df.columns.str.strip()
        return missing_info_df
    
    def highlight_missing_info(self, threshold=10):
        
        #欠損率に基づいて欠損値情報をハイライト表示
        missing_info_df = self.get_missing_info()

        def gradient_highlight(val):
            if isinstance(val, (int, float)):
                if val < threshold:
                    return ''  # ハイライトなし
                elif threshold <= val < 30:
                    return 'background-color: #ffe6e6;'  # 薄いピンク
                elif 30 <= val < 50:
                    return 'background-color: #ffcccc;'  # 薄い赤
                elif 50 <= val < 70:
                    return 'background-color: #ff9999;'  # 標準的な赤
                else:
                    return 'background-color: #ff4d4d;'  # 濃い赤
            return ''

        # ハイライト適用
        styled_missing_info = missing_info_df.style.applymap(
            gradient_highlight, subset=['欠損率 (%)']
        ).hide(axis='index').set_table_attributes('class="table table-bordered table-hover"')

        return styled_missing_info.to_html()
    
    # 特定の列削除
    def remove_columns(self, columns):
        # 指定した列を削除しデータフレームを更新
        existing_columns = [col for col in columns if col in self.df.columns]
        if not existing_columns:
            raise ValueError('指定した列が見つかりません。:{columns}')
        
        self.df.drop(columns=existing_columns, inplace=True)
        return self
    
    # 欠損値をmean, median, modeで補完
    def fill_missing(self, strategy='mean', columns=None):
        
        if columns in None:
            columns = self.df.select_dtypes(include=['number']).columns.to_list() # 数値列を取得


        for col in columns:
            if col not in self.df.columns:
                raise ValueError(f"指定された列'{col}'は存在しません。")
            
            if strategy == 'mean':
                self.df[col].fillna(self.df[col].mean(), inplace=True) # 欠損値を平均値で補完
        
            elif strategy == 'median':
                self.df[col].fillna(self.df[col].median(), inplace=True) # 欠損値を中央値で補完

            elif strategy == 'mode':
                self.df[col].fillna(self.df[col].mode()[0], inplace=True) # 欠損値を最頻値で補完
        
            else:
                raise ValueError(f"strategyには'mean', 'median', 'mode'のいずれかを指定してください。")

        return self
    
    def fill_missing_gradient_boosting(self, target_column, feature_columns, n_estimators=100, learning_rate=0.1, max_depth=3):
        # 欠損値を勾配ブースティングで補完
        # データを欠損値の有無で分割
        if target_column not in self.df.columns:
            raise ValueError(f"指定されたターゲット列'{target_column}'は存在しません。")
        
        missing_features = [col for col in feature_columns if col not in self.df.columns]
        if missing_features:
            raise ValueError(f"指定された特徴量列'{missing_features}'は存在しません。")
        
        train_data = self.df[self.df[target_column].notnull()] # 欠損値がないデータ
        test_data = self.df[self.df[target_column].isnull()] # 欠損値があるデータ

        if train_data.empty or test_data.empty:
            raise ValueError("欠損値のある行または欠損値のない行が存在しません。")
        
        # カテゴリ変数かを判定
        is_categorical = self.df[target_column].dtype == 'object'

        # カテゴリ変数の場合はラベルエンコーディング
        if is_categorical:
            le_target = LabelEncoder()
            train_data[target_column] = le_target.fit_transform(train_data[target_column].astype(str)) # ターゲット列をラベルエンコーディング
            self.encorders[target_column] = le_target # エンコーダを保存

        # 特徴量のエンコーディング
        for col in feature_columns:
           if self.df[col].dtype == 'object':
               le = LabelEncoder()
               train_data[col] = le.fit_transform(train_data[col].astype(str))
               test_data[col] = le.transform(test_data[col].astype(str))
               self.encorders[col] = le

        X_train = train_data[feature_columns]
        y_train = train_data[target_column]
        X_test = test_data[feature_columns]

        # 勾配ブースティングモデルの作成
        model = XGBClassifier(n_estimators=n_estimators, learning_rate=learning_rate, max_depth=max_depth, radom_state=0)\
            if is_categorical else XGBRegressor(n_estimators=n_estimators, learning_rate=learning_rate, max_depth=max_depth, radom_state=0)
        
        model.fit(X_train, y_train)

        # 欠損値の補完
        predicted_values = model.predict(X_test)
        if is_categorical:
            predicted_values = le_target.inverse_transform(predicted_values)

        self.df.loc[self.df[target_column].isnull(), target_column] = predicted_values

        return self
        

        
        

app.py

ルーティング等
app.py
from flask import Flask, request, render_template, redirect, url_for, send_file
from models import db, UploadedFile
from csv_processor import CSVProcessor
import os

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///files.db'
db.init_app(app)

UPLOAD_FOLDER = "uploads"
if not os.path.exists(UPLOAD_FOLDER):
    os.makedirs(UPLOAD_FOLDER)

@app.route('/', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        file = request.files['file']
        if file and file.filename.endswith('.csv'):
            filepath = os.path.join(UPLOAD_FOLDER, file.filename)
            file.save(filepath)

            # 同じファイル名が既に存在する場合は削除
            existing_file = UploadedFile.query.filter_by(filename=file.filename).first()
            if existing_file:
                os.remove(existing_file.filepath)
                db.session.delete(existing_file)
                db.session.commit()

            # 新しいファイルをDBに保存
            uploaded_file = UploadedFile(filename=file.filename, filepath=filepath)
            db.session.add(uploaded_file)
            db.session.commit()
            return redirect(url_for('display_file', file_id=uploaded_file.id))

    files = UploadedFile.query.order_by(UploadedFile.created_at.desc()).all()
    return render_template('index.html', files=files)

@app.route('/file/<int:file_id>')
def display_file(file_id):
    file_record = UploadedFile.query.get_or_404(file_id)
    processor = CSVProcessor(file_record.filepath)

    table = processor.get_preview()
    basic_info = processor.get_basic_info()
    missing_info = processor.highlight_missing_info(threshold=10)
    all_columns = processor.df.columns.tolist()
    files = UploadedFile.query.order_by(UploadedFile.created_at.desc()).all()

    return render_template('index.html', table=table, basic_info=basic_info, missing_info=missing_info,
                           filename=file_record.filename, all_columns=all_columns, files=files, current_file_id=file_id)

@app.route('/remove_columns/<int:file_id>', methods=['POST'])
def remove_columns(file_id):
    file_record = UploadedFile.query.get_or_404(file_id)
    processor = CSVProcessor(file_record.filepath)

    columns_to_remove = request.form.getlist('columns_to_remove')

    if not columns_to_remove:
        return redirect(url_for('display_file', file_id=file_id, error="削除する列を選択してください。"))

    try:
        processor.remove_columns(columns_to_remove)
        processor.df.to_csv(file_record.filepath, index=False)
        return redirect(url_for('display_file', file_id=file_id))

    except Exception as e:
        return redirect(url_for('display_file', file_id=file_id, error=str(e)))

@app.route('/fill_missing/<int:file_id>', methods=['POST'])
def fill_missing(file_id):
    file_record = UploadedFile.query.get_or_404(file_id)
    processor = CSVProcessor(file_record.filepath)

    columns_to_fill = request.form.getlist('columns_to_fill')
    strategy = request.form.get('strategy')

    if not columns_to_fill:
        return redirect(url_for('display_file', file_id=file_id, error="補完する列を選択してください。"))

    try:
        processor.fill_missing(strategy=strategy, columns=columns_to_fill)
        processor.df.to_csv(file_record.filepath, index=False)
        return redirect(url_for('display_file', file_id=file_id))

    except Exception as e:
        return redirect(url_for('display_file', file_id=file_id, error=str(e)))

@app.route('/fill_missing_gb/<int:file_id>', methods=['POST'])
def fill_missing_gb(file_id):
    file_record = UploadedFile.query.get_or_404(file_id)
    processor = CSVProcessor(file_record.filepath)

    target_column = request.form.get('target_column')
    feature_columns = request.form.getlist('feature_columns')
    n_estimators = int(request.form.get('n_estimators', 100))
    learning_rate = float(request.form.get('learning_rate', 0.1))
    max_depth = int(request.form.get('max_depth', 3))

    if not target_column or not feature_columns:
        return redirect(url_for('display_file', file_id=file_id, error="補完するターゲット列と特徴量を選択してください。"))

    try:
        processor.fill_missing_gradient_boosting(target_column, feature_columns, n_estimators, learning_rate, max_depth)
        processor.df.to_csv(file_record.filepath, index=False)
        return redirect(url_for('display_file', file_id=file_id))

    except Exception as e:
        return redirect(url_for('display_file', file_id=file_id, error=str(e)))

@app.route('/delete_file/<int:file_id>', methods=['POST'])
def delete_file(file_id):
    file_record = UploadedFile.query.get_or_404(file_id)

    # ファイルを削除
    try:
        os.remove(file_record.filepath)
    except FileNotFoundError:
        pass

    # データベースからも削除
    db.session.delete(file_record)
    db.session.commit()

    return redirect(url_for('upload_file'))

@app.route('/download/<int:file_id>')
def download_file(file_id):
    file_record = UploadedFile.query.get_or_404(file_id)
    return send_file(file_record.filepath, as_attachment=True, download_name=file_record.filename)

if __name__ == '__main__':
    app.run(debug=True)

models.py

flask_sqlalchemyを用いた簡易的なデータベース。
models.py
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime

db = SQLAlchemy()

class UploadedFile(db.Model):
    __tablename__ = 'uploaded_files'
    
    id = db.Column(db.Integer, primary_key=True)
    filename = db.Column(db.String(255), nullable=False)
    filepath = db.Column(db.String(255), nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    def __repr__(self):
        return f'<UploadedFile {self.filename}>'

index.html

templatesはindex.htmlのみ
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSVファイル管理ツール</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        .table-container {
            overflow-x: auto;
            max-height: 800px;
        }
        .col-preview { width: 35%; }
        .col-middle { width: 30%; }
        .col-info { width: 35%; }
    </style>
</head>
<body>
    <div class="container-fluid mt-4">
        <h1 class="text-center mb-4">CSVファイル管理ツール</h1>

        <!-- ファイルアップロードフォーム -->
        <form method="POST" enctype="multipart/form-data" class="mb-4">
            <input type="file" name="file" class="form-control mb-2" accept=".csv" required>
            <button type="submit" class="btn btn-primary w-100">ファイルアップロード</button>
        </form>

        <!-- ファイルリスト -->
        <h3>アップロード済みファイル</h3>
        <ul class="list-group mb-4">
            {% for file in files %}
                <li class="list-group-item d-flex justify-content-between align-items-center">
                    <a href="{{ url_for('display_file', file_id=file.id) }}">{{ file.filename }}</a>
                    <form method="POST" action="{{ url_for('delete_file', file_id=file.id) }}" class="d-inline">
                        <button type="submit" class="btn btn-danger btn-sm">削除</button>
                    </form>
                </li>
            {% endfor %}
        </ul>

        {% if table %}
        <div class="row">
            <!-- 左側(プレビュー) -->
            <div class="col-preview">
                <h3>データプレビュー: {{ filename }}</h3>
                <div class="table-container table-responsive">{{ table | safe }}</div>
            </div>

            <!-- 中央(列削除 & 欠損値補完) -->
            <div class="col-middle">
                <h3>データ前処理</h3>

                <!-- 列削除フォーム -->
                <form method="POST" action="{{ url_for('remove_columns', file_id=current_file_id) }}" class="mb-4">
                    <h5>列削除</h5>
                    {% for column in all_columns %}
                        <div class="form-check">
                            <input class="form-check-input" type="checkbox" name="columns_to_remove" value="{{ column }}" id="remove_{{ column }}">
                            <label class="form-check-label" for="remove_{{ column }}">{{ column }}</label>
                        </div>
                    {% endfor %}
                    <button type="submit" class="btn btn-danger w-100 mt-2">選択した列を削除</button>
                </form>

                <!-- 欠損値補完(平均・中央値・最頻値) -->
                <form method="POST" action="{{ url_for('fill_missing', file_id=current_file_id) }}" class="mb-4">
                    <h5>欠損値補完(平均・中央値・最頻値)</h5>
                    <label class="form-label">補完する列を選択:</label>
                    {% for column in all_columns %}
                        <div class="form-check">
                            <input class="form-check-input" type="checkbox" name="columns_to_fill" value="{{ column }}" id="fill_{{ column }}">
                            <label class="form-check-label" for="fill_{{ column }}">{{ column }}</label>
                        </div>
                    {% endfor %}
                    <label class="form-label mt-2">補完方法:</label>
                    <select class="form-select" name="strategy" required>
                        <option value="mean">平均値補完</option>
                        <option value="median">中央値補完</option>
                        <option value="mode">最頻値補完</option>
                    </select>
                    <button type="submit" class="btn btn-info w-100 mt-2">欠損値を補完</button>
                </form>

                <!-- 欠損値補完(勾配ブースティング) -->
                <form method="POST" action="{{ url_for('fill_missing_gb', file_id=current_file_id) }}" class="mb-4">
                    <h5>欠損値補完(勾配ブースティング)</h5>
                    <label class="form-label">ターゲット列:</label>
                    <select class="form-select" name="target_column" required>
                        <option value="" disabled selected>選択してください</option>
                        {% for column in all_columns %}
                            <option value="{{ column }}">{{ column }}</option>
                        {% endfor %}
                    </select>

                    <label class="form-label mt-2">特徴量を選択:</label>
                    {% for column in all_columns %}
                        <div class="form-check">
                            <input class="form-check-input" type="checkbox" name="feature_columns" value="{{ column }}" id="feature_{{ column }}">
                            <label class="form-check-label" for="feature_{{ column }}">{{ column }}</label>
                        </div>
                    {% endfor %}

                    <label class="form-label mt-2">パラメータ設定:</label>
                    <input type="number" class="form-control mb-2" name="n_estimators" value="100" min="10" required>
                    <input type="number" class="form-control mb-2" name="learning_rate" step="0.01" value="0.1" required>
                    <input type="number" class="form-control mb-2" name="max_depth" value="3" required>
                    <button type="submit" class="btn btn-warning w-100 mt-2">欠損値を補完</button>
                </form>

                <!-- データのダウンロード -->
                <a href="{{ url_for('download_file', file_id=current_file_id) }}" class="btn btn-success w-100 mt-3">データをダウンロード</a>
            </div>

            <!-- 右側(欠損情報 & 基本情報) -->
            <div class="col-info">
                <h3>データ情報</h3>
                <div>{{ basic_info | safe }}</div>

                <h3 class="mt-4">欠損値情報</h3>
                <div class="table-container table-responsive">{{ missing_info | safe }}</div>
            </div>
        </div>
        {% endif %}
    </div>
</body>
</html>

今後の展望

・行削除の追加

・ワンホットエンコーディングなどデータ加工機能の更なる追加

・勾配ブースティング以外の汎用性のある欠損値補完の追加

・データのmergeなど複数ファイルを扱えるようにする

・一部グラフ等の可視化ツール作成

・複数ファイルを同時に見えるようにする

...etc

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?