20220224のC#に関する記事は10件です。

プロパティとしてのクラス(Table Per Type編)

EntityFrameworkで設計していると 「この項目、外部からはプロパティとして動作してほしいけど、内部的には別クラスのインスタンスとして実装したいな」と考えることがある。 そこでIDと値だけのジェネリックインターフェイスを定義してみる。 IClassAsProperty.cs public interface IClassAsProperty<T> where T : struct { public T value { get; set; } } でクラスをプロパティとして扱うにはジェネリック静的プロパティを定義する。 ClassAsProperty.cs public static class ClassAsProperty { public static T? GetmethodT<T, T2>(ref T2 TargetPoperty) where T : struct where T2 : IClassAsProperty<T> { return TargetPoperty?.value; } public static void setMetgidT<T, T2>(T value, ref T2 TargetProperty) where T : struct where T2 : IClassAsProperty<T>, new() { if (TargetProperty is null) { TargetProperty = new T2(); } TargetProperty.value = value; } } 何をしているかというと getメソッド時にはnull許容型にしておいてnullであればnullを返すようにする。 setメソッド時にはnullであれば新規インスタンスを生成して値を格納するとする。 実際の使い方は次回で
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【C#】ValueObject作成時のGetHashCode()のオーバーライドの実装方法

前提知識(知っている方はスキップ推奨) そもそもValueObjectって何? ざっくりいうと、システム固有のオブジェクト(独自型)です。 値が持つビジネスルールを表現することができます。 詳しくはググってみてください。 そもそもハッシュ関数って何? 入力されたデータを"何らかの値"にして返却する関数です。 "何らかの値"を生成するときは、ある一定のルールに従って行われます。 したがって、同じ値をハッシュ関数に与えると、同じ値が返却されます。 尚、ハッシュ関数が返却する値をハッシュ値と言います。 ValueObjectが単一の値しか持たない場合 プロパティに対してGetHashCode()をするだけでOKです。 public class Id { public int Value { get; set;} // ...略 public override int GetHashCode() { return Value.GetHashCode(); } // ...略 } ValueObjectが複数の値を持つ場合 ###C#7以降の場合tupleを使用する。 public class Name { public string First { get; set;} public string Last { get; set;} // ...略 public override int GetHashCode() { return (First, Last).GetHashCode(); } // ...略 } ###.NET Standard2.1以降の場合HashCode.Combineを使用する。 class Name { public Name(string first, string last) { First = first; Last = last; } public string First { get; set; } public string Last { get; set; } // ...略 public override int GetHashCode() { return HashCode.Combine(First, Last); } // ...略 } その他、自力でやる場合は素数をかけて排他論理和をとる class Name { public Name(string first, string last) { First = first; Last = last; } public string First { get; set; } public string Last { get; set; } // ...略 public override int GetHashCode() { return (First.GetHashCode() * 397) ^ Last.GetHashCode(); } // ...略 } 適当にやるとどうなる? 例えば、return First.GetHashCode() ^ Last.GetHashCode();のようにしてしまうと、 値がひっくり返っている場合に同一のハッシュ値となってしまいます。 上述のNameクラスで例えると、 「吉野 葵(よしの あおい)さん」と「葵 吉野(あおい よしの)さん」は別人です。 しかし、return First.GetHashCode() ^ Last.GetHashCode();という実装だと ただの排他論理和のため、 「吉野 葵(よしの あおい)さん」と「葵 吉野(あおい よしの)さん」のハッシュ値が同一となってしまいます。 説明した上記3つの例では、違うハッシュ値が返ります。 まとめ ValueObjectが単一の値しか持たない場合 return Value.GetHashCode();でOK ValueObjectが複数の値を持つ場合 tuppleを使う:return (First, Last).GetHashCode(); HashCode.Combineを使う:return HashCode.Combine(First, Last); 素数+排他論理和を使う:return (First.GetHashCode() * 397) ^ Last.GetHashCode(); 参考文献 ハッシュ値を組み合わせる 【C#】ValueObjectの実装例 ハッシュ関数 (hash function)とは
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Nature Remo Cloud API + Lambdaで温度を記録して折れ線グラフを表示する自分ち専用のAndroidアプリを作った

Nature Remo Cloud API + Lambdaで温度を記録して折れ線グラフを表示する自分ち専用のAndroidアプリを作りました。 経緯 我が家ではNature Remoというスマートリモコンを使っており、Alexaやスマホアプリ経由でいろんな家電を操作できるようにしている そのNature RemoではNature Remo Cloud APIというものが提供されており、HTTPで簡単に情報が取れるらしい 最近エアコン暖房がついててもたいしてあったかくない気がする Nature Remoに搭載されている温度センサーの値と、エアコンの稼働状況をAPIから定期的に取得し、保存して見える化すればエアコンが本当に調子が悪いのかわかるのでは? 単純に室温の変化が見えたら面白そう 気象庁ホームページのリニューアルでアメダスのデータも簡単に取れるようになったらしいのでついでに外気温も取得してみよう つくったもの AWS Lambdaで動くバッチ(RemoBatch) Cloud Watch Eventsで10分おきに動作させる Nature Remoから室内気温、エアコンがついているorいない、動作モード(冷房か暖房か)、エアコンの設定温度を取得 同時に気象庁HPのアメダスデータから外気温を取得 それらをDynamoDBに保存 AWS Lambda + API Gatewayによるデータ取得API(GetRemoInfo) DynamoDBに保存したデータを取得するためのAPI リクエストでいつからいつまでのデータを取得したいか指定し、JSONでその期間内のデータを返す 折れ線グラフを表示するAndroidアプリ(RemoTemperatureViewer) 上記GetRemoInfoAPIを叩き、取得したJSONから折れ線グラフを表示する RemoBatch コード:https://github.com/nishinsoba/RemoBatch 年末年始に作成。 とりあえずすぐ動くものが欲しかったので、業務で触ったことがあるC#でコーディングしました。 基本的にはひたすら外部のAPIを叩き、レスポンスのJSONを解析して、最後にDynamoDBに詰めているだけなので難しいことはしていないです。 Nature Remo Cloud APIを叩く際に必要となるトークンはLambdaの環境変数としています。 年末年始に作成してしばらく放置していたので、その間にDynamoDBにデータがたまっていったのですが、途中でNature Remo Cloud API側で仕様変更があったらしく(?)途中からエアコンを使用しているorしていないが取得できなくなっていたので修正しました。 参考ページ Nature Remo Cloud API Nature Remoの公式APIの使い方 新しい気象庁サイトからJSONデータが取得できる件 AWS公式 .NETドキュメントモデル DynamoDBへの書き込みにドキュメントモデルを使ってみました。 気象庁アメダスデータの取得 外気温を知るために気象庁のアメダスデータを使用しています。(我が家は神奈川県某所にあるので、神奈川県内のアメダス地点のデータを使っています) https://www.jma.go.jp/bosai/amedas/data/map/yyyyMMddHHmm00.json (yyyyMMddHHmmは任意)でデータを取得できるようですが、10分おきに更新されているようなので、mmは10で割り切れる数である必要があります。 つまり、 https://www.jma.go.jp/bosai/amedas/data/map/20220222222000.json (2022/2/22 22:20)ならばOKですが、 https://www.jma.go.jp/bosai/amedas/data/map/20220222222200.json (2022/2/22 22:22)だとNot Foundとなります。 また、現在時刻から近すぎるとこれまたNot Foundになるようなので、RemoBatchでは現在時刻より15分以上前のデータを取得するようにしています。(そのため外気温のデータはリアルタイムではないですが、目安として使えればいいやということで) //現在時刻(JST) var jstZoneInfo = TZConvert.GetTimeZoneInfo("Tokyo Standard Time"); DateTime jst = TimeZoneInfo.ConvertTime(DateTimeOffset.Now, jstZoneInfo).DateTime; string jstStr = jst.ToString("yyyy/MM/dd HH:mm:ss"); //アメダスデータ //現在気温の取得には15分程度ラグがあるので、15分前の気温を取得することにする var before15Minutesjst = jst.AddMinutes(-15); var jstAmedasStr = before15Minutesjst.ToString("yyyyMMddHH"); var jstAmedasStrMinute = before15Minutesjst.ToString("mm").Substring(0, 1); var amedasUrl = "https://www.jma.go.jp/bosai/amedas/data/map/" + jstAmedasStr + jstAmedasStrMinute + "000.json"; 力技ではある。 DynamoDB こんなかんじです。 プライマリーキーを日付にしてしまったのが若干使いづらくて後悔はしています。 GetRemoInfo コード:https://github.com/nishinsoba/GetRemoInfo DynamoDBの中身を検索してJSONで返すAPIです。バッチと同じくC#で書きました。 APIキーを設定しています(我が家のエアコン稼働状況とかが見れてしまうので!) requestにクエリパラメータで取得したい期間fromとto(どちらもyyyyMMddHHmmss)を指定してGETすると、from ~ toの間の日時のレコードを検索して返却します。 DynamoDBからの読み込みには、Batchの方はドキュメントモデルを使ったのでこちらではオブジェクト永続性モデルを使ってみました。 レスポンスにそこそこ時間がかかるのが改善したいポイントです。 費用が高くならないようにプロビジョンドキャパシティーユニットを低めに設定している そもそもScanに時間がかかる(DB設計の問題?) Lambdaのコールドスタートにも時間がかかる のが原因かなと思っています。 以下はレスポンスの一例です。 取得期間中の外気温と室温の平均値も返すようにしてみました。 response { "result_message": "SUCCESS", "remo_data": [ { "datetime": "2022/02/22 10:10:15", "is_using_aircon": 0, "outdoor_temperature": 7.3, "room_temperature": 17, "aircon_mode": "warm", "aircon_temperature": 20 }, { "datetime": "2022/02/22 10:20:15", "is_using_aircon": 0, "outdoor_temperature": 7.6, "room_temperature": 17, "aircon_mode": "warm", "aircon_temperature": 20 } ], "start_date_time": "2022/02/22 10:10:15", "end_date_time": "2022/02/22 10:20:15", "average_room_temperature": "17", "average_outdoor_temperature": "7.449999999999999" } RemoTemperatureViewer コード:https://github.com/nishinsoba/RemoTemperatureViewer 上記GetRemoInfo APIで取得したJSONデータを折れ線グラフで表示するAndroidアプリです。 別にAndroidでなくてもよいのですが、最近触れていないので復習と、MVVM + LiveDataの勉強がしたかったのでAndroidにしました。 StartFragmentでユーザにfrom(データ取得期間の開始)とto(取得期間の終わり)を入力してもらう FirstFragmentで入力された日付・時間情報をRemoTemperatureViewModelが保持 OKボタンが押されたらRemoTemperatureViewModelがAPIを叩く APIからレスポンスが帰ってきたら、折れ線グラフ描画の準備をする GraphFragmentが折れ線グラフを表示する グラフを表示は横画面にも対応 (※エアコンを使用していないときは緑の線が0℃になる) というのが基本的な流れになります。 工夫したところ⓵ 誰からでもAPIを叩けてしまうと困るので、GetRemoInfoにAPIキーを設定しています。そのためこのアプリから叩くときもAPIキーが必要となるので、初回起動時にユーザに入力してもらうようにしています。 内部的にはSharedPreferencesとして入力してもらったトークンを保存しています。 FirstFragment /** * GetRemoInfoを叩くためのトークンを持っているかチェックし、持っていなければ入力ダイアログを表示する */ private fun checkTokenExist(): String?{ val sharedPref = requireActivity().getSharedPreferences("remo_viewer_setting",Context.MODE_PRIVATE) var token = sharedPref.getString("token",null) if (token == null){ // ダイアログを二重表示しない if( dialog != null && dialog!!.isShowing() ) { return null } // トークン入力ダイアログを表示する val myEdit = EditText(activity) dialog = AlertDialog.Builder(requireActivity()) .setTitle("トークンを入力してください") .setView(myEdit) .setPositiveButton("OK", DialogInterface.OnClickListener { _, _ -> // OKボタン押したときの処理 val inputText = myEdit.getText().toString() if (!inputText.isEmpty()) { sharedPref.edit().putString("token", inputText).apply() token = inputText } }) .show() } return token } 工夫したところ② また、最初の画面で日付と時刻を入力するのがめんどくさいので、よく使われるであろう「直近24時間」と「直近1週間」の設定を一発でできるボタンを設置しました。 感想 せっかくMVVMで作ってみたのにFragment内も結構ごちゃごちゃしてしまったのが今後直したい…という感じです。 折れ線グラフの表示はこちらの記事が大変わかりやすく、必要な情報もすべて網羅されていたので大変助かりました。 外気温と室温の連動がわかるような、わからないような… 料理をする夜の時間帯が室温のピークだということがわかります。 結果 初めて個人でがっつり何かを作ったので楽しかった AndroidのMVVM,LiveDataが理解できた(と思う) 結局エアコンの暖房の効きが悪いどうかはわからない Qiitaに初投稿してみてドキドキです
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ML-Agents入門 4足歩行モデル

はじめに ※この記事はRICORA Advent Calendar 12/25 の記事になります。 本記事ではUnityの深層強化学習パッケージのML-Agentsによる4足歩行モデルの実装を行います。 ML-Agents完全初心者の方はML-Agents入門から読むことをおススメします。ML-Agentsのインストールから簡単なモデル作成まで行っています。 ここではモデルと環境の作成方法を紹介した後に、4足歩行モデルを実現するための報酬設計の考え方やトレーニングの高速化について主に紹介します。 モデルと環境の準備 モデルと環境をどのように作成したかについて説明します。 モデルの作成 以下のような4足2関節のモデルを作成しました。 今回はこのモデルに歩行を学習させるのがメインタスクとなります。具体的にはエージェントが目標に到達するというタスクを立てて、これを学習させていきます。 このモデルは次のような親子関係の3Dオブジェクトからなっています。 ここでBugAgentは空の3Dオブジェクトであり、Behavior ParametersとDecision RequesterをComponentとして付けます。 bodyは緑色のCapsule、l1,l2,r1,r2は赤色のSphere、各legは青色のCapsuleになります。このように親子関係を組むことで各部位が上位のパーツの動きを反映できるようになります。親子関係が無いと体が動いているのに足がついていかない、といった関連性のない挙動になってしまいます。 l1,l2,r1,r2と各legにはHinge Jointという動きに制限を掛けるComponentを付けています。今回は強化学習にこのComponentを操作させてモデルに歩行を学習させます。Connected Bodyに一つ上のオブジェクトを選択しましょう。またHingeの方向にも注意です。 色々なものが見えてますが今は気にしないでください(笑) Hinge JointのLimitsのmin maxはともに0にしておきましょう。上の写真のような操作を行うとオブジェクトがどの方向に回転できるかを示す円が出てきます。今回は回転方向が地面に垂直になるように調整しました。角度の調整はAxisの値を調整することでできます。 またBugAgentを除くオブジェクトはRigidBodyを持っています。基本的な重さは1ですが飛び跳ね防止のためbodyのみ重さを2.5にしています。単純に体積の大きい部位ほど重さを増やすといった仕様にしても面白いと思います。時間がある人は色々試してみましょう。 環境の作成 加えて地面に接地している各legと地面のPlaneに摩擦を設定しました。Projectウィンドを右クリックしてCreateからPhysic Materialを選択すると摩擦に使えるマテリアルを取得することができます。 マテリアルを選択してDynamic Friction(動摩擦)とStatic Friction(静止摩擦)をそれぞれ1に設定しましょう。 物体にこれを適用するためにはデフォルトでついているMesh ColliderというComponentにこれを付与します。Mesh CollierのMaterialのところにドラッグ&ドロップしてください。上で説明したように4本のlegとfloor(床のplane)にそれぞれ同じ操作を行います。 歩行の目標にするTragetをfloor上に配置します。今回はBugAgentとTargetの距離を20mにしてあります。 空のGame Objectを生成してそこにBugAreaという名前を付けました。次の階層構造になるように作ったオブジェクトが配置されるようにしましょう。 環境のプレハブ化 ここまでに作成した環境のプレハブ化というものを行います。プレハブ化を利用することで環境の量産と同時変更を行うことができるようになります。後に高速化を行うためのステップとなります。 今作成したBugAgentをProjectウィンドにドラッグ&ドロップします。画像ではBugAgentや諸々が水色の文字で表示されていますが、プレハブ化前は白色になっているはずです。プレハブ化ができると名前が水色になります。 Projectウィンド内にプレハブ化されたBugAreaが表示されているのでダブルクリックしてプレハブの作業エリアに移動してみましょう。 画像のような背景が青い空間に移動したと思います。ここでプレハブの編集を行うことができます。モデルや環境を変更したいときは個別のオブジェクトではなく、このプレハブから編集するようにしましょう。プレハブでコピーしたオブジェクト全体に同様の変更を適用することができます。 ML-Agent用コンポネントの設定 環境の最後の設定としてML-Agentsに必要なComponentの設定を行いましょう。 BugAgentにBehavior ParametersとScriptとDecision Requesterを付けます。スクリプトの名前はBugAgentとしました。 Bug Agent(Script)のTargetの部分は実際にプログラムを書くと出てくるので今は気にしないでください。プログラムを書いたらTargetのところに設置しているtargetオブジェクトをドラッグ&ドロップしてください。それぞれのComponentの数値は画像と同じ値にセットしましょう。 これで環境の準備が整いました。次はプログラムです。 スクリプトと報酬設計の考え方 BugAgent.csの中身を見ながらどのように報酬設計を行ったか解説します。 BugAgent.cs using System.Collections.Generic; using System; using UnityEngine; using Unity.MLAgents; using Unity.MLAgents.Sensors; public class BugAgent : Agent { public Transform target; private GameObject body; private GameObject[] thighs = new GameObject[4]; private GameObject[] legs = new GameObject[4]; private HingeJoint[] thighs_hinge = new HingeJoint[4]; private HingeJoint[] legs_hinge = new HingeJoint[4]; private JointMotor[] thighs_motor = new JointMotor[4]; private JointMotor[] legs_motor = new JointMotor[4]; private int phase = 0; private float rotate_phase = 1.0f; public override void Initialize(){ this.body = this.transform.Find("body").gameObject; this.thighs[0] = this.body.transform.Find("l1").gameObject; this.thighs[1] = this.body.transform.Find("l2").gameObject; this.thighs[2] = this.body.transform.Find("r1").gameObject; this.thighs[3] = this.body.transform.Find("r2").gameObject; int limit = 30; for(int i=0;i<4;i++){ this.legs[i] = this.thighs[i].transform.Find("leg").gameObject; this.thighs_hinge[i] = thighs[i].GetComponent<HingeJoint>(); this.legs_hinge[i] = legs[i].GetComponent<HingeJoint>(); this.thighs_motor[i] = this.thighs_hinge[i].motor; this.legs_motor[i] = this.legs_hinge[i].motor; JointLimits thigh_limits = thighs_hinge[i].limits; JointLimits leg_limits = legs_hinge[i].limits; thigh_limits.min = -limit; thigh_limits.max = limit; thigh_limits.bounciness = 0; thigh_limits.bounceMinVelocity = 0; thighs_hinge[i].limits = thigh_limits; thighs_hinge[i].useLimits = true; leg_limits.min = -limit; leg_limits.max = limit; leg_limits.bounciness = 0; leg_limits.bounceMinVelocity = 0; legs_hinge[i].limits = leg_limits; legs_hinge[i].useLimits = true; } } public override void OnEpisodeBegin(){ this.body.transform.localPosition = new Vector3(0,1.3f,-10); this.body.transform.localRotation = Quaternion.Euler(90, 0, 0); for(int i=0;i<4;i++){ this.thighs[i].transform.localRotation = Quaternion.Euler(-90, 0, 0); this.legs[i].transform.localRotation = Quaternion.Euler(0,0,0); this.thighs_motor[i].targetVelocity = 0; this.thighs_hinge[i].motor = this.thighs_motor[i]; this.legs_motor[i].targetVelocity = 0; this.legs_hinge[i].motor = this.legs_motor[i]; } this.phase = 0; this.rotate_phase = 1.0f; target.localPosition = new Vector3(0, 1.0f, 10.0f); } public override void CollectObservations(VectorSensor sensor){ sensor.AddObservation(target.localPosition); sensor.AddObservation(this.body.transform.localPosition); sensor.AddObservation(this.body.transform.localRotation); sensor.AddObservation(this.body.transform.up.z); sensor.AddObservation(this.body.transform.forward.y); for(int i=0;i<4;i++){ sensor.AddObservation(this.thighs[i].transform.localRotation.x); sensor.AddObservation(this.legs[i].transform.localRotation.x); } } public override void OnActionReceived(float[] vectorAction){ float force = 1500.0f; for(int i=0;i<4;i++){ this.thighs_motor[i].targetVelocity = vectorAction[i]*force; this.legs_motor[i].targetVelocity = vectorAction[i+4]*force; this.thighs_hinge[i].motor = this.thighs_motor[i]; this.legs_hinge[i].motor = this.legs_motor[i]; } AddReward(-0.001f); float distanceToTarget = Vector3.Distance(this.body.transform.localPosition, target.localPosition); if (distanceToTarget < 18.0f - this.phase && this.phase < 15){ AddReward(0.05f); this.phase += 1; } if (distanceToTarget < 2.0f){ AddReward(2.0f); EndEpisode(); } Vector3 rotate_angle = this.body.transform.localRotation.eulerAngles; float rotation = this.body.transform.up.z; bool is_rotate = rotation < Math.Cos(Math.PI/6.0f * rotate_phase); bool is_flip_z = this.body.transform.forward.y > 0; if (this.body.transform.localPosition.y < 0.0f || is_flip_z || rotation < Math.Cos(Math.PI/6.0f * 3.0f)){ EndEpisode(); } if(is_rotate && rotate_phase < 2){ AddReward(-0.1f); rotate_phase += 1.0f; } } public override void Heuristic(float[] actionsOut){ actionsOut[0] = Input.GetAxis("Horizontal"); actionsOut[1] = Input.GetAxis("Vertical"); } public void Update(){ } } UnityとML-Agentのスクリプトの書き方については長くなるので省略させてもらいます。 ここではOnActionRecived関数について説明していきます。この関数内では主に報酬の設定をAddReward関数の呼び出しで行っていて、全部で4か所あることが分かると思います。 それぞれのAddRewardの意味 最初の報酬は微量のマイナス報酬で、エージェントが行動をするたびに発生します。これはエージェントにできるだけ早くゴールすることを促すための報酬で、早くゴールした個体の方がマイナス報酬の累積が少なくなるのでより高い評価を受けることになります。これによって最適な移動を学習することができます。 次の報酬は少量のプラス報酬で、エージェントがターゲットと一定距離近づく度に発生します。具体的にはアメフトのフィールドを想像すると分かりやすいと思います。ここではターゲットに1m近づく度にプラス報酬をもらえるようにしました。これはエージェントのスムーズな学習を促すためのものです。 次にメインのゴールに対するプラス報酬です。これはエージェントがターゲットに到着した際に発生する報酬でそれと同時に環境のリセットが入ります。 最後は回転に対する少量のマイナス報酬です。これはエージェントが上から見て15度回転するたびに発生するマイナス報酬で15度と30度の2回発生します。方式は1つ目の距離報酬と同じです。これによってエージェントの不要な回転を抑制し真っすぐ歩くことを促します。 報酬設計の推移 4つの報酬について上で説明しました。なぜこのようになったのか詳細について説明します。 まず最初は3のエージェントがターゲットにゴールすることのみに報酬を設けていました。しかしこれだけでとそもそもゴールすることがなかなかできないので学習の進みが非常に悪くなります。そこで2の報酬を設定しました。 次に発生した問題はエージェントの移動経路です。2の報酬によってゴールができるようになりましたがこれだとどのような経路でもゴールすれば同じ報酬がもらえるため、最適でない経路や行動が発生していました。そこで1の微量なマイナス報酬を設定することでこれの最適化を試みました。マイナス報酬は設計が難しく値をあまり大きくしてしまうと、すぐにひっくり返って終了させるなどのマイナスを避ける望ましくない動きが発生してしまいます。この報酬によってかなりゴールまでの時間が短縮されました。 最後に発生していたのがエージェントが途中で180度旋回しながらゴールを目指していたというものです。4足歩行のエージェントにとって最も困難な問題は左右のバランスを取ることだったようで、実験を通して先ほどの微量なマイナス報酬の調整だけではこの問題を解決できないと気づきました。そこで直接的に旋回に対してマイナス報酬を設けることにしましたがこれがとても難しかったです。マイナスが大きいと旋回を避けるのがよほど難しいのか、スタート地点から一切動かないといった結果に何回もたどり着いてしまいました。最終的に4の形に落ち着いてこれによってできるだけ真っすぐとゴールを目指す最適な動きを生成することができました。 トレーニングの設定 トレーニングの設定は以下のようになっています。詳しくは前回の記事を参照してください。 BugAgent.yaml behaviors: BugAgent: trainer_type: ppo hyperparameters: batch_size: 10 buffer_size: 100 learning_rate: 0.0003 beta: 0.005 epsilon: 0.2 lambd: 0.95 num_epoch: 3 learning_rate_schedule: linear network_settings: normalize: true hidden_units: 128 num_leyers: 3 vis_encode_type: simple reward_signals: extrinsic: gamma: 0.99 strength: 1.0 keep_checkpoints: 5 checkpoint_interval: 100000 max_steps: 30000000 time_horizon: 64 summary_freq: 5000 threaded: true トレーニングの高速化 今回のタスクは前回のタスクと比べると格段に内容が難しくなっています。そのためある程度トレーニングを高速化する必要があります。ここでは最も簡単なプレハブから環境を量産する方法を紹介します。 環境の作成の際にプレハブ化を行いました。プレハブ化されたオブジェクトはSceneウィンドにドラッグ&ドロップすることでコピーすることができます。 自分は画像のように15個の環境をコピーして同時に学習をさせました。これによってモデルの収束を早めることができます。 特に操作はなくトレーニングを実行すればすべての環境で並列して学習が行われます。これ以外にもビルドしてアプリ化することでトレーニングを高速化することもできますが今回は使わなかったので紹介しません。気になる人は公式のドキュメントを参照してみてください。 トレーニング結果 以下がトレーニング結果になります。そこそこ4足歩行と言える動きを作れていると思います! 足の動かす順番が少しおかしかったり若干跳ねたりしているのでまだ少し改良の余地がありそうです。 自分は力尽きたのでやる気のある人は是非トライしてみてください! まとめ この記事では4足歩行のモデルをUnity ML-Agents上で学習する方法を紹介してみました。強化学習を3Dモデルで行いたいと考えている人の役に少しでもたてたら嬉しいです。 気づいていたかもしれませんが、この記事はすべての作業が完了してから書いたものなのでどこかで手順が抜け落ちていたりするかもしれません。そのようなことがあればコメントなどで教えていただけると幸いです。 ここまで読んでいただきありがとうございました。楽しいML-Agentsライフをお送りください! 参考 ML-Agents公式GitHub Unity ML-Agents 実践ゲームプログラミング v1.1対応版 (Unityではじめる機械学習・強化学習)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

基本的なコンソールアプリケーションの構造 with ConsoleAppFramework

2022版を作った後で ConsoleAppFramework https://github.com/Cysharp/ConsoleAppFramework を見つけたので記録。 パッケージ <PackageReference Include="ConsoleAppFramework" Version="4.0.6" /> <PackageReference Include="NLog.Extensions.Logging" Version="1.7.4" /> 設定ファイル appsettings.json { "Id": "101", "Name": "name1" } nlog.config <?xml version="1.0" encoding="utf-8" ?> <nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" autoReload="true" internalLogLevel="Error" internalLogFile="${basedir}\logs\internal-nlog.log"> <extensions> </extensions> <variable name="logDirectory" value="${basedir}\logs" /> <targets> <target xsi:type="File" name="file" fileName="${logDirectory}/log-${shortdate}.log" layout="${longdate} [${level:uppercase=true}] [${threadid}] ${callsite:includeNamespace=false}#${callsite-linenumber} ${message} ${exception:format=tostring}" /> <target name="Console" xsi:type="console" layout="${longdate} [${level:uppercase=true}] [${threadid}] ${callsite}#${callsite-linenumber} ${message} ${exception:format=tostring}"/> </targets> <rules> <logger name="*" minlevel="Trace" writeTo="file,console" /> </rules> </nlog> いずれも "常にコピーする" または "新しい場合はコピーする"を設定しておく 設定クラス class SampleSettings { public int Id { get; set; } public string Name { get; set; } = ""; } string はnullable警告が出るので回避のため空文字列で初期化しておくとか、nullableにしておくとか、 nullable referenceを使わないとかしておく。 コマンドクラス コマンドの本体 class Foo : ConsoleAppBase { private readonly Microsoft.Extensions.Logging.ILogger _logger; private readonly SampleSettings _settings; public Foo(IOptions<SampleSettings> settings, ILogger<Foo> logger) { _settings = settings.Value; _logger = logger; } public void Hello(string name) { _logger.LogTrace($"{_settings.Id} {_settings.Name}"); _logger.LogTrace($"{name}"); } } ConsoleAppFramework が Microsoft.Extentions.DependencyInjection をサポートしているおかげで対象Commandへの注入が効く。 ここではIOption と ILogger を注入しているけど、EFCore6とか使うならDbContext も注入できるだろうしほかの関連サービスも行けるはず。 プログラム本体 今回は VS2022/c#10 なので void Main(string[] args 不要 var builder = ConsoleApp.CreateBuilder(args); builder.ConfigureServices((ctx, services) => { // Register appconfig.json to IOption<MyConfig> services.Configure<SampleSettings>(ctx.Configuration); // Using Cysharp/ZLogger for logging to file services.AddLogging(logging => { logging.ClearProviders(); logging.AddNLog(); logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace); }); }); var app = builder.Build(); app.AddCommands<Foo>(); app.Run(); ConfigureServices 内でサービス設定やらログ設定やらを実行しておく。 その他 前回よりはコード記述量が大幅に減る。よい。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[C#]拡張メソッドを使って、I/Fの変更無しに扱えるフィールドやサービスを増やす

はじめに 拡張メソッドを使うことがありましたので、そのとき得た知見をまとめることを目的としています。 マイクロソフト公式の拡張メソッドのガイドラインはこちら。 拡張メソッドを使うことになった背景を簡単に説明します。 I/Fを変更するとそのI/Fを実装するすべてのクラスに変更の影響があり、実装を修正しなければなりません。 特に、プロパティやメソッドが頻度高く増え続ける可能性があるI/Fかつ、複数の外部のプロジェクトでI/Fが実装される場合、毎回、その実装クラスを修正しなければならず、変更がし辛いです。 この問題を解決するために拡張メソッドを利用しました。以下にこの時取った解決策を記載します。 解決方法 前提として、I/Fとその具象プロジェクトは別プロジェクトになっており、外部からはI/Fがあるプロジェクトのみが参照可能です。 I/Fは、固有のプロパティやメソッドは一切持たないようにします。 I/Fは、フィールドやサービスを設定・取得するための汎用的なメソッドのみにします。 public interface ISampleContext { /// <summary> /// サービスを取得します /// </summary> /// <typeparam name="T">サービスのインターフェース</typeparam> /// <returns>サービス</returns> T GetService<T>(T defaultValue = default(T)!) where T : class; /// <summary> /// サービスを登録します /// </summary> /// <param name="instance">サービスの実クラス</param> /// <typeparam name="T">サービスのインターフェース</typeparam> void RegisterService<T>(T instance); /// <summary> /// フィールドを取得します /// </summary> /// <param name="fieldName">フィールドの名前</param> /// <param name="defaultValue">デフォルト値</param> /// <typeparam name="T">フィールドの型</typeparam> /// <returns>フィールド</returns> T GetField<T>(string fieldName, T defaultValue = default(T)!); /// <summary> /// フィールドを設定します /// </summary> /// <param name="fieldName">フィールドの名前</param> /// <param name="value">設定したい値</param> void SetField(string fieldName, object value); } I/Fの実装クラスでは、フィールドやサービスを保持するためのディクショナリを持ち、メソッドの実装ではそのディクショナリへの値の設定と、ディクショナリから値を返すだけの実装にします。 internal class SampleContext : ISampleContext { /// <summary> /// サービスを保持するディクショナリ /// </summary> private readonly IDictionary<Type, object> m_Services = new Dictionary<Type, object>(); /// <summary> /// フィールドを保持するディクショナリ /// </summary> private readonly IDictionary<string, object> m_Fields = new Dictionary<string, object>(); /// <summary> /// サービスを取得します /// </summary> /// <typeparam name="T">サービスのインターフェース</typeparam> /// <returns>サービス</returns> public T GetService<T>() where T : class { if (!m_Services.ContainsKey(typeof(T))) return defaultValue; if (!(m_Services[typeof(T)] is T service)) return defaultValue; return service; } /// <summary> /// サービスを登録します /// </summary> /// <param name="instance">サービスの実クラス</param> /// <typeparam name="T">サービスのインターフェース</typeparam> public void RegisterService<T>(T instance) { if (m_Services.ContainsKey(typeof(T))) return; if(instance == null) throw new Exception("instance != null"); m_Services.Add(typeof(T), instance); } /// <summary> /// フィールドを取得します /// </summary> /// <param name="fieldName">フィールドの名前</param> /// <param name="defaultValue">デフォルト値</param> /// <typeparam name="T">フィールドの型</typeparam> /// <returns>フィールド</returns> public T GetField<T>(string fieldName, T defaultValue = default(T)!) { if (string.IsNullOrEmpty(fieldName)) return defaultValue; if (!m_Fields.ContainsKey(fieldName)) return defaultValue; if (!(m_Fields[fieldName] is T field)) return defaultValue; return field; } /// <summary> /// フィールドを設定します /// </summary> /// <param name="fieldName">フィールドの名前</param> /// <param name="value">設定したい値</param> public void SetField(string fieldName, object value) { if(string.IsNullOrEmpty(fieldName)) throw new Exception("fieldName != null"); if (!m_Fields.ContainsKey(fieldName!)) { m_Fields.Add(fieldName!, value); } else { m_Fields[fieldName!] = value; } } } I/Fで定義したメソッドを直接使うこともできます。 ただし、このままだとこのI/Fの利用者が、サービスを取得したうえで、サービスに委譲する処理を実装したり、文字列でフィールド名を指定したりする必要があるため使い勝手が悪いです。 拡張メソッドを使うことで、解消します。以下のようなイメージです。 public static class SampleContextExtensions { /// <summary> /// XXXの処理をします /// </summary> /// <param name="self"></param> /// <returns></returns> public static void DoSomething(this IControlContext self) { // I/Fのメソッドを使用してサービスを取得 var xxxService = self?.GetService<IXXXService>(); // サービスに処理を委譲 xxxService?.Something(); } /// <summary> /// YYYを取得します /// </summary> /// <param name="self"></param> /// <returns>YYY</returns> public static IYYY? GetYYYField(this IControlContext self) { // I/Fのメソッドを使用してフィールドの値を取得 return self?.GetField<IYYY>(nameof(IYYY)); } /// <summary> /// YYYを設定します /// </summary> /// <param name="self"></param> /// <param name="value"></param> public static void SetYYYField(this IControlContext self, IYYY value) { // I/Fのメソッドを使用してフィールドの値を設定 self.SetField(nameof(IYYY), value); } } このように、I/Fでは固有のプロパティやメソッドを定義せず汎用的なメソッドのみを用意し、そのバリエーションや便利メソッドは拡張メソッドで定義することがポイントです。 こうすることで、I/Fを変更する必要が無いため、I/Fを実装する他ユーザーなどへの影響がありません。 また、拡張メソッドはI/Fと同じプロジェクトに定義します。こうすることで、I/Fを参照するプロジェクトでも拡張メソッドが使用できるようになり便利です。 さいごに 拡張メソッドの存在自体は知っていましたが、どういう場合に使えるのか知らなかったので今回記事にしてみました。 マイクロソフト公式では、拡張メソッド使用時の注意事項がたくさん書かれており、むやみな拡張メソッドの使用は避けることが推奨されていますので、ご注意ください。特に、他の人が作ったソースコードに対して拡張メソッドを作るのは、推奨していないようでした。 自分がライブラリを公開したりする場合には、I/Fの変更をせずに済み、ライブラリの利用者への影響が無くなるため、今回のような拡張メソッドを使った方法は有用だと思いました。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Terrainの地形を動的に自動生成する

Terrainの地形の保存先 Terrainの地形のデータはTerrainDataの中のheightmapにfloatの2次元配列で保存されている。 heightmapは地形の高さを表すテクスチャのようなものであり、要素の中には0.0f~1.0fの値が入っています。 この値はTerrainの高さの割合で、例えばTerrainの高さが5.0fの時、要素の値が0.5fだった場合高さは2.5fとなる。 今回は、このheightmapを変更して地形を作っていきます。 活用する変数とメソッド TerrainDataを取得する Terrain terrain = GetComponent<Terrain>(); TerrainData terrainData = terrain.terrainData; Terrainの大きさを変更する terrainData.size = new Vector3(x, height, z); heightmapの解像度を取得する int heightmapSize = terrainData.heightmapResolution; //地形の大きさ heightmapを渡す //地形のデータを渡す terrainData.SetHeights(0, 0, heightmap); サンプルプログラム パーリンノイズを利用した地形を生成するサンプルです。 インスペクターの生成か、Generate()を呼ぶことで生成できます。 TerrainGenerator.cs using System.Collections; using System.Collections.Generic; using UnityEngine; #if UNITY_EDITOR using UnityEditor; #endif [RequireComponent(typeof(Terrain))] public class TerrainGenerator : MonoBehaviour { [SerializeField] Vector2Int terrainSize; //Terrainのサイズ [SerializeField] float height; //Terrainの高さ [SerializeField] float relief; //地形の滑らかさ float seedX, seedY; public void Generate() { //Terrain関連のコンポーネントを取得する Terrain terrain = GetComponent<Terrain>(); TerrainCollider terrainCollider = GetComponent<TerrainCollider>(); TerrainData terrainData = terrain.terrainData; //シード値を設定する seedX = Random.value * 100f; seedY = Random.value * 100f; //Terrainのサイズを変更する terrainData.size = new Vector3(terrainSize.x, height, terrainSize.y); //地形に関する変数を用意する int heightmapSize = terrainData.heightmapResolution; //地形の大きさ float[,] heightmap = new float[heightmapSize, heightmapSize]; //地形のデータ(0 ~ 1) //地形を変更する for(int y = 0; y < heightmapSize; y++) { for(int x = 0; x < heightmapSize; x++) { float sampleX = seedX + x / relief; float sampleY = seedY + y / relief; float noise = Mathf.PerlinNoise(sampleX, sampleY); heightmap[x, y] = noise; } } //地形のデータを渡す terrainData.SetHeights(0, 0, heightmap); //作ったTerrainDataを渡す terrain.terrainData = terrainData; terrainCollider.terrainData = terrainData; } } /// <summary> /// インスペクターに「生成」のボタンを作る /// </summary> #if UNITY_EDITOR [CanEditMultipleObjects] [CustomEditor(typeof(TerrainGenerator))] public class TerrainGeneratorEditor : Editor { public override void OnInspectorGUI() { TerrainGenerator terrainGenerator = target as TerrainGenerator; base.OnInspectorGUI(); EditorGUILayout.Space(); if (GUILayout.Button("生成")) { terrainGenerator.Generate(); } } } #endif
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WPF DataGridでExcelのようにセルを範囲選択して集計する

業務アプリケーションを開発した時にExcelを使いこなすユーザーがいらっしゃいました。 使い続けていくうちにDataGridがワークシートに見えたのか、このようなリクエストが来ました。 「えぇ~~~っ!」と思ったのですが、結果的に懇切丁寧にDataGridにフィルタ機能や集計機能を実装するよりも工数削減になりました。 最初っから思考的に拒絶してはいかんですね。 MainWindow.xaml <Window x:Class="GridSample.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:GridSample" mc:Ignorable="d" Title="MainWindow" Height="270" Width="500"> <StackPanel> <DataGrid x:Name="dataGrid" SelectedCellsChanged="DataGrid_SelectedCellsChanged" SelectionUnit="Cell"/> <!-- リアルタイム集計値の表示エリア --> <Border Padding="5" Background="LightGray"> <StackPanel Orientation="Horizontal"> <TextBlock Width="150" x:Name="lblAverage" Text="平均" Margin="12,0,0,0"/> <TextBlock Width="150" x:Name="lblCount" Text="選択セルの個数" Margin="12,0,0,0"/> <TextBlock Width="150" x:Name="lblSum" Text="合計" /> </StackPanel> </Border> </StackPanel> </Window> MainWindow.xaml.cs using System; using System.Collections.ObjectModel; using System.Windows; using System.Windows.Controls; namespace GridSample { /// <summary> /// MainWindow.xaml の相互作用ロジック /// </summary> public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); ObservableCollection<SampleRecord> records = new ObservableCollection<SampleRecord>(); records.Add(new SampleRecord() { Name = "A", Number1 = 1000, Number2 = 2000, }); records.Add(new SampleRecord() { Name = "B", Number1 = 7000, Number2 = 6000, }); records.Add(new SampleRecord() { Name = "C", Number1 = 4000, Number2 = 5000, }); this.dataGrid.ItemsSource = records; } private void DataGrid_SelectedCellsChanged(object sender, SelectedCellsChangedEventArgs e) { int cellCount = 0; int cellCountOfTypeNumber = 0; decimal average = 0; decimal sum = 0; foreach (var cell in dataGrid.SelectedCells) { if (cell != null) { // 値があるセルであれば、カウントを増やす cellCount += 1; decimal addSum = 0; // 数値項目か判定 string value = ((TextBlock)cell.Column.GetCellContent(cell.Item)).Text; if (Decimal.TryParse(value, out addSum)) { // 平均値を算出するため、数値型のセルのカウントを行う cellCountOfTypeNumber += 1; // 合計値を加算 sum += addSum; } } } // 選択されたセルの情報を、各項目に反映する if (cellCountOfTypeNumber >= 1) { average = sum / cellCountOfTypeNumber; } lblAverage.Text = string.Format("平均 : {0:#,0}", average); lblCount.Text = string.Format("データの個数 : {0}", cellCount); lblSum.Text = string.Format("合計 : {0:#,0}", sum); } } }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WPF DataGridでExcelのようにセルを範囲選択して集計してくだされ

業務アプリケーションを開発した時にExcelを使いこなすユーザーがいらっしゃいました。 使い続けていくうちにDataGridがワークシートに見えたのか、このようなリクエストが来ました。 「えぇ~~~っ!」と思ったのですが、結果的に懇切丁寧にDataGridにフィルタ機能や集計機能を実装するよりも工数削減になりました。 最初っから思考的に拒絶してはいかんですね。 MainWindow.xaml <Window x:Class="GridSample.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:GridSample" mc:Ignorable="d" Title="MainWindow" Height="270" Width="500"> <StackPanel> <DataGrid x:Name="dataGrid" SelectedCellsChanged="DataGrid_SelectedCellsChanged" SelectionUnit="Cell"/> <!-- リアルタイム集計値の表示エリア --> <Border Padding="5" Background="LightGray"> <StackPanel Orientation="Horizontal"> <TextBlock Width="150" x:Name="lblAverage" Text="平均" Margin="12,0,0,0"/> <TextBlock Width="150" x:Name="lblCount" Text="選択セルの個数" Margin="12,0,0,0"/> <TextBlock Width="150" x:Name="lblSum" Text="合計" /> </StackPanel> </Border> </StackPanel> </Window> MainWindow.xaml.cs using System; using System.Collections.ObjectModel; using System.Windows; using System.Windows.Controls; namespace GridSample { /// <summary> /// MainWindow.xaml の相互作用ロジック /// </summary> public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); ObservableCollection<SampleRecord> records = new ObservableCollection<SampleRecord>(); records.Add(new SampleRecord() { Name = "A", Number1 = 1000, Number2 = 2000, }); records.Add(new SampleRecord() { Name = "B", Number1 = 7000, Number2 = 6000, }); records.Add(new SampleRecord() { Name = "C", Number1 = 4000, Number2 = 5000, }); this.dataGrid.ItemsSource = records; } private void DataGrid_SelectedCellsChanged(object sender, SelectedCellsChangedEventArgs e) { int cellCount = 0; int cellCountOfTypeNumber = 0; decimal average = 0; decimal sum = 0; foreach (var cell in dataGrid.SelectedCells) { if (cell != null) { // 値があるセルであれば、カウントを増やす cellCount += 1; decimal addSum = 0; // 数値項目か判定 string value = ((TextBlock)cell.Column.GetCellContent(cell.Item)).Text; if (Decimal.TryParse(value, out addSum)) { // 平均値を算出するため、数値型のセルのカウントを行う cellCountOfTypeNumber += 1; // 合計値を加算 sum += addSum; } } } // 選択されたセルの情報を、各項目に反映する if (cellCountOfTypeNumber >= 1) { average = sum / cellCountOfTypeNumber; } lblAverage.Text = string.Format("平均 : {0:#,0}", average); lblCount.Text = string.Format("データの個数 : {0}", cellCount); lblSum.Text = string.Format("合計 : {0:#,0}", sum); } } }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[C#]DictionaryのKeyをカスタムクラス、ValueをListにする

概要 業務にて、タイトルの通りカスタムクラスとLINQを組み合わせたDictionaryを使った実装が必要になったため、勉強と記録がてら記事作成します。 やりたいこと ざっくり、以下のような感じです。 (実際は全然違う内容ですが、便宜上置き換えています) 社員がノートに1行ずつ起票をしていく運用のノートがある。 ノートでは、同じ社員が複数行起票することがある。 このノートにおいて、各社員が何行目に起票したかを整理し、表示する。 上記において、 社員情報-記入した行のリスト という形のDictionayを使用することで対応しました。 ポイント Dictionaryにおいて、Keyを同一とみなすためには以下を満たす必要があるようです。 GetHashCodeの結果が同じであること Equalsの結果がtrueであること したがって、Keyに独自のクラスを使用する場合は、上記2つの関数を適切にオーバーライドする必要があります。 実装例 以下のような感じにしました。 ※とりいそぎで書いているので、最後のコンソール出力はソートせずにdictionaryに入れた順で出しちゃってます。 ※foreachのループの中でループカウンタをとってくる方法は、もっといいやり方があるかも… 社員クラス public class Employee { public string id { get; set; } public string name { get; set; } // コンストラクタ public Employee(string id, string name) { this.id = id; this.name = name; } // Equalsをオーバーライド(※VisualStudioで自動生成) public override bool Equals(object obj) { return obj is Employee employee && id == employee.id && name == employee.name; } // GetHashCodeをオーバーライド(※VisualStudioで自動生成) public override int GetHashCode() { int hashCode = -48284730; hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(id); hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(name); return hashCode; } } Main // 結果の格納先 Dictionary<Employee, List<int>> dictionary = new Dictionary<Employee, List<int>>(); // 社員群 Employee john1 = new Employee("A0001", "John"); Employee tom = new Employee("A0002", "Tom"); Employee john2 = new Employee("A0003", "John"); Employee emily = new Employee("A0004", "Emily"); Employee alex = new Employee("A0005", "Alex"); // 記帳順のリスト List<Employee> entryList = new List<Employee>() { john1, tom, john1, emily, alex, john2, john1, john2, alex }; List<int> valueList; int i = 0; Console.WriteLine("--- 記帳順 ---"); // 記帳順を格納していく foreach (Employee employee in entryList) { i++; // dictionaryに登録済かを判定しつつ、登録済の場合はリストを取得 if (dictionary.TryGetValue(employee, out valueList)) { // 登録済の場合、リストに値を追加して更新 valueList.Add(i); dictionary[employee] = valueList; } else { // 初出の場合、登録 dictionary.Add(employee, new List<int> { i }); } Console.WriteLine(string.Format("i={0}, {1}:{2}", i, employee.id, employee.name)); } Console.WriteLine("--- 社員ごとの記録 ---"); // 結果を出力 foreach (Employee employee in dictionary.Keys) { Console.WriteLine(string.Format("{0}:{1} - {2}", employee.id, employee.name, string.Join(", ", dictionary[employee]))); } 結果 --- 記帳順 --- i=1, A0001:John i=2, A0002:Tom i=3, A0001:John i=4, A0004:Emily i=5, A0005:Alex i=6, A0003:John i=7, A0001:John i=8, A0003:John i=9, A0005:Alex --- 社員ごとの記録 --- A0001:John - 1, 3, 7 A0002:Tom - 2 A0004:Emily - 4 A0005:Alex - 5, 9 A0003:John - 6, 8 参考リンク C#で自作クラス(カスタムクラス)をキーにする方法(Dictionary,HashSet)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む