1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RustでCSVクレンジングを学ぶ:表記揺れ・列入れ替わり・重複を「記録しながら直す」

1
Posted at

1. はじめに

CSVは、構文として正しく読み込めても、業務データとして正しいとは限りません。

たとえば、次のような値が混ざります。

  • 前後に空白がある顧客ID
  • 全角数字とハイフンが混ざった電話番号
  • 月日を一意に判断できない日付
  • 途中にもう一度現れるヘッダ行
  • 正規化すると同じになる重複ID

この記事では、RustでCSVクレンジング処理について勉強したことをまとめています。CSVクレンジングを作る際は以下に注意しました。

  1. あらかじめ許可した表記揺れだけを自動補正する
  2. 一意に判断できない値は推測せず、Invalidとして扱う
  3. 補正・無効化・スキップの理由を後から追跡できるようにする

どの入力にどのルールを適用し、それらを後から追跡できることが重要になります。この記事では、この性質を監査可能性(auditability)と呼びます。

クレンジングを監査可能にするため、成果物としては、整形済みCSVだけでなく、次の3つを同時に想定します。

成果物 主な読み手 役割
cleaned.csv 後続システム 採用可能な値だけを渡す
issues.csv 調査・監査担当者 どの行・列を、なぜ補正・無効化・スキップしたかを追う
summary 実行者・運用担当者 件数の偏りから入力全体の異常傾向を見つける

今回の範囲

サンプルには次の前提を置きます。

  • 入力の文字コードはUTF-8
  • 列構成と列順は固定
  • customer_idfull_nameは必須
  • 国コード、日付、電話番号は限定したルールで処理
  • 重複したcustomer_idは先勝ち

実運用では、文字コード、ロケール、タイムゾーン、個人情報の保管期間、入力元ごとのスキーマバージョンなども入力契約として決める必要があります。今回はクレンジング設計の核に焦点を絞ります。

2. サンプルCSVと処理方針

次のCSVには、意図的にいくつかの問題を混ぜています。

customer_id,full_name,email,phone,country,signup_date,total_spend,is_subscribed
 C001 , Ada Lovelace , ADA@EXAMPLE.COM , 090-1234-5678 , Japan ,2024/03/01,"¥12,300",yes
C002, Grace Hopper, 080-1111-2222, grace@example.com, JP,03/02/2024,"$98.70",TRUE
customer_id,full_name,email,phone,country,signup_date,total_spend,is_subscribed
C003, Alan Turing, alan.example.com, +44 20 7946 0958, United Kingdom,2024-13-01,"12,34,56",はい
C004,  ,   , 12345, Wakanda,2024-04-01,"free",unknown
C001, Ada L., ada@example.com, 09012345678, japan,2024-03-01,"12300",1
C005,Michael J,mic@example.com,06012345678,US,2017-04-01,"120",true

最初から正規化されているデータ行は、最後のC005だけです。ここでいう行番号は、ヘッダを含むCSVの物理行番号です。

主な問題 今回の判断
2 前後空白、全角電話番号、通貨記号、emailドメインの大文字 意味が一意に決まるため補正して出力
3 emailとphoneが逆、03/02/2024が曖昧 2列の特徴が一致した場合だけ入れ替え、日付は空欄にして記録
4 データ途中にヘッダが再登場 データ行として出力せず、スキップ理由を記録
5 email、日付、金額が不正 任意列なので空欄にし、行自体は出力
6 必須の氏名が空欄 行全体をスキップ
7 正規化後のcustomer_idが重複 後から現れた行をスキップ
8 問題なし そのまま出力

ここでは、直せる値と、直したように見せてはいけない値を分けることが大切です。

090-1234-5678は、今回の入力契約では09012345678に一意に寄せられます。一方、03/02/2024が3月2日か2月3日かは、入力だけでは決まりません。分からないものに堂々と日付を与えると、見た目だけは立派な誤データが完成します。

3. 実装前に判断ルールを決める

3.1 セルの状態を4種類に分ける

各セルの処理結果を、次の4状態に分類します。

状態 意味 cleaned.csvへの出力 issues.csvへの記録
Clean 入力をそのまま採用できる 値を出力 不要
Repaired 許可したルールで補正できた 補正後の値を出力 補正前後と理由を記録
Empty 入力が空欄 任意列なら空欄。必須列ならInvalidへ変更 必要に応じて記録
Invalid 壊れている、または一意に解釈できない 任意列なら空欄 生値と理由を記録

