1. はじめに
CSVは、構文として正しく読み込めても、業務データとして正しいとは限りません。
たとえば、次のような値が混ざります。
- 前後に空白がある顧客ID
- 全角数字とハイフンが混ざった電話番号
- 月日を一意に判断できない日付
- 途中にもう一度現れるヘッダ行
- 正規化すると同じになる重複ID
この記事では、RustでCSVクレンジング処理について勉強したことをまとめています。CSVクレンジングを作る際は以下に注意しました。
- あらかじめ許可した表記揺れだけを自動補正する
- 一意に判断できない値は推測せず、
Invalidとして扱う - 補正・無効化・スキップの理由を後から追跡できるようにする
どの入力にどのルールを適用し、それらを後から追跡できることが重要になります。この記事では、この性質を監査可能性(auditability)と呼びます。
クレンジングを監査可能にするため、成果物としては、整形済みCSVだけでなく、次の3つを同時に想定します。
| 成果物 | 主な読み手 | 役割 |
|---|---|---|
cleaned.csv |
後続システム | 採用可能な値だけを渡す |
issues.csv |
調査・監査担当者 | どの行・列を、なぜ補正・無効化・スキップしたかを追う |
summary |
実行者・運用担当者 | 件数の偏りから入力全体の異常傾向を見つける |
今回の範囲
サンプルには次の前提を置きます。
- 入力の文字コードはUTF-8
- 列構成と列順は固定
-
customer_idとfull_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 |
壊れている、または一意に解釈できない | 任意列なら空欄 | 生値と理由を記録 |
EmptyとInvalidを分けるのは、「入力されていない」と「入力されたが利用できない」では、原因も対処も異なるからです。
また、無効な生値を出力用の値と同じ場所に残しません。出力してよい値は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のドメイン部分だけを小文字化
- 明示的に許可した国名エイリアスから国コードへの変換
一方、曖昧な日付、壊れた桁区切り、未知の真偽値は都合よく解釈しません。分からない値をfalseや0へ倒すと、「未確認」が「否定」と同じ意味になってしまいます。
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.csv、issues.csv、summaryを確認しやすくするために付けています。
サンプル実装は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レコードは次の順番で処理します。
- 途中に混ざったヘッダを、列の正規化より先に除外する
- 複数列を比べながら行う補正を、列ごとの処理より先に行う
-
SCHEMAの各ルールを適用する - 必須列が
Invalidなら行をスキップする - 正規化後の
customer_idで重複を判定する - 各
FieldResultをissues.csvとsummaryへ反映する -
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)
}
}
}
前後空白と連続空白は整えますが、大文字・小文字や名前そのものは変更しません。
ハマりポイント
McDonald、van 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.COMはADA@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,56が123456として通ってしまいます。掃除のつもりが証拠隠滅になりかねません。
今回の入力契約では,を千区切り、.を小数点とします。ロケールによって意味が変わるため、これは入力契約次第になります。
7.7 is_subscribed:unknownをfalseにしない
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"),
}
}
}
「購読していない」と「分からない」は別の状態です。unknownをfalseへ変換すると、三値だった情報を二値へ押し込めることになります。現場猫が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)
}
}
}
UKとGB、USAとUS、日本と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です。C001とC001を別人として扱うと、表記揺れが重複チェックをすり抜けます。
重複への対応には、先勝ち、後勝ち、両方出力、隔離などがあります。今回は先に現れた行を採用し、後続行を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クレンジングで難しいのは、文字列の変換そのものよりも、安全に直せる範囲を決め、その判断に説明を付けることです。
今回の実装では、次のように責務を分けました。
-
FieldResultでClean、Repaired、Empty、Invalidを区別する -
cleaned: Option<String>には出力してよい値だけを入れる - 列内・行横断・ファイル全体のルールを分離する
- 曖昧な値を推測しない
-
cleaned.csv、issues.csv、summaryを同じ処理結果から作る
迷った値をそれらしく埋めるより、Invalidとして記録しておくほうが安全な場面は多くあります。
採用した値だけでなく、採用しなかった値とその理由まで説明できることが、業務で使えるCSVクレンジングの土台になります。