25
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

データサイエンスプラットフォームEsunaの前処理UI/Elixirコードは、入力データ解析の後、データ内容から自動生成されている

Last updated at Posted at 2018-12-09

(この記事は「言語実装 Advent Calendar 2018」の8日目です)

fukuoka.ex代表のpiacereです
ご覧いただいて、ありがとうございます:bow:

「Esuna」というデータサイエンスプラットフォームを、Elixir/Phoenix+Vue.jsで開発・提供していますが、データ前処理のUI生成と、その後の前処理Elixirコード生成が、ちょっとした言語実装のようなものになっているので、その流れを解説したいと思います
image.png
(Advent Calendarへの投稿が1日遅れになってスミマセン…)

なお、「Phoenix」は、ElixirのWebフレームワークです

内容が、面白かったり、役に立ったら、「いいね」よろしくお願いします :wink:

「Esuna」のデータ前処理は4つのステップで構成されている

データサイエンスプラットフォーム「Esuna」は、上記のようなUIを持っており、入力項目毎のデータ前処理を、画面右のような、プロパティウインドウで設定でき、設定した内容通りに、データ変換/集計を行うElixirコードを生成します

この処理の流れを、大まかに分けると、以下の4ステップで構成されています

① プロパティウインドウに表示する前処理UIを、Vue.jsのdata部から生成する ② 前処理UIに設定された内容がVue.jsのdata部に反映され、前処理規則としてPhoenix APIに引き渡される ③ 前処理規則を受け取ったAPIは、その内容に沿ったElixirコードを生成する ④ 生成されたElixirコードを実行し、前処理後の加工/集計データをEsunaの結果テーブルに反映する

これらの各ステップについて、説明していきます

①前処理UIは、Vue.jsのdata部から生成する

Vue.jsのdata部には、加工前のデータの列名群(original_columns)と、プロパティウインドウに出力するUIの元となる前処理規則(properties)があり、元データのインポートが行われた際に呼び出されるonImport()にて、original_columnsを元データ取得APIで更新し、その後、propertiesがoriginal_columnsに合わせて構築されます

var app = new Vue
( {
	el: '#app',
	data: 
	{
		original_columns:	[], 
		original_rows:		[], 
		properties:
		{ 
			a5_row_filter: 
			{ 
				_template: 
				[ 
					{
						on:        false, 
						condition: '>=', 
						value:     2009, 
					}, 
					{
						on:        false, 
						condition: '<', 
						value:     2017, 
					}, 
				], 
			}, 
			b5_exclude_column: 
			{ 
				_template: 
				[ 
					{
						on: false, 
					}, 
				], 
			}, 
			c5_row_random_sampling: 
			{
				_template: 
				[ 
					{
						on:    false, 
						value: 20, 
					},
				], 
			},
			d5_group_by: 
			{
				_template: 
				[ 
					{
						on:       false, 
						column:   'Item_Weight', 
						group_by: 'sum', 
					},
				], 
			},
			…(他の前処理項目が続く)…
		}, 
		…(他のデータが続く)…
	}, 
	methods: 
	{ 
		onImport: async function()
		{
			// Import original datas from import API
			await axios.get
			( 
				'/original_data' 
			)
			.then( response => 
			{ 
				this.original_columns  = response.data.columns 
				this.original_rows     = response.data.rows 
				…(他の受信項目が続く)…
			} )

			// Build properties for imported columns from templates, after Delete templates
			for ( var property in this.properties )
			{
				for ( var i = 0; i < this.original_columns.length; i++ )
				{
					this.properties[ property ][ this.original_columns[ i ].name ] = _.cloneDeep( this.properties[ property ]._template )
				}
			}
			delete this.properties[ property ]._template
			…(他のインポート時処理が続く)…
		}, 
		…(他のメソッドが続く)…
	}, 
	…(他の処理定義が続く)…
} )

具体例として、前処理カテゴリである「a5_row_filter」等は、配下に「_template」を持っており、「a5_row_filter」配下に、original_columnsの全列分、「_template」の複製を作り、全列用のプロパティを生成します(生成後、「_template」を削除します)