EmptyInvalidを分けるのは、「入力されていない」と「入力されたが利用できない」では、原因も対処も異なるからです。

また、無効な生値を出力用の値と同じ場所に残しません。出力してよい値はcleaned: Option<String>に置き、生値は監査ログ側で保持します。

3.2 セルの結果から行の採否を決める

今回の行レベルの方針は次のとおりです。

必須列が Invalid       -> 行全体を skipped
任意列が Invalid       -> そのセルを空欄にして行は出力
embedded header        -> 行全体を skipped
重複 customer_id       -> 後から現れた行を skipped
CSV parser error       -> cleaned.csvには出さず、parser issueとして記録

これは唯一解ではありません。重複を先勝ちにするか後勝ちにするか、無効な任意列を空欄にするか隔離ファイルへ送るかは、後続システムとの契約によって変わります。

大切なのは、方針をコードの偶然に任せないことです。文章とテストで先に固定します。

3.3 「推測しない」を補正ルールにする

自動補正を許可するのは、入力契約上、変換後の意味が一意に決まる場合だけです。

  • 前後空白の削除
  • 全角数字からASCII数字への変換
  • 電話番号の区切り記号の除去
  • emailのドメイン部分だけを小文字化
  • 明示的に許可した国名エイリアスから国コードへの変換

一方、曖昧な日付、壊れた桁区切り、未知の真偽値は都合よく解釈しません。分からない値をfalse0へ倒すと、「未確認」が「否定」と同じ意味になってしまいます。

4. 処理全体を3つの範囲に分ける

判断に必要な範囲ごとに、責務を次の3つに分けます。

  • 列内ルール:emailの形式、電話番号の文字、日付形式など
  • 行横断ルール:email列とphone列の入れ替わりなど
  • ファイル全体ルール:重複ID、パーサエラーなど

処理全体の流れは次のとおりです。

入力文字列
  -> csv::Readerでレコード化
  -> パーサエラーを分離
  -> 埋め込まれたヘッダを検出
  -> email / phoneの入れ替わりを行全体で判定
  -> SCHEMAに従って各セルを正規化・検証
  -> 必須列エラーと重複を判定
  -> cleaned.csvへ書き出し
  -> 同時にissues.csvとsummaryを更新

emailの形式検証は列単体で完結しますが、email列とphone列の入れ替わりは2列を同時に見なければ判断できません。重複は過去の行との比較が必要です。

5. 動かし方

この記事で使用するソースコードは、次のGitHubリポジトリで公開しています。

git clone https://github.com/fumiya-data/csv_cleanse_article.git
cd csv_cleanse_article
cargo run --quiet

使用している依存クレートは次の3つです。

[dependencies]
chrono = "0.4"
csv = "1"
rust_decimal = "1"

--quietはCargo自身の進行メッセージを抑制するオプションです。プログラムが出力するcleaned.csvissues.csvsummaryを確認しやすくするために付けています。

サンプル実装は3つの成果物をStringとして返し、main.rsから標準出力へ表示します。本当の運用では、同じ内容をファイルやオブジェクトストレージへ保存する想定です。

6. 型で「列」と「処理結果」を表す

文字列を加工する前に、判断結果を型で表します。これにより、「補正済みの値」と「監査のために残した不正値」を区別しやすくなります。

6.1 Columnで列を表す

まず、CSVの列をenumで表します。

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Column {
    CustomerId,
    FullName,
    Email,
    Phone,
    Country,
    SignupDate,
    TotalSpend,
    IsSubscribed,
}

impl Column {
    pub const ALL: [Column; 8] = [
        Column::CustomerId,
        Column::FullName,
        Column::Email,
        Column::Phone,
        Column::Country,
        Column::SignupDate,
        Column::TotalSpend,
        Column::IsSubscribed,
    ];

    pub fn index(self) -> usize {
        match self {
            Column::CustomerId => 0,
            Column::FullName => 1,
            Column::Email => 2,
            Column::Phone => 3,
            Column::Country => 4,
            Column::SignupDate => 5,
            Column::TotalSpend => 6,
            Column::IsSubscribed => 7,
        }
    }

    pub fn header(self) -> &'static str {
        match self {
            Column::CustomerId => "customer_id",
            Column::FullName => "full_name",
            Column::Email => "email",
            Column::Phone => "phone",
            Column::Country => "country",
            Column::SignupDate => "signup_date",
            Column::TotalSpend => "total_spend",
            Column::IsSubscribed => "is_subscribed",
        }
    }
}

