http://ngmaster.mooo.com/ngmaster/xe/index.php?document_srl=5435&mid=STUDY_FRAMEWORK
안녕하세요. 소심비형입니다. 요즘 같은 날씨에는 불쾌지수가 상당히 높아질거 같아요. 후덥지근한데다가 습도까지 높아서 야외활동 하기에는 그리 좋은 날씨는 아닙니다. 이럴때일수록 타인을 배려하는 마음을 가졌으면 합니다.
이 글은 2회에 걸쳐서 Box and whisker plot(상자 수염 그림)을 만드는 내용으로 작성할 예정입니다. 사실 DevExpress를 사용하지 않고 다른 Third-party 제품을 사용한다면 이미 박스 플롯을 만들어주는 컨트롤이 있을수도 있습니다. 없다면, 윈폼을 이용해서 만들어야 할 수도 있구요. 윈폼을 이용해서 만들어도 이정도는 쉽게 만들 수 있을듯 하지만 여기에서는 DevExpress의 Chart를 이용하여 Box plot을 그려보도록 하겠습니다. 왜냐하면, Chart Control에 있는 다른 여러가지 기능들도 통합하여 활용하기 위함입니다. 편하게 글을 작성하기 위해 명칭을 Box plot 또는 박스 플롯으로 통일 하겠습니다.
아래 이미지는 Box Plot을 설명하는 그림입니다. 그림에는 표시되지 않았지만 MIN, MAX를 벗어나는 영역의 값들은 Outlier(이상치)라고 합니다. 함께 만들어볼 Box plot에는 이상치까지 표시할 수 있도록 할 예정입니다.
박스 플롯은 분포의 형태를 쉽게 확인할 수 있습니다. 위의 그림에서 알 수 있듯이 사분위수 박스가 있고 박스의 중간에 위치한 선은 중앙값(Median)을 나타냅니다. 1사분위(1Q)와 3사분위(3Q)까지의 거리를 IQR(Interquartile Range, 사분 범위)이라 부르고, 최대값(Max)에서 최소값(Min)의 차이를 Range라고 부릅니다.
박스플롯과 정규분포와의 관계는 아래 그림을 통해 알 수 있습니다. 여기에서 정규분포와 표준편차에 대해 설명하는 것은 무리이므로 건너뛰도록 하겠습니다. (잘 몰라서...) 혹시라도 제조 솔루션이나 수율 관련 솔루션에 종사 하시거나 관련 업계에 계신분들은 기초통계학 책을 한번 보시는것도 그리 나쁘지 않습니다. 아무래도 자신이 만드는 분석용 툴이 어떤 용도로 사용되고, 분석용 툴에서 뽑아주는 각종 그래프와 리포트에 대해 해석(분석) 정도는 할 수 있는게 좋지 않을까 생각합니다. 고객과의 미팅에서 그래도 어느정도 대화가 되려면 말이죠^^;
DevExpress의 Chart에는 통계 관련 계산해주는 모듈이 없습니다. 미리 계산된 통계 데이타를 넘겨주면 그림을 그려주는 방식이므로 먼저 통계에 사용할 Statistics Engine(통계 엔진)을 만들어야 합니다. 이번 강좌에서는 확장 메서드를 이용한 통계 엔진을 아주 간단하게 구현하고 다음에 DevExpress사의 Chart를 이용하여 Box plot을 만들어 보도록 하겠습니다.
아래와 같이 NG.StatisticsEngine 프로젝트를 추가합니다. 이 프로젝트는 클래스 라이브러리로 생성합니다.
평균 및 사분위수에 사용할 확장 메서드 클래스를 아래와 같이 2개 추가합니다.
먼저 평균에 대한 확장 메서드를 구현합니다. 기본적으로 Enumerable에는 Average가 구현되어 있습니다. 내용을 풀어보면 모든 요소를 더한 후 요소의 수만큼 나눈 값으로 통계학에서는 Mean이라 부릅니다.
AverageExtension.cs
23 24 25 26 27 28 29 30 31 32 | /// <summary> /// 요소 집단의 평균값을 가져옵니다. /// <para>모든 요소의 값을 더한 후 요소의 수로 나눈 값입니다.</para> /// </summary> /// <param name="values">Mean을 계산하기 위한 요소의 집단입니다.</param> /// <returns>평균값을 반환합니다.</returns> public static double Mean( this IEnumerable< double > values) { return values.Average(); } |
간단하게 호출해서 사용할 수 있도록 메서드를 오버로딩합니다.
AverageExtension.cs
12 13 14 15 16 17 18 19 20 21 | /// <summary> /// 요소 집단의 평균값을 가져옵니다. /// <para>모든 요소의 값을 더한 후 요소의 수로 나눈 값입니다.</para> /// </summary> /// <param name="values">Mean을 계산하기 위한 요소의 집단입니다.</param> /// <returns>평균값을 반환합니다.</returns> public static double Mean( this double [] values) { return Mean(values.AsEnumerable()); } |
이제 박스의 중앙값(Median)을 표시하기 위해 요소 집단의 중앙값을 계산하는 확장 메서드를 추가합니다.
AverageExtension.cs
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 | /// <summary> /// 요소 집단의 중앙값을 가져옵니다. /// <para>요소의 수를 정렬한 후 중앙에 위치하는 값을 반환합니다.</para> /// <para>ex) { 1, 1, 1, 1, 4, 100, 100, 100, 100 }의 배열에서 중앙값은 4입니다. 만약 100이 하나 더 있다면, 중앙값은 (4 + 100) / 2입니다.</para> /// </summary> /// <param name="values">Median을 계산하기 위한 요소의 집단입니다.</param> /// <returns>중앙값을 반환합니다.</returns> public static double Median( this IEnumerable< double > values) { List< double > orderedList = values .OrderBy(numbers => numbers) .ToList(); int listSize = orderedList.Count; double result; if (listSize % 2 == 0) // even { int midIndex = listSize / 2; result = ((orderedList.ElementAt(midIndex - 1) + orderedList.ElementAt(midIndex)) / 2); } else // odd { double element = ( double )listSize / 2; element = Math.Round(element, MidpointRounding.AwayFromZero); result = orderedList.ElementAt(( int )(element - 1)); } return result; } |
Mean(평균)처럼 간단하게 중앙값을 사용하기 위해 Median을 오버로딩합니다.
AverageExtension.cs
34 35 36 37 38 39 40 41 42 43 44 | /// <summary> /// 요소 집단의 중앙값을 가져옵니다. /// <para>요소의 수를 정렬한 후 중앙에 위치하는 값을 반환합니다.</para> /// <para>ex) { 1, 1, 1, 1, 4, 100, 100, 100, 100 }의 배열에서 중앙값은 4입니다. 만약 100이 하나 더 있다면, 중앙값은 (4 + 100) / 2입니다.</para> /// </summary> /// <param name="values">Median을 계산하기 위한 요소의 집단입니다.</param> /// <returns>중앙값을 반환합니다.</returns> public static double Median( this double [] values) { return Median(values.AsEnumerable()); } |
여기에서 사용되지는 않지만, WaferMap Control에서 웨이퍼의 측정값 중에서 최빈값(요소의 분포에서 최다 도수값들)을 표시할 수도 있습니다. 계측에서 한 필드에 10번 이상 측정하게 되는데 이 값들을 모두 웨이퍼 위에 올려놓으면 빠르게 어떤 경향 또는 방향성을 파악하기가 어렵습니다. 따라서, 평균이나 중앙값 또는 최빈값만 보여주는게 분석툴 입장에서는 효과적일 수도 있습니다. 최빈값은 0개 이상 나올 수 있으므로 반환값을 유일하게 배열로 넘겨줍니다.
AverageExtension.cs
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | /// <summary> /// 요소 집단의 최빈값을 가져옵니다. /// <para>요소의 분포에서 최다 도수를 갖는 값을 반환합니다.</para> /// <para>ex) { 1, 2, 2, 3, 4, 5, 5, 6, 7 } 배열에서 최빈값은 2와 5가 됩니다. 만약, 2가 하나 더 포함된다면 최빈값은 2가 됩니다.</para> /// </summary> /// <param name="values">Modes를 계산하기 위한 요소의 집단입니다.</param> /// <returns>최빈값을 반환합니다.</returns> public static IEnumerable< double > Modes( this IEnumerable< double > values) { var list = values.Where(v => v == 0).ToList(); int cnt = list.Count; var modesList = values .GroupBy(group => group) .Select(valueCluster => new { Value = valueCluster.Key, Occurrence = valueCluster.Count(), }) .ToList(); int maxOccurrence = modesList .Max(g => g.Occurrence); return modesList .Where(x => x.Occurrence == maxOccurrence && maxOccurrence > 1) .Select(x => x.Value); } } |
최빈값도 사용 편의성을 고려해 오버로딩 해줍니다.
AverageExtension.cs
79 80 81 82 83 84 85 86 87 88 89 | /// <summary> /// 요소 집단의 최빈값을 가져옵니다. /// <para>요소의 분포에서 최다 도수를 갖는 값을 반환합니다.</para> /// <para>ex) { 1, 2, 2, 3, 4, 5, 5, 6, 7 } 배열에서 최빈값은 2와 5가 됩니다. 만약, 2가 하나 더 포함된다면 최빈값은 2가 됩니다.</para> /// </summary> /// <param name="values">Modes를 계산하기 위한 요소의 집단입니다.</param> /// <returns>최빈값을 반환합니다.</returns> public static double [] Modes( this double [] values) { return Modes(values.AsEnumerable()).ToArray(); } |
이제 Box plot을 그리는데 핵심인 사분위를 계산할 수 있는 확장 메서드를 추가합니다. 먼저 구간에 대한 보간을 구할 수 있는 메서드를 추가합니다.
QuartileExtension.cs
127 128 129 130 | private static double Interpolate( this double a, double b, double remainder) { return (b - a) * remainder; } |
사분위를 계산하기 위한 가장 핵심이 되는 메서드를 추가합니다. 사실 대부분이 이 메서드를 호출하여 처리할 수 있도록 할 예정입니다. 따라서 접근 한정자를 private으로 처리하는게 좋겠다고 생각했습니다. 하지만, 직접 호출할수도 있고 계산 로직을 파생할수도 있기에 protected로 해도 되지 않을까 고민해봅니다.
QuartileExtension.cs
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | /// <summary> /// 인자로 넘어온 위치의 사분위수를 반환합니다. /// </summary> /// <param name="list">사분위수를 계산하기 위한 요소의 목록입니다.</param> /// <param name="quartile"></param> /// <returns></returns> private static double GetQuartile(IOrderedEnumerable< double > list, double quartile) { if (list.Count() < 5) return 0; double result; double index = quartile * (list.Count() + 1); double remainder = index % 1; index = Math.Floor(index) - 1; if (remainder.Equals(0)) result = list.ElementAt(( int )index); else { double value = list.ElementAt(( int )index); double interpolateIndex = index + 1; double interpolationValue = value.Interpolate(list.ElementAt(( int )(index + 1)), remainder); result = value + interpolationValue; } return result; } |
IQR를 구하기 위한 메서드를 추가합니다.
QuartileExtension.cs
87 88 89 90 91 92 93 94 95 96 97 98 | /// <summary> /// 제1 사분위수와 제3 사분위수 사이의 범위를 반환합니다. /// </summary> /// <remarks> /// 제3 사분위수(C75)와 제1 사분위수(C25)의 범위입니다. 이는 변산성의 정도를 분포의 중앙에 위치한 중앙값의 좌우로부터 동일한 백분율을 가진 두 점간의 거리에 의해 알아보려는 것이며, 만약 사분범위가 크면 보다 흩어진 분포이고, 작으면 밀집된 분포임을 알 수 있습니다. 자료의 극단적인 값들에 의한 영향을 덜 받는 장점이 있습니다. /// </remarks> /// <param name="values">사분위수를 계산하기 위한 요소의 목록입니다.</param> /// <returns>제1 사분위수와 제3 사분위수 사이의 범위를 반환합니다.</returns> public static double InterQuartileRange( this IOrderedEnumerable< double > values) { return values.UpperQuartile() - values.LowerQuartile(); } |
IQR을 좀 더 쉽게 호출하기 위해 오버로딩을 추가합니다.
QuartileExtension.cs
74 75 76 77 78 79 80 81 82 83 84 85 | /// <summary> /// 제1 사분위수와 제3 사분위수 사이의 범위를 반환합니다. /// </summary> /// <remarks> /// 제3 사분위수(C75)와 제1 사분위수(C25)의 범위입니다. 이는 변산성의 정도를 분포의 중앙에 위치한 중앙값의 좌우로부터 동일한 백분율을 가진 두 점간의 거리에 의해 알아보려는 것이며, 만약 사분범위가 크면 보다 흩어진 분포이고, 작으면 밀집된 분포임을 알 수 있습니다. 자료의 극단적인 값들에 의한 영향을 덜 받는 장점이 있습니다. /// </remarks> /// <param name="values">사분위수를 계산하기 위한 요소의 목록입니다.</param> /// <returns>제1 사분위수와 제3 사분위수 사이의 범위를 반환합니다.</returns> public static double InterQuartileRange( this double [] values) { return InterQuartileRange(values.OrderBy(v => v)); } |
각 사분위수를 구하는 확장 메서드를 추가합니다. 이 메서드를 보면 GetQuartile을 어떻게 사용해야 하는지를 쉽게 알 수 있습니다.
QuartileExtension.cs
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 | /// <summary> /// 제1 사분위수를 반환합니다. /// </summary> /// <param name="values">제1 사분위수를 계산하기 위한 요소의 목록입니다.</param> /// <returns>제1 사분위수를 반환합니다.</returns> public static double LowerQuartile( this double [] values) { return LowerQuartile(values.OrderBy(v => v)); } /// <summary> /// 제1 사분위수를 반환합니다. /// </summary> /// <param name="values">제1 사분위수를 계산하기 위한 요소의 목록입니다.</param> /// <returns>제1 사분위수를 반환합니다.</returns> public static double LowerQuartile( this IOrderedEnumerable< double > values) { return GetQuartile(values, 0.25); } /// <summary> /// 제3 사분위수를 반환합니다. /// </summary> /// <param name="values">제3 사분위수를 계산하기 위한 요소의 목록입니다.</param> /// <returns>제3 사분위수를 반환합니다.</returns> public static double UpperQuartile( this double [] values) { return UpperQuartile(values.OrderBy(v => v)); } /// <summary> /// 제3 사분위수를 반환합니다. /// </summary> /// <param name="values">제3 사분위수를 계산하기 위한 요소의 목록입니다.</param> /// <returns>제3 사분위수를 반환합니다.</returns> public static double UpperQuartile( this IOrderedEnumerable< double > values) { return GetQuartile(values, 0.75); } /// <summary> /// 제2 사분위수를 반환합니다. /// </summary> /// <param name="values">제2 사분위수를 계산하기 위한 요소의 목록입니다.</param> /// <returns>제2 사분위수를 반환합니다.</returns> public static double MiddleQuartile( this double [] values) { return MiddleQuartile(values.OrderBy(v => v)); } /// <summary> /// 2 사분위수를 반환합니다. /// </summary> /// <param name="values">2 사분위수를 계산하기 위한 요소의 목록입니다.</param> /// <returns>2 사분위수를 반환합니다.</returns> public static double MiddleQuartile( this IOrderedEnumerable< double > values) { return GetQuartile(values, 0.50); } |
이렇게해서 박스 플롯을 그리기 위한 데이타를 생성할 수 있는 통계 엔진을 간단하게 만들어 보았습니다. 다음에는 DevExpress사의 Chart control을 이용하여 Box plot을 만들고, 확장 메서드를 이용하여 데이타와 연동할 수 있도록 하겠습니다. 사실 MATLAB을 사용하는게 더 좋을 수 있습니다. 이런 통계나 관련 컨트롤들을 일일이 만드는 것보다는 돈주고 MATLAB을 사서 쓰는게 효율적일수도 있겠죠^^;
다음 시간에...
댓글