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

<h1>特徴量エンジニアリングツール</h1>
就活等で成果物として記録します。（githubの扱いにまだ慣れていない）<br>
pythonのFlaskを用いて開発を行いました。<br>

主な機能は...<br>
・CSVファイルのプレビュー<br>
・データの基本情報（df.info）の表示<br>
・列ごとの欠損値数、欠損率の表示<br>
・列削除<br>
・平均値・中央値・最頻値による欠損値補完<br>
・勾配ブースティングによる欠損値補完<br>
勾配ブースティングについては、ユーザーが任意の特徴量、パラメータをある程度選択できます。<br>

<h2>requirements.txt</h2>

```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
```

<h2>csv_processor.py</h2>
ファイルの読み込みから各種機能の主な部分がコーディングされています。<br>

```python: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
        

        
        

```
<h2>app.py</h2>
ルーティング等

```python: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)

```

<h2>models.py</h2>
flask_sqlalchemyを用いた簡易的なデータベース。

```python: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}>'

```

<h2>index.html</h2>
templatesはindex.htmlのみ

```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>

```


<h2>今後の展望</h2>

・行削除の追加<br>
・ワンホットエンコーディングなどデータ加工機能の更なる追加<br>
・勾配ブースティング以外の汎用性のある欠損値補完の追加<br>
・データのmergeなど複数ファイルを扱えるようにする<br>
・一部グラフ等の可視化ツール作成<br>
・複数ファイルを同時に見えるようにする<br>
...etc