Column::Email.index()のように列位置を取得し、fields[2]のようなマジックナンバーを処理の各所に散らしません。

ハマりポイント

enumのバリアント順をそのまま列番号として使うと、並び替えがCSVの意味まで変えてしまいます。列番号は明示しておくほうが安全です。

6.2 ColumnRuleで列ごとの責務をまとめる

各列の必須列と任意列の区別、処理関数を、SCHEMAというテーブルへ集約します。

pub struct ColumnRule {
    pub column: Column,
    pub required: bool,
    pub normalize: fn(&str) -> FieldResult,
}

pub const SCHEMA: &[ColumnRule] = &[
    ColumnRule {
        column: Column::CustomerId,
        required: true,
        normalize: columns::customer_id::process,
    },
    ColumnRule {
        column: Column::FullName,
        required: true,
        normalize: columns::full_name::process,
    },
    ColumnRule {
        column: Column::Email,
        required: false,
        normalize: columns::email::process,
    },
    ColumnRule {
        column: Column::Phone,
        required: false,
        normalize: columns::phone::process,
    },
    ColumnRule {
        column: Column::Country,
        required: false,
        normalize: columns::country::process,
    },
    ColumnRule {
        column: Column::SignupDate,
        required: false,
        normalize: columns::signup_date::process,
    },
    ColumnRule {
        column: Column::TotalSpend,
        required: false,
        normalize: columns::total_spend::process,
    },
    ColumnRule {
        column: Column::IsSubscribed,
        required: false,
        normalize: columns::is_subscribed::process,
    },
];

6.3 FieldResultで出力値と判断を分ける

各セルの処理結果は次のように表します。

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldDisposition {
    Clean,
    Repaired,
    Empty,
    Invalid,
}

#[derive(Debug, Clone)]
pub struct FieldResult {
    // cleaned.csvへ書いてよい値だけを入れる。
    pub cleaned: Option<String>,
    // このセルをどう扱ったか。
    pub disposition: FieldDisposition,
    // 補正または無効化の理由。
    pub reason: Option<String>,
}

impl FieldResult {
    pub fn clean(value: impl Into<String>) -> Self {
        Self {
            cleaned: Some(value.into()),
            disposition: FieldDisposition::Clean,
            reason: None,
        }
    }

    pub fn repaired(value: impl Into<String>, reason: impl Into<String>) -> Self {
        Self {
            cleaned: Some(value.into()),
            disposition: FieldDisposition::Repaired,
            reason: Some(reason.into()),
        }
    }

    pub fn empty() -> Self {
        Self {
            cleaned: None,
            disposition: FieldDisposition::Empty,
            reason: None,
        }
    }

    pub fn invalid(reason: impl Into<String>) -> Self {
        Self {
            cleaned: None,
            disposition: FieldDisposition::Invalid,
            reason: Some(reason.into()),
        }
    }

    pub fn output_value(&self) -> String {
        self.cleaned.clone().unwrap_or_default()
    }
}

責務は次のように分かれます。

cleaned      cleaned.csvに書いてよい値
raw          issues.csv側で保持する入力値
disposition  セルをどう扱ったか
reason       補正・無効化の理由

不正値をvalue: Stringに残すと、それが出力用なのか監査用なのかが曖昧になります。cleaned: Option<String>には、出力してよい値しか入れないほうが安全です。

6.4 処理順について

1レコードは次の順番で処理します。

  1. 途中に混ざったヘッダを、列の正規化より先に除外する
  2. 複数列を比べながら行う補正を、列ごとの処理より先に行う
  3. SCHEMAの各ルールを適用する
  4. 必須列がInvalidなら行をスキップする
  5. 正規化後のcustomer_idで重複を判定する
  6. FieldResultissues.csvsummaryへ反映する
  7. output_value()だけをcleaned.csvへ書く

列数が足りない行は、fields.get(...).unwrap_or("")で不足列を空欄として扱います。その後、必須列のEmptyだけをInvalidへ格上げします。

この順番により、「列がない」「空文字だった」「値はあるが壊れている」を段階的に扱えます。

7. 列ごとの正規化・検証ルール

ここからは、各列について「何を補正し、何を無効とするか」を見ていきます。

7.1 customer_id:IDを数値にしない

pub mod customer_id {
    use crate::FieldResult;

