クロス表
LINQをつかってサクサク集計処理が書けるC#ですが、標準にあるLINQでは、集計できるのですが、うまく表示してくれないものに、クロス表があります。
クロス表とはこういうものね。
要素α | 要素β | |
---|---|---|
要素A | 100 | 200 |
要素B | 40 | 24 |
要素C | 30 | 34 |
縦と横の要素を掛け合わせた値が入るやつです。
エクセルではピポットテーブルをつかってやる、あれです。
二つの変数間の関係を見るうえで、基本かつ最強であるものです。
こういうクロス集計は、クエリ的には簡単です。
list.GroupBy(n=> new {n.Item1,n.Item2 })
.Select(n=> new {n.Key.Item1,n.Key.Item2,Count= n.Count()});
二つの要素をGroupByのKeyにすればいいだけです。
でも、上のクエリでの出力(JSON)は、
[
{ Item1 : "要素A" ,Item2 : "要素α" ,Count : 100},
{ Item1 : "要素A" ,Item2 : "要素β" ,Count : 200},
....
]
という形で、二つの値のペアと、カウントという形になります。
これは、可読性が良くありません。クロス表がほしい。
前提
以降、昔に書いた記事、
TestProjectはC#のスクリプト実行環境
で書いたように、テストプロジェクトで使うことを前提としています。
テストプロジェクトは、メソッド一つを簡単に実行できるわ、時間計測もしてくれるわ、デバッグもできるわで、超便利です。
そして、このような拡張メソッドを定義しています。
public static string ConsoleWriteLine(this string text)
{
System.Console.WriteLine(text);
return text;
}
実行結果で、コンソール出力をみれるので、テストプロジェクトは便利です。
また、拡張メソッドで、Nullチェックを本来しないとだめですが、記事の可読性のため飛ばしています。
クロス表を作る拡張メソッド
こういう拡張メソッドを作りました。TSV形式のテキストに変換します。タプル構文を使います。
タプル構文とDictionaryの相性はとてもいいです。
/// <summary>
/// Dictionary<(T,T1),T2> をクロス表に変換する。
/// </summary>
/// <returns></returns>
public static string ToCrossTable<T, T1, T2>(this Dictionary<(T row, T1 colm), T2> dic,
Func<T, string> row_label ,
Func<T1, string> colm_label)
{
HashSet<T> hash_row = new HashSet<T>();
HashSet<T1> hash_colm = new HashSet<T1>();
foreach (var item in dic.Keys)
{
hash_row.Add(item.row);
hash_colm.Add(item.colm);
}
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.Append("\t");
foreach (var item in hash_colm.OrderBy(n => n))
{
stringBuilder.Append($"{colm_label(item)}\t");
}
stringBuilder.AppendLine();
foreach (var row in hash_row.OrderBy(n => n))
{
stringBuilder.Append($"{row_label(row)}\t");
foreach (var colm in hash_colm.OrderBy(n => n))
{
if (dic.ContainsKey((row, colm)))
{
stringBuilder.Append(dic[(row, colm)]);
}
stringBuilder.Append("\t");
}
stringBuilder.AppendLine();
}
return stringBuilder.ToString();
}
public static string ToCrossTable<T,T1,T2>(this Dictionary<(T,T1),T2> dic)
{
return ToCrossTable(dic, n => n.ToString(), n => n.ToString());
}
基本的な使い方
list.GroupBy(n=> (n.Item1,n.Item2))
.ToDictionary(n=>n.Key,n=> n.Count())
.ToCrossTable()
.ConsoleWriteLine();
これで、クロス表がコンソール出力できます。
TSVなので、結果をエクセルに張り付ければ、エクセルで見れます。
初めは、これで充分とか思っていたのですが、
文字列のソートでは、10以上の数値を含むと適切にソートをしてくれない問題がありました。
1月,10月,11月,12月,2月,3月,4月,5月,6月,7月,8月,9月
文字列あるあるです。そのため、
list.GroupBy(n=> (n.Month,n.Day))
.ToDictionary(n=>n.Key,n=> n.Count())
.ToCrossTable(n=>$"{n}月",n=> $"{n}日" )
.ConsoleWriteLine();
という形で、ソート可能な数値で、GroupByをしてから、文字列をあとから作る、というモードも用意しました。
これで、さくっと、クロス表が作れます。
応用編 Dictionary<(row,col),T> へ変換する
Dictionary<(row,col),T> の形を作れば、クロス表を作れるのが分かりました。
つまり、Dictionary<(row,col),T> の形に変形すれば、なんでもクロス表が作れるということです。
var results = list.GroupBy(n=>n.Key)
.Select(x => new{
x.Key,
ItemCountDic = x.Select(n=>n.Item).GroupBy(n=>n).ToDictonary(n=>n.Key,n=>n.Count()),
UserCountDic = x.Select(n=>n.UserName).GroupBy(n=>n).ToDictonary(n=>n.Key,n=>n.Count()),
}).ToArray();
こういう形で、一つの変数でまずGroupByをして、そのあとで、対象を変えながら、GroupByをするということはよくやります。
形式的には、オブジェクトの中に、Dictionaryがあるパターンです。
これも、クロス表にしたいですよね。
このような拡張メソッドを定義しておきます。
public static Dictionary<(T1,T2),T3> ToTupleDic<T,T1,T2,T3>(this IEnumerable<T> list,
Func<T,T1> row_label,
Func<T,Dictionary<T2,T3>> colm_dic)
{
return list.SelectMany(n => colm_dic(n).ToDictionary(m => (row_label(n), m.Key), m => m.Value))
.ToDictionary(n => n.Key, n => n.Value);
}
SelectManyの中で、ペアのDicを展開することで、Dictionary<(T1,T2),T3>を作ります。
これは、SelectManyの使い方のちょいテクですが、毎回考えるのが面倒なので、拡張メソッド化しておくと便利です。
使い方はこんな感じです。
results.ToTupleDic(n=>n.Key,n=>n.ItemCountDic)
.ToCrossTable()
.ConsoleWriteLine();
楽々クロス表出力。
応用編 オブジェクトをDictionaryに変換する拡張メソッド
オブジェクトの中に、Dictionaryがある形でもクロス表が作れることが分かりました。
Dictionaryさえ作ってしまえば、クロス表が作れるということです。
public static Dictionary<string,object> ToPropertiesDic(this object obj)
{
return obj.GetType().GetProperties().ToDictionary(n=>n.Name,n=> n.GetValue(obj));
}
このような形で、オブジェクトをDictionaryに変換する拡張メソッドを作ると、
results.ToTupleDic(n=>n.Key,n=>n.Item.ToPropertiesDic())
.ToCrossTable()
.ConsoleWriteLine();
このような形でクロス表が作れます。
応用編 割合に変換する拡張メソッド
実数を見るより、割合を見た方が分かりやすいことはあります。
こういう拡張メソッドをサクッと定義しましょう。
public static Dictionary<T, double> ToRate<T>(this Dictionary<T, int> dic)
{
var all_count = dic.Sum(n => n.Value);
return dic.ToDictionary(n => n.Key, n => n.Value / (double)all_count);
}
使い方
list.GroupBy(n=> (n.Item1,n.Item2))
.ToDictionary(n=>n.Key,n=> n.Count())
.ToRate()
.ToCrossTable()
.ConsoleWriteLine();
とToRateを入れるだけで、割合に変換できます。
おまけ。Tsvを出力する拡張メソッド
public static string ToTsv<T>(this IEnumerable<T> list)
{
System.Text.StringBuilder stringBuilder = new System.Text.StringBuilder();
stringBuilder.AppendLine(string.Join("\t", typeof(T).GetProperties().Select(n => n.Name).ToArray()));
foreach (var item in list)
{
stringBuilder.AppendLine(string.Join("\t", typeof(T).GetProperties().Select(n => n.GetValue(item)?.ToString())));
}
return stringBuilder.ToString();
}
クロス表ではないのですが、この拡張メソッドもとても便利です。
使い方
list.ToTsv().ConsoleWriteLine();
楽ちん出力。オブジェクト内オブジェクトがなければ、なんでも出力できます。
オブジェクト内オブジェクトがあるものだと、ToJSON() を定義して使うといいです。
まとめ
テストプロジェクトは、スクリプト実行環境で、とても便利。
メソッドチェーン楽しい。
さくさくクロス表が作れてデータ集計、超はかどる。