これを、全ての前処理カテゴリに対して行います


			a5_row_filter: 
			{ 
				Item_Identifier: 
				[ 
					{
						on:        false, 
						condition: '>=', 
						value:     2009, 
					}, 
					{
						on:        false, 
						condition: '<', 
						value:     2017, 
					}, 
				], 
				Item_Weight: 
				[ 
					{
						on:        false, 
						condition: '>=', 
						value:     2009, 
					}, 
					{
						on:        false, 
						condition: '<', 
						value:     2017, 
					}, 
				], 
				…(全列分のプロパティデータが複製される)…
			}, 

UI側は、Vue.js側data部のoriginal_columns全列分に相当する前処理UIをpropertiesから生成します
たとえば、"on" アイテムは、image.png のスイッチを生成します
"value" アイテムはテキストフィールドを、"column" アイテムはインポートデータの列名コンボボックスを、"condition" アイテムは、以下のような条件判断コンボボックスを生成…といった感じで、設定項目に沿ったUIパーツを生成します
image.png

<div v-for="column in original_columns" v-bind:style="'display: ' + properties._display[ column.name ]">
<label class="caption">{{ column.name }}</label>

<span v-for="category_name in properties_keys" v-if="category_name != '_display'">
<label>{{ category_name.substr( 3 ) }}</label>

<div v-for="( item, item_no ) in properties[ category_name ][ column.name ]" class="grid-x grid-padding-x">
	<div v-if="item.on != null" class="large-2 colummns">
		<div class="switch tiny">
			<input type="checkbox" v-model="item.on" v-bind:id="column.name + '_' + category_name + '_' + item_no" class="switch-input">
			<label v-bind:for="column.name + '_' + category_name + '_' + item_no" class="switch-paddle"></label>
		</div>
	</div>
	<div v-if="item.condition != null" class="large-2 colummns">
		<select v-model="item.condition">
			<option v-for="condition in conditions">{{ condition }}
		</select>
	</div>
	<div v-if="item.value != null" class="large-5 colummns">
		<input type="text" placeholder="Enter value" v-model="item.value" class="form-control">
	</div>
	<div v-if="item.column != null" class="large-7 colummns">
		<select v-model="item.column">
			<option v-for="column in original_columns">{{ column.name }}
		</select>
	</div>
	…(他の前処理UI項目が続く)…
</div>
</span>

こうして生成されたプロパティウインドウ上の前処理UIは、Vue.js側のpropertiesと双方向データバインディングで連動しており、UIで設定した内容が、propertiesに反映されるようになります

②前処理規則をVue.jsからPhoenixの前処理コード生成APIに引き渡す

プロパティウインドウで設定した前処理規則(properties)は、Vue.js内のaxios経由で、Phoenixの前処理コード生成APIに渡されます