    pub fn process(raw: &str) -> FieldResult {
        let trimmed = raw.trim();

        if trimmed.is_empty() {
            return FieldResult::empty();
        }

        let ok = trimmed
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');

        if !ok {
            return FieldResult::invalid(
                "customer_id contains unsupported characters"
            );
        }

        if trimmed != raw {
            FieldResult::repaired(trimmed, "trimmed surrounding whitespace")
        } else {
            FieldResult::clean(trimmed)
        }
    }
}

ハマりポイント

IDは数値ではなく識別子です。000123を整数へ変換して123にすると、別のIDになり得ます。IDは足し算しないので、数値にする必要もありません。

7.2 full_name:勝手にTitle Caseへ変えない

pub mod full_name {
    use crate::FieldResult;

    pub fn process(raw: &str) -> FieldResult {
        let trimmed = raw.trim();

        if trimmed.is_empty() {
            return FieldResult::empty();
        }

        let collapsed = trimmed
            .split_whitespace()
            .collect::<Vec<_>>()
            .join(" ");

        if collapsed != raw {
            FieldResult::repaired(
                collapsed,
                "trimmed and collapsed whitespace"
            )
        } else {
            FieldResult::clean(collapsed)
        }
    }
}

前後空白と連続空白は整えますが、大文字・小文字や名前そのものは変更しません。

ハマりポイント

McDonaldvan der Waals、日本語名、法人名など、人名には文化と文脈があります。Title Caseへの一律変換は、入力契約なしに行うべきではありません。

7.3 email:ドメインだけを小文字化する

pub mod email {
    use crate::FieldResult;

    pub fn process(raw: &str) -> FieldResult {
        let compact: String = raw
            .trim()
            .chars()
            .filter(|c| !c.is_whitespace())
            .collect();

        if compact.is_empty() {
            return FieldResult::empty();
        }

        if compact.matches('@').count() != 1 {
            return FieldResult::invalid("email must contain exactly one @");
        }

        let (local, domain) = compact
            .split_once('@')
            .expect("checked exactly one @");

        if local.is_empty() || domain.is_empty() {
            return FieldResult::invalid(
                "email local part and domain must not be empty"
            );
        }

        if !domain.contains('.') {
            return FieldResult::invalid("email domain must contain dot");
        }

        let normalized = format!("{}@{}", local, domain.to_ascii_lowercase());

        if normalized != raw {
            FieldResult::repaired(
                normalized,
                "removed whitespace and lowercased domain"
            )
        } else {
            FieldResult::clean(normalized)
        }
    }

    pub fn looks_like(raw: &str) -> bool {
        process(raw).cleaned.is_some()
    }
}

このサンプルでは、email内の空白を入力ノイズとして除去する契約にしています。実運用で内部空白を無効値とするなら、除去せずInvalidを返すように変更します。

検証はRFC完全準拠ではなく、業務ETL向けの最低限の構造検査です。巨大な正規表現ですべてを解決しようとすると、メールアドレス仕様の深い森へ入ることになります。

また、ドメインは小文字化できますが、local partは理論上大小文字を区別し得ます。そのため、ADA@EXAMPLE.COMADA@example.comにしますが、入力契約なしにada@example.comへは変えません。

7.4 phone:ASCIIへ寄せてから桁数を数える

pub mod phone {
    use crate::FieldResult;

    pub fn process(raw: &str) -> FieldResult {
        let trimmed = raw.trim();

        if trimmed.is_empty() {
            return FieldResult::empty();
        }

        let mut out = String::new();

        for c in trimmed.chars() {
            if c == '+' && out.is_empty() {
                out.push(c);
                continue;
            }

            if let Some(digit) = to_ascii_digit(c) {
                out.push(digit);
                continue;
            }

            if c.is_whitespace() || is_separator(c) {
                continue;
            }

            return FieldResult::invalid(
                format!("phone contains unsupported character: {c}")
            );
        }

        let digits = out.strip_prefix('+').unwrap_or(&out);

        if digits.len() < 7 || digits.len() > 15 {
            return FieldResult::invalid(
                "phone number must have 7 to 15 digits"
            );
        }

        if out != trimmed {
            FieldResult::repaired(
                out,
                "normalized phone separators and digits"
            )
        } else {
            FieldResult::clean(out)
        }
    }

    fn to_ascii_digit(c: char) -> Option<char> {
        match c {
            '0'..='9' => Some(c),
            '0'..='9' => {
                let offset = c as u32 - '0' as u32;
                char::from_u32('0' as u32 + offset)
            }
            _ => None,
        }
    }

    fn is_separator(c: char) -> bool {
        matches!(c, '-' | '(' | ')' | 'ー' | '−' | '‑' | '-')
    }

    pub fn looks_like(raw: &str) -> bool {
        process(raw).cleaned.is_some()
    }
}

ハマりポイント

Rustのstr::len()が返すのは文字数ではなくUTF-8のバイト数です。全角数字を含んだまま長さを調べると意図とずれるため、ASCII数字へ正規化してから検証します。

電話番号を厳密に扱うなら、E.164や国別ルールが必要です。今回の7〜15桁は、最低限の品質ルールに留まります。

7.5 signup_date:複数形式を試し、曖昧さを残す

pub mod signup_date {
    use chrono::NaiveDate;

    use crate::FieldResult;

    const FORMATS: &[&str] = &[
        "%Y-%m-%d",
        "%Y/%m/%d",
        "%Y.%m.%d",
        "%m/%d/%Y",
        "%d/%m/%Y",
    ];

    pub fn process(raw: &str) -> FieldResult {
        let trimmed = raw.trim();

        if trimmed.is_empty() {
            return FieldResult::empty();
        }

        let mut candidates = Vec::new();

        for format in FORMATS {
            if let Ok(date) = NaiveDate::parse_from_str(trimmed, format) {
                candidates.push(date);
            }
        }

        candidates.sort();
        candidates.dedup();

        match candidates.as_slice() {
            [] => FieldResult::invalid(
                "date does not match supported formats"
            ),
            [date] => {
                let normalized = date.format("%Y-%m-%d").to_string();

                if normalized != trimmed {
                    FieldResult::repaired(
                        normalized,
                        "normalized date format"
                    )
                } else {
                    FieldResult::clean(normalized)
                }
            }
            _ => FieldResult::invalid("date format is ambiguous"),
        }
    }
}

03/04/2024は3月4日にも4月3日にも解釈できます。そこで、受け付ける全形式でパースし、異なる日付候補が複数できた場合はInvalidにします。

一意に解釈できる     -> YYYY-MM-DDへ正規化
複数に解釈できる     -> Invalidとしてissues.csvへ記録
ロケール方針が決まる -> 入力契約とルールを変更

7.6 total_spend:金額にf64を使わない

pub mod total_spend {
    use rust_decimal::Decimal;

    use crate::FieldResult;

    pub fn process(raw: &str) -> FieldResult {
        let trimmed = raw.trim();

        if trimmed.is_empty() {
            return FieldResult::empty();
        }

        let candidate: String = trimmed
            .chars()
            .filter(|c| !matches!(*c, '¥' | '$' | '€') && !c.is_whitespace())
            .collect();

        if candidate.is_empty() {
            return FieldResult::invalid(
                "total_spend has no numeric content"
            );
        }

        let (integer_part, fractional_part) = match candidate.split_once('.') {
            Some((integer, fraction)) => (integer, Some(fraction)),
            None => (candidate.as_str(), None),
        };

        if let Some(fraction) = fractional_part {
            if fraction.is_empty()
                || !fraction.chars().all(|c| c.is_ascii_digit())
            {
                return FieldResult::invalid(
                    "total_spend has invalid decimal part"
                );
            }
        }

        let unsigned_integer = integer_part
            .strip_prefix('-')
            .unwrap_or(integer_part);

        if !valid_comma_grouping(unsigned_integer) {
            return FieldResult::invalid(
                "total_spend has invalid comma grouping"
            );
        }

        let normalized = candidate.replace(',', "");

        match normalized.parse::<Decimal>() {
            Ok(value) if value >= Decimal::ZERO => {
                let output = value.to_string();

                if output != trimmed {
                    FieldResult::repaired(
                        output,
                        "removed currency symbol, whitespace, or separators"
                    )
                } else {
                    FieldResult::clean(output)
                }
            }
            Ok(_) => FieldResult::invalid(
                "total_spend must not be negative"
            ),
            Err(_) => FieldResult::invalid(
                "total_spend is not a decimal number"
            ),
        }
    }

    fn valid_comma_grouping(integer: &str) -> bool {
        if integer.is_empty() {
            return false;
        }

        if !integer.contains(',') {
            return integer.chars().all(|c| c.is_ascii_digit());
        }

        let parts: Vec<&str> = integer.split(',').collect();

        if parts[0].is_empty() || parts[0].len() > 3 {
            return false;
        }

        parts[0].chars().all(|c| c.is_ascii_digit())
            && parts[1..].iter().all(|part| {
                part.len() == 3
                    && part.chars().all(|c| c.is_ascii_digit())
            })
    }
}

金額にはrust_decimal::Decimalを使い、f64の丸め誤差を避けます。

また、カンマを消す前に3桁区切りとして正しいか確認します。先にすべてのカンマを消すと、12,34,56123456として通ってしまいます。掃除のつもりが証拠隠滅になりかねません。

今回の入力契約では,を千区切り、.を小数点とします。ロケールによって意味が変わるため、これは入力契約次第になります。

7.7 is_subscribedunknownfalseにしない

pub mod is_subscribed {
    use crate::FieldResult;

    pub fn process(raw: &str) -> FieldResult {
        let trimmed = raw.trim();
        let normalized = trimmed.to_ascii_lowercase();

        if normalized.is_empty() {
            return FieldResult::empty();
        }

        match normalized.as_str() {
            "true" if trimmed == raw && trimmed == "true" => {
                FieldResult::clean("true")
            }
            "false" if trimmed == raw && trimmed == "false" => {
                FieldResult::clean("false")
            }
            "true" | "t" | "yes" | "y" | "1" => {
                FieldResult::repaired("true", "normalized truthy value")
            }
            "false" | "f" | "no" | "n" | "0" => {
                FieldResult::repaired("false", "normalized falsy value")
            }
            "はい" => FieldResult::repaired(
                "true",
                "normalized Japanese truthy value"
            ),
            "いいえ" => FieldResult::repaired(
                "false",
                "normalized Japanese falsy value"
            ),
            _ => FieldResult::invalid("boolean value is not recognized"),
        }
    }
}

「購読していない」と「分からない」は別の状態です。unknownfalseへ変換すると、三値だった情報を二値へ押し込めることになります。現場猫がfalseヨシ!と言っても、「分からない」ものとしてInvalid扱いにして理由を記録します。

7.8 country:エイリアス表の範囲を入力契約にする

pub mod country {
    use crate::FieldResult;

    pub fn process(raw: &str) -> FieldResult {
        let trimmed = raw.trim();
        let normalized = trimmed.to_ascii_lowercase();

        if normalized.is_empty() {
            return FieldResult::empty();
        }

        let code = match normalized.as_str() {
            "jp" | "jpn" | "japan" | "日本" => "JP",
            "us" | "usa" | "united states"
                | "united states of america" => "US",
            "uk" | "gb" | "great britain"
                | "united kingdom" => "GB",
            _ => return FieldResult::invalid(
                "country is not in alias table"
            ),
        };

        if code != raw {
            FieldResult::repaired(code, "normalized country to code")
        } else {
            FieldResult::clean(code)
        }
    }
}

UKGBUSAUS日本Japanなど、国や地域の表記揺れは際限なく増えます。

サンプルでは出力を2文字コードへ寄せていますが、「何を受け入れ、何へ変換するか」は入力契約として決めます。実運用では、採用するコード体系や地域の扱いも明記する必要があります。

8. 行・ファイル全体を見るルール

列単体の関数だけでは判断できない問題もあります。複数列、過去の行、CSVパーサの状態が必要なルールは、列モジュールから分離します。

8.1 emailとphoneの入れ替わりは行全体で扱う

email列とphone列の入れ替わりは、2列を同時に見なければ判断できません。

列内ルール:
  email列の値がemailとして妥当か
  phone列の値がphoneとして妥当か

行横断ルール:
  email列が電話番号らしく、
  phone列がemailらしい場合だけswapする
pub fn repair_contact_swap(fields: &mut [String]) -> Option<String> {
    let email_index = Column::Email.index();
    let phone_index = Column::Phone.index();

    let email_raw = fields.get(email_index)?.as_str();
    let phone_raw = fields.get(phone_index)?.as_str();

    let email_looks_phone =
        crate::columns::phone::looks_like(email_raw);
    let phone_looks_email =
        crate::columns::email::looks_like(phone_raw);

    if email_looks_phone && phone_looks_email {
        fields.swap(email_index, phone_index);
        return Some("email and phone looked swapped".to_string());
    }

    None
}

片方だけが怪しい場合は、自動で入れ替えません。2つの特徴が同時に一致した場合だけ補正します。

8.2 embedded headerは、無視するだけでなく記録する

複数ファイルの単純結合などでは、データ途中にヘッダが再登場することがあります。

fn is_header_row(fields: &[String]) -> bool {
    let matched = Column::ALL
        .iter()
        .filter(|column| {
            fields
                .get(column.index())
                .map(|value| normalize_header(value) == column.header())
                .unwrap_or(false)
        })
        .count();

    matched >= Column::ALL.len() - 1
}