var app = new Vue
( {
	
	methods: 
	{ 
		
		onProcessedData: async function( evt )
		{
			// Send properties to build preprocess API
			console.log( this.properties )
			await axios.post
			( 
				'/processed_data', 
				{
					'processed_data': 
					{
						'properties': this.properties, 
						…(他の送信項目が続く)…
					}
				} 
			)
			
		}, 
		

Phoenixの前処理コード生成API側は、以下のコントローラで実装しており、PreprocessGenerator.generateにpropertiesを渡すことで、前処理Elixirコードを生成します

defmodule EsunaPreprocessWeb.ProcessedDataController do
	
	def create(conn, %{"processed_data" => processed_data_params}) do
		code = PreprocessGenerator.generate( processed_data_params[ "properties" ], processed_data_params[ "source" ] )

		File.write!( "lib/generated/preprocessor.ex", 
			"""
			defmodule Preprocessor do
				def execute() do
					#{ code }
					|> Fl.write_map_list!( "datas/RetailSampleProcessed.csv" )
				end
			end
			"""
		)
		json( conn, %{} )
	end
	
end

③前処理コード生成APIは、前処理規則に沿ったElixirコードを生成する

APIから呼び出されるPreprocessGenerator.generateは、前処理規則(properties)のカテゴリ毎に、"on" アイテムがtrue(≒ONになっている)のものを対象に、Elixirコード生成を行っています

ポイントとなるのは、複数定義されているcategoryが、「カテゴリ名による関数パターンマッチ」をうまく活用し、カテゴリ毎のプロパティパースとElixirコード生成の分岐をスマートに処理していることです

defmodule PreprocessGenerator do
	def generate( properties ) do
		properties
		|> Map.keys
		|> Enum.map( &category( &1, properties[ &1 ] ) )
	end

	def category( "a5_row_filter", columns_property ) do
		fomulas = columns_property
			|> Map.keys 
			|> Enum.reduce( "", fn key, acc -> 
				acc <> 
				( 
					columns_property[ key ] 
					|> Enum.filter( fn %{ "on" => on } -> on == true end ) 
					|> Enum.reduce( "", fn item, part -> part <> out_condition( key, item[ "condition" ], item[ "value" ] ) end ) 
				)
			end )
		if fomulas != "", do: "\t\t|> Enum.filter( &( " <> cut_last_logical( fomulas ) <> ") )\n", else: ""
	end

	def category( "b5_exclude_column", columns_property ) do
		fomulas = columns_property
			|> Map.keys
			|> Enum.reduce( "", fn key, acc -> 
				acc <> 
				( 
					columns_property[ key ] 
					|> Enum.filter( fn %{ "on" => on } -> on == true end ) 
					|> Enum.reduce( "", fn _item, part -> part <> out_key( key ) end )
				)
			end )
		if fomulas != "", do: "\t\t|> Enum.map( &( Map.drop( &1, [ " <> cut_last_comma( fomulas ) <> " ] ) ) )\n", else: ""
	end

	def category( "c5_row_random_sampling", columns_property ) do
		fomulas = columns_property
			|> Map.keys
			|> Enum.reduce( "", fn key, acc -> 
				acc <> 
				( 
					columns_property[ key ] 
					|> Enum.filter( fn %{ "on" => on } -> on == true end ) 
					|> Enum.reduce( "", fn item, part -> part <> out_number_value( item[ "value" ] ) end )
				)
			end )
		if fomulas != "", do: "\t\t|> Enum.take_random( " <> cut_last_comma( fomulas ) <> " )\n", else: ""
	end
	…(他の前処理カテゴリを処理する関数が続く)…

	def category( _key, _item ), do: ""

	def out_key( key ),                         do: "\"#{ key }\", "
	def out_number_value( value ),              do: "#{ value }, "
	def out_string_value( value ),              do: "\"#{ value }\", "
	def out_condition( key, condition, value ), do: "&1[ \"#{ key }\" ] #{ condition } \"#{ value }\" && "

	def cut_last_comma( str ),   do: str |> String.slice( 0..-3 )
	def cut_last_logical( str ), do: str |> String.slice( 0..-4 )

まず、前処理規則(properties)は、PreprocessGenerator.generateに、以下の形で渡されてきます

%{
  "a5_row_filter" => 
  %{ 
    
    "Outlet_Establishment_Year" => 
    [
      %{"condition" => ">=", "on" => true, "value" => "2006"},
      %{"condition" => "<", "on" => true, "value" => 2017}
    ],
    "Outlet_Identifier" => 
    [
      %{"condition" => ">=", "on" => false, "value" => 2009},
      %{"condition" => "<", "on" => false, "value" => 2017}
    ],
    
  },
  "b5_exclude_column" => 
  %{ 
    
    "Outlet_Establishment_Year" => [%{"on" => false}],
    "Outlet_Identifier" => [%{"on" => false}],
    
  }, 
}, 

たとえば、「a5_row_filter」は、行の絞り込みをするための範囲設定として、以下のようなデータ構造でcolumns_propertyが渡されてくるので、全列に対し、「"condition" アイテムによる条件判断(コンボボックスで設定した、「<」「<=」「>」「>=」「==」「!=」のいずれか)」と「"value" アイテムに設定された値」の組み合わせを式として生成し、その後、Enum.filterの内部関数に埋め込むことで、前処理Elixirコードを生成します

  %{ 
    
    "Outlet_Establishment_Year" => 
    [
      %{"condition" => ">=", "on" => true, "value" => "2006"},
      %{"condition" => "<", "on" => true, "value" => 2017}
    ],
    "Outlet_Identifier" => 
    [
      %{"condition" => ">=", "on" => false, "value" => 2009},
      %{"condition" => "<", "on" => false, "value" => 2017}
    ],
    
  },

同じ要領で、「b5_exclude_column」であればEnum.mapとMap.dropの組み合わせを、「c5_row_random_sampling」であればEnum.take_randomを生成…といった感じで、各カテゴリに沿った式と、前処理Elixirコードを生成します

生成されたElixirコードは、以下のような感じで、前処理UIに設定した通りのデータ加工/集計を、ロード対象の元データに対して、Elixirのパイプで縦に並べた処理となります

defmodule Preprocessor do
	def execute() do
		Excel.load_map( "RetailSample.xlsx", Excel.sheet_names( "RetailSample.xlsx" ) |> List.first )
		|> Enum.filter( &( &1[ "Outlet_Establishment_Year" ] >= "2006" && &1[ "Outlet_Establishment_Year" ] < "2017" ) )
		|> Enum.map( &( Map.drop( &1, [ "Item_Weight" ] ) ) )
		|> Enum.take_random( 10 )
		|> MapUtil.get_dummies( [ "Item_Fat_Content" ] )
		|> Fl.write_map_list!( "datas/RetailSampleProcessed.csv" )
	end
end

Elixirは、データに対する加工/集計処理が、Enumで大抵、揃っているため、こうした前処理を作り込むのも、非常にカンタンです

④生成された前処理Elixirコードを実行し、結果データ反映

生成された前処理Elixirコードは、前処理Elixirコード実行APIの呼び出し時、PhoenixのAutoReloadによってビルドされ、その実行結果(processed_columns、processed_rows)をAPIの戻りとして受信します

var app = new Vue
( {
	
	data: 
	{
		processed_columns:	[], 
		processed_rows:		[], 
		…(他のデータが続く)…
	}, 
	methods: 
	{ 
		
		onProcessedData: async function( evt )
		{
			
			// Load preprocessed datas
			await axios.get
			( 
				'/processed_data' 
			)
			.then( response => 
			{ 
				this.processed_columns = response.data.columns 
				this.processed_rows    = response.data.rows 
				…(他の受信項目が続く)…
			} )
			
		}, 
		

受信したprocessed_columns、processed_rowsは、Vue.jsの双方向データバインディングにより、「Processed data」タブ配下にあるデータ表示テーブルへと表示反映されます
image.png

終わり

データサイエンスプラットフォーム「Esuna」の前処理UI生成と、前処理Elixirコード生成について、実際のコードを交えて解説しましたが、いかがでしたでしょうか?

もしかしたら、今回のコラム内容は、私の他コラムと比べ、けっこう実践的で、少し複雑なデータ構造とその処理を扱っているため、Vue.jsとPhoenixの繋ぎ込み部分や、Elixirコード生成処理が難解に感じられたかも知れません

しかし、ElixirとVue.jsの記述能力の高さが相まって、他言語で同等の処理を書いたときと、比べ物にならない位シンプルに造られており、手入力したコードは、HTMLも含め、800行程度です

また、SPA(Single Page Application)+内部API数本で構成されており、アーキテクチャも単純です

この全容は、近々、OSSとして公開されるEsunaをお待ちください

p.s.「いいね」よろしくお願いします

ページ左上の image.pngimage.png のクリックを、どうぞよろしくお願いします:bow:
ここの数字が増えると、書き手としては「ウケている」という感覚が得られ、連載を更に進化させていくモチベーションになりますので、もっとElixirネタを見たいというあなた、私達と一緒に盛り上げてください!:tada:

25
10
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
25
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?