fn normalize_header(value: &str) -> String {
    value
        .trim()
        .chars()
        .map(|c| {
            if c == ' ' || c == '-' {
                '_'
            } else {
                c.to_ascii_lowercase()
            }
        })
        .collect()
}

使用側では、データ行として出力せず、スキップ件数と理由を記録します。

if is_header_row(&fields) {
    report.rows_skipped += 1;
    report.embedded_headers += 1;
    report.record_row_issue(
        line_number,
        "",
        "",
        "",
        "skipped",
        "embedded header row inside the dataset",
    );
    continue;
}

途中に現れるヘッダは、大げさに言うとヘッダゾンビです。continueで倒すだけでなく、なぜ1行減ったのか分かるように討伐ログを残します。

このサンプルでは8列中7列以上がヘッダ名と一致したらヘッダ行とみなします。誤検出の許容度はデータの性質に応じて調整してください。

8.3 重複は正規化後に判定する

重複チェックは、ファイル全体の状態を持つルールです。

use std::collections::HashSet;

pub struct DuplicateChecker {
    seen_customer_ids: HashSet<String>,
}

impl DuplicateChecker {
    pub fn new() -> Self {
        Self {
            seen_customer_ids: HashSet::new(),
        }
    }

    pub fn check(&mut self, customer_id: &str) -> bool {
        !self
            .seen_customer_ids
            .insert(customer_id.to_string())
    }
}

HashSet::insertは新規追加ならtrue、すでに存在すればfalseを返すため、!で反転して「重複しているか」を返します。

比較するのは生値ではなく、正規化後のcustomer_idです。C001C001を別人として扱うと、表記揺れが重複チェックをすり抜けます。

重複への対応には、先勝ち、後勝ち、両方出力、隔離などがあります。今回は先に現れた行を採用し、後続行をskippedとしてissues.csvへ残します。

9. 実行結果

サンプルをcargo run --quietで実行すると、次の3つが出力されます。

9.1 cleaned.csv:採用可能な値だけを渡す

customer_id,full_name,email,phone,country,signup_date,total_spend,is_subscribed
C001,Ada Lovelace,ADA@example.com,09012345678,JP,2024-03-01,12300,true
C002,Grace Hopper,grace@example.com,08011112222,JP,,98.70,true
C003,Alan Turing,,+442079460958,GB,,,true
C005,Michael J,mic@example.com,06012345678,US,2017-04-01,120,true
  • C002の日付03/02/2024は2通りに解釈できるため、空欄にしています
  • C003のemail、日付、金額はInvalidですが任意列なので、行自体は出力しています
  • C004は必須のfull_nameが空欄のため、行ごとスキップしています
  • 後から現れたC001は重複のためスキップしています
  • C005はすべてCleanなので、そのまま出力しています

9.2 issues.csv:補正・無効化・スキップの根拠を残す

issues.csvの列は次のとおりです。

run_id,line_number,issue_type,column,raw_value,cleaned_value,disposition,reason

実際の出力です。

run_id,line_number,issue_type,column,raw_value,cleaned_value,disposition,reason
demo-run,2,field,customer_id, C001 ,C001,repaired,trimmed surrounding whitespace
demo-run,2,field,full_name, Ada Lovelace ,Ada Lovelace,repaired,trimmed and collapsed whitespace
demo-run,2,field,email, ADA@EXAMPLE.COM ,ADA@example.com,repaired,removed whitespace and lowercased domain
demo-run,2,field,phone, 090-1234-5678 ,09012345678,repaired,normalized phone separators and digits
demo-run,2,field,country, Japan ,JP,repaired,normalized country to code
demo-run,2,field,signup_date,2024/03/01,2024-03-01,repaired,normalized date format
demo-run,2,field,total_spend,"¥12,300",12300,repaired,"removed currency symbol, whitespace, or separators"
demo-run,2,field,is_subscribed,yes,true,repaired,normalized truthy value
demo-run,3,row,,,,repaired,email and phone looked swapped
demo-run,3,field,full_name, Grace Hopper,Grace Hopper,repaired,trimmed and collapsed whitespace
demo-run,3,field,email, grace@example.com,grace@example.com,repaired,removed whitespace and lowercased domain
demo-run,3,field,phone, 080-1111-2222,08011112222,repaired,normalized phone separators and digits
demo-run,3,field,country, JP,JP,repaired,normalized country to code
demo-run,3,field,signup_date,03/02/2024,,invalid,date format is ambiguous
demo-run,3,field,total_spend,$98.70,98.70,repaired,"removed currency symbol, whitespace, or separators"
demo-run,3,field,is_subscribed,TRUE,true,repaired,normalized truthy value
demo-run,4,row,,,,skipped,embedded header row inside the dataset
demo-run,5,field,full_name, Alan Turing,Alan Turing,repaired,trimmed and collapsed whitespace
demo-run,5,field,email, alan.example.com,,invalid,email must contain exactly one @
demo-run,5,field,phone, +44 20 7946 0958,+442079460958,repaired,normalized phone separators and digits
demo-run,5,field,country, United Kingdom,GB,repaired,normalized country to code
demo-run,5,field,signup_date,2024-13-01,,invalid,date does not match supported formats
demo-run,5,field,total_spend,"12,34,56",,invalid,total_spend has invalid comma grouping
demo-run,5,field,is_subscribed,はい,true,repaired,normalized Japanese truthy value
demo-run,6,row,full_name,  ,,skipped,required field is missing
demo-run,7,row,customer_id,C001,C001,skipped,duplicate customer_id

run_idは実行単位、line_numberは入力上の位置、issue_typeはセル・行・パーサのどの範囲で起きた問題かを表します。処理した理由reasonは最後に付けます。

このログにより、「値が空なのは元から空だったのか、無効化されたのか」「なぜ入力より行数が減ったのか」を後から調べられます。

9.3 summary:全体の異常傾向を見る

run_id: demo-run

records_seen:              7
rows_written:              4
rows_skipped:              3
parser_errors:             0
embedded_headers:          1
duplicate_rows:            1
required_field_failures:   1
rows_with_repairs:         3
rows_with_invalid_fields:  2

field results for rows_written:
  customer_id      clean=3 repaired=1 empty=0 invalid=0
  full_name        clean=1 repaired=3 empty=0 invalid=0
  email            clean=1 repaired=2 empty=0 invalid=1
  phone            clean=1 repaired=3 empty=0 invalid=0
  country          clean=1 repaired=3 empty=0 invalid=0
  signup_date      clean=1 repaired=1 empty=0 invalid=2
  total_spend      clean=1 repaired=2 empty=0 invalid=1
  is_subscribed    clean=1 repaired=3 empty=0 invalid=0

明細ログだけでは、入力全体の傾向をつかみにくいことがあります。summaryがあれば、たとえば日付のInvalidだけが急増したときに、入力元の形式変更を疑えます。

なお、列別件数はcleaned.csvへ採用した行について集計しています。行ごとスキップしたデータは、行レベルのスキップ件数とissues.csvで追跡します。

10. 実運用へ進むためにまだ足りないもの

このサンプルは設計を説明するための最小構成です。実運用では、少なくとも次の点を決める必要があります。

  • 入力文字コードとBOMの扱い
  • 列名、列順、列数、スキーマバージョン
  • 日付のロケールとタイムゾーン
  • 電話番号の国別ルール
  • 国・地域コードの体系
  • 通貨コード、小数点、桁区切り、端数処理
  • 重複時の優先順位
  • 不正行を空欄化、スキップ、隔離のどれで扱うか
  • issues.csvに個人情報をどこまで残すか
  • run_id、入力ファイル名、ルールバージョンの保持方法
  • 異常件数が閾値を超えた場合に処理を失敗させるか

特に監査ログには生値が入るため、保存期間とアクセス制御が必要です。追跡はできても誰でも見られるわけではありません。

また、今回のリポジトリには自動テストを追加していません。本格利用では、各列の境界値、曖昧な日付、CSVパーサエラー、重複順序、集計値をテストで固定する必要があります。

11. まとめ

CSVクレンジングで難しいのは、文字列の変換そのものよりも、安全に直せる範囲を決め、その判断に説明を付けることです。

今回の実装では、次のように責務を分けました。

  • FieldResultCleanRepairedEmptyInvalidを区別する
  • cleaned: Option<String>には出力してよい値だけを入れる
  • 列内・行横断・ファイル全体のルールを分離する
  • 曖昧な値を推測しない
  • cleaned.csvissues.csvsummaryを同じ処理結果から作る

迷った値をそれらしく埋めるより、Invalidとして記録しておくほうが安全な場面は多くあります。

採用した値だけでなく、採用しなかった値とその理由まで説明できることが、業務で使えるCSVクレンジングの土台になります